Show unread badges for notifications
This commit is contained in:
parent
2c64c19127
commit
0851cfbde8
|
@ -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">
|
||||||
|
|
|
@ -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 => (
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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