1
0
mirror of https://github.com/h3poteto/whalebird-desktop synced 2025-01-03 12:30:00 +01:00

Make unread notifications stand out

* And implement mark-as-read button
This commit is contained in:
AkiraFukushima 2024-01-28 18:49:20 +09:00
parent 375f13689d
commit f1d9f42c1d
No known key found for this signature in database
GPG Key ID: B6E51BAC4DE1A957
3 changed files with 87 additions and 19 deletions

View File

@ -18,7 +18,8 @@
"cw": "Media hidden", "cw": "Media hidden",
"report": "Report {user}", "report": "Report {user}",
"open_original": "Open original page" "open_original": "Open original page"
} },
"mark_as_read": "Mark as read"
}, },
"accounts": { "accounts": {
"new": { "new": {
@ -116,7 +117,8 @@
"upload_error": "Failed to upload the file", "upload_error": "Failed to upload the file",
"compose": { "compose": {
"post_failed": "Failed to post a status" "post_failed": "Failed to post a status"
} },
"failed_mark": "Failed to mark as read"
}, },
"profile": { "profile": {
"follow": "Follow", "follow": "Follow",

View File

@ -4,9 +4,12 @@ import { useEffect, useState, useCallback, useRef, Dispatch, SetStateAction } fr
import { FormattedMessage, useIntl } from 'react-intl' import { FormattedMessage, useIntl } from 'react-intl'
import { Virtuoso } from 'react-virtuoso' import { Virtuoso } from 'react-virtuoso'
import Notification from './notification/Notification' import Notification from './notification/Notification'
import { Input, 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 { FaCheck } from 'react-icons/fa6'
import { useToast } from '@/utils/toast'
const TIMELINE_STATUSES_COUNT = 30 const TIMELINE_STATUSES_COUNT = 30
const TIMELINE_MAX_STATUSES = 2147483647 const TIMELINE_MAX_STATUSES = 2147483647
@ -21,11 +24,15 @@ 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 [unreads, setUnreads] = 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 [pleromaUnreads, setPleromaUnreads] = useState<Array<string>>([])
const { formatMessage } = useIntl()
const scrollerRef = useRef<HTMLElement | null>(null) const scrollerRef = useRef<HTMLElement | null>(null)
const streaming = useRef<WebSocketInterface | null>(null) const streaming = useRef<WebSocketInterface | null>(null)
const router = useRouter() const router = useRouter()
const showToast = useToast()
const { formatMessage } = useIntl()
useEffect(() => { useEffect(() => {
const f = async () => { const f = async () => {
@ -33,6 +40,7 @@ export default function Notifications(props: Props) {
setNotifications(res) setNotifications(res)
const instance = await props.client.getInstance() const instance = await props.client.getInstance()
const c = generator(props.account.sns, instance.data.urls.streaming_api, props.account.access_token, 'Whalebird') 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 = c.userSocket()
streaming.current.on('connect', () => { streaming.current.on('connect', () => {
console.log('connected to notifications') console.log('connected to notifications')
@ -43,6 +51,7 @@ export default function Notifications(props: Props) {
} else { } else {
setNotifications(current => [notification, ...current]) setNotifications(current => [notification, ...current])
} }
updateMarker(props.client)
}) })
} }
f() f()
@ -60,6 +69,16 @@ export default function Notifications(props: Props) {
} }
}, [props.client]) }, [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<Array<Entity.Notification>> => { const loadNotifications = async (client: MegalodonInterface, maxId?: string): Promise<Array<Entity.Notification>> => {
let options = { limit: 30 } let options = { limit: 30 }
if (maxId) { if (maxId) {
@ -69,6 +88,18 @@ export default function Notifications(props: Props) {
return res.data 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 updateStatus = (status: Entity.Status) => {
const renew = notifications.map(n => { const renew = notifications.map(n => {
if (n.status === undefined || n.status === null) { if (n.status === undefined || n.status === null) {
@ -85,6 +116,22 @@ export default function Notifications(props: Props) {
setNotifications(renew) 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 () => { const loadMore = useCallback(async () => {
console.debug('appending') console.debug('appending')
try { try {
@ -119,13 +166,13 @@ export default function Notifications(props: Props) {
<div className="text-lg font-bold"> <div className="text-lg font-bold">
<FormattedMessage id="timeline.notifications" /> <FormattedMessage id="timeline.notifications" />
</div> </div>
<div className="w-64 text-xs"> <div className="w-64 text-xs text-right">
<form> <button className="text-gray-400 text-base py-1" title={formatMessage({ id: 'timeline.mark_as_read' })} onClick={read}>
<Input type="text" placeholder={formatMessage({ id: 'timeline.search' })} disabled /> <FaCheck />
</form> </button>
</div> </div>
</div> </div>
<div className="timeline overflow-y-auto w-full overflow-x-hidden" style={{ height: 'calc(100% - 50px)' }}> <div className="timeline overflow-y-auto w-full overflow-x-hidden" style={{ height: 'calc(100% - 44px)' }}>
{notifications.length > 0 ? ( {notifications.length > 0 ? (
<Virtuoso <Virtuoso
style={{ height: '100%' }} style={{ height: '100%' }}
@ -137,16 +184,29 @@ export default function Notifications(props: Props) {
className="timeline-scrollable" className="timeline-scrollable"
data={notifications} data={notifications}
endReached={loadMore} endReached={loadMore}
itemContent={(_, notification) => ( itemContent={(_, notification) => {
<Notification let shadow = {}
client={props.client} if (marker) {
account={props.account} if (marker.unread_count && pleromaUnreads.includes(notification.id)) {
notification={notification} shadow = { boxShadow: '4px 0 2px var(--tw-shadow-color) inset' }
onRefresh={updateStatus} } else if (parseInt(marker.last_read_id) < parseInt(notification.id)) {
key={notification.id} shadow = { boxShadow: '4px 0 2px var(--tw-shadow-color) inset' }
openMedia={media => props.setAttachment(media)} }
/> }
)}
return (
<div className="box-border shadow-teal-500/80" style={shadow}>
<Notification
client={props.client}
account={props.account}
notification={notification}
onRefresh={updateStatus}
key={notification.id}
openMedia={media => props.setAttachment(media)}
/>
</div>
)
}}
/> />
) : ( ) : (
<div className="w-full pt-6" style={{ height: '100%' }}> <div className="w-full pt-6" style={{ height: '100%' }}>

View File

@ -0,0 +1,6 @@
export type Marker = {
last_read_id: string
version: number
updated_at: string
unread_count?: number
}