From e0f107f0285936964afeeec8f4efbb312d9e3c22 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 30 Dec 2024 16:43:11 +0200 Subject: [PATCH] web/roomlist: add unread counters for spaces Fixes #570 --- web/src/api/statestore/main.ts | 39 +++++++++++++- web/src/api/statestore/room.ts | 3 +- web/src/api/statestore/space.ts | 74 ++++++++++++++++++++++---- web/src/index.css | 6 +++ web/src/ui/roomlist/Entry.tsx | 19 +------ web/src/ui/roomlist/FakeSpace.tsx | 9 ++-- web/src/ui/roomlist/RoomList.css | 82 +++++++++++++++++++---------- web/src/ui/roomlist/RoomList.tsx | 15 ++---- web/src/ui/roomlist/Space.tsx | 3 ++ web/src/ui/roomlist/UnreadCount.tsx | 73 +++++++++++++++++++++++++ 10 files changed, 251 insertions(+), 72 deletions(-) create mode 100644 web/src/ui/roomlist/UnreadCount.tsx diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 724754a..50b07d1 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -39,7 +39,7 @@ import { } from "../types" import { InvitedRoomStore } from "./invitedroom.ts" import { RoomStateStore } from "./room.ts" -import { RoomListFilter, SpaceEdgeStore, SpaceOrphansSpace } from "./space.ts" +import { DirectChatSpace, RoomListFilter, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts" export interface RoomListEntry { room_id: RoomID @@ -76,6 +76,13 @@ export class StateStore { readonly topLevelSpaces = new NonNullCachedEventDispatcher([]) readonly spaceEdges: Map = new Map() readonly spaceOrphans = new SpaceOrphansSpace(this) + readonly directChatsSpace = new DirectChatSpace() + readonly unreadsSpace = new UnreadsSpace() + readonly pseudoSpaces = [ + this.spaceOrphans, + this.directChatsSpace, + this.unreadsSpace, + ] as const currentRoomListQuery: string = "" currentRoomListFilter: RoomListFilter | null = null readonly accountData: Map = new Map() @@ -177,6 +184,25 @@ export class StateStore { } } + #applyUnreadModification(meta: RoomListEntry | null, oldMeta: RoomListEntry | undefined | null) { + const someMeta = meta ?? oldMeta + if (!someMeta) { + return + } + if (this.spaceOrphans.include(someMeta)) { + this.spaceOrphans.applyUnreads(meta, oldMeta) + return + } + if (this.directChatsSpace.include(someMeta)) { + this.directChatsSpace.applyUnreads(meta, oldMeta) + } + for (const space of this.spaceEdges.values()) { + if (space.include(someMeta)) { + space.applyUnreads(meta, oldMeta) + } + } + } + applySync(sync: SyncCompleteData) { if (sync.clear_state && this.rooms.size > 0) { console.info("Clearing state store as sync told to reset and there are rooms in the store") @@ -186,9 +212,11 @@ export class StateStore { const changedRoomListEntries = new Map() for (const data of sync.invited_rooms ?? []) { const room = new InvitedRoomStore(data, this) + const oldEntry = this.inviteRooms.get(room.room_id) this.inviteRooms.set(room.room_id, room) if (!resyncRoomList) { changedRoomListEntries.set(room.room_id, room) + this.#applyUnreadModification(room, oldEntry) } if (this.activeRoomID === room.room_id) { this.switchRoom?.(room.room_id) @@ -209,7 +237,10 @@ export class StateStore { const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room)) room.applySync(data) if (roomListEntryChanged) { - changedRoomListEntries.set(roomID, this.#makeRoomListEntry(data, room)) + const entry = this.#makeRoomListEntry(data, room) + changedRoomListEntries.set(roomID, entry) + this.#applyUnreadModification(entry, room.roomListEntry) + room.roomListEntry = entry } if (!resyncRoomList) { // When we join a valid replacement room, hide the tombstoned room. @@ -256,6 +287,10 @@ export class StateStore { .map(entry => this.#makeRoomListEntry(entry)) .filter(entry => entry !== null)) updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp) + for (const entry of updatedRoomList) { + this.#applyUnreadModification(entry, undefined) + this.rooms.get(entry.room_id)!.roomListEntry = entry + } } else if (changedRoomListEntries.size > 0) { updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id)) for (const entry of changedRoomListEntries.values()) { diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 7419a55..73903a8 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -42,7 +42,7 @@ import { UserID, roomStateGUIDToString, } from "../types" -import type { StateStore } from "./main.ts" +import type { RoomListEntry, StateStore } from "./main.ts" function arraysAreEqual(arr1?: T[], arr2?: T[]): boolean { if (!arr1 || !arr2) { @@ -126,6 +126,7 @@ export class RoomStateStore { readUpToRow = -1 hasMoreHistory = true hidden = false + roomListEntry: RoomListEntry | undefined | null constructor(meta: DBRoom, private parent: StateStore) { this.roomID = meta.room_id diff --git a/web/src/api/statestore/space.ts b/web/src/api/statestore/space.ts index 1815533..8e7eb02 100644 --- a/web/src/api/statestore/space.ts +++ b/web/src/api/statestore/space.ts @@ -15,26 +15,79 @@ // along with this program. If not, see . import { RoomListEntry, StateStore } from "@/api/statestore/main.ts" import { DBSpaceEdge, RoomID } from "@/api/types" +import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" export interface RoomListFilter { id: string include(room: RoomListEntry): boolean } -export const DirectChatSpace: RoomListFilter = { - id: "fi.mau.gomuks.direct_chats", - include: room => !!room.dm_user_id, +export interface SpaceUnreadCounts { + unread_messages: number + unread_notifications: number + unread_highlights: number } -export const UnreadsSpace: RoomListFilter = { - id: "fi.mau.gomuks.unreads", - include: room => Boolean(room.unread_messages - || room.unread_notifications - || room.unread_highlights - || room.marked_unread), +const emptyUnreadCounts: SpaceUnreadCounts = { + unread_messages: 0, + unread_notifications: 0, + unread_highlights: 0, } -export class SpaceEdgeStore implements RoomListFilter { +export abstract class Space implements RoomListFilter { + counts = new NonNullCachedEventDispatcher(emptyUnreadCounts) + + abstract id: string + abstract include(room: RoomListEntry): boolean + + applyUnreads(newCounts?: SpaceUnreadCounts | null, oldCounts?: SpaceUnreadCounts | null) { + const mergedCounts: SpaceUnreadCounts = { + unread_messages: this.counts.current.unread_messages + + (newCounts?.unread_messages ?? 0) - (oldCounts?.unread_messages ?? 0), + unread_notifications: this.counts.current.unread_notifications + + (newCounts?.unread_notifications ?? 0) - (oldCounts?.unread_notifications ?? 0), + unread_highlights: this.counts.current.unread_highlights + + (newCounts?.unread_highlights ?? 0) - (oldCounts?.unread_highlights ?? 0), + } + if (mergedCounts.unread_messages < 0) { + mergedCounts.unread_messages = 0 + } + if (mergedCounts.unread_notifications < 0) { + mergedCounts.unread_notifications = 0 + } + if (mergedCounts.unread_highlights < 0) { + mergedCounts.unread_highlights = 0 + } + if ( + mergedCounts.unread_messages !== this.counts.current.unread_messages + || mergedCounts.unread_notifications !== this.counts.current.unread_notifications + || mergedCounts.unread_highlights !== this.counts.current.unread_highlights + ) { + this.counts.emit(mergedCounts) + } + } +} + +export class DirectChatSpace extends Space { + id = "fi.mau.gomuks.direct_chats" + + include(room: RoomListEntry): boolean { + return Boolean(room.dm_user_id) + } +} + +export class UnreadsSpace extends Space { + id = "fi.mau.gomuks.unreads" + + include(room: RoomListEntry): boolean { + return Boolean(room.unread_messages + || room.unread_notifications + || room.unread_highlights + || room.marked_unread) + } +} + +export class SpaceEdgeStore extends Space { #children: DBSpaceEdge[] = [] #childRooms: Set = new Set() #flattenedRooms: Set = new Set() @@ -42,6 +95,7 @@ export class SpaceEdgeStore implements RoomListFilter { readonly #parentSpaces: Set = new Set() constructor(public id: RoomID, private parent: StateStore) { + super() } #addParent(parent: SpaceEdgeStore) { diff --git a/web/src/index.css b/web/src/index.css index 6517adc..5d6f39d 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -55,6 +55,9 @@ --unread-counter-notification-bg: rgba(50, 150, 0, 0.7); --unread-counter-marked-unread-bg: var(--unread-counter-notification-bg); --unread-counter-highlight-bg: rgba(200, 0, 0, 0.7); + --space-unread-counter-message-bg: rgb(100, 100, 100, 0.9); + --space-unread-counter-notification-bg: rgb(50, 150, 0); + --space-unread-counter-highlight-bg: rgb(200, 0, 0); --sender-color-0: #a4041d; --sender-color-1: #9b2200; @@ -136,6 +139,9 @@ --unread-counter-message-bg: rgba(255, 255, 255, 0.5); --unread-counter-notification-bg: rgba(150, 255, 0, 0.7); --unread-counter-highlight-bg: rgba(255, 50, 50, 0.7); + --space-unread-counter-message-bg: rgb(200, 200, 200, 0.8); + --space-unread-counter-notification-bg: rgb(150, 255, 0); + --space-unread-counter-highlight-bg: rgb(255, 50, 50); --sender-color-0: #ff877c; --sender-color-1: #f6913d; diff --git a/web/src/ui/roomlist/Entry.tsx b/web/src/ui/roomlist/Entry.tsx index c26f2fa..901d64f 100644 --- a/web/src/ui/roomlist/Entry.tsx +++ b/web/src/ui/roomlist/Entry.tsx @@ -21,6 +21,7 @@ import useContentVisibility from "@/util/contentvisibility.ts" import { getDisplayname } from "@/util/validation.ts" import ClientContext from "../ClientContext.ts" import MainScreenContext from "../MainScreenContext.ts" +import UnreadCount from "./UnreadCount.tsx" export interface RoomListEntryProps { room: RoomListEntry @@ -56,14 +57,6 @@ function getPreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null): function renderEntry(room: RoomListEntry) { const [previewText, croppedPreviewText] = getPreviewText(room.preview_event, room.preview_sender) - const unreadCount = room.unread_messages || room.unread_notifications || room.unread_highlights - const countIsBig = Boolean(room.unread_notifications || room.unread_highlights) - let unreadCountDisplay = unreadCount.toString() - if (unreadCount > 999 && countIsBig) { - unreadCountDisplay = "99+" - } else if (unreadCount > 9999 && countIsBig) { - unreadCountDisplay = "999+" - } return <>
@@ -78,15 +71,7 @@ function renderEntry(room: RoomListEntry) {
{room.name}
{previewText &&
{croppedPreviewText}
}
- {(room.unread_messages || room.marked_unread) ?
-
- {unreadCountDisplay} -
-
: null} + } diff --git a/web/src/ui/roomlist/FakeSpace.tsx b/web/src/ui/roomlist/FakeSpace.tsx index f3ffa53..f8c595a 100644 --- a/web/src/ui/roomlist/FakeSpace.tsx +++ b/web/src/ui/roomlist/FakeSpace.tsx @@ -14,7 +14,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { JSX } from "react" -import type { RoomListFilter } from "@/api/statestore/space.ts" +import { RoomListFilter, Space } from "@/api/statestore/space.ts" +import { useEventAsState } from "@/util/eventdispatcher.ts" +import UnreadCount from "./UnreadCount.tsx" import HomeIcon from "@/icons/home.svg?react" import NotificationsIcon from "@/icons/notifications.svg?react" import PersonIcon from "@/icons/person.svg?react" @@ -22,7 +24,7 @@ import TagIcon from "@/icons/tag.svg?react" import "./RoomList.css" export interface FakeSpaceProps { - space: RoomListFilter | null + space: Space | null setSpace: (space: RoomListFilter | null) => void isActive: boolean } @@ -43,10 +45,11 @@ const getFakeSpaceIcon = (space: RoomListFilter | null): JSX.Element | null => { } const FakeSpace = ({ space, setSpace, isActive }: FakeSpaceProps) => { + const unreads = useEventAsState(space?.counts) return
setSpace(space)}> + {getFakeSpaceIcon(space)}
- } export default FakeSpace diff --git a/web/src/ui/roomlist/RoomList.css b/web/src/ui/roomlist/RoomList.css index ca6504a..cc83edf 100644 --- a/web/src/ui/roomlist/RoomList.css +++ b/web/src/ui/roomlist/RoomList.css @@ -51,6 +51,17 @@ div.space-bar { width: 100%; height: 100%; } + + > div.room-entry-unreads { + z-index: 2; + height: 0; + position: relative; + + > div.unread-count { + position: absolute; + top: 0; + } + } } } @@ -138,51 +149,64 @@ div.room-entry { } } } +} + +div.room-entry-unreads { + display: flex; + align-items: center; + justify-content: center; + width: 3rem; + margin-right: .25rem; + + > div.unread-count { + --unread-count-size: 1rem; + --unread-count-padding-inline: calc(var(--unread-count-size)/4); + --unread-count-padding-block: calc(var(--unread-count-size)/8); - > div.room-entry-unreads { display: flex; align-items: center; justify-content: center; - width: 3rem; - margin-right: .25rem; + border-radius: var(--unread-count-size); + color: var(--unread-counter-text-color); - > div.unread-count { - --unread-count-size: 1rem; - --unread-count-padding-inline: calc(var(--unread-count-size)/4); - --unread-count-padding-block: calc(var(--unread-count-size)/8); + background-color: var(--unread-counter-message-bg); + height: var(--unread-count-size); + min-width: calc(var(--unread-count-size) - 2*(var(--unread-count-padding-inline) - var(--unread-count-padding-block))); - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--unread-count-size); - color: var(--unread-counter-text-color); + line-height: 1; + font-size: .75em; - background-color: var(--unread-counter-message-bg); - height: var(--unread-count-size); - min-width: calc(var(--unread-count-size) - 2*(var(--unread-count-padding-inline) - var(--unread-count-padding-block))); + padding-inline: var(--unread-count-padding-inline); + padding-block: var(--unread-count-padding-block); - line-height: 1; - font-size: .75em; + &.big { + --unread-count-size: 1.5rem; + font-size: 1em; + font-weight: bold; + } - padding-inline: var(--unread-count-padding-inline); - padding-block: var(--unread-count-padding-block); + &.marked-unread { + background-color: var(--unread-counter-marked-unread-bg); + } - &.notified, &.marked-unread, &.highlighted { - --unread-count-size: 1.5rem; - font-size: 1em; - font-weight: bold; - } + &.notified { + background-color: var(--unread-counter-notification-bg); + } - &.marked-unread { - background-color: var(--unread-counter-marked-unread-bg); - } + &.highlighted { + background-color: var(--unread-counter-highlight-bg); + } + + &.space { + --unread-count-size: .75rem; + background-color: var(--space-unread-counter-message-bg); &.notified { - background-color: var(--unread-counter-notification-bg); + background-color: var(--space-unread-counter-notification-bg); } &.highlighted { - background-color: var(--unread-counter-highlight-bg); + background-color: var(--space-unread-counter-highlight-bg); } } } diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx index 2e84442..9b6ace3 100644 --- a/web/src/ui/roomlist/RoomList.tsx +++ b/web/src/ui/roomlist/RoomList.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import React, { use, useCallback, useRef, useState } from "react" -import { DirectChatSpace, RoomListFilter, UnreadsSpace } from "@/api/statestore/space.ts" +import { RoomListFilter } from "@/api/statestore/space.ts" import type { RoomID } from "@/api/types" import { useEventAsState } from "@/util/eventdispatcher.ts" import reverseMap from "@/util/reversemap.ts" @@ -75,12 +75,6 @@ const RoomList = ({ activeRoomID }: RoomListProps) => { } const roomListFilter = client.store.roomListFilterFunc - const pseudoSpaces = [ - null, - DirectChatSpace, - UnreadsSpace, - client.store.spaceOrphans, - ] return
{
- {pseudoSpaces.map(pseudoSpace => + {client.store.pseudoSpaces.map(pseudoSpace => )} {spaces.map(roomID => { + const unreads = useEventAsState(client.store.spaceEdges.get(roomID)?.counts) const room = useEventAsState(client.store.rooms.get(roomID)?.meta) if (!room) { return } return
+ {room.name}
} diff --git a/web/src/ui/roomlist/UnreadCount.tsx b/web/src/ui/roomlist/UnreadCount.tsx new file mode 100644 index 0000000..1c4f9c1 --- /dev/null +++ b/web/src/ui/roomlist/UnreadCount.tsx @@ -0,0 +1,73 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { SpaceUnreadCounts } from "@/api/statestore/space.ts" + +interface UnreadCounts extends SpaceUnreadCounts { + marked_unread?: boolean +} + +interface UnreadCountProps { + counts: UnreadCounts | null + space?: true +} + +const UnreadCount = ({ counts, space }: UnreadCountProps) => { + if (!counts) { + return null + } + const unreadCount = space + ? counts.unread_highlights || counts.unread_notifications || counts.unread_messages + : counts.unread_messages || counts.unread_notifications || counts.unread_highlights + if (!unreadCount && !counts.marked_unread) { + return null + } + const countIsBig = !space && Boolean(counts.unread_notifications || counts.unread_highlights || counts.marked_unread) + let unreadCountDisplay = unreadCount.toString() + if (unreadCount > 999 && countIsBig) { + unreadCountDisplay = "99+" + } else if (unreadCount > 9999 && countIsBig) { + unreadCountDisplay = "999+" + } + const classNames = ["unread-count"] + if (countIsBig) { + classNames.push("big") + } + let unreadCountTitle = unreadCount.toString() + if (space) { + classNames.push("space") + unreadCountTitle = [ + counts.unread_highlights && `${counts.unread_highlights} highlights`, + counts.unread_notifications && `${counts.unread_notifications} notifications`, + counts.unread_messages && `${counts.unread_messages} messages`, + ].filter(x => !!x).join("\n") + } + if (counts.marked_unread) { + classNames.push("marked-unread") + } + if (counts.unread_notifications) { + classNames.push("notified") + } + if (counts.unread_highlights) { + classNames.push("highlighted") + } + return
+
+ {unreadCountDisplay} +
+
+} + +export default UnreadCount