Merge pull request #4814 from h3poteto/feat/unread-badge

Show unread badges for notifications
This commit is contained in:
AkiraFukushima 2024-01-28 23:58:01 +09:00 committed by GitHub
commit f31759b656
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 { Context } from '@/utils/i18n'
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 { useUnreads } from '@/utils/unreads'
import { unreadCount } from '@/entities/marker'
type LayoutProps = {
children: React.ReactNode
@ -28,6 +40,7 @@ export default function Layout({ children }: LayoutProps) {
const router = useRouter()
const { formatMessage } = useIntl()
const streamings = useRef<Array<WebSocketInterface>>([])
const { unreads, setUnreads } = useUnreads()
for (let i = 1; i < 9; i++) {
useHotkeys(`mod+${i}`, () => {
@ -50,6 +63,18 @@ export default function Layout({ children }: LayoutProps) {
// Start user streaming for notification
const client = generator(account.sns, account.url, account.access_token, 'Whalebird')
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 socket = ws.userSocket()
streamings.current = [...streamings.current, socket]
@ -148,15 +173,17 @@ export default function Layout({ children }: LayoutProps) {
</List>
</PopoverContent>
</Popover>
<Avatar
alt={account.domain}
src={account.avatar}
title={`${account.username}@${account.domain}`}
aria-label={`${account.username}@${account.domain}`}
className="p-1"
onClick={() => openAccount(account.id)}
onContextMenu={() => openContextMenu(account.id)}
/>
<Badge className="mt-1" color="green" invisible={!(unreads[account.id.toString()] > 0)}>
<Avatar
alt={account.domain}
src={account.avatar}
title={`${account.username}@${account.domain}`}
aria-label={`${account.username}@${account.domain}`}
className="p-1"
onClick={() => openAccount(account.id)}
onContextMenu={() => openContextMenu(account.id)}
/>
</Badge>
</div>
))}
<div className="flex flex-col items-center">

View File

@ -1,5 +1,5 @@
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 { useRouter } from 'next/router'
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 { useIntl } from 'react-intl'
import Jump from '../Jump'
import { useUnreads } from '@/utils/unreads'
type LayoutProps = {
children: React.ReactNode
@ -15,6 +16,7 @@ type LayoutProps = {
export default function Layout({ children }: LayoutProps) {
const router = useRouter()
const { formatMessage } = useIntl()
const { unreads } = useUnreads()
const [account, setAccount] = useState<Account | null>(null)
const [lists, setLists] = useState<Array<Entity.List>>([])
@ -89,6 +91,16 @@ export default function Layout({ children }: LayoutProps) {
>
<ListItemPrefix>{page.icon}</ListItemPrefix>
<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>
))}
{lists.map(list => (

View File

@ -7,9 +7,10 @@ import Notification from './notification/Notification'
import { Spinner } from '@material-tailwind/react'
import { useRouter } from 'next/router'
import Detail from '../detail/Detail'
import { Marker } from '@/entities/marker'
import { Marker, unreadCount } from '@/entities/marker'
import { FaCheck } from 'react-icons/fa6'
import { useToast } from '@/utils/toast'
import { useUnreads } from '@/utils/unreads'
const TIMELINE_STATUSES_COUNT = 30
const TIMELINE_MAX_STATUSES = 2147483647
@ -22,7 +23,7 @@ type Props = {
export default function Notifications(props: Props) {
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 [marker, setMarker] = useState<Marker | null>(null)
const [pleromaUnreads, setPleromaUnreads] = useState<Array<string>>([])
@ -31,7 +32,7 @@ export default function Notifications(props: Props) {
const streaming = useRef<WebSocketInterface | null>(null)
const router = useRouter()
const showToast = useToast()
const { setUnreads } = useUnreads()
const { formatMessage } = useIntl()
useEffect(() => {
@ -47,7 +48,7 @@ export default function Notifications(props: Props) {
})
streaming.current.on('notification', (notification: Entity.Notification) => {
if (scrollerRef.current && scrollerRef.current.scrollTop > 10) {
setUnreads(current => [notification, ...current])
setUnreadNotifications(current => [notification, ...current])
} else {
setNotifications(current => [notification, ...current])
}
@ -57,7 +58,7 @@ export default function Notifications(props: Props) {
f()
return () => {
setUnreads([])
setUnreadNotifications([])
setFirstItemIndex(TIMELINE_MAX_STATUSES)
setNotifications([])
if (streaming.current) {
@ -73,11 +74,20 @@ export default function Notifications(props: Props) {
// 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.
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)
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>> => {
let options = { limit: 30 }
@ -126,6 +136,11 @@ export default function Notifications(props: Props) {
const marker = res.data as Entity.Marker
if (marker.notifications) {
setMarker(marker.notifications)
setUnreads(current =>
Object.assign({}, current, {
[props.account.id?.toString()]: 0
})
)
}
} catch {
showToast({ text: formatMessage({ id: 'alert.failed_mark' }), type: 'failure' })
@ -144,13 +159,13 @@ export default function Notifications(props: Props) {
const prependUnreads = useCallback(() => {
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)
setUnreads(() => remains)
setUnreadNotifications(() => remains)
setFirstItemIndex(() => firstItemIndex - u.length)
setNotifications(() => [...u, ...notifications])
return false
}, [firstItemIndex, notifications, setNotifications, unreads])
}, [firstItemIndex, notifications, setNotifications, unreadNotifications])
const timelineClass = () => {
if (router.query.detail) {

View File

@ -1,6 +1,15 @@
import { Entity } from 'megalodon'
export type Marker = {
last_read_id: string
version: number
updated_at: string
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 { ThemeProvider } from '@material-tailwind/react'
import { ToastProvider } from '@/utils/toast'
import { UnreadsProvider } from '@/utils/unreads'
export default function MyApp({ Component, pageProps }: AppProps) {
const customTheme = {
@ -104,11 +105,13 @@ export default function MyApp({ Component, pageProps }: AppProps) {
<ThemeProvider value={customTheme}>
<IntlProviderWrapper>
<ToastProvider>
<AccountLayout>
<TimelineLayout>
<Component {...pageProps} />
</TimelineLayout>
</AccountLayout>
<UnreadsProvider>
<AccountLayout>
<TimelineLayout>
<Component {...pageProps} />
</TimelineLayout>
</AccountLayout>
</UnreadsProvider>
</ToastProvider>
</IntlProviderWrapper>
</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>
}