diff --git a/locales/en/translation.json b/locales/en/translation.json index 9e40b8cc..5899a916 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -18,7 +18,8 @@ "cw": "Media hidden", "report": "Report {user}", "open_original": "Open original page" - } + }, + "mark_as_read": "Mark as read" }, "accounts": { "new": { @@ -116,7 +117,8 @@ "upload_error": "Failed to upload the file", "compose": { "post_failed": "Failed to post a status" - } + }, + "failed_mark": "Failed to mark as read" }, "profile": { "follow": "Follow", diff --git a/renderer/components/timelines/Notifications.tsx b/renderer/components/timelines/Notifications.tsx index 75a6b4f9..d8360a6e 100644 --- a/renderer/components/timelines/Notifications.tsx +++ b/renderer/components/timelines/Notifications.tsx @@ -4,9 +4,12 @@ import { useEffect, useState, useCallback, useRef, Dispatch, SetStateAction } fr import { FormattedMessage, useIntl } from 'react-intl' import { Virtuoso } from 'react-virtuoso' import Notification from './notification/Notification' -import { Input, Spinner } from '@material-tailwind/react' +import { Spinner } from '@material-tailwind/react' import { useRouter } from 'next/router' import Detail from '../detail/Detail' +import { Marker } from '@/entities/marker' +import { FaCheck } from 'react-icons/fa6' +import { useToast } from '@/utils/toast' const TIMELINE_STATUSES_COUNT = 30 const TIMELINE_MAX_STATUSES = 2147483647 @@ -21,11 +24,15 @@ export default function Notifications(props: Props) { const [notifications, setNotifications] = useState>([]) const [unreads, setUnreads] = useState>([]) const [firstItemIndex, setFirstItemIndex] = useState(TIMELINE_MAX_STATUSES) + const [marker, setMarker] = useState(null) + const [pleromaUnreads, setPleromaUnreads] = useState>([]) - const { formatMessage } = useIntl() const scrollerRef = useRef(null) const streaming = useRef(null) const router = useRouter() + const showToast = useToast() + + const { formatMessage } = useIntl() useEffect(() => { const f = async () => { @@ -33,6 +40,7 @@ export default function Notifications(props: Props) { setNotifications(res) const instance = await props.client.getInstance() const c = generator(props.account.sns, instance.data.urls.streaming_api, props.account.access_token, 'Whalebird') + updateMarker(props.client) streaming.current = c.userSocket() streaming.current.on('connect', () => { console.log('connected to notifications') @@ -43,6 +51,7 @@ export default function Notifications(props: Props) { } else { setNotifications(current => [notification, ...current]) } + updateMarker(props.client) }) } f() @@ -60,6 +69,16 @@ export default function Notifications(props: Props) { } }, [props.client]) + useEffect(() => { + // 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 u = allNotifications.slice(0, marker.unread_count).map(n => n.id) + setPleromaUnreads(u) + } + }, [marker, unreads, notifications]) + const loadNotifications = async (client: MegalodonInterface, maxId?: string): Promise> => { let options = { limit: 30 } if (maxId) { @@ -69,6 +88,18 @@ export default function Notifications(props: Props) { return res.data } + const updateMarker = async (client: MegalodonInterface) => { + try { + const res = await client.getMarkers(['notifications']) + const marker = res.data as Entity.Marker + if (marker.notifications) { + setMarker(marker.notifications) + } + } catch (err) { + console.error(err) + } + } + const updateStatus = (status: Entity.Status) => { const renew = notifications.map(n => { if (n.status === undefined || n.status === null) { @@ -85,6 +116,22 @@ export default function Notifications(props: Props) { setNotifications(renew) } + const read = async () => { + try { + await props.client.saveMarkers({ notifications: { last_read_id: notifications[0].id } }) + if (props.account.sns === 'pleroma') { + await props.client.readNotifications({ max_id: notifications[0].id }) + } + const res = await props.client.getMarkers(['notifications']) + const marker = res.data as Entity.Marker + if (marker.notifications) { + setMarker(marker.notifications) + } + } catch { + showToast({ text: formatMessage({ id: 'alert.failed_mark' }), type: 'failure' }) + } + } + const loadMore = useCallback(async () => { console.debug('appending') try { @@ -119,13 +166,13 @@ export default function Notifications(props: Props) {
-
-
- -
+
+
-
+
{notifications.length > 0 ? ( ( - props.setAttachment(media)} - /> - )} + itemContent={(_, notification) => { + let shadow = {} + if (marker) { + if (marker.unread_count && pleromaUnreads.includes(notification.id)) { + shadow = { boxShadow: '4px 0 2px var(--tw-shadow-color) inset' } + } else if (parseInt(marker.last_read_id) < parseInt(notification.id)) { + shadow = { boxShadow: '4px 0 2px var(--tw-shadow-color) inset' } + } + } + + return ( +
+ props.setAttachment(media)} + /> +
+ ) + }} /> ) : (
diff --git a/renderer/entities/marker.ts b/renderer/entities/marker.ts new file mode 100644 index 00000000..41a78ea9 --- /dev/null +++ b/renderer/entities/marker.ts @@ -0,0 +1,6 @@ +export type Marker = { + last_read_id: string + version: number + updated_at: string + unread_count?: number +}