Whalebird-desktop-client-ma.../renderer/components/timelines/Timeline.tsx

337 lines
11 KiB
TypeScript
Raw Normal View History

2023-11-03 12:37:40 +01:00
import { Account } from '@/db'
2024-03-18 17:14:58 +01:00
import { Entity, MegalodonInterface, WebSocketInterface } from 'megalodon'
import { useEffect, useState, useCallback, useRef, FormEvent } from 'react'
2023-11-03 12:37:40 +01:00
import { Virtuoso } from 'react-virtuoso'
import Status from './status/Status'
2023-11-04 07:32:37 +01:00
import { FormattedMessage, useIntl } from 'react-intl'
2023-11-15 16:15:44 +01:00
import Detail from '../detail/Detail'
2023-11-17 15:01:54 +01:00
import { useRouter } from 'next/router'
2023-11-21 16:56:26 +01:00
import Compose from '../compose/Compose'
2023-12-21 16:43:45 +01:00
import { useHotkeys } from 'react-hotkeys-hook'
import { Input, Spinner } from '@material-tailwind/react'
import parse from 'parse-link-header'
2023-11-03 12:37:40 +01:00
2023-11-05 17:09:32 +01:00
const TIMELINE_STATUSES_COUNT = 30
2023-11-04 16:22:50 +01:00
const TIMELINE_MAX_STATUSES = 2147483647
2023-11-03 12:37:40 +01:00
type Props = {
timeline: string
account: Account
client: MegalodonInterface
openMedia: (media: Array<Entity.Attachment>, index: number) => void
2023-11-03 12:37:40 +01:00
}
2023-12-21 16:43:45 +01:00
2023-11-03 12:37:40 +01:00
export default function Timeline(props: Props) {
const [statuses, setStatuses] = useState<Array<Entity.Status>>([])
2023-11-04 16:22:50 +01:00
const [unreads, setUnreads] = useState<Array<Entity.Status>>([])
const [firstItemIndex, setFirstItemIndex] = useState(TIMELINE_MAX_STATUSES)
2023-11-21 16:56:26 +01:00
const [composeHeight, setComposeHeight] = useState(120)
2023-12-29 14:42:35 +01:00
const [list, setList] = useState<Entity.List | null>(null)
2024-09-01 05:24:36 +02:00
const [tag, setTag] = useState<Entity.Tag | null>(null)
2024-02-27 12:49:27 +01:00
const [filters, setFilters] = useState<Array<Entity.Filter>>([])
const [nextMaxId, setNextMaxId] = useState<string | null>(null)
2023-11-04 16:22:50 +01:00
2023-11-17 15:01:54 +01:00
const router = useRouter()
2023-11-04 07:32:37 +01:00
const { formatMessage } = useIntl()
2023-11-04 16:22:50 +01:00
const scrollerRef = useRef<HTMLElement | null>(null)
const streaming = useRef<WebSocketInterface | null>(null)
2023-11-23 06:07:48 +01:00
const composeRef = useRef<HTMLDivElement | null>(null)
2024-01-13 03:26:16 +01:00
useHotkeys('mod+r', () => reload())
2023-11-23 06:07:48 +01:00
useEffect(() => {
const observer = new ResizeObserver(entries => {
entries.forEach(el => {
setComposeHeight(el.contentRect.height)
})
})
if (composeRef.current) {
observer.observe(composeRef.current)
}
return () => {
observer.disconnect()
}
}, [])
2023-11-03 12:37:40 +01:00
useEffect(() => {
const f = async () => {
2024-02-27 12:49:27 +01:00
const f = await loadFilter(props.timeline, props.client)
setFilters(f)
2023-11-03 12:37:40 +01:00
const res = await loadTimeline(props.timeline, props.client)
setStatuses(res)
2023-12-29 14:42:35 +01:00
setList(null)
2024-09-01 05:24:36 +02:00
setTag(null)
2023-11-04 16:22:50 +01:00
switch (props.timeline) {
case 'home': {
2024-03-18 17:14:58 +01:00
streaming.current = await props.client.userStreaming()
2023-11-04 16:22:50 +01:00
break
}
case 'local': {
2024-03-18 17:14:58 +01:00
streaming.current = await props.client.localStreaming()
2023-11-04 16:22:50 +01:00
break
}
case 'public': {
2024-03-18 17:14:58 +01:00
streaming.current = await props.client.publicStreaming()
2023-11-04 16:22:50 +01:00
break
}
2023-12-29 14:42:35 +01:00
default: {
const match = props.timeline.match(/list_(\d+)/)
2024-03-08 16:42:55 +01:00
if (match && match[1] && typeof match[1] === 'string') {
2023-12-29 14:42:35 +01:00
const res = await props.client.getList(match[1])
2024-03-18 17:14:58 +01:00
streaming.current = await props.client.listStreaming(match[1])
2023-12-29 14:42:35 +01:00
setList(res.data)
2024-09-01 05:24:36 +02:00
} else {
const tag_match = props.timeline.match(/tag_(\w+)/)
if (tag_match && tag_match[1] && typeof tag_match[1] === 'string') {
const res = await props.client.getTag(tag_match[1])
streaming.current = await props.client.tagStreaming(tag_match[1])
setTag(res.data)
}
2023-12-29 14:42:35 +01:00
}
break
}
2023-11-04 16:22:50 +01:00
}
if (streaming.current) {
streaming.current.on('connect', () => {
console.log(`connected to ${props.timeline}`)
})
streaming.current.on('update', (status: Entity.Status) => {
if (scrollerRef.current && scrollerRef.current.scrollTop > 10) {
setUnreads(current => [status, ...current])
} else {
setStatuses(current => [status, ...current])
}
})
}
2023-11-03 12:37:40 +01:00
}
f()
2023-11-04 16:22:50 +01:00
return () => {
setUnreads([])
setFirstItemIndex(TIMELINE_MAX_STATUSES)
setStatuses([])
2023-11-04 16:22:50 +01:00
if (streaming.current) {
streaming.current.removeAllListeners()
streaming.current.stop()
2023-11-09 15:29:10 +01:00
streaming.current = null
2023-11-04 16:22:50 +01:00
console.log(`closed ${props.timeline}`)
}
}
}, [props.timeline, props.client, props.account])
2023-11-03 12:37:40 +01:00
2024-02-27 12:49:27 +01:00
const loadFilter = async (tl: string, client: MegalodonInterface): Promise<Array<Entity.Filter>> => {
2024-03-15 13:21:04 +01:00
try {
const res = await client.getFilters()
let context = 'home'
switch (tl) {
case 'home':
context = 'home'
break
case 'local':
case 'public':
context = 'public'
break
default:
context = 'home'
break
}
return res.data.filter(f => f.context.includes(context))
} catch (e) {
console.error(e)
return []
2024-02-27 12:49:27 +01:00
}
}
2023-11-03 12:37:40 +01:00
const loadTimeline = async (tl: string, client: MegalodonInterface, maxId?: string): Promise<Array<Entity.Status>> => {
let options = { limit: 30 }
if (maxId) {
options = Object.assign({}, options, { max_id: maxId })
}
switch (tl) {
case 'home': {
const res = await client.getHomeTimeline(options)
return res.data
}
case 'local': {
const res = await client.getLocalTimeline(options)
return res.data
}
case 'public': {
const res = await client.getPublicTimeline(options)
return res.data
}
2024-03-08 16:51:13 +01:00
case 'favourites': {
const res = await client.getFavourites(options)
const link = parse(res.headers.link)
if (link !== null && link.next) {
setNextMaxId(link.next.max_id)
}
2024-03-08 16:51:13 +01:00
return res.data
}
2024-03-08 16:42:55 +01:00
case 'bookmarks': {
const res = await client.getBookmarks(options)
const link = parse(res.headers.link)
if (link !== null && link.next) {
setNextMaxId(link.next.max_id)
}
2024-03-08 16:42:55 +01:00
return res.data
}
2023-11-03 12:37:40 +01:00
default: {
2023-12-29 14:42:35 +01:00
// Check list
const match = tl.match(/list_(\d+)/)
2024-09-01 05:24:36 +02:00
if (match && match[1] && typeof match[1] === 'string') {
2023-12-29 14:42:35 +01:00
const res = await client.getListTimeline(match[1], options)
return res.data
2024-09-01 05:24:36 +02:00
} else {
// Check tag
const tag_match = tl.match(/tag_(\w+)/)
if (tag_match && tag_match[1] && typeof tag_match[1] === 'string') {
const res = await client.getTagTimeline(tag_match[1], options)
return res.data
}
2023-12-29 14:42:35 +01:00
}
2023-11-03 12:37:40 +01:00
return []
}
}
}
2023-11-04 05:03:56 +01:00
const updateStatus = (current: Array<Entity.Status>, status: Entity.Status) => {
const renew = current.map(s => {
if (s.id === status.id) {
return status
} else if (s.reblog && s.reblog.id === status.id) {
return Object.assign({}, s, { reblog: status })
} else if (status.reblog && s.id === status.reblog.id) {
return status.reblog
} else if (status.reblog && s.reblog && s.reblog.id === status.reblog.id) {
return Object.assign({}, s, { reblog: status.reblog })
} else {
return s
}
})
return renew
}
2023-12-21 16:43:45 +01:00
const reload = useCallback(async () => {
const res = await loadTimeline(props.timeline, props.client)
setStatuses(res)
}, [props.timeline, props.client, setStatuses])
2023-11-04 15:03:47 +01:00
const loadMore = useCallback(async () => {
console.debug('appending')
let maxId = null
switch (props.timeline) {
case 'favourites':
case 'bookmarks':
if (!nextMaxId) {
return
}
maxId = nextMaxId
break
default:
maxId = statuses[statuses.length - 1].id
break
}
2023-11-04 15:03:47 +01:00
const append = await loadTimeline(props.timeline, props.client, maxId)
setStatuses(last => [...last, ...append])
}, [props.client, statuses, setStatuses, nextMaxId])
2023-11-04 15:03:47 +01:00
2023-11-04 16:22:50 +01:00
const prependUnreads = useCallback(() => {
console.debug('prepending')
const u = unreads.slice().reverse().slice(0, TIMELINE_STATUSES_COUNT).reverse()
const remains = u.slice(0, -1 * TIMELINE_STATUSES_COUNT)
setUnreads(() => remains)
setFirstItemIndex(() => firstItemIndex - u.length)
setStatuses(() => [...u, ...statuses])
return false
}, [firstItemIndex, statuses, setStatuses, unreads])
2023-11-17 15:01:54 +01:00
const timelineClass = () => {
2023-11-28 16:37:21 +01:00
if (router.query.detail) {
2023-11-17 15:01:54 +01:00
return 'timeline-with-drawer'
}
return 'timeline'
}
const backToTop = () => {
scrollerRef.current.scrollTo({
top: 0,
behavior: 'smooth'
})
}
const search = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault()
const word = ((ev.target as HTMLFormElement).elements[0] as HTMLInputElement).value
if (word.length > 0) {
router.push({
query: Object.assign({}, router.query, {
timeline: 'search',
q: word
})
})
}
}
2023-11-03 12:37:40 +01:00
return (
<div className="flex timeline-wrapper">
2023-11-17 15:01:54 +01:00
<section className={`h-full ${timelineClass()}`}>
2024-03-09 10:26:43 +01:00
<div className="w-full theme-bg theme-text-primary p-2 flex justify-between">
<div className="text-lg font-bold cursor-pointer" onClick={() => backToTop()}>
2024-09-01 05:24:36 +02:00
{
props.timeline.match(/list_(\d+)/) ? <>{list && list.title}</> :
(props.timeline.match(/tag_(\w+)/) ? <>{tag && `# ${tag.name}`}</> :
<FormattedMessage id={`timeline.${props.timeline}`} />)}
2023-11-17 15:01:54 +01:00
</div>
<div className="w-64 text-xs">
<form onSubmit={ev => search(ev)}>
<Input
type="text"
color="blue-gray"
placeholder={formatMessage({ id: 'timeline.search' })}
containerProps={{ className: 'h-7' }}
2024-09-01 07:21:03 +02:00
className="!py-1 !px-2 !text-xs placeholder:opacity-100 text-white"
/>
2023-11-17 15:01:54 +01:00
</form>
</div>
2023-11-04 07:32:37 +01:00
</div>
2023-11-28 16:37:21 +01:00
<div className="overflow-x-hidden" style={{ height: 'calc(100% - 50px)' }}>
{statuses.length > 0 ? (
<Virtuoso
style={{ height: `calc(100% - ${composeHeight}px)` }}
scrollerRef={ref => {
scrollerRef.current = ref as HTMLElement
}}
className="timeline-scrollable"
firstItemIndex={firstItemIndex}
atTopStateChange={prependUnreads}
data={statuses}
endReached={loadMore}
itemContent={(_, status) => (
<Status
client={props.client}
account={props.account}
status={status}
key={status.id}
onRefresh={status => setStatuses(current => updateStatus(current, status))}
openMedia={props.openMedia}
2024-02-27 12:49:27 +01:00
filters={filters}
/>
)}
/>
) : (
2024-01-12 14:14:33 +01:00
<div className="w-full pt-6" style={{ height: `calc(100% - ${composeHeight}px)` }}>
<Spinner className="m-auto" />
</div>
)}
2023-11-23 06:07:48 +01:00
<div ref={composeRef}>
<Compose client={props.client} />
</div>
2023-11-03 12:37:40 +01:00
</div>
2023-11-17 15:01:54 +01:00
</section>
<Detail client={props.client} account={props.account} className="detail" openMedia={props.openMedia} />
2023-11-17 15:01:54 +01:00
</div>
2023-11-03 12:37:40 +01:00
)
}