@@ -13,17 +13,26 @@ import { useClient } from '../contexts/Client'
1313import { useRefWithUpdate } from '../hooks/useRefWithUpdate'
1414import { TimelineReader } from '@concrnt/client'
1515import { MessageContainer } from './message'
16- import { CssVar , Divider } from '@concrnt/ui'
16+ import { Avatar , CssVar , Divider } from '@concrnt/ui'
1717import { ErrorBoundary } from 'react-error-boundary'
1818import { PullToRefresh } from './PullToRefresh'
1919import { MessageSkeleton } from './message/MessageSkeleton'
2020import { RenderError } from './message/RenderError'
2121import { Loading } from './message/Loading'
22+ import { MdArrowUpward } from 'react-icons/md'
23+
24+ interface NewArrivalIcon {
25+ id : string
26+ author : string
27+ src : string
28+ }
2229
2330interface Props extends ScrollViewProps {
2431 timelines : string [ ]
2532}
2633
34+ const SCROLL_HALT_THRESHOLD = 100
35+
2736export const RealtimeTimeline = ( props : Props ) => {
2837 const { client } = useClient ( )
2938
@@ -39,21 +48,49 @@ export const RealtimeTimeline = (props: Props) => {
3948 const [ hasMoreData , setHasMoreData ] = useState < boolean > ( false )
4049 const [ initialLoaded , setInitialLoaded ] = useState ( false )
4150
51+ /** 新着バッジ用ステート */
52+ const [ newArrivals , setNewArrivals ] = useState < NewArrivalIcon [ ] > ( [ ] )
53+ const newArrivalsRef = useRef < NewArrivalIcon [ ] > ( [ ] )
54+
55+ // refとstateを同期
56+ useEffect ( ( ) => {
57+ newArrivalsRef . current = newArrivals
58+ } , [ newArrivals ] )
59+
4260 useEffect ( ( ) => {
4361 console . log ( 'Initializing timeline reader for timelines:' , props . timelines )
4462 let isCancelled = false
4563 setInitialLoaded ( false )
64+ setNewArrivals ( [ ] )
4665 const request = async ( ) => {
4766 if ( ! client ) return
4867
4968 return client . newTimelineReader ( ) . then ( ( t ) => {
5069 if ( isCancelled ) return
70+ t . haltUpdate = false
5171 t . onUpdate = ( ) => {
5272 startTransition ( ( ) => {
5373 update ( )
5474 } )
5575 }
5676
77+ t . onNewItem = ( item ) => {
78+ if ( isCancelled ) return
79+ if ( ! t . haltUpdate ) return
80+ if ( ! item . href ) return
81+
82+ client . getMessage ( item . href ) . then ( ( msg ) => {
83+ if ( isCancelled ) return
84+ if ( ! msg ) return
85+ const icon = msg . authorProfile ?. avatar
86+ if ( ! icon ) return
87+ setNewArrivals ( ( prev ) => {
88+ if ( prev . find ( ( e ) => e . src === icon ) ) return prev
89+ return [ { id : item . href ! , author : msg . author , src : icon } , ...prev ]
90+ } )
91+ } )
92+ }
93+
5794 reader . current = t
5895 t . listen ( props . timelines )
5996 . then ( ( hasMoreData ) => {
@@ -90,16 +127,25 @@ export const RealtimeTimeline = (props: Props) => {
90127 const onRefresh = useCallback ( async ( ) => {
91128 if ( ! reader . current ) return
92129 console . log ( 'Pull to refresh: reloading timeline' )
130+ setNewArrivals ( [ ] )
93131 setIsFetching ( true )
94132 try {
95133 await reader . current . reload ( )
96- // リロード完了後に少し待機して、ユーザーにフィードバックを見せる
97134 await new Promise ( ( resolve ) => setTimeout ( resolve , 500 ) )
98135 } finally {
99136 setIsFetching ( false )
100137 }
101138 } , [ reader ] )
102139
140+ /** 新着バッジクリック時の処理 */
141+ const handleNewArrivalClick = useCallback ( ( ) => {
142+ setNewArrivals ( [ ] )
143+ if ( scrollRef . current ) {
144+ scrollRef . current . scrollTo ( { top : 0 , behavior : 'smooth' } )
145+ }
146+ onRefresh ( )
147+ } , [ onRefresh ] )
148+
103149 useEffect ( ( ) => {
104150 const el = scrollRef . current
105151 if ( ! el ) return
@@ -109,6 +155,11 @@ export const RealtimeTimeline = (props: Props) => {
109155 // PullToRefresh用にスクロール位置を記録
110156 scrollPositionRef . current = el . scrollTop
111157
158+ // haltUpdate制御:スクロールが閾値を超えたら自動更新を停止
159+ if ( reader . current ) {
160+ reader . current . haltUpdate = el . scrollTop > SCROLL_HALT_THRESHOLD || newArrivalsRef . current . length > 0
161+ }
162+
112163 if ( el . scrollHeight - el . scrollTop - el . clientHeight < 500 ) {
113164 if ( loadingRef . current ) return
114165 if ( ! hasMoreData ) return
@@ -141,65 +192,166 @@ export const RealtimeTimeline = (props: Props) => {
141192 }
142193 } , [ scrollRef , reader , hasMoreData , initialLoaded ] )
143194
195+ const maxDisplayAvatars = 4
196+ const displayedArrivals = newArrivals . slice ( 0 , maxDisplayAvatars )
197+ const extraCount = newArrivals . length - maxDisplayAvatars
198+
144199 return (
145200 < PullToRefresh positionRef = { scrollPositionRef } isFetching = { isFetching } onRefresh = { onRefresh } >
146201 < div
147202 style = { {
203+ position : 'relative' ,
148204 display : 'flex' ,
205+ flex : 1 ,
149206 flexDirection : 'column' ,
150- gap : '8px' ,
151- padding : '8px 0' ,
152- overflowX : 'hidden' ,
153- overflowY : 'auto' ,
154- overscrollBehaviorY : 'none'
207+ overflow : 'hidden'
155208 } }
156- ref = { scrollRef }
157209 >
158- { ! initialLoaded &&
159- Array . from ( { length : 10 } ) . map ( ( _ , i ) => (
160- < div key = { i } style = { { padding : `0 ${ CssVar . space ( 2 ) } ` } } >
161- < MessageSkeleton />
162- </ div >
163- ) ) }
164- { reader . current ?. body . map ( ( item ) => (
165- < Fragment key = { item . timestamp . getTime ( ) ?? item . href } >
166- < ErrorBoundary FallbackComponent = { RenderError } >
167- < div
168- style = { {
169- padding : `0 ${ CssVar . space ( 2 ) } ` ,
170- contentVisibility : 'auto'
171- } }
172- >
173- < Suspense key = { item . timestamp . getTime ( ) ?? item . href } fallback = { < MessageSkeleton /> } >
174- < MessageContainer
175- uri = { item . href }
176- source = { item . source }
177- lastUpdated = { item . lastUpdate ?. getTime ( ) ?? 0 }
178- content = { item . content }
179- />
180- </ Suspense >
181- </ div >
182- </ ErrorBoundary >
183- < Divider />
184- </ Fragment >
185- ) ) }
186- { loading && < Loading message = { 'Loading...' } /> }
187- { ! hasMoreData && (
188- < div
210+ { /* 新着バッジ */ }
211+ < div
212+ style = { {
213+ position : 'absolute' ,
214+ top : '8px' ,
215+ left : 0 ,
216+ right : 0 ,
217+ display : 'flex' ,
218+ justifyContent : 'center' ,
219+ zIndex : 10 ,
220+ pointerEvents : 'none' ,
221+ transition : 'opacity 0.2s ease, transform 0.2s ease' ,
222+ opacity : newArrivals . length > 0 ? 1 : 0 ,
223+ transform : newArrivals . length > 0 ? 'scale(1)' : 'scale(0.8)'
224+ } }
225+ >
226+ < button
227+ onClick = { handleNewArrivalClick }
189228 style = { {
190- padding : '8px' ,
191- fontSize : '12px' ,
192- color : '#888' ,
193- width : '100%' ,
194- height : '100px' ,
229+ pointerEvents : newArrivals . length > 0 ? 'auto' : 'none' ,
195230 display : 'flex' ,
196231 alignItems : 'center' ,
197- justifyContent : 'center'
232+ gap : '4px' ,
233+ padding : '4px 12px' ,
234+ border : 'none' ,
235+ borderRadius : '100px' ,
236+ backgroundColor : CssVar . contentLink ,
237+ color : '#fff' ,
238+ cursor : 'pointer' ,
239+ boxShadow : '0 2px 8px rgba(0,0,0,0.2)' ,
240+ fontSize : '14px'
198241 } }
199242 >
200- -- End of Timeline --
201- </ div >
202- ) }
243+ < MdArrowUpward size = { 16 } />
244+ < div style = { { display : 'flex' , alignItems : 'center' } } >
245+ { displayedArrivals . map ( ( item , i ) => (
246+ < div
247+ key = { item . id }
248+ style = { {
249+ marginLeft : i > 0 ? '-6px' : '0' ,
250+ borderRadius : '50%' ,
251+ overflow : 'hidden' ,
252+ border : '1.5px solid #fff' ,
253+ width : '22px' ,
254+ height : '22px' ,
255+ flexShrink : 0
256+ } }
257+ >
258+ < Avatar
259+ ccid = { item . author }
260+ src = { item . src }
261+ style = { {
262+ width : '22px' ,
263+ height : '22px' ,
264+ borderRadius : '50%'
265+ } }
266+ />
267+ </ div >
268+ ) ) }
269+ { extraCount > 0 && (
270+ < div
271+ style = { {
272+ marginLeft : '-6px' ,
273+ width : '22px' ,
274+ height : '22px' ,
275+ borderRadius : '50%' ,
276+ backgroundColor : 'rgba(255,255,255,0.3)' ,
277+ border : '1.5px solid #fff' ,
278+ display : 'flex' ,
279+ alignItems : 'center' ,
280+ justifyContent : 'center' ,
281+ fontSize : '10px' ,
282+ fontWeight : 'bold' ,
283+ color : '#fff' ,
284+ flexShrink : 0
285+ } }
286+ >
287+ +{ extraCount }
288+ </ div >
289+ ) }
290+ </ div >
291+ </ button >
292+ </ div >
293+
294+ < div
295+ style = { {
296+ display : 'flex' ,
297+ flexDirection : 'column' ,
298+ gap : '8px' ,
299+ padding : '8px 0' ,
300+ overflowX : 'hidden' ,
301+ overflowY : 'auto' ,
302+ overscrollBehaviorY : 'none'
303+ } }
304+ ref = { scrollRef }
305+ >
306+ { ! initialLoaded &&
307+ Array . from ( { length : 10 } ) . map ( ( _ , i ) => (
308+ < div key = { i } style = { { padding : `0 ${ CssVar . space ( 2 ) } ` } } >
309+ < MessageSkeleton />
310+ </ div >
311+ ) ) }
312+ { reader . current ?. body . map ( ( item ) => (
313+ < Fragment key = { item . timestamp . getTime ( ) ?? item . href } >
314+ < ErrorBoundary FallbackComponent = { RenderError } >
315+ < div
316+ style = { {
317+ padding : `0 ${ CssVar . space ( 2 ) } ` ,
318+ contentVisibility : 'auto'
319+ } }
320+ >
321+ < Suspense
322+ key = { item . timestamp . getTime ( ) ?? item . href }
323+ fallback = { < MessageSkeleton /> }
324+ >
325+ < MessageContainer
326+ uri = { item . href }
327+ source = { item . source }
328+ lastUpdated = { item . lastUpdate ?. getTime ( ) ?? 0 }
329+ content = { item . content }
330+ />
331+ </ Suspense >
332+ </ div >
333+ </ ErrorBoundary >
334+ < Divider />
335+ </ Fragment >
336+ ) ) }
337+ { loading && < Loading message = { 'Loading...' } /> }
338+ { ! hasMoreData && (
339+ < div
340+ style = { {
341+ padding : '8px' ,
342+ fontSize : '12px' ,
343+ color : '#888' ,
344+ width : '100%' ,
345+ height : '100px' ,
346+ display : 'flex' ,
347+ alignItems : 'center' ,
348+ justifyContent : 'center'
349+ } }
350+ >
351+ -- End of Timeline --
352+ </ div >
353+ ) }
354+ </ div >
203355 </ div >
204356 </ PullToRefresh >
205357 )
0 commit comments