diff --git a/renderer/components/layouts/account.tsx b/renderer/components/layouts/account.tsx index 9900d6db..31d0d83a 100644 --- a/renderer/components/layouts/account.tsx +++ b/renderer/components/layouts/account.tsx @@ -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>([]) + 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) { - openAccount(account.id)} - onContextMenu={() => openContextMenu(account.id)} - /> + 0)}> + openAccount(account.id)} + onContextMenu={() => openContextMenu(account.id)} + /> + ))}
diff --git a/renderer/components/layouts/timelines.tsx b/renderer/components/layouts/timelines.tsx index db0eae26..328f190e 100644 --- a/renderer/components/layouts/timelines.tsx +++ b/renderer/components/layouts/timelines.tsx @@ -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(null) const [lists, setLists] = useState>([]) @@ -89,6 +91,16 @@ export default function Layout({ children }: LayoutProps) { > {page.icon} {page.title} + {page.id === 'notifications' && unreads[account?.id?.toString()] ? ( + + + + ) : null} ))} {lists.map(list => ( diff --git a/renderer/components/timelines/Notifications.tsx b/renderer/components/timelines/Notifications.tsx index d8360a6e..5e1810aa 100644 --- a/renderer/components/timelines/Notifications.tsx +++ b/renderer/components/timelines/Notifications.tsx @@ -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>([]) - const [unreads, setUnreads] = useState>([]) + const [unreadNotifications, setUnreadNotifications] = useState>([]) const [firstItemIndex, setFirstItemIndex] = useState(TIMELINE_MAX_STATUSES) const [marker, setMarker] = useState(null) const [pleromaUnreads, setPleromaUnreads] = useState>([]) @@ -31,7 +32,7 @@ export default function Notifications(props: Props) { const streaming = useRef(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> => { 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) { diff --git a/renderer/entities/marker.ts b/renderer/entities/marker.ts index 41a78ea9..09d55ad7 100644 --- a/renderer/entities/marker.ts +++ b/renderer/entities/marker.ts @@ -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): number { + if (marker.unread_count !== undefined) { + return marker.unread_count + } + return notifications.filter(n => parseInt(n.id) > parseInt(marker.last_read_id)).length +} diff --git a/renderer/pages/_app.tsx b/renderer/pages/_app.tsx index 692568b6..2fc29315 100644 --- a/renderer/pages/_app.tsx +++ b/renderer/pages/_app.tsx @@ -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) { - - - - - + + + + + + + diff --git a/renderer/utils/unreads.tsx b/renderer/utils/unreads.tsx new file mode 100644 index 00000000..1990c8b3 --- /dev/null +++ b/renderer/utils/unreads.tsx @@ -0,0 +1,29 @@ +import { Dispatch, SetStateAction, createContext, useContext, useState } from 'react' + +type UnreadsType = { [key: string]: number } + +type Context = { + unreads: UnreadsType + setUnreads: Dispatch> +} + +const UnreadsContext = createContext({ + unreads: {}, + setUnreads: (_: UnreadsType) => {} +}) + +UnreadsContext.displayName = 'UnreadsContext' + +export const useUnreads = () => { + return useContext(UnreadsContext) +} + +type Props = { + children: React.ReactNode +} + +export const UnreadsProvider: React.FC = ({ children }) => { + const [unreads, setUnreads] = useState({}) + + return {children} +}