Skip to content

Commit b6ecc3a

Browse files
authored
Merge pull request #83 from concrnt/newArrivalBadge
スクロール中の自動更新停止と画面上部へのバッジ表示に対応しました
2 parents 7fa727f + a07d38c commit b6ecc3a

2 files changed

Lines changed: 400 additions & 96 deletions

File tree

app/src/components/RealtimeTimeline.tsx

Lines changed: 200 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,26 @@ import { useClient } from '../contexts/Client'
1313
import { useRefWithUpdate } from '../hooks/useRefWithUpdate'
1414
import { TimelineReader } from '@concrnt/client'
1515
import { MessageContainer } from './message'
16-
import { CssVar, Divider } from '@concrnt/ui'
16+
import { Avatar, CssVar, Divider } from '@concrnt/ui'
1717
import { ErrorBoundary } from 'react-error-boundary'
1818
import { PullToRefresh } from './PullToRefresh'
1919
import { MessageSkeleton } from './message/MessageSkeleton'
2020
import { RenderError } from './message/RenderError'
2121
import { 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

2330
interface Props extends ScrollViewProps {
2431
timelines: string[]
2532
}
2633

34+
const SCROLL_HALT_THRESHOLD = 100
35+
2736
export 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

Comments
 (0)