Merge pull request #4814 from h3poteto/feat/unread-badge
Show unread badges for notifications
This commit is contained in:
commit
f31759b656
|
@ -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">
|
||||
|
|
|
@ -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 => (
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
}
|
Loading…
Reference in New Issue