Show unread badges for notifications

This commit is contained in:
AkiraFukushima 2024-01-28 22:17:47 +09:00
parent 2c64c19127
commit 0851cfbde8
No known key found for this signature in database
GPG Key ID: B6E51BAC4DE1A957
6 changed files with 121 additions and 26 deletions

View File

@ -9,8 +9,20 @@ import generateNotification from '@/utils/notification'
import generator, { Entity, WebSocketInterface } from 'megalodon' import generator, { Entity, WebSocketInterface } from 'megalodon'
import { Context } from '@/utils/i18n' import { Context } from '@/utils/i18n'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { Avatar, IconButton, List, ListItem, ListItemPrefix, Popover, PopoverContent, PopoverHandler } from '@material-tailwind/react' import {
Avatar,
Badge,
IconButton,
List,
ListItem,
ListItemPrefix,
Popover,
PopoverContent,
PopoverHandler
} from '@material-tailwind/react'
import Thirdparty from '../Thirdparty' import Thirdparty from '../Thirdparty'
import { useUnreads } from '@/utils/unreads'
import { unreadCount } from '@/entities/marker'
type LayoutProps = { type LayoutProps = {
children: React.ReactNode children: React.ReactNode
@ -28,6 +40,7 @@ export default function Layout({ children }: LayoutProps) {
const router = useRouter() const router = useRouter()
const { formatMessage } = useIntl() const { formatMessage } = useIntl()
const streamings = useRef<Array<WebSocketInterface>>([]) const streamings = useRef<Array<WebSocketInterface>>([])
const { unreads, setUnreads } = useUnreads()
for (let i = 1; i < 9; i++) { for (let i = 1; i < 9; i++) {
useHotkeys(`mod+${i}`, () => { useHotkeys(`mod+${i}`, () => {
@ -50,6 +63,18 @@ export default function Layout({ children }: LayoutProps) {
// Start user streaming for notification // Start user streaming for notification
const client = generator(account.sns, account.url, account.access_token, 'Whalebird') const client = generator(account.sns, account.url, account.access_token, 'Whalebird')
const instance = await client.getInstance() const instance = await client.getInstance()
const notifications = (await client.getNotifications()).data
const res = await client.getMarkers(['notifications'])
const marker = res.data as Entity.Marker
if (marker.notifications) {
const count = unreadCount(marker.notifications, notifications)
setUnreads(current =>
Object.assign({}, current, {
[account.id?.toString()]: count
})
)
}
const ws = generator(account.sns, instance.data.urls.streaming_api, account.access_token, 'Whalebird') const ws = generator(account.sns, instance.data.urls.streaming_api, account.access_token, 'Whalebird')
const socket = ws.userSocket() const socket = ws.userSocket()
streamings.current = [...streamings.current, socket] streamings.current = [...streamings.current, socket]
@ -148,15 +173,17 @@ export default function Layout({ children }: LayoutProps) {
</List> </List>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Avatar <Badge className="mt-1" color="green" invisible={!(unreads[account.id.toString()] > 0)}>
alt={account.domain} <Avatar
src={account.avatar} alt={account.domain}
title={`${account.username}@${account.domain}`} src={account.avatar}
aria-label={`${account.username}@${account.domain}`} title={`${account.username}@${account.domain}`}
className="p-1" aria-label={`${account.username}@${account.domain}`}
onClick={() => openAccount(account.id)} className="p-1"
onContextMenu={() => openContextMenu(account.id)} onClick={() => openAccount(account.id)}
/> onContextMenu={() => openContextMenu(account.id)}
/>
</Badge>
</div> </div>
))} ))}
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">

View File

@ -1,5 +1,5 @@
import { Account, db } from '@/db' import { Account, db } from '@/db'
import { Card, List, ListItem, ListItemPrefix } from '@material-tailwind/react' import { Card, Chip, List, ListItem, ListItemPrefix, ListItemSuffix } from '@material-tailwind/react'
import generator, { Entity } from 'megalodon' import generator, { Entity } from 'megalodon'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -7,6 +7,7 @@ import { useHotkeys } from 'react-hotkeys-hook'
import { FaBell, FaGlobe, FaHouse, FaList, FaUsers } from 'react-icons/fa6' import { FaBell, FaGlobe, FaHouse, FaList, FaUsers } from 'react-icons/fa6'
import { useIntl } from 'react-intl' import { useIntl } from 'react-intl'
import Jump from '../Jump' import Jump from '../Jump'
import { useUnreads } from '@/utils/unreads'
type LayoutProps = { type LayoutProps = {
children: React.ReactNode children: React.ReactNode
@ -15,6 +16,7 @@ type LayoutProps = {
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const router = useRouter() const router = useRouter()
const { formatMessage } = useIntl() const { formatMessage } = useIntl()
const { unreads } = useUnreads()
const [account, setAccount] = useState<Account | null>(null) const [account, setAccount] = useState<Account | null>(null)
const [lists, setLists] = useState<Array<Entity.List>>([]) const [lists, setLists] = useState<Array<Entity.List>>([])
@ -89,6 +91,16 @@ export default function Layout({ children }: LayoutProps) {
> >
<ListItemPrefix>{page.icon}</ListItemPrefix> <ListItemPrefix>{page.icon}</ListItemPrefix>
<span className="sidebar-menu text-ellipsis whitespace-nowrap overflow-hidden">{page.title}</span> <span className="sidebar-menu text-ellipsis whitespace-nowrap overflow-hidden">{page.title}</span>
{page.id === 'notifications' && unreads[account?.id?.toString()] ? (
<ListItemSuffix>
<Chip
value={unreads[account.id.toString()]}
variant="ghost"
size="sm"
className="rounded-full text-blue-100 bg-blue-600"
/>
</ListItemSuffix>
) : null}
</ListItem> </ListItem>
))} ))}
{lists.map(list => ( {lists.map(list => (

View File

@ -7,9 +7,10 @@ import Notification from './notification/Notification'
import { Spinner } from '@material-tailwind/react' import { Spinner } from '@material-tailwind/react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Detail from '../detail/Detail' import Detail from '../detail/Detail'
import { Marker } from '@/entities/marker' import { Marker, unreadCount } from '@/entities/marker'
import { FaCheck } from 'react-icons/fa6' import { FaCheck } from 'react-icons/fa6'
import { useToast } from '@/utils/toast' import { useToast } from '@/utils/toast'
import { useUnreads } from '@/utils/unreads'
const TIMELINE_STATUSES_COUNT = 30 const TIMELINE_STATUSES_COUNT = 30
const TIMELINE_MAX_STATUSES = 2147483647 const TIMELINE_MAX_STATUSES = 2147483647
@ -22,7 +23,7 @@ type Props = {
export default function Notifications(props: Props) { export default function Notifications(props: Props) {
const [notifications, setNotifications] = useState<Array<Entity.Notification>>([]) const [notifications, setNotifications] = useState<Array<Entity.Notification>>([])
const [unreads, setUnreads] = useState<Array<Entity.Notification>>([]) const [unreadNotifications, setUnreadNotifications] = useState<Array<Entity.Notification>>([])
const [firstItemIndex, setFirstItemIndex] = useState(TIMELINE_MAX_STATUSES) const [firstItemIndex, setFirstItemIndex] = useState(TIMELINE_MAX_STATUSES)
const [marker, setMarker] = useState<Marker | null>(null) const [marker, setMarker] = useState<Marker | null>(null)
const [pleromaUnreads, setPleromaUnreads] = useState<Array<string>>([]) const [pleromaUnreads, setPleromaUnreads] = useState<Array<string>>([])
@ -31,7 +32,7 @@ export default function Notifications(props: Props) {
const streaming = useRef<WebSocketInterface | null>(null) const streaming = useRef<WebSocketInterface | null>(null)
const router = useRouter() const router = useRouter()
const showToast = useToast() const showToast = useToast()
const { setUnreads } = useUnreads()
const { formatMessage } = useIntl() const { formatMessage } = useIntl()
useEffect(() => { useEffect(() => {
@ -47,7 +48,7 @@ export default function Notifications(props: Props) {
}) })
streaming.current.on('notification', (notification: Entity.Notification) => { streaming.current.on('notification', (notification: Entity.Notification) => {
if (scrollerRef.current && scrollerRef.current.scrollTop > 10) { if (scrollerRef.current && scrollerRef.current.scrollTop > 10) {
setUnreads(current => [notification, ...current]) setUnreadNotifications(current => [notification, ...current])
} else { } else {
setNotifications(current => [notification, ...current]) setNotifications(current => [notification, ...current])
} }
@ -57,7 +58,7 @@ export default function Notifications(props: Props) {
f() f()
return () => { return () => {
setUnreads([]) setUnreadNotifications([])
setFirstItemIndex(TIMELINE_MAX_STATUSES) setFirstItemIndex(TIMELINE_MAX_STATUSES)
setNotifications([]) setNotifications([])
if (streaming.current) { if (streaming.current) {
@ -73,11 +74,20 @@ export default function Notifications(props: Props) {
// In pleroma, last_read_id is incorrect. // In pleroma, last_read_id is incorrect.
// Items that have not been marked may also be read. So, if marker has unread_count, we should use it for unreads. // Items that have not been marked may also be read. So, if marker has unread_count, we should use it for unreads.
if (marker && marker.unread_count) { if (marker && marker.unread_count) {
const allNotifications = unreads.concat(notifications) const allNotifications = unreadNotifications.concat(notifications)
const u = allNotifications.slice(0, marker.unread_count).map(n => n.id) const u = allNotifications.slice(0, marker.unread_count).map(n => n.id)
setPleromaUnreads(u) setPleromaUnreads(u)
} }
}, [marker, unreads, notifications])
if (marker) {
const count = unreadCount(marker, unreadNotifications.concat(notifications))
setUnreads(current =>
Object.assign({}, current, {
[props.account.id.toString()]: count
})
)
}
}, [marker, unreadNotifications, notifications])
const loadNotifications = async (client: MegalodonInterface, maxId?: string): Promise<Array<Entity.Notification>> => { const loadNotifications = async (client: MegalodonInterface, maxId?: string): Promise<Array<Entity.Notification>> => {
let options = { limit: 30 } let options = { limit: 30 }
@ -126,6 +136,11 @@ export default function Notifications(props: Props) {
const marker = res.data as Entity.Marker const marker = res.data as Entity.Marker
if (marker.notifications) { if (marker.notifications) {
setMarker(marker.notifications) setMarker(marker.notifications)
setUnreads(current =>
Object.assign({}, current, {
[props.account.id?.toString()]: 0
})
)
} }
} catch { } catch {
showToast({ text: formatMessage({ id: 'alert.failed_mark' }), type: 'failure' }) showToast({ text: formatMessage({ id: 'alert.failed_mark' }), type: 'failure' })
@ -144,13 +159,13 @@ export default function Notifications(props: Props) {
const prependUnreads = useCallback(() => { const prependUnreads = useCallback(() => {
console.debug('prepending') console.debug('prepending')
const u = unreads.slice().reverse().slice(0, TIMELINE_STATUSES_COUNT).reverse() const u = unreadNotifications.slice().reverse().slice(0, TIMELINE_STATUSES_COUNT).reverse()
const remains = u.slice(0, -1 * TIMELINE_STATUSES_COUNT) const remains = u.slice(0, -1 * TIMELINE_STATUSES_COUNT)
setUnreads(() => remains) setUnreadNotifications(() => remains)
setFirstItemIndex(() => firstItemIndex - u.length) setFirstItemIndex(() => firstItemIndex - u.length)
setNotifications(() => [...u, ...notifications]) setNotifications(() => [...u, ...notifications])
return false return false
}, [firstItemIndex, notifications, setNotifications, unreads]) }, [firstItemIndex, notifications, setNotifications, unreadNotifications])
const timelineClass = () => { const timelineClass = () => {
if (router.query.detail) { if (router.query.detail) {

View File

@ -1,6 +1,15 @@
import { Entity } from 'megalodon'
export type Marker = { export type Marker = {
last_read_id: string last_read_id: string
version: number version: number
updated_at: string updated_at: string
unread_count?: number unread_count?: number
} }
export function unreadCount(marker: Marker, notifications: Array<Entity.Notification>): number {
if (marker.unread_count !== undefined) {
return marker.unread_count
}
return notifications.filter(n => parseInt(n.id) > parseInt(marker.last_read_id)).length
}

View File

@ -5,6 +5,7 @@ import TimelineLayout from '@/components/layouts/timelines'
import { IntlProviderWrapper } from '@/utils/i18n' import { IntlProviderWrapper } from '@/utils/i18n'
import { ThemeProvider } from '@material-tailwind/react' import { ThemeProvider } from '@material-tailwind/react'
import { ToastProvider } from '@/utils/toast' import { ToastProvider } from '@/utils/toast'
import { UnreadsProvider } from '@/utils/unreads'
export default function MyApp({ Component, pageProps }: AppProps) { export default function MyApp({ Component, pageProps }: AppProps) {
const customTheme = { const customTheme = {
@ -104,11 +105,13 @@ export default function MyApp({ Component, pageProps }: AppProps) {
<ThemeProvider value={customTheme}> <ThemeProvider value={customTheme}>
<IntlProviderWrapper> <IntlProviderWrapper>
<ToastProvider> <ToastProvider>
<AccountLayout> <UnreadsProvider>
<TimelineLayout> <AccountLayout>
<Component {...pageProps} /> <TimelineLayout>
</TimelineLayout> <Component {...pageProps} />
</AccountLayout> </TimelineLayout>
</AccountLayout>
</UnreadsProvider>
</ToastProvider> </ToastProvider>
</IntlProviderWrapper> </IntlProviderWrapper>
</ThemeProvider> </ThemeProvider>

View File

@ -0,0 +1,29 @@
import { Dispatch, SetStateAction, createContext, useContext, useState } from 'react'
type UnreadsType = { [key: string]: number }
type Context = {
unreads: UnreadsType
setUnreads: Dispatch<SetStateAction<UnreadsType>>
}
const UnreadsContext = createContext<Context>({
unreads: {},
setUnreads: (_: UnreadsType) => {}
})
UnreadsContext.displayName = 'UnreadsContext'
export const useUnreads = () => {
return useContext(UnreadsContext)
}
type Props = {
children: React.ReactNode
}
export const UnreadsProvider: React.FC<Props> = ({ children }) => {
const [unreads, setUnreads] = useState<UnreadsType>({})
return <UnreadsContext.Provider value={{ unreads, setUnreads }}>{children}</UnreadsContext.Provider>
}