[READ-ONLY] Mirror of https://github.com/thang-qt/ThreadLine. Alternative frontend for HackerNews and Lobsters threadline.thangqt.com/
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(comments): track unread comments with localStorage and support click-to-scroll

Quang Thang (Jul 22, 2026, 8:01 PM +0700) 5bb746a3 ce5a405d

+242 -10
+56
src/cache.ts
··· 72 72 safeWrite(commentsCacheKey(source, sourceId), { comments, cachedAt: Date.now() } satisfies CachedComments); 73 73 } 74 74 75 + const SEEN_COMMENTS_PREFIX = 'threadline.seen.v1'; 76 + const SEEN_INDEX_KEY = 'threadline.seen_index.v1'; 77 + const MAX_SEEN_THREADS = 100; 78 + 79 + export function seenCommentsKey(source: Source, sourceId: string): string { 80 + return `${SEEN_COMMENTS_PREFIX}:${source}:${sourceId}`; 81 + } 82 + 83 + export function hasSeenComments(source: Source, sourceId: string): boolean { 84 + if (!canUseStorage()) return false; 85 + return window.localStorage.getItem(seenCommentsKey(source, sourceId)) !== null; 86 + } 87 + 88 + export function readSeenComments(source: Source, sourceId: string): string[] { 89 + const key = seenCommentsKey(source, sourceId); 90 + const cached = safeRead<string[]>(key); 91 + if (!Array.isArray(cached)) return []; 92 + 93 + // Update LRU index position 94 + updateSeenIndex(key); 95 + 96 + return cached; 97 + } 98 + 99 + export function writeSeenComments(source: Source, sourceId: string, commentIds: string[]): void { 100 + const key = seenCommentsKey(source, sourceId); 101 + safeWrite(key, commentIds); 102 + updateSeenIndex(key, true); 103 + } 104 + 105 + function updateSeenIndex(key: string, isWrite = false): void { 106 + if (!canUseStorage()) return; 107 + try { 108 + const raw = window.localStorage.getItem(SEEN_INDEX_KEY); 109 + let index: string[] = raw ? JSON.parse(raw) : []; 110 + if (!Array.isArray(index)) index = []; 111 + 112 + // Remove if already exists to push to front 113 + index = index.filter(k => k !== key); 114 + index.unshift(key); 115 + 116 + // Evict oldest if limit exceeded 117 + if (isWrite && index.length > MAX_SEEN_THREADS) { 118 + const toRemove = index.slice(MAX_SEEN_THREADS); 119 + for (const k of toRemove) { 120 + window.localStorage.removeItem(k); 121 + } 122 + index = index.slice(0, MAX_SEEN_THREADS); 123 + } 124 + 125 + window.localStorage.setItem(SEEN_INDEX_KEY, JSON.stringify(index)); 126 + } catch { 127 + // Ignore storage issues 128 + } 129 + } 130 + 75 131 export function ageLabel(timestamp: number): string { 76 132 const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); 77 133 if (seconds < 45) return 'just now';
+23 -3
src/components/discussion/CommentThread.tsx
··· 15 15 moveFrom: (id: string, direction: number) => void; 16 16 storyAuthor?: string; 17 17 source: Source; 18 + newCommentIds: Set<string>; 19 + onJumpToNextNew?: () => void; 18 20 } 19 21 20 - export function CommentThread({ node, depth, collapsed, active, visibleIds, refs, toggle, moveFrom, storyAuthor, source }: CommentThreadProps) { 22 + export function CommentThread({ node, depth, collapsed, active, visibleIds, refs, toggle, moveFrom, storyAuthor, source, newCommentIds, onJumpToNextNew }: CommentThreadProps) { 21 23 const hasChildren = node.children.length > 0; 22 24 const isCollapsed = collapsed.has(node.id); 23 25 const isAuthor = Boolean(node.author && storyAuthor && node.author === storyAuthor); 26 + const isNew = newCommentIds.has(node.id); 24 27 const visibleIndex = visibleIds.indexOf(node.id); 25 28 const hasPrevious = visibleIndex > 0; 26 29 const hasNext = visibleIndex >= 0 && visibleIndex < visibleIds.length - 1; 27 - return <li className={`comment ${active === node.id ? 'active' : ''}`}> 30 + return <li className={`comment ${active === node.id ? 'active' : ''} ${isNew ? 'new' : ''}`}> 28 31 <div className="comment-content" ref={element => { if (element) refs.current.set(node.id, element); }} tabIndex={-1}> 29 32 <div className="comment-byline"> 30 33 <button className="comment-toggle" onClick={() => toggle(node.id)} aria-expanded={!isCollapsed} title={isCollapsed ? 'Expand comment' : 'Collapse comment'}>{isCollapsed ? '+' : '−'}</button> 31 34 <strong className={isAuthor ? 'comment-author op' : 'comment-author'}>{node.author ?? 'anonymous'}{isAuthor && <span className="op-badge">OP</span>}</strong> 32 35 <time>{timeAgo(node.createdAt)}</time> 36 + {isNew && ( 37 + <span 38 + role="button" 39 + tabIndex={0} 40 + className="new-badge clickable" 41 + onClick={onJumpToNextNew} 42 + onKeyDown={(e) => { 43 + if (e.key === 'Enter' || e.key === ' ') { 44 + e.preventDefault(); 45 + onJumpToNextNew?.(); 46 + } 47 + }} 48 + title="Jump to next new comment" 49 + > 50 + new 51 + </span> 52 + )} 33 53 {hasPrevious && <button onClick={() => moveFrom(node.id, -1)} aria-label={`Previous comment before ${node.author ?? 'anonymous'}`}>↑ prev</button>} 34 54 {hasNext && <button onClick={() => moveFrom(node.id, 1)} aria-label={`Next comment after ${node.author ?? 'anonymous'}`}>↓ next</button>} 35 55 </div> 36 56 {!isCollapsed && <div className="comment-body" dangerouslySetInnerHTML={{ __html: sanitizeHtml(node.html, SOURCE_ORIGIN[source]) }}/>} 37 57 </div> 38 - {hasChildren && !isCollapsed && (depth >= 40 ? <p className="muted">Further replies are available on the original thread.</p> : <ol className="comment-list">{node.children.map(child => <CommentThread key={child.id} node={child} depth={depth + 1} collapsed={collapsed} active={active} visibleIds={visibleIds} refs={refs} toggle={toggle} moveFrom={moveFrom} storyAuthor={storyAuthor} source={source}/>)}</ol>)} 58 + {hasChildren && !isCollapsed && (depth >= 40 ? <p className="muted">Further replies are available on the original thread.</p> : <ol className="comment-list">{node.children.map(child => <CommentThread key={child.id} node={child} depth={depth + 1} collapsed={collapsed} active={active} visibleIds={visibleIds} refs={refs} toggle={toggle} moveFrom={moveFrom} storyAuthor={storyAuthor} source={source} newCommentIds={newCommentIds} onJumpToNextNew={onJumpToNextNew}/>)}</ol>)} 39 59 </li>; 40 60 }
+8 -1
src/components/discussion/DiscussionToolbar.tsx
··· 3 3 4 4 interface DiscussionToolbarProps { 5 5 commentsCount: number; 6 + newCommentsCount: number; 7 + onJumpToNextNew?: () => void; 6 8 readHere: boolean; 7 9 settings: ReaderSettings; 8 10 onChange: (settings: ReaderSettings) => void; 9 11 onCloseReader: () => void; 10 12 } 11 13 12 - export function DiscussionToolbar({ commentsCount, readHere, settings, onChange, onCloseReader }: DiscussionToolbarProps) { 14 + export function DiscussionToolbar({ commentsCount, newCommentsCount, onJumpToNextNew, readHere, settings, onChange, onCloseReader }: DiscussionToolbarProps) { 13 15 return <div className="discussion-heading"> 14 16 <h2>Discussion</h2> 15 17 <div className="discussion-meta-group"> 16 18 <span className="comments-count">{commentsCount} comments</span> 19 + {newCommentsCount > 0 && ( 20 + <button className="new-comments-indicator" onClick={onJumpToNextNew} title="Jump to next new comment"> 21 + {newCommentsCount} new 22 + </button> 23 + )} 17 24 <QuickSettings settings={settings} onChange={onChange} /> 18 25 {readHere && <button className="reader-close-btn" onClick={onCloseReader} title="Close split reader" aria-label="Close reader and return to feed"> 19 26 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="close-icon" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
+83 -4
src/components/discussion/DiscussionView.tsx
··· 1 1 import { useEffect, useRef, useState } from 'react'; 2 2 import { Link } from 'react-router-dom'; 3 - import type { ReaderSettings, Story } from '../../types'; 3 + import type { CommentNode, ReaderSettings, Story } from '../../types'; 4 4 import { articleUrl, sourceLabel } from '../../lib/sources'; 5 5 import { useComments } from '../../hooks/useComments'; 6 6 import { useCommentNavigation } from '../../hooks/useCommentNavigation'; 7 7 import { CommentThread } from './CommentThread'; 8 8 import { DiscussionToolbar } from './DiscussionToolbar'; 9 9 import { SplitReader } from '../reader/SplitReader'; 10 + import { hasSeenComments, readSeenComments, writeSeenComments } from '../../cache'; 10 11 11 12 interface DiscussionViewProps { 12 13 story: Story; ··· 22 23 const [readHere, setReadHere] = useState(initialReadHere); 23 24 const dialogRef = useRef<HTMLDivElement>(null); 24 25 const { comments, error, loading } = useComments(story); 25 - const { active, refs, visibleIds, moveFrom } = useCommentNavigation(comments, collapsed); 26 + const { active, refs, visibleIds, moveFrom, focusId } = useCommentNavigation(comments, collapsed); 26 27 const currentArticleUrl = articleUrl(story); 27 28 29 + const [newCommentIds, setNewCommentIds] = useState<Set<string>>(new Set()); 30 + const initialSeenIdsRef = useRef<Set<string> | null>(null); 31 + const currentStoryKeyRef = useRef<string>(''); 32 + 33 + const storyKey = `${story.source}:${story.sourceId}`; 34 + 35 + // Reset session refs if the user switches stories 36 + if (currentStoryKeyRef.current !== storyKey) { 37 + currentStoryKeyRef.current = storyKey; 38 + initialSeenIdsRef.current = null; 39 + setNewCommentIds(new Set()); 40 + } 41 + 42 + useEffect(() => { 43 + if (loading || comments.length === 0) return; 44 + 45 + const getFlatCommentIds = (nodes: CommentNode[]): string[] => { 46 + const ids: string[] = []; 47 + const traverse = (list: CommentNode[]) => { 48 + for (const node of list) { 49 + ids.push(node.id); 50 + if (node.children) traverse(node.children); 51 + } 52 + }; 53 + traverse(nodes); 54 + return ids; 55 + }; 56 + 57 + const currentIds = getFlatCommentIds(comments); 58 + 59 + if (initialSeenIdsRef.current === null) { 60 + const hasHistory = hasSeenComments(story.source, story.sourceId); 61 + if (!hasHistory) { 62 + writeSeenComments(story.source, story.sourceId, currentIds); 63 + initialSeenIdsRef.current = new Set(currentIds); 64 + setNewCommentIds(new Set()); 65 + } else { 66 + const seenIds = readSeenComments(story.source, story.sourceId); 67 + const seenSet = new Set(seenIds); 68 + initialSeenIdsRef.current = seenSet; 69 + 70 + const newIds = currentIds.filter(id => !seenSet.has(id)); 71 + setNewCommentIds(new Set(newIds)); 72 + 73 + const updatedSeen = new Set([...seenIds, ...currentIds]); 74 + writeSeenComments(story.source, story.sourceId, Array.from(updatedSeen)); 75 + } 76 + } else { 77 + const seenSet = initialSeenIdsRef.current; 78 + const newIds = currentIds.filter(id => !seenSet.has(id)); 79 + setNewCommentIds(new Set(newIds)); 80 + 81 + const currentSeen = readSeenComments(story.source, story.sourceId); 82 + const updatedSeen = new Set([...currentSeen, ...currentIds]); 83 + writeSeenComments(story.source, story.sourceId, Array.from(updatedSeen)); 84 + } 85 + }, [storyKey, comments, loading]); 86 + 28 87 useEffect(() => setReadHere(initialReadHere), [story.source, story.sourceId, initialReadHere]); 29 88 30 89 useEffect(() => { ··· 53 112 }); 54 113 const closeReader = () => onClose ? onClose() : setReadHere(false); 55 114 115 + const jumpToNextNew = () => { 116 + const visibleNewIds = visibleIds.filter(id => newCommentIds.has(id)); 117 + if (visibleNewIds.length === 0) return; 118 + 119 + let targetId = visibleNewIds[0]; 120 + if (active) { 121 + const currentIndex = visibleNewIds.indexOf(active); 122 + if (currentIndex !== -1 && currentIndex < visibleNewIds.length - 1) { 123 + targetId = visibleNewIds[currentIndex + 1]; 124 + } else { 125 + const activeIndex = visibleIds.indexOf(active); 126 + const nextNew = visibleNewIds.find(id => visibleIds.indexOf(id) > activeIndex); 127 + if (nextNew) { 128 + targetId = nextNew; 129 + } 130 + } 131 + } 132 + focusId(targetId); 133 + }; 134 + 56 135 const discussionColumn = <section className="discussion-column" aria-label="Discussion" style={readHere ? { flex: 1 } : undefined}> 57 - <DiscussionToolbar commentsCount={visibleIds.length} readHere={readHere} settings={settings} onChange={onChange} onCloseReader={closeReader} /> 58 - {loading && comments.length === 0 ? <p className="loader">Fetching discussion…</p> : error && comments.length === 0 ? <p className="error-card">{error}</p> : comments.length ? <ol className="comment-list">{comments.map(node => <CommentThread key={node.id} node={node} depth={0} collapsed={collapsed} active={active} visibleIds={visibleIds} refs={refs} toggle={toggle} moveFrom={moveFrom} storyAuthor={story.author} source={story.source}/>)}</ol> : <p className="muted">No comments yet.</p>} 136 + <DiscussionToolbar commentsCount={visibleIds.length} newCommentsCount={newCommentIds.size} onJumpToNextNew={jumpToNextNew} readHere={readHere} settings={settings} onChange={onChange} onCloseReader={closeReader} /> 137 + {loading && comments.length === 0 ? <p className="loader">Fetching discussion…</p> : error && comments.length === 0 ? <p className="error-card">{error}</p> : comments.length ? <ol className="comment-list">{comments.map(node => <CommentThread key={node.id} node={node} depth={0} collapsed={collapsed} active={active} visibleIds={visibleIds} refs={refs} toggle={toggle} moveFrom={moveFrom} storyAuthor={story.author} source={story.source} newCommentIds={newCommentIds} onJumpToNextNew={jumpToNextNew}/>)}</ol> : <p className="muted">No comments yet.</p>} 59 138 </section>; 60 139 61 140 return <div className={`discussion-page ${readHere ? 'reading' : ''} ${overlay ? 'discussion-overlay' : ''}`} ref={dialogRef} role={overlay ? 'dialog' : undefined} aria-modal={overlay ? true : undefined} aria-label={overlay ? `${story.title} reader and discussion` : undefined} tabIndex={overlay ? -1 : undefined}>
+2 -2
src/hooks/useCommentNavigation.ts
··· 18 18 setActive(commentId); 19 19 requestAnimationFrame(() => { 20 20 const element = refs.current.get(commentId); 21 - element?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 21 + element?.scrollIntoView({ behavior: 'smooth', block: 'center' }); 22 22 element?.focus({ preventScroll: true }); 23 23 }); 24 24 }; ··· 29 29 if (next) focusId(next); 30 30 }; 31 31 32 - return { active, refs, visibleIds, moveFrom }; 32 + return { active, refs, visibleIds, moveFrom, focusId }; 33 33 }
+9
src/styles/00-theme.css
··· 35 35 --color-border-active: hsl(354, 85%, 48%); 36 36 37 37 --color-active-bg: hsl(354, 85%, 97%); 38 + --color-success: hsl(142, 72%, 29%); 39 + --bg-badge-new: hsl(142, 76%, 93%); 40 + --color-badge-new-text: hsl(142, 72%, 29%); 38 41 --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03); 39 42 --shadow-md: 0 4px 12px 0 rgba(0, 0, 0, 0.04); 40 43 } ··· 62 65 --color-border-active: hsl(7, 86%, 72%); 63 66 64 67 --color-active-bg: hsl(7, 34%, 19%); 68 + --color-success: hsl(142, 70%, 45%); 69 + --bg-badge-new: hsl(142, 50%, 15%); 70 + --color-badge-new-text: hsl(142, 70%, 55%); 65 71 --shadow-sm: 0 2px 5px rgba(0, 0, 0, 0.28); 66 72 --shadow-md: 0 10px 30px rgba(0, 0, 0, 0.38); 67 73 } ··· 87 93 --color-border-active: hsl(10, 75%, 40%); 88 94 89 95 --color-active-bg: hsl(10, 60%, 86%); 96 + --color-success: hsl(142, 60%, 30%); 97 + --bg-badge-new: hsl(142, 45%, 84%); 98 + --color-badge-new-text: hsl(142, 60%, 25%); 90 99 --shadow-sm: 0 1px 3px 0 rgba(40, 20, 0, 0.05); 91 100 --shadow-md: 0 6px 18px 0 rgba(40, 20, 0, 0.08); 92 101 }
+26
src/styles/05-discussion.css
··· 282 282 letter-spacing: 0.05em; 283 283 } 284 284 285 + .new-comments-indicator { 286 + display: inline-flex; 287 + align-items: center; 288 + font-size: 0.7rem; 289 + font-weight: 700; 290 + text-transform: uppercase; 291 + letter-spacing: 0.05em; 292 + padding: 0.25rem 0.6rem; 293 + border-radius: 12px; 294 + background-color: var(--bg-badge-new); 295 + color: var(--color-badge-new-text); 296 + border: 1px solid transparent; 297 + cursor: pointer; 298 + transition: all var(--transition-fast); 299 + user-select: none; 300 + } 301 + 302 + .new-comments-indicator:hover { 303 + transform: scale(1.05); 304 + box-shadow: 0 2px 8px rgba(34, 197, 94, 0.2); 305 + } 306 + 307 + .new-comments-indicator:active { 308 + transform: scale(0.95); 309 + } 310 + 285 311 /* Quick Reader Settings in Discussion Header (Popover menu) */ 286 312 .quick-settings-container { 287 313 position: relative;
+35
src/styles/06-comments.css
··· 175 175 background-color: transparent; 176 176 border-radius: 0; 177 177 } 178 + 179 + /* Styling for new comments */ 180 + .comment.new > .comment-content { 181 + border-left: 3px solid var(--color-success); 182 + padding-left: 0.75rem; 183 + border-top-left-radius: 0; 184 + border-bottom-left-radius: 0; 185 + } 186 + 187 + .new-badge { 188 + display: inline-block; 189 + font-size: 0.6rem; 190 + font-weight: 800; 191 + text-transform: uppercase; 192 + padding: 1px 4px; 193 + border-radius: 3px; 194 + background-color: var(--bg-badge-new); 195 + color: var(--color-badge-new-text); 196 + line-height: 1; 197 + border: none; 198 + } 199 + 200 + .new-badge.clickable { 201 + cursor: pointer; 202 + transition: transform var(--transition-fast), filter var(--transition-fast); 203 + } 204 + 205 + .new-badge.clickable:hover { 206 + transform: scale(1.05); 207 + filter: brightness(0.95); 208 + } 209 + 210 + .new-badge.clickable:active { 211 + transform: scale(0.95); 212 + }