Skip to content

Commit 472df17

Browse files
committed
feat(pagination, ui): add real pagination, page routes, and scroll/Sticky layout polish
- lib/protocol.ts: - Add countPostsInThread and fetchPostPage using messages.search with addOffset=(totalPages - page)*pageSize and limit=pageSize. - Remove GetHistory fallback from searchPostCards; rely on search only. - Sort page items oldest-first; add docs and cleanups. - app/App.tsx: - Add thread page route /forum/:id/board/:boardId/thread/:threadId/page/:page. - features/forum/BoardPage.tsx: - Wire pagination: read :page, fetch only that page via fetchPostPage, map to UI. - Add First/Prev/Next/Last buttons with disabled states and k/n page indicator. - Default thread navigation to /page/1; redirect only when threadId present but page missing. - Fix board breadcrumb navigation loop (no bounce back into thread). - Use pageData.items (replace old posts var). - Make thread top bar non-sticky per final UX decision. - styles/theme.css: - Keep app header sticky; make sidebar sticky + independently scrollable (no scrollbar shown). - Make right content column the scrolling region; set content height to viewport below header. - Hide scrollbars within content viewport without changing scroll behavior. fix: first page rendering empty (switch from negative limit to correct offset + positive limit)
1 parent 0913781 commit 472df17

File tree

4 files changed

+135
-47
lines changed

4 files changed

+135
-47
lines changed

src/app/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export default function App() {
5252
<Route path="/forum/:id" element={<RequireAuth><ForumPage /></RequireAuth>} />
5353
<Route path="/forum/:id/board/:boardId" element={<RequireAuth><BoardPage /></RequireAuth>} />
5454
<Route path="/forum/:id/board/:boardId/thread/:threadId" element={<RequireAuth><BoardPage /></RequireAuth>} />
55+
<Route path="/forum/:id/board/:boardId/thread/:threadId/page/:page" element={<RequireAuth><BoardPage /></RequireAuth>} />
5556
<Route path="/settings" element={<RequireAuth><SettingsPage /></RequireAuth>} />
5657
<Route path="*" element={<RequireAuth><DiscoverPage /></RequireAuth>} />
5758
</Routes>

src/features/forum/BoardPage.tsx

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { useEffect, useState } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22
import { Link, useNavigate, useParams } from 'react-router-dom';
33
import ForumList from '@components/ForumList';
44
import { useForumsStore } from '@state/forums';
55
import { getInputPeerForForumId } from '@lib/telegram/peers';
66
import { useQuery } from '@tanstack/react-query';
7-
import { ThreadMeta, searchThreadCards, searchPostCards, composeThreadCard, composePostCard, generateIdHash, searchBoardCards, getLastPostForThread } from '@lib/protocol';
7+
import { ThreadMeta, searchThreadCards, composeThreadCard, composePostCard, generateIdHash, searchBoardCards, getLastPostForThread, fetchPostPage } from '@lib/protocol';
88
import { deleteMessages, sendPlainMessage, getClient, editMessage } from '@lib/telegram/client';
99
import MessageList from '@components/MessageList';
1010
import { getAvatarBlob, setAvatarBlob } from '@lib/db';
@@ -15,7 +15,7 @@ import SidebarToggle from '@components/SidebarToggle';
1515
import { formatTimeSince } from '@lib/time';
1616

1717
export default function BoardPage() {
18-
const { id, boardId, threadId } = useParams();
18+
const { id, boardId, threadId, page } = useParams();
1919
const forumId = Number(id);
2020
const navigate = useNavigate();
2121
const initForums = useForumsStore((s) => s.initFromStorage);
@@ -97,12 +97,21 @@ export default function BoardPage() {
9797
const activeThread = (threads || []).find((t) => t.id === activeThreadId) || null;
9898
const [openMenuForThreadId, setOpenMenuForThreadId] = useState<string | null>(null);
9999

100-
const { data: posts = [], isLoading: loadingPosts, error: postsError, refetch: refetchPosts } = useQuery({
101-
queryKey: ['posts', forumId, boardId, activeThreadId],
100+
// Normalize current page, default to 1 and redirect to include page param if missing.
101+
const currentPage = Math.max(1, Number.isFinite(Number(page)) ? Number(page) : 1);
102+
useEffect(() => {
103+
// Only redirect when the URL actually contains a threadId but lacks a page.
104+
if (threadId && !page) {
105+
navigate(`/forum/${forumId}/board/${boardId}/thread/${threadId}/page/1`, { replace: true });
106+
}
107+
}, [threadId, page, forumId, boardId, navigate]);
108+
109+
const { data: pageData, isLoading: loadingPosts, error: postsError, refetch: refetchPosts } = useQuery<{ items: any[]; count: number; pages: number }>({
110+
queryKey: ['posts', forumId, boardId, activeThreadId, currentPage],
102111
queryFn: async () => {
103-
if (!activeThreadId) return [] as any[];
112+
if (!activeThreadId) return { items: [] as any[], count: 0, pages: 1 };
104113
const input = getInputPeerForForumId(forumId);
105-
const items = await searchPostCards(input, String(activeThreadId));
114+
const { items, count, pages } = await fetchPostPage(input, String(activeThreadId), currentPage, 10);
106115
// Build author map and load avatars once per unique user.
107116
const uniqueUserIds = Array.from(new Set(items.map((p) => p.fromUserId).filter(Boolean))) as number[];
108117
const client = await getClient();
@@ -137,7 +146,6 @@ export default function BoardPage() {
137146
userIdToUrl[uid] = undefined;
138147
}
139148
}
140-
// Map to display messages, preserving metadata used by both branches.
141149
const mapped = items.map((p) => ({
142150
id: p.messageId,
143151
from: p.fromUserId ? (p.user?.username ? '@' + p.user.username : [p.user?.firstName, p.user?.lastName].filter(Boolean).join(' ')) : 'unknown',
@@ -171,7 +179,7 @@ export default function BoardPage() {
171179
canDelete: false,
172180
}));
173181
mapped.sort((a, b) => a.date - b.date);
174-
return mapped as any[];
182+
return { items: mapped as any[], count, pages };
175183
},
176184
enabled: Number.isFinite(forumId) && Boolean(activeThreadId),
177185
staleTime: 5_000,
@@ -228,6 +236,8 @@ export default function BoardPage() {
228236
const [draftAttachments, setDraftAttachments] = useState<DraftAttachment[]>([]);
229237
const [isEditing, setIsEditing] = useState(false);
230238
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
239+
const [showPostSubmitted, setShowPostSubmitted] = useState(false);
240+
const hidePostSubmittedTimerRef = useRef<number | undefined>(undefined);
231241

232242
async function prepareUploadedInputMedia(uploaded: any, file: File): Promise<PreparedInputMedia> {
233243
// Always send uploads as files (documents).
@@ -318,13 +328,15 @@ export default function BoardPage() {
318328
const firstText = composePostCard(idHash, activeThreadId, { content: composerText });
319329
await sendMediaMessage(input, firstText, prepared[0]!.inputMedia);
320330
}
331+
try { if (hidePostSubmittedTimerRef.current) { clearTimeout(hidePostSubmittedTimerRef.current); } } catch {}
332+
setShowPostSubmitted(true);
333+
hidePostSubmittedTimerRef.current = window.setTimeout(() => { setShowPostSubmitted(false); }, 10000);
321334
}
322335

323336
setComposerText('');
324337
setDraftAttachments([]);
325338
setIsEditing(false);
326339
setEditingMessageId(null);
327-
setTimeout(() => { refetchPosts(); }, 250);
328340
} catch (e: any) {
329341
alert(e?.message ?? 'Failed to send post');
330342
}
@@ -423,6 +435,11 @@ export default function BoardPage() {
423435
</aside>
424436
<SidebarToggle />
425437
<main className="main">
438+
{showPostSubmitted && (
439+
<div onClick={() => setShowPostSubmitted(false)} style={{ position: 'fixed', top: 56, left: 0, right: 0, zIndex: 20, display: 'flex', justifyContent: 'center' }}>
440+
<div className="card" style={{ padding: 8, cursor: 'pointer' }}>Post submitted. It should become visible within a few minutes.</div>
441+
</div>
442+
)}
426443
{!activeThreadId ? (
427444
<div className="card" style={{ padding: 12 }}>
428445
<div className="row" style={{ alignItems: 'center' }}>
@@ -444,7 +461,7 @@ export default function BoardPage() {
444461
) : (
445462
<div className="gallery boards" style={{ marginTop: 12 }}>
446463
{threads.map((t) => (
447-
<div key={t.id} className="chiclet" style={{ position: 'relative' }} onClick={() => { setSelectedThreadId(t.id); navigate(`/forum/${forumId}/board/${boardId}/thread/${t.id}`); }}>
464+
<div key={t.id} className="chiclet" style={{ position: 'relative' }} onClick={() => { setSelectedThreadId(t.id); navigate(`/forum/${forumId}/board/${boardId}/thread/${t.id}/page/1`); }}>
448465
<div className="title">{t.title}</div>
449466
{(() => {
450467
const lp: any = (lastPostByThreadId as any)[t.id];
@@ -478,8 +495,9 @@ export default function BoardPage() {
478495
</div>
479496
) : (
480497
<div className="card" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
481-
<div style={{ padding: 12, borderBottom: '1px solid var(--border)' }}>
482-
<div className="row" style={{ alignItems: 'center' }}>
498+
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
499+
<div style={{ padding: 12, borderBottom: '1px solid var(--border)' }}>
500+
<div className="row" style={{ alignItems: 'center' }}>
483501
<h3 style={{ margin: 0 }}>
484502
<Link to={`/forum/${forumId}`}>{forumTitle}</Link>
485503
{' > '}
@@ -488,12 +506,31 @@ export default function BoardPage() {
488506
<span>{activeThread ? activeThread.title : 'Thread'}</span>
489507
</h3>
490508
<div className="spacer" />
509+
{(() => {
510+
const totalPages = pageData?.pages ?? 1;
511+
const onFirst = () => activeThreadId && navigate(`/forum/${forumId}/board/${boardId}/thread/${activeThreadId}/page/1`);
512+
const onPrev = () => activeThreadId && navigate(`/forum/${forumId}/board/${boardId}/thread/${activeThreadId}/page/${Math.max(1, currentPage - 1)}`);
513+
const onNext = () => activeThreadId && navigate(`/forum/${forumId}/board/${boardId}/thread/${activeThreadId}/page/${Math.min(totalPages, currentPage + 1)}`);
514+
const onLast = () => activeThreadId && navigate(`/forum/${forumId}/board/${boardId}/thread/${activeThreadId}/page/${totalPages}`);
515+
const atFirst = currentPage <= 1;
516+
const atLast = currentPage >= totalPages;
517+
return (
518+
<div className="row" style={{ alignItems: 'center', gap: 6 }}>
519+
<button className="btn" disabled={atFirst} onClick={onFirst} title="First">⏮️</button>
520+
<button className="btn" disabled={atFirst} onClick={onPrev} title="Previous">◀️</button>
521+
<button className="btn" disabled={atLast} onClick={onNext} title="Next">▶️</button>
522+
<button className="btn" disabled={atLast} onClick={onLast} title="Last">⏭️</button>
523+
<div style={{ marginLeft: 8, color: 'var(--muted)' }}>{currentPage}/{totalPages}</div>
524+
</div>
525+
);
526+
})()}
527+
</div>
491528
</div>
492-
</div>
493-
<div style={{ flex: 1, overflow: 'auto', padding: 0 }}>
529+
<div style={{ padding: 0 }}>
494530
{loadingPosts ? <div style={{ padding: 12 }}>Loading...</div> : postsError ? <div style={{ padding: 12, color: 'var(--danger)' }}>{(postsError as any)?.message ?? 'Error'}</div> : (
495-
<MessageList messages={posts as any[]} currentUserId={resolvedUserId} onEditPost={onEditPost} onDeletePost={onDeletePost} />
531+
<MessageList messages={(pageData?.items ?? []) as any[]} currentUserId={resolvedUserId} onEditPost={onEditPost} onDeletePost={onDeletePost} />
496532
)}
533+
</div>
497534
</div>
498535
<div className="composer">
499536
<div className="col" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>

src/lib/protocol.ts

Lines changed: 73 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -264,34 +264,6 @@ export async function searchPostCards(input: Api.TypeInputPeer, parentThreadId:
264264
seenMsgIds.add(msgId);
265265
items.push({ id: parsed.id, parentThreadId, messageId: msgId, fromUserId, user: fromUserId ? usersMap[String(fromUserId)] : undefined, date: Number(m.date), content: parsed.data.content, media: m.media, groupedId: m.groupedId ? String(m.groupedId) : undefined });
266266
}
267-
268-
// Fallback: also scan recent history to merge very fresh posts (handles indexing/tokenization delays).
269-
if (items.length < queryLimit) {
270-
let offsetId = 0;
271-
const pageSize = Math.min(100, queryLimit);
272-
let pages = 0;
273-
while (items.length < queryLimit && pages < 30) {
274-
const page: any = await client.invoke(new Api.messages.GetHistory({ peer: input, offsetId, addOffset: 0, limit: pageSize }));
275-
(page.users ?? []).forEach((u: any) => { usersMap[String(u.id)] = u; });
276-
const batch: any[] = (page.messages ?? []).filter((m: any) => m.className === 'Message' || m._ === 'message');
277-
if (!batch.length) break;
278-
for (const m of batch) {
279-
const msgId = Number(m.id);
280-
if (seenMsgIds.has(msgId)) continue;
281-
const parsed = parsePostCard(m.message ?? '');
282-
if (!parsed) continue;
283-
if (parsed.parentThreadId !== parentThreadId) continue;
284-
const fromUserId: number | undefined = m.fromId?.userId ? Number(m.fromId.userId) : undefined;
285-
seenMsgIds.add(msgId);
286-
items.push({ id: parsed.id, parentThreadId, messageId: msgId, fromUserId, user: fromUserId ? usersMap[String(fromUserId)] : undefined, date: Number(m.date), content: parsed.data.content, media: m.media, groupedId: m.groupedId ? String(m.groupedId) : undefined });
287-
if (items.length >= queryLimit) break;
288-
}
289-
// pagination: next offset is the last message id in this batch.
290-
offsetId = Number(batch[batch.length - 1].id);
291-
pages++;
292-
}
293-
}
294-
295267
return items;
296268
}
297269

@@ -333,3 +305,76 @@ export async function getLastPostForBoard(
333305
return latest;
334306
}
335307

308+
// ---- Pagination helpers ----
309+
310+
/**
311+
* Count posts within a thread using Telegram's messages.search count field.
312+
*/
313+
export async function countPostsInThread(input: Api.TypeInputPeer, parentThreadId: string): Promise<number> {
314+
const client = await getClient();
315+
const q = `fg.post ${parentThreadId}`;
316+
// Use limit=0 to request only the count; Telegram suggests conflictingly
317+
// that 0 will faux-reset itself to 10 and also that it will not do that in
318+
// two separate places in the documentation.
319+
// Fall back to limit=1 if count is absent.
320+
const res: any = await client.invoke(new Api.messages.Search({ peer: input, q, limit: 0, filter: new Api.InputMessagesFilterEmpty() }));
321+
const countFromRes = (typeof res?.count === 'number') ? res.count : undefined;
322+
if (typeof countFromRes === 'number') return countFromRes;
323+
try {
324+
const res2: any = await client.invoke(new Api.messages.Search({ peer: input, q, limit: 1, filter: new Api.InputMessagesFilterEmpty() }));
325+
return (typeof res2?.count === 'number') ? res2.count : (Array.isArray(res2?.messages) ? res2.messages.length : 0);
326+
} catch {
327+
return 0;
328+
}
329+
}
330+
331+
/**
332+
* Fetch a specific page of posts (oldest-first pagination) using addOffset and negative limit.
333+
* Page 1 is the oldest page. Page size defaults to 10.
334+
* See Telegram offset semantics: core.telegram.org/api/offsets
335+
*/
336+
export async function fetchPostPage(
337+
input: Api.TypeInputPeer,
338+
parentThreadId: string,
339+
page: number,
340+
pageSize: number = 10,
341+
): Promise<{ items: PostCard[]; count: number; pages: number }> {
342+
const client = await getClient();
343+
const q = `fg.post ${parentThreadId}`;
344+
const count = await countPostsInThread(input, parentThreadId);
345+
const totalPages = Math.max(1, Math.ceil(Math.max(0, count) / pageSize));
346+
const clampedPage = Math.min(Math.max(1, page), totalPages);
347+
const lastPageLength = count === 0 ? 0 : ((count - 1) % pageSize) + 1;
348+
const isLastPage = clampedPage === totalPages;
349+
const addOffset = (count === 0)
350+
? 0
351+
: (isLastPage ? 0 : (lastPageLength + Math.max(0, (totalPages - clampedPage - 1)) * pageSize));
352+
const limit = count === 0
353+
? 0
354+
: (isLastPage ? lastPageLength : pageSize);
355+
356+
const res: any = await client.invoke(new Api.messages.Search({
357+
peer: input,
358+
q,
359+
addOffset,
360+
limit,
361+
filter: new Api.InputMessagesFilterEmpty(),
362+
} as any));
363+
364+
const usersMap: Record<string, any> = {};
365+
(res.users ?? []).forEach((u: any) => { usersMap[String(u.id)] = u; });
366+
const messages: any[] = (res.messages ?? []).filter((m: any) => m.className === 'Message' || m._ === 'message');
367+
const items: PostCard[] = [];
368+
for (const m of messages) {
369+
const parsed = parsePostCard(m.message ?? '');
370+
if (!parsed) continue;
371+
if (parsed.parentThreadId !== parentThreadId) continue;
372+
const fromUserId: number | undefined = m.fromId?.userId ? Number(m.fromId.userId) : undefined;
373+
const msgId = Number(m.id);
374+
items.push({ id: parsed.id, parentThreadId, messageId: msgId, fromUserId, user: fromUserId ? usersMap[String(fromUserId)] : undefined, date: Number(m.date), content: parsed.data.content, media: m.media, groupedId: m.groupedId ? String(m.groupedId) : undefined });
375+
}
376+
// Ensure oldest-first order within page.
377+
items.sort((a, b) => (a.date ?? 0) - (b.date ?? 0));
378+
return { items, count, pages: totalPages };
379+
}
380+

src/styles/theme.css

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,14 @@ a:hover { text-decoration: underline; }
180180
.btn.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color: #071219; border-color: transparent; }
181181
.btn.ghost { background: transparent; box-shadow: none; }
182182

183-
.content { display: grid; grid-template-columns: 280px 1fr; height: 100%; }
184-
.sidebar { border-right: 1px solid var(--border); background: var(--bg-elev); padding: 12px; overflow: auto; }
185-
.main { padding: 12px; overflow: hidden; }
183+
.content { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 56px); }
184+
.sidebar { border-right: 1px solid var(--border); background: var(--bg-elev); padding: 12px; overflow: auto; position: sticky; top: 56px; height: calc(100vh - 56px); scrollbar-width: none; -ms-overflow-style: none; }
185+
.sidebar::-webkit-scrollbar { display: none; }
186+
.main { padding: 12px; overflow: auto; height: 100%; scrollbar-width: none; -ms-overflow-style: none; }
187+
.main::-webkit-scrollbar { display: none; }
188+
/* Hide scrollbars for any nested scrollable areas inside content viewport */
189+
.main * { scrollbar-width: none; -ms-overflow-style: none; }
190+
.main *::-webkit-scrollbar { display: none; }
186191
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); }
187192

188193
/* Lists */

0 commit comments

Comments
 (0)