1- import { useCallback , useMemo , useState } from "react" ;
1+ import { useCallback , useEffect , useMemo , useState } from "react" ;
22import useSWR from "swr" ;
33import { useHeaderSlot } from "../../shared/components/header-slot" ;
44import { Icon } from "../../shared/components/icon" ;
55import { FadeUp , Stagger } from "../../shared/components/motion" ;
6+ import { Select } from "../../shared/components/select" ;
67import { StateBanner } from "../../shared/components/state-banner" ;
78import { useSettings } from "../../shared/context/settings-context" ;
89import { openExternalLink } from "../../shared/lib/external-link" ;
@@ -13,14 +14,23 @@ import {
1314 loadBanner ,
1415 loadedValue ,
1516} from "../../shared/lib/load-state" ;
17+ import { usePersistedString } from "../../shared/lib/persisted" ;
1618import type { NewsDigest } from "../../types/api/NewsDigest" ;
19+ import type { NewsSourceInfo } from "../../types/api/NewsSourceInfo" ;
1720import { HeadlineRow } from "./_components/headline-row" ;
1821
1922const PAGE = 20 ;
2023
24+ /** The picker's "no filter" value. Not a `NewsSource`, so it cannot collide. */
25+ const ALL = "all" ;
26+
27+ const SOURCE_KEY = "news.source" ;
28+
2129export function News ( ) {
2230 const { t } = useSettings ( ) ;
2331 const [ visible , setVisible ] = useState ( PAGE ) ;
32+ const [ sources , setSources ] = useState < NewsSourceInfo [ ] > ( [ ] ) ;
33+ const [ saved , setSaved ] = usePersistedString ( SOURCE_KEY ) ;
2434 const {
2535 data : state ,
2636 isValidating,
@@ -36,8 +46,30 @@ export function News() {
3646 [ mutate ] ,
3747 ) ;
3848
49+ useEffect ( ( ) => {
50+ api
51+ . newsSources ( )
52+ . then ( setSources )
53+ . catch ( ( ) => setSources ( [ ] ) ) ;
54+ } , [ ] ) ;
55+
3956 const loading = isValidating ;
4057
58+ // A source saved before it was renamed or dropped must not leave the list
59+ // permanently empty, so an unknown key reads as no filter at all. Until the
60+ // catalogue arrives nothing is known yet, and the saved key is trusted —
61+ // dropping it for that moment would flash the unfiltered list.
62+ const known = sources . length === 0 || sources . some ( ( source ) => source . id === saved ) ;
63+ const selected = saved && known ? saved : ALL ;
64+
65+ const pick = useCallback (
66+ ( next : string ) => {
67+ setSaved ( next === ALL ? null : next ) ;
68+ setVisible ( PAGE ) ;
69+ } ,
70+ [ setSaved ] ,
71+ ) ;
72+
4173 const refreshButton = useMemo (
4274 ( ) => (
4375 < button
@@ -56,41 +88,82 @@ export function News() {
5688 useHeaderSlot ( refreshButton ) ;
5789
5890 const digest = loadedValue ( state ) ;
59- const items = digest ?. items . slice ( 0 , visible ) ?? [ ] ;
91+ const filtered =
92+ selected === ALL
93+ ? ( digest ?. items ?? [ ] )
94+ : ( digest ?. items ?? [ ] ) . filter ( ( item ) => item . source === selected ) ;
95+ const items = filtered . slice ( 0 , visible ) ;
6096 const banner = loadBanner ( state , fetchedAtLabel ( digest ?. freshness ) ) ;
6197 const freshness = digest ? fetchedAtLabel ( digest . freshness ) : null ;
6298
99+ // Filtered to one source, the row's own label repeats the picker.
100+ const showSource = selected === ALL ;
101+
102+ // Filtered, the only failure worth reporting is the chosen source's own —
103+ // otherwise an empty list is explained by papers the reader is not reading.
104+ const selectedName = sources . find ( ( source ) => source . id === selected ) ?. name ;
105+ const failed = ( digest ?. failedSources ?? [ ] ) . filter (
106+ ( name ) => selected === ALL || name === selectedName ,
107+ ) ;
108+
109+ const emptyMessage = selected === ALL ? t ( "state.not-yet" ) : t ( "news.none-from-source" ) ;
110+
63111 return (
64112 < StateBanner state = { banner } onRetry = { ( ) => load ( true ) } >
113+ < div className = "mb-2" >
114+ < Select
115+ ariaLabel = { t ( "news.source" ) }
116+ value = { selected }
117+ onChange = { pick }
118+ options = { [ { id : ALL , label : t ( "news.all-sources" ) } ] }
119+ groups = { [
120+ {
121+ label : t ( "news.group-nepali" ) ,
122+ options : sources
123+ . filter ( ( source ) => ! source . english )
124+ . map ( ( source ) => ( { id : source . id as string , label : source . name } ) ) ,
125+ } ,
126+ {
127+ label : t ( "news.group-english" ) ,
128+ options : sources
129+ . filter ( ( source ) => source . english )
130+ . map ( ( source ) => ( { id : source . id as string , label : source . name } ) ) ,
131+ } ,
132+ ] }
133+ />
134+ </ div >
135+
65136 { items . length === 0 && ! loading ? (
66137 < div className = "flex flex-col items-center justify-center gap-2 py-10 text-center" >
67138 < Icon name = "news" className = "size-8 text-text-muted" />
68- < p className = "text-text-secondary" > { t ( "state.not-yet" ) } </ p >
139+ < p className = "text-text-secondary" > { emptyMessage } </ p >
69140 </ div >
70141 ) : (
71142 < Stagger className = "space-y-1" >
72143 { items . map ( ( item ) => (
73144 < FadeUp key = { `${ item . source } -${ item . link } ` } >
74- < HeadlineRow item = { item } onOpen = { ( ) => openExternalLink ( item . link ) } />
145+ < HeadlineRow
146+ item = { item }
147+ showSource = { showSource }
148+ onOpen = { ( ) => openExternalLink ( item . link ) }
149+ />
75150 </ FadeUp >
76151 ) ) }
77152 </ Stagger >
78153 ) }
79154
80- { digest && visible < digest . items . length && (
155+ { visible < filtered . length && (
81156 < button
82157 type = "button"
83158 onClick = { ( ) => setVisible ( ( count ) => count + PAGE ) }
84159 className = "mt-2 w-full py-1.5 text-center text-[11px] text-text-muted hover:text-text-secondary"
85160 >
86- { digest . items . length - visible } more
161+ { filtered . length - visible } more
87162 </ button >
88163 ) }
89164
90- { digest && digest . failedSources . length > 0 && (
91- < p className = "mt-2 text-[10px] text-text-muted" >
92- Could not reach { digest . failedSources . join ( ", " ) } .
93- </ p >
165+ { failed . length > 0 && (
166+ < p className = "mt-2 text-[10px] text-text-muted" > Could not reach { failed . join ( ", " ) } .</ p >
94167 ) }
95168
96169 { freshness && items . length > 0 && (
0 commit comments