Skip to content

Commit c78e491

Browse files
author
IM.codes
committed
Improve P2P discussion detail follow controls
1 parent 88a8760 commit c78e491

File tree

10 files changed

+231
-21
lines changed

10 files changed

+231
-21
lines changed

web/src/i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@
356356
"phase_queued": "Queued",
357357
"round_label": "Round",
358358
"hop_label": "Hop",
359+
"auto_follow_latest": "Auto-follow latest",
359360
"scroll_top": "Scroll to top",
360361
"scroll_bottom": "Scroll to bottom"
361362
},

web/src/i18n/locales/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@
356356
"phase_queued": "En cola",
357357
"round_label": "Ronda",
358358
"hop_label": "Salto",
359+
"auto_follow_latest": "Seguir lo último automáticamente",
359360
"scroll_top": "Ir arriba",
360361
"scroll_bottom": "Ir abajo"
361362
},

web/src/i18n/locales/ja.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@
356356
"phase_queued": "待機中",
357357
"round_label": "ラウンド",
358358
"hop_label": "ホップ",
359+
"auto_follow_latest": "最新に自動追従",
359360
"scroll_top": "先頭へスクロール",
360361
"scroll_bottom": "末尾へスクロール"
361362
},

web/src/i18n/locales/ko.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@
356356
"phase_queued": "대기열",
357357
"round_label": "라운드",
358358
"hop_label": "",
359+
"auto_follow_latest": "최신 내용 자동 따라가기",
359360
"scroll_top": "맨 위로 스크롤",
360361
"scroll_bottom": "맨 아래로 스크롤"
361362
},

web/src/i18n/locales/ru.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@
356356
"phase_queued": "В очереди",
357357
"round_label": "Раунд",
358358
"hop_label": "Шаг",
359+
"auto_follow_latest": "Автопрокрутка к последнему",
359360
"scroll_top": "Прокрутить вверх",
360361
"scroll_bottom": "Прокрутить вниз"
361362
},

web/src/i18n/locales/zh-CN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@
356356
"phase_queued": "排队中",
357357
"round_label": "轮次",
358358
"hop_label": "跳转",
359+
"auto_follow_latest": "自动跟随最新",
359360
"scroll_top": "滚动到顶部",
360361
"scroll_bottom": "滚动到底部"
361362
},

web/src/i18n/locales/zh-TW.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@
356356
"phase_queued": "排隊中",
357357
"round_label": "輪次",
358358
"hop_label": "跳轉",
359+
"auto_follow_latest": "自動跟隨最新",
359360
"scroll_top": "捲動到頂部",
360361
"scroll_bottom": "捲動到底部"
361362
},

web/src/pages/DiscussionsPage.tsx

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,21 @@ export function DiscussionsPage({ ws, initialSelectedId, liveDiscussions = [], o
3030
const [discussions, setDiscussions] = useState<P2pDiscussion[]>([]);
3131
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
3232
const [content, setContent] = useState<string | null>(null);
33+
const [autoFollow, setAutoFollow] = useState(true);
3334
const [loading, setLoading] = useState(true);
3435
// Track which id we last requested, to prevent stale response overwriting current selection
3536
const pendingReadIdRef = useRef<string | null>(null);
36-
const detailRef = useRef<HTMLDivElement>(null);
37+
const detailScrollRef = useRef<HTMLDivElement>(null);
38+
39+
const scrollDetailToTop = useCallback((behavior: ScrollBehavior = 'smooth') => {
40+
detailScrollRef.current?.scrollTo({ top: 0, behavior });
41+
}, []);
42+
43+
const scrollDetailToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
44+
const el = detailScrollRef.current;
45+
if (!el) return;
46+
el.scrollTo({ top: el.scrollHeight, behavior });
47+
}, []);
3748

3849
const loadList = useCallback(() => {
3950
if (!ws) return;
@@ -46,6 +57,7 @@ export function DiscussionsPage({ ws, initialSelectedId, liveDiscussions = [], o
4657
const selectDiscussion = useCallback((id: string) => {
4758
setSelected(id);
4859
setContent(null);
60+
setAutoFollow(true);
4961
pendingReadIdRef.current = id;
5062
ws?.send({ type: 'p2p.read_discussion', id });
5163
}, [ws]);
@@ -119,6 +131,13 @@ export function DiscussionsPage({ ws, initialSelectedId, liveDiscussions = [], o
119131
});
120132
}, [ws, selected, loadList]);
121133

134+
useEffect(() => {
135+
if (!selected || content === null || !autoFollow) return;
136+
requestAnimationFrame(() => {
137+
scrollDetailToBottom(content.length > 4000 ? 'auto' : 'smooth');
138+
});
139+
}, [selected, content, autoFollow, scrollDetailToBottom]);
140+
122141
const formatTime = (ts: number) => new Date(ts).toLocaleString();
123142

124143
// Find matching live discussion for progress display
@@ -186,43 +205,59 @@ export function DiscussionsPage({ ws, initialSelectedId, liveDiscussions = [], o
186205
))}
187206
</div>
188207

189-
<div ref={detailRef} class={`discussions-detail${selected ? ' discussions-detail-fullscreen' : ''}`}>
190-
{!selected && (
191-
<div class="discussions-empty">{t('p2p.discussions.select')}</div>
192-
)}
208+
<div class={`discussions-detail${selected ? ' discussions-detail-fullscreen' : ''}`}>
193209
{selected && (
194210
<div class="discussions-nav-row">
195211
<button
196212
class="discussions-back-btn"
197-
onClick={() => { setSelected(null); setContent(null); }}
213+
onClick={() => { setSelected(null); setContent(null); setAutoFollow(true); }}
198214
>
199215
{t('p2p.picker.back')}
200216
</button>
217+
<label class="discussions-follow-toggle">
218+
<input
219+
type="checkbox"
220+
checked={autoFollow}
221+
onChange={(e) => setAutoFollow((e.target as HTMLInputElement).checked)}
222+
/>
223+
<span>{t('p2p.discussions.auto_follow_latest')}</span>
224+
</label>
201225
<button
202226
class="discussions-scroll-btn"
203-
onClick={() => detailRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
227+
onClick={() => {
228+
setAutoFollow(false);
229+
scrollDetailToTop();
230+
}}
204231
title={t('p2p.discussions.scroll_top')}
205232
>
206233
207234
</button>
208235
<button
209236
class="discussions-scroll-btn"
210-
onClick={() => detailRef.current?.scrollTo({ top: detailRef.current.scrollHeight, behavior: 'smooth' })}
237+
onClick={() => {
238+
setAutoFollow(true);
239+
scrollDetailToBottom();
240+
}}
211241
title={t('p2p.discussions.scroll_bottom')}
212242
>
213243
214244
</button>
215245
</div>
216246
)}
217-
{selected && content === null && (
218-
<div class="discussions-empty">{t('common.loading')}</div>
219-
)}
220-
{selected && content !== null && (
221-
<div
222-
class="discussions-markdown"
223-
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
224-
/>
225-
)}
247+
<div ref={detailScrollRef} class="discussions-detail-scroll">
248+
{!selected && (
249+
<div class="discussions-empty">{t('p2p.discussions.select')}</div>
250+
)}
251+
{selected && content === null && (
252+
<div class="discussions-empty">{t('common.loading')}</div>
253+
)}
254+
{selected && content !== null && (
255+
<div
256+
class="discussions-markdown"
257+
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
258+
/>
259+
)}
260+
</div>
226261
</div>
227262
</div>
228263
</div>

web/src/styles.css

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,10 +1138,39 @@ body {
11381138
.discussions-markdown strong { color: #f1f5f9; }
11391139
.discussions-markdown a { color: #60a5fa; text-decoration: none; }
11401140
.discussions-markdown a:hover { text-decoration: underline; }
1141-
.discussions-detail { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
1142-
.discussions-nav-row { display: none; align-items: center; gap: 8px; padding: 8px 16px 0; flex-shrink: 0; }
1143-
@media (max-width: 768px) { .discussions-nav-row { display: flex; } }
1141+
.discussions-detail { flex: 1; min-height: 0; display: flex; flex-direction: column; }
1142+
.discussions-detail-scroll { flex: 1; min-height: 0; overflow-y: auto; }
1143+
.discussions-nav-row {
1144+
display: flex;
1145+
align-items: center;
1146+
gap: 8px;
1147+
padding: 10px 16px;
1148+
flex-shrink: 0;
1149+
position: sticky;
1150+
top: 0;
1151+
z-index: 2;
1152+
background: linear-gradient(180deg, rgba(10, 14, 26, 0.98), rgba(10, 14, 26, 0.94));
1153+
border-bottom: 1px solid rgba(30, 41, 59, 0.9);
1154+
}
11441155
.discussions-back-btn { background: none; border: none; color: #3b82f6; cursor: pointer; padding: 0; font-size: 14px; line-height: 1.2; text-align: left; }
1156+
@media (min-width: 769px) { .discussions-back-btn { display: none; } }
1157+
.discussions-follow-toggle {
1158+
display: inline-flex;
1159+
align-items: center;
1160+
gap: 8px;
1161+
min-width: 0;
1162+
margin-right: auto;
1163+
color: #cbd5e1;
1164+
font-size: 13px;
1165+
user-select: none;
1166+
}
1167+
.discussions-follow-toggle input {
1168+
margin: 0;
1169+
accent-color: #38bdf8;
1170+
}
1171+
.discussions-follow-toggle span {
1172+
white-space: nowrap;
1173+
}
11451174
.discussions-scroll-btn { background: none; border: 1px solid #334155; color: #94a3b8; cursor: pointer; border-radius: 4px; width: 28px; height: 28px; font-size: 14px; display: flex; align-items: center; justify-content: center; padding: 0; flex-shrink: 0; }
11461175
.discussions-scroll-btn:active { background: #334155; color: #e2e8f0; }
11471176
.discussions-detail-header { padding: 16px; border-bottom: 1px solid #1e293b; }
@@ -1169,8 +1198,9 @@ body {
11691198
/* When a discussion is selected on mobile, detail goes full-screen over the list */
11701199
.discussions-layout .discussions-detail-fullscreen {
11711200
position: absolute; inset: 0; z-index: 10; background: #0a0e1a;
1172-
display: flex; flex-direction: column; overflow-y: auto;
1201+
display: flex; flex-direction: column;
11731202
}
1203+
.discussions-follow-toggle span { white-space: normal; line-height: 1.2; }
11741204
}
11751205

11761206
/* AskQuestionDialog */
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5+
import { h } from 'preact';
6+
import { render, screen, fireEvent, act, cleanup, waitFor } from '@testing-library/preact';
7+
import type { ServerMessage, WsClient } from '../../src/ws-client.js';
8+
import { DiscussionsPage } from '../../src/pages/DiscussionsPage.js';
9+
10+
vi.mock('react-i18next', () => ({
11+
useTranslation: () => ({
12+
t: (key: string) => key,
13+
}),
14+
}));
15+
16+
vi.mock('../../src/components/P2pProgressCard.js', () => ({
17+
P2pProgressCard: () => null,
18+
}));
19+
20+
describe('DiscussionsPage', () => {
21+
let handler: ((msg: ServerMessage) => void) | null = null;
22+
let ws: WsClient;
23+
let scrollToMock: ReturnType<typeof vi.fn>;
24+
25+
beforeEach(() => {
26+
scrollToMock = vi.fn();
27+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
28+
cb(0);
29+
return 1;
30+
});
31+
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
32+
configurable: true,
33+
value: scrollToMock,
34+
});
35+
36+
ws = {
37+
send: vi.fn(),
38+
onMessage: (next: (msg: ServerMessage) => void) => {
39+
handler = next;
40+
return () => { handler = null; };
41+
},
42+
} as unknown as WsClient;
43+
});
44+
45+
afterEach(() => {
46+
cleanup();
47+
vi.restoreAllMocks();
48+
handler = null;
49+
});
50+
51+
it('defaults to auto-follow latest and scrolls to bottom when discussion content updates', async () => {
52+
const { container } = render(<DiscussionsPage ws={ws} />);
53+
54+
expect(ws.send).toHaveBeenCalledWith({ type: 'p2p.list_discussions' });
55+
56+
await act(async () => {
57+
handler?.({
58+
type: 'p2p.list_discussions_response',
59+
discussions: [{ id: 'disc-1', fileName: 'disc-1.md', preview: 'Topic 1', mtime: 100 }],
60+
} as ServerMessage);
61+
});
62+
63+
fireEvent.click(screen.getByText('Topic 1'));
64+
expect(ws.send).toHaveBeenLastCalledWith({ type: 'p2p.read_discussion', id: 'disc-1' });
65+
66+
const scrollEl = container.querySelector('.discussions-detail-scroll') as HTMLDivElement;
67+
Object.defineProperty(scrollEl, 'scrollHeight', {
68+
configurable: true,
69+
value: 640,
70+
});
71+
72+
await act(async () => {
73+
handler?.({
74+
type: 'p2p.read_discussion_response',
75+
id: 'disc-1',
76+
content: 'Updated markdown',
77+
} as ServerMessage);
78+
});
79+
80+
expect((screen.getByLabelText('p2p.discussions.auto_follow_latest') as HTMLInputElement).checked).toBe(true);
81+
await waitFor(() => {
82+
expect(scrollToMock).toHaveBeenCalledWith({ top: 640, behavior: 'smooth' });
83+
});
84+
});
85+
86+
it('disables follow when unchecked, and re-enables it from the bottom arrow', async () => {
87+
const { container } = render(<DiscussionsPage ws={ws} />);
88+
89+
await act(async () => {
90+
handler?.({
91+
type: 'p2p.list_discussions_response',
92+
discussions: [{ id: 'disc-2', fileName: 'disc-2.md', preview: 'Topic 2', mtime: 100 }],
93+
} as ServerMessage);
94+
});
95+
96+
fireEvent.click(screen.getByText('Topic 2'));
97+
98+
const scrollEl = container.querySelector('.discussions-detail-scroll') as HTMLDivElement;
99+
Object.defineProperty(scrollEl, 'scrollHeight', {
100+
configurable: true,
101+
value: 720,
102+
});
103+
104+
await act(async () => {
105+
handler?.({
106+
type: 'p2p.read_discussion_response',
107+
id: 'disc-2',
108+
content: 'Initial content',
109+
} as ServerMessage);
110+
});
111+
112+
scrollToMock.mockClear();
113+
114+
const checkbox = screen.getByLabelText('p2p.discussions.auto_follow_latest') as HTMLInputElement;
115+
fireEvent.click(checkbox);
116+
expect(checkbox.checked).toBe(false);
117+
118+
await act(async () => {
119+
handler?.({
120+
type: 'p2p.read_discussion_response',
121+
id: 'disc-2',
122+
content: 'New content after manual scroll',
123+
} as ServerMessage);
124+
});
125+
126+
expect(scrollToMock).not.toHaveBeenCalled();
127+
128+
fireEvent.click(screen.getByTitle('p2p.discussions.scroll_top'));
129+
expect(checkbox.checked).toBe(false);
130+
expect(scrollToMock).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
131+
132+
scrollToMock.mockClear();
133+
134+
fireEvent.click(screen.getByTitle('p2p.discussions.scroll_bottom'));
135+
expect(checkbox.checked).toBe(true);
136+
expect(scrollToMock).toHaveBeenCalledWith({ top: 720, behavior: 'smooth' });
137+
});
138+
});

0 commit comments

Comments
 (0)