337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import { Account } from '@/db'
|
|
import { Entity, MegalodonInterface, WebSocketInterface } from 'megalodon'
|
|
import { useEffect, useState, useCallback, useRef, FormEvent } from 'react'
|
|
import { Virtuoso } from 'react-virtuoso'
|
|
import Status from './status/Status'
|
|
import { FormattedMessage, useIntl } from 'react-intl'
|
|
import Detail from '../detail/Detail'
|
|
import { useRouter } from 'next/router'
|
|
import Compose from '../compose/Compose'
|
|
import { useHotkeys } from 'react-hotkeys-hook'
|
|
import { Input, Spinner } from '@material-tailwind/react'
|
|
import parse from 'parse-link-header'
|
|
|
|
const TIMELINE_STATUSES_COUNT = 30
|
|
const TIMELINE_MAX_STATUSES = 2147483647
|
|
|
|
type Props = {
|
|
timeline: string
|
|
account: Account
|
|
client: MegalodonInterface
|
|
openMedia: (media: Array<Entity.Attachment>, index: number) => void
|
|
}
|
|
|
|
export default function Timeline(props: Props) {
|
|
const [statuses, setStatuses] = useState<Array<Entity.Status>>([])
|
|
const [unreads, setUnreads] = useState<Array<Entity.Status>>([])
|
|
const [firstItemIndex, setFirstItemIndex] = useState(TIMELINE_MAX_STATUSES)
|
|
const [composeHeight, setComposeHeight] = useState(120)
|
|
const [list, setList] = useState<Entity.List | null>(null)
|
|
const [tag, setTag] = useState<Entity.Tag | null>(null)
|
|
const [filters, setFilters] = useState<Array<Entity.Filter>>([])
|
|
const [nextMaxId, setNextMaxId] = useState<string | null>(null)
|
|
|
|
const router = useRouter()
|
|
const { formatMessage } = useIntl()
|
|
const scrollerRef = useRef<HTMLElement | null>(null)
|
|
const streaming = useRef<WebSocketInterface | null>(null)
|
|
const composeRef = useRef<HTMLDivElement | null>(null)
|
|
useHotkeys('mod+r', () => reload())
|
|
|
|
useEffect(() => {
|
|
const observer = new ResizeObserver(entries => {
|
|
entries.forEach(el => {
|
|
setComposeHeight(el.contentRect.height)
|
|
})
|
|
})
|
|
if (composeRef.current) {
|
|
observer.observe(composeRef.current)
|
|
}
|
|
return () => {
|
|
observer.disconnect()
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const f = async () => {
|
|
const f = await loadFilter(props.timeline, props.client)
|
|
setFilters(f)
|
|
const res = await loadTimeline(props.timeline, props.client)
|
|
setStatuses(res)
|
|
setList(null)
|
|
setTag(null)
|
|
switch (props.timeline) {
|
|
case 'home': {
|
|
streaming.current = await props.client.userStreaming()
|
|
break
|
|
}
|
|
case 'local': {
|
|
streaming.current = await props.client.localStreaming()
|
|
break
|
|
}
|
|
case 'public': {
|
|
streaming.current = await props.client.publicStreaming()
|
|
break
|
|
}
|
|
default: {
|
|
const match = props.timeline.match(/list_(\d+)/)
|
|
if (match && match[1] && typeof match[1] === 'string') {
|
|
const res = await props.client.getList(match[1])
|
|
streaming.current = await props.client.listStreaming(match[1])
|
|
setList(res.data)
|
|
} 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)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
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])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
f()
|
|
|
|
return () => {
|
|
setUnreads([])
|
|
setFirstItemIndex(TIMELINE_MAX_STATUSES)
|
|
setStatuses([])
|
|
if (streaming.current) {
|
|
streaming.current.removeAllListeners()
|
|
streaming.current.stop()
|
|
streaming.current = null
|
|
console.log(`closed ${props.timeline}`)
|
|
}
|
|
}
|
|
}, [props.timeline, props.client, props.account])
|
|
|
|
const loadFilter = async (tl: string, client: MegalodonInterface): Promise<Array<Entity.Filter>> => {
|
|
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 []
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
case 'favourites': {
|
|
const res = await client.getFavourites(options)
|
|
const link = parse(res.headers.link)
|
|
if (link !== null && link.next) {
|
|
setNextMaxId(link.next.max_id)
|
|
}
|
|
return res.data
|
|
}
|
|
case 'bookmarks': {
|
|
const res = await client.getBookmarks(options)
|
|
const link = parse(res.headers.link)
|
|
if (link !== null && link.next) {
|
|
setNextMaxId(link.next.max_id)
|
|
}
|
|
return res.data
|
|
}
|
|
default: {
|
|
// Check list
|
|
const match = tl.match(/list_(\d+)/)
|
|
if (match && match[1] && typeof match[1] === 'string') {
|
|
const res = await client.getListTimeline(match[1], options)
|
|
return res.data
|
|
} 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
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
const reload = useCallback(async () => {
|
|
const res = await loadTimeline(props.timeline, props.client)
|
|
setStatuses(res)
|
|
}, [props.timeline, props.client, setStatuses])
|
|
|
|
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
|
|
}
|
|
|
|
const append = await loadTimeline(props.timeline, props.client, maxId)
|
|
setStatuses(last => [...last, ...append])
|
|
}, [props.client, statuses, setStatuses, nextMaxId])
|
|
|
|
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])
|
|
|
|
const timelineClass = () => {
|
|
if (router.query.detail) {
|
|
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
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex timeline-wrapper">
|
|
<section className={`h-full ${timelineClass()}`}>
|
|
<div className="w-full theme-bg theme-text-primary p-2 flex justify-between">
|
|
<div className="text-lg font-bold cursor-pointer" onClick={() => backToTop()}>
|
|
{
|
|
props.timeline.match(/list_(\d+)/) ? <>{list && list.title}</> :
|
|
(props.timeline.match(/tag_(\w+)/) ? <>{tag && `# ${tag.name}`}</> :
|
|
<FormattedMessage id={`timeline.${props.timeline}`} />)}
|
|
</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' }}
|
|
className="!py-1 !px-2 !text-xs placeholder:opacity-100 text-white"
|
|
/>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<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}
|
|
filters={filters}
|
|
/>
|
|
)}
|
|
/>
|
|
) : (
|
|
<div className="w-full pt-6" style={{ height: `calc(100% - ${composeHeight}px)` }}>
|
|
<Spinner className="m-auto" />
|
|
</div>
|
|
)}
|
|
|
|
<div ref={composeRef}>
|
|
<Compose client={props.client} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<Detail client={props.client} account={props.account} className="detail" openMedia={props.openMedia} />
|
|
</div>
|
|
)
|
|
}
|