From a0bc1b0d17f2badab4689165f8acecca9c647d40 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 29 Dec 2024 14:39:43 +0200 Subject: [PATCH 01/16] web/statestore: fix dm_user_id field in room list entries --- web/src/api/media.ts | 4 ++-- web/src/api/statestore/main.ts | 4 ++-- web/src/api/statestore/room.ts | 2 +- web/src/api/types/mxtypes.ts | 2 +- web/src/ui/rightpanel/UserInfoMutualRooms.tsx | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/web/src/api/media.ts b/web/src/api/media.ts index 5ece955..5c97ac1 100644 --- a/web/src/api/media.ts +++ b/web/src/api/media.ts @@ -103,8 +103,8 @@ export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: Conten if ("dm_user_id" in room) { dmUserID = room.dm_user_id } else if ("lazy_load_summary" in room) { - dmUserID = room.lazy_load_summary?.heroes?.length === 1 - ? room.lazy_load_summary.heroes[0] : undefined + dmUserID = room.lazy_load_summary?.["m.heroes"]?.length === 1 + ? room.lazy_load_summary["m.heroes"][0] : undefined } return getAvatarURL(dmUserID ?? room.room_id, { displayname: room.name, diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 65dac61..f4332f8 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -143,8 +143,8 @@ export class StateStore { const name = entry.meta.name ?? "Unnamed room" return { room_id: entry.meta.room_id, - dm_user_id: entry.meta.lazy_load_summary?.heroes?.length === 1 - ? entry.meta.lazy_load_summary.heroes[0] : undefined, + dm_user_id: entry.meta.lazy_load_summary?.["m.heroes"]?.length === 1 + ? entry.meta.lazy_load_summary["m.heroes"][0] : undefined, sorting_timestamp: entry.meta.sorting_timestamp, preview_event, preview_sender, diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 3896de7..20b4ae3 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -62,7 +62,7 @@ function arraysAreEqual(arr1?: T[], arr2?: T[]): boolean { function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean { return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] && ll1?.["m.invited_member_count"] === ll2?.["m.invited_member_count"] && - arraysAreEqual(ll1?.heroes, ll2?.heroes) + arraysAreEqual(ll1?.["m.heroes"], ll2?.["m.heroes"]) } function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean { diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 45fe52c..5932e3e 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -43,7 +43,7 @@ export interface TombstoneEventContent { } export interface LazyLoadSummary { - heroes?: UserID[] + "m.heroes"?: UserID[] "m.joined_member_count"?: number "m.invited_member_count"?: number } diff --git a/web/src/ui/rightpanel/UserInfoMutualRooms.tsx b/web/src/ui/rightpanel/UserInfoMutualRooms.tsx index 3238699..fadd041 100644 --- a/web/src/ui/rightpanel/UserInfoMutualRooms.tsx +++ b/web/src/ui/rightpanel/UserInfoMutualRooms.tsx @@ -40,8 +40,8 @@ const MutualRooms = ({ client, userID }: MutualRoomsProps) => { } return { room_id: roomID, - dm_user_id: roomData.meta.current.lazy_load_summary?.heroes?.length === 1 - ? roomData.meta.current.lazy_load_summary.heroes[0] : undefined, + dm_user_id: roomData.meta.current.lazy_load_summary?.["m.heroes"]?.length === 1 + ? roomData.meta.current.lazy_load_summary["m.heroes"][0] : undefined, name: roomData.meta.current.name ?? "Unnamed room", avatar: roomData.meta.current.avatar, search_name: "", From e750c19e8a55df2fad1288bdc3d3f9667d7f3337 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 29 Dec 2024 15:07:18 +0200 Subject: [PATCH 02/16] web/mainscreen: fix handling popstate event to null state --- web/src/ui/MainScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 2ae5194..63173ed 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -294,8 +294,8 @@ const MainScreen = () => { const roomID = evt.state?.room_id ?? null if (roomID !== client.store.activeRoomID) { context.setActiveRoom(roomID, { - alias: ensureString(evt?.state.source_alias) || undefined, - via: ensureStringArray(evt?.state.source_via), + alias: ensureString(evt.state?.source_alias) || undefined, + via: ensureStringArray(evt.state?.source_via), }, false) } context.setRightPanel(evt.state?.right_panel ?? null, false) From a1bddd6b6b2bc599d6c0ac062653351e8a77e2da Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 29 Dec 2024 16:37:14 +0200 Subject: [PATCH 03/16] web/timeline: add special message menu for mobile --- web/src/ui/timeline/TimelineEvent.tsx | 18 +++++++++++-- web/src/ui/timeline/menu/EventMenu.tsx | 19 +++++++++++--- web/src/ui/timeline/menu/index.css | 26 ++++++++++++++++--- web/src/ui/timeline/menu/index.ts | 2 +- web/src/ui/timeline/menu/usePrimaryItems.tsx | 10 ++++--- .../ui/timeline/menu/useSecondaryItems.tsx | 13 ++++++---- 6 files changed, 68 insertions(+), 20 deletions(-) diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 54c0726..760428e 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -27,7 +27,7 @@ import ReadReceipts from "./ReadReceipts.tsx" import { ReplyIDBody } from "./ReplyBody.tsx" import URLPreviews from "./URLPreviews.tsx" import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content" -import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu" +import { EventFixedMenu, EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu" import ErrorIcon from "@/icons/error.svg?react" import PendingIcon from "@/icons/pending.svg?react" import SentIcon from "@/icons/sent.svg?react" @@ -98,6 +98,19 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven />, }) } + const onClick = (mouseEvt: React.MouseEvent) => { + const targetElem = mouseEvt.target as HTMLElement + if ( + targetElem.tagName === "A" + || targetElem.tagName === "IMG" + ) { + return + } + mouseEvt.preventDefault() + openModal({ + content: , + }) + } const memberEvt = useRoomMember(client, roomCtx.store, evt.sender) const memberEvtContent = memberEvt?.content as MemberEventContent | undefined const BodyType = getBodyType(evt) @@ -175,11 +188,12 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven data-event-id={evt.event_id} className={wrapperClassNames.join(" ")} onContextMenu={onContextMenu} + onClick={!disableMenu && isMobileDevice ? onClick : undefined} > {!disableMenu && !isMobileDevice &&
- +
} {replyAboveMessage} {renderAvatar &&
void } -export const EventHoverMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => { - const elements = usePrimaryItems(use(ClientContext)!, useRoomContext(), evt, true, undefined, setForceOpen) +export const EventHoverMenu = ({ evt, roomCtx, setForceOpen }: EventHoverMenuProps) => { + const elements = usePrimaryItems(use(ClientContext)!, roomCtx, evt, true, false, undefined, setForceOpen) return
{elements}
} @@ -43,7 +44,7 @@ export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) = export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => { const client = use(ClientContext)! - const primary = usePrimaryItems(client, roomCtx, evt, false, style, undefined) + const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined) const secondary = useSecondaryItems(client, roomCtx, evt) return
{primary} @@ -51,3 +52,13 @@ export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {secondary}
} + +export const EventFixedMenu = ({ evt, roomCtx }: Omit) => { + const client = use(ClientContext)! + const primary = usePrimaryItems(client, roomCtx, evt, false, true, undefined, undefined) + const secondary = useSecondaryItems(client, roomCtx, evt, false) + return
+ {primary} + {secondary} +
+} diff --git a/web/src/ui/timeline/menu/index.css b/web/src/ui/timeline/menu/index.css index 9005345..da720c7 100644 --- a/web/src/ui/timeline/menu/index.css +++ b/web/src/ui/timeline/menu/index.css @@ -2,13 +2,9 @@ div.event-hover-menu { position: absolute; right: .5rem; top: -1.5rem; - background-color: var(--background-color); border: 1px solid var(--border-color); border-radius: .5rem; - display: flex; - gap: .25rem; padding: .125rem; - z-index: 1; > button { width: 2rem; @@ -16,6 +12,28 @@ div.event-hover-menu { } } +div.event-hover-menu, div.event-fixed-menu { + display: flex; + gap: .25rem; + background-color: var(--background-color); + z-index: 1; +} + +div.event-fixed-menu { + position: fixed; + inset: 0 0 auto; + height: 3rem; + padding: .25rem; + border-bottom: 1px solid var(--border-color); + justify-content: right; + flex-direction: row-reverse; + + > button { + width: 3rem; + height: 3rem; + } +} + div.event-context-menu { position: fixed; background-color: var(--background-color); diff --git a/web/src/ui/timeline/menu/index.ts b/web/src/ui/timeline/menu/index.ts index fc25eb0..4f89140 100644 --- a/web/src/ui/timeline/menu/index.ts +++ b/web/src/ui/timeline/menu/index.ts @@ -13,5 +13,5 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -export { EventExtraMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx" +export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx" export { getModalStyleFromMouse } from "./util.ts" diff --git a/web/src/ui/timeline/menu/usePrimaryItems.tsx b/web/src/ui/timeline/menu/usePrimaryItems.tsx index 1ebb0a9..d1f374c 100644 --- a/web/src/ui/timeline/menu/usePrimaryItems.tsx +++ b/web/src/ui/timeline/menu/usePrimaryItems.tsx @@ -37,9 +37,11 @@ export const usePrimaryItems = ( roomCtx: RoomContextData, evt: MemDBEvent, isHover: boolean, + isFixed: boolean, style?: CSSProperties, setForceOpen?: (forceOpen: boolean) => void, ) => { + const names = !isHover && !isFixed const closeModal = !isHover ? use(ModalCloseContext) : noop const openModal = use(ModalContext) @@ -108,11 +110,11 @@ export const usePrimaryItems = ( return <> {didFail && } {canReact && } {canSend && } {canEdit && } {isHover && } diff --git a/web/src/ui/timeline/menu/useSecondaryItems.tsx b/web/src/ui/timeline/menu/useSecondaryItems.tsx index f577aca..134d4f7 100644 --- a/web/src/ui/timeline/menu/useSecondaryItems.tsx +++ b/web/src/ui/timeline/menu/useSecondaryItems.tsx @@ -32,6 +32,7 @@ export const useSecondaryItems = ( client: Client, roomCtx: RoomContextData, evt: MemDBEvent, + names = true, ) => { const closeModal = use(ModalCloseContext) const openModal = use(ModalContext) @@ -102,20 +103,22 @@ export const useSecondaryItems = ( && (evt.sender === client.userID || ownPL >= redactOtherPL) return <> - + {ownPL >= pinPL && (pins.includes(evt.event_id) ? : )} - + {canRedact && } + >{names && "Remove"}} } From 8fa54a5beabc47534e3fa08828e276b1ff8c9620 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 29 Dec 2024 16:44:33 +0200 Subject: [PATCH 04/16] web/timeline: fix fixed menu redact button color --- web/src/ui/timeline/menu/index.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/src/ui/timeline/menu/index.css b/web/src/ui/timeline/menu/index.css index da720c7..e5abf5c 100644 --- a/web/src/ui/timeline/menu/index.css +++ b/web/src/ui/timeline/menu/index.css @@ -31,6 +31,10 @@ div.event-fixed-menu { > button { width: 3rem; height: 3rem; + + &.redact-button { + color: var(--error-color); + } } } From f83b914af096ac129cc4f5463ddde9b662ca1579 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 29 Dec 2024 17:01:08 +0200 Subject: [PATCH 05/16] web/timeline: align fixed menu size with room header --- web/src/ui/timeline/menu/index.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/ui/timeline/menu/index.css b/web/src/ui/timeline/menu/index.css index e5abf5c..2750816 100644 --- a/web/src/ui/timeline/menu/index.css +++ b/web/src/ui/timeline/menu/index.css @@ -22,9 +22,10 @@ div.event-hover-menu, div.event-fixed-menu { div.event-fixed-menu { position: fixed; inset: 0 0 auto; - height: 3rem; + height: 3.5rem; padding: .25rem; border-bottom: 1px solid var(--border-color); + box-sizing: border-box; justify-content: right; flex-direction: row-reverse; From 08a1712850c97f475dde7aa540c3a6f32f907ddb Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 29 Dec 2024 17:30:27 +0200 Subject: [PATCH 06/16] web/modal: don't capture input in context menu modal --- web/src/ui/modal/Modal.tsx | 31 ++++++++++++++++----------- web/src/ui/modal/contexts.ts | 1 + web/src/ui/timeline/TimelineEvent.tsx | 18 +++++++++++++--- web/src/ui/timeline/menu/index.css | 1 + web/src/vite-env.d.ts | 2 ++ 5 files changed, 38 insertions(+), 15 deletions(-) diff --git a/web/src/ui/modal/Modal.tsx b/web/src/ui/modal/Modal.tsx index 83a1176..1fc3ab6 100644 --- a/web/src/ui/modal/Modal.tsx +++ b/web/src/ui/modal/Modal.tsx @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { JSX, useCallback, useLayoutEffect, useReducer, useRef } from "react" +import React, { JSX, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react" import { ModalCloseContext, ModalContext, ModalState } from "./contexts.ts" const ModalWrapper = ({ children }: { children: React.ReactNode }) => { @@ -40,7 +40,7 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => { evt.stopPropagation() } const openModal = useCallback((newState: ModalState) => { - if (!history.state?.modal) { + if (!history.state?.modal && newState.captureInput !== false) { history.pushState({ ...(history.state ?? {}), modal: true }, "") } setState(newState) @@ -50,6 +50,9 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => { if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) { wrapperRef.current.focus() } + }, [state]) + useEffect(() => { + window.closeModal = onClickWrapper const listener = (evt: PopStateEvent) => { if (!evt.state?.modal) { setState(null) @@ -57,7 +60,7 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => { } window.addEventListener("popstate", listener) return () => window.removeEventListener("popstate", listener) - }, [state]) + }, []) let modal: JSX.Element | null = null if (state) { let content = {state.content} @@ -68,15 +71,19 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
} - modal =
- {content} -
+ if (state.captureInput !== false) { + modal =
+ {content} +
+ } else { + modal = content + } } return {children} diff --git a/web/src/ui/modal/contexts.ts b/web/src/ui/modal/contexts.ts index e7fa7c7..dad5963 100644 --- a/web/src/ui/modal/contexts.ts +++ b/web/src/ui/modal/contexts.ts @@ -32,6 +32,7 @@ export interface ModalState { boxClass?: string innerBoxClass?: string onClose?: () => void + captureInput?: boolean } type openModal = (state: ModalState) => void diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 760428e..38b15d7 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -107,9 +107,21 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven return } mouseEvt.preventDefault() - openModal({ - content: , - }) + if (window.hackyOpenEventContextMenu === evt.event_id) { + window.closeModal() + window.hackyOpenEventContextMenu = undefined + } else { + openModal({ + content: , + captureInput: false, + onClose: () => { + if (window.hackyOpenEventContextMenu === evt.event_id) { + window.hackyOpenEventContextMenu = undefined + } + }, + }) + window.hackyOpenEventContextMenu = evt.event_id + } } const memberEvt = useRoomMember(client, roomCtx.store, evt.sender) const memberEvtContent = memberEvt?.content as MemberEventContent | undefined diff --git a/web/src/ui/timeline/menu/index.css b/web/src/ui/timeline/menu/index.css index 2750816..535273b 100644 --- a/web/src/ui/timeline/menu/index.css +++ b/web/src/ui/timeline/menu/index.css @@ -28,6 +28,7 @@ div.event-fixed-menu { box-sizing: border-box; justify-content: right; flex-direction: row-reverse; + overflow-x: auto; > button { width: 3rem; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index ceffa1a..9cef2d8 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -14,5 +14,7 @@ declare global { mainScreenContext: MainScreenContextFields openLightbox: (params: { src: string, alt: string }) => void gcSettings: GCSettings + hackyOpenEventContextMenu?: string + closeModal: () => void } } From 622bc5d8042bcd844c27eef8c280b8c74fb9e249 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 29 Dec 2024 17:51:40 +0200 Subject: [PATCH 07/16] web/timeline: fix scrolling mobile context menu --- web/src/ui/timeline/menu/index.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/ui/timeline/menu/index.css b/web/src/ui/timeline/menu/index.css index 535273b..44bb8c0 100644 --- a/web/src/ui/timeline/menu/index.css +++ b/web/src/ui/timeline/menu/index.css @@ -29,10 +29,12 @@ div.event-fixed-menu { justify-content: right; flex-direction: row-reverse; overflow-x: auto; + overflow-y: hidden; > button { width: 3rem; height: 3rem; + flex-shrink: 0; &.redact-button { color: var(--error-color); From 326b06c702f6b003afe6ebf18485863c23c8c0a3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 28 Dec 2024 18:36:41 +0200 Subject: [PATCH 08/16] hicli/database: store spaces edges --- pkg/hicli/database/database.go | 46 ++-- pkg/hicli/database/room.go | 3 +- pkg/hicli/database/space.go | 250 ++++++++++++++++++ .../database/upgrades/00-latest-revision.sql | 27 +- pkg/hicli/database/upgrades/10-spaces.sql | 113 ++++++++ pkg/hicli/events.go | 1 + pkg/hicli/init.go | 10 + pkg/hicli/paginate.go | 9 +- pkg/hicli/sync.go | 100 ++++++- pkg/hicli/syncwrap.go | 1 + 10 files changed, 533 insertions(+), 27 deletions(-) create mode 100644 pkg/hicli/database/space.go create mode 100644 pkg/hicli/database/upgrades/10-spaces.sql diff --git a/pkg/hicli/database/database.go b/pkg/hicli/database/database.go index 7299f21..ed1a1b4 100644 --- a/pkg/hicli/database/database.go +++ b/pkg/hicli/database/database.go @@ -17,16 +17,17 @@ import ( type Database struct { *dbutil.Database - Account AccountQuery - AccountData AccountDataQuery - Room RoomQuery - InvitedRoom InvitedRoomQuery - Event EventQuery - CurrentState CurrentStateQuery - Timeline TimelineQuery - SessionRequest SessionRequestQuery - Receipt ReceiptQuery - Media MediaQuery + Account *AccountQuery + AccountData *AccountDataQuery + Room *RoomQuery + InvitedRoom *InvitedRoomQuery + Event *EventQuery + CurrentState *CurrentStateQuery + Timeline *TimelineQuery + SessionRequest *SessionRequestQuery + Receipt *ReceiptQuery + Media *MediaQuery + SpaceEdge *SpaceEdgeQuery } func New(rawDB *dbutil.Database) *Database { @@ -35,16 +36,17 @@ func New(rawDB *dbutil.Database) *Database { return &Database{ Database: rawDB, - Account: AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)}, - AccountData: AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)}, - Room: RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)}, - InvitedRoom: InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)}, - Event: EventQuery{QueryHelper: eventQH}, - CurrentState: CurrentStateQuery{QueryHelper: eventQH}, - Timeline: TimelineQuery{QueryHelper: eventQH}, - SessionRequest: SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)}, - Receipt: ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)}, - Media: MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)}, + Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)}, + AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)}, + Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)}, + InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)}, + Event: &EventQuery{QueryHelper: eventQH}, + CurrentState: &CurrentStateQuery{QueryHelper: eventQH}, + Timeline: &TimelineQuery{QueryHelper: eventQH}, + SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)}, + Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)}, + Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)}, + SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)}, } } @@ -79,3 +81,7 @@ func newAccountData(_ *dbutil.QueryHelper[*AccountData]) *AccountData { func newAccount(_ *dbutil.QueryHelper[*Account]) *Account { return &Account{} } + +func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge { + return &SpaceEdge{} +} diff --git a/pkg/hicli/database/room.go b/pkg/hicli/database/room.go index f26ef15..234f00f 100644 --- a/pkg/hicli/database/room.go +++ b/pkg/hicli/database/room.go @@ -34,7 +34,8 @@ const ( ` upsertRoomFromSyncQuery = ` UPDATE room - SET creation_content = COALESCE(room.creation_content, $2), + SET room_type = COALESCE(room.room_type, json($2)->>'$.type'), + creation_content = COALESCE(room.creation_content, $2), tombstone_content = COALESCE(room.tombstone_content, $3), name = COALESCE($4, room.name), name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END, diff --git a/pkg/hicli/database/space.go b/pkg/hicli/database/space.go new file mode 100644 index 0000000..8ff6710 --- /dev/null +++ b/pkg/hicli/database/space.go @@ -0,0 +1,250 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package database + +import ( + "context" + "database/sql" + + "go.mau.fi/util/dbutil" + "maunium.net/go/mautrix/id" +) + +const ( + getAllSpaceChildren = ` + SELECT space_id, child_id, depth, child_event_rowid, "order", suggested, parent_event_rowid, canonical, parent_validated + FROM space_edge + WHERE (space_id = $1 OR $1 = '') AND depth IS NOT NULL AND (child_event_rowid IS NOT NULL OR parent_validated) + ORDER BY depth, space_id, "order", child_id + ` + // language=sqlite - for some reason GoLand doesn't auto-detect SQL when using WITH RECURSIVE + recalculateAllSpaceChildDepths = ` + UPDATE space_edge SET depth = NULL; + WITH RECURSIVE + top_level_spaces AS ( + SELECT space_id + FROM (SELECT DISTINCT(space_id) FROM space_edge) outeredge + INNER JOIN room ON outeredge.space_id = room.room_id AND room.room_type = 'm.space' + WHERE NOT EXISTS( + SELECT 1 + FROM space_edge inneredge + INNER JOIN room ON inneredge.space_id = room.room_id + WHERE inneredge.child_id=outeredge.space_id + AND (inneredge.child_event_rowid IS NOT NULL OR inneredge.parent_validated) + ) + ), + children AS ( + SELECT space_id, child_id, 1 AS depth, space_id AS path + FROM space_edge + WHERE space_id IN top_level_spaces AND (child_event_rowid IS NOT NULL OR parent_validated) + UNION + SELECT se.space_id, se.child_id, c.depth+1, c.path || se.space_id + FROM space_edge se + INNER JOIN children c ON se.space_id = c.child_id + WHERE instr(c.path, se.space_id) = 0 + AND c.depth < 10 + AND (child_event_rowid IS NOT NULL OR parent_validated) + ) + UPDATE space_edge + SET depth = c.depth + FROM children c + WHERE space_edge.space_id = c.space_id AND space_edge.child_id = c.child_id; + ` + revalidateAllParents = ` + UPDATE space_edge + SET parent_validated=(SELECT EXISTS( + SELECT 1 + FROM room + INNER JOIN current_state cs ON cs.room_id = room.room_id AND cs.event_type = 'm.room.power_levels' AND cs.state_key = '' + INNER JOIN event pls ON cs.event_rowid = pls.rowid + INNER JOIN event edgeevt ON space_edge.parent_event_rowid = edgeevt.rowid + WHERE room.room_id = space_edge.space_id + AND room.room_type = 'm.space' + AND COALESCE( + ( + SELECT value + FROM json_each(pls.content, 'users') + WHERE key=edgeevt.sender AND type='integer' + ), + pls.content->>'$.users_default', + 0 + ) >= COALESCE( + pls.content->>'$.events."m.space.child"', + pls.content->>'$.state_default', + 50 + ) + )) + WHERE parent_event_rowid IS NOT NULL + ` + revalidateAllParentsPointingAtSpaceQuery = revalidateAllParents + ` AND space_id=$1` + revalidateAllParentsOfRoomQuery = revalidateAllParents + ` AND child_id=$1` + revalidateSpecificParentQuery = revalidateAllParents + ` AND space_id=$1 AND child_id=$2` + clearSpaceChildrenQuery = ` + UPDATE space_edge SET child_event_rowid=NULL, "order"=NULL, suggested=false + WHERE space_id=$1 + ` + clearSpaceParentsQuery = ` + UPDATE space_edge SET parent_event_rowid=NULL, canonical=false, parent_validated=false + WHERE child_id=$1 + ` + deleteEmptySpaceEdgeRowsQuery = ` + DELETE FROM space_edge WHERE child_event_rowid IS NULL AND parent_event_rowid IS NULL + ` + addSpaceChildQuery = ` + INSERT INTO space_edge (space_id, child_id, child_event_rowid, "order", suggested) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (space_id, child_id) DO UPDATE + SET child_event_rowid=EXCLUDED.child_event_rowid, + "order"=EXCLUDED."order", + suggested=EXCLUDED.suggested + ` + addSpaceParentQuery = ` + INSERT INTO space_edge (space_id, child_id, parent_event_rowid, canonical) + VALUES ($1, $2, $3, $4) + ON CONFLICT (space_id, child_id) DO UPDATE + SET parent_event_rowid=EXCLUDED.parent_event_rowid, + canonical=EXCLUDED.canonical, + parent_validated=false + ` +) + +var massInsertSpaceParentBuilder = dbutil.NewMassInsertBuilder[SpaceParentEntry, [1]any](addSpaceParentQuery, "($%d, $1, $%d, $%d)") +var massInsertSpaceChildBuilder = dbutil.NewMassInsertBuilder[SpaceChildEntry, [1]any](addSpaceChildQuery, "($1, $%d, $%d, $%d, $%d)") + +type SpaceEdgeQuery struct { + *dbutil.QueryHelper[*SpaceEdge] +} + +func (seq *SpaceEdgeQuery) AddChild(ctx context.Context, spaceID, childID id.RoomID, childEventRowID EventRowID, order string, suggested bool) error { + return seq.Exec(ctx, addSpaceChildQuery, spaceID, childID, childEventRowID, order, suggested) +} + +func (seq *SpaceEdgeQuery) AddParent(ctx context.Context, spaceID, childID id.RoomID, parentEventRowID EventRowID, canonical bool) error { + return seq.Exec(ctx, addSpaceParentQuery, spaceID, childID, parentEventRowID, canonical) +} + +type SpaceParentEntry struct { + ParentID id.RoomID + EventRowID EventRowID + Canonical bool +} + +func (spe SpaceParentEntry) GetMassInsertValues() [3]any { + return [...]any{spe.ParentID, spe.EventRowID, spe.Canonical} +} + +type SpaceChildEntry struct { + ChildID id.RoomID + EventRowID EventRowID + Order string + Suggested bool +} + +func (sce SpaceChildEntry) GetMassInsertValues() [4]any { + return [...]any{sce.ChildID, sce.EventRowID, sce.Order, sce.Suggested} +} + +func (seq *SpaceEdgeQuery) SetChildren(ctx context.Context, spaceID id.RoomID, children []SpaceChildEntry, removedChildren []id.RoomID, clear bool) error { + if clear { + err := seq.Exec(ctx, clearSpaceChildrenQuery, spaceID) + if err != nil { + return err + } + } else { + + } + if len(removedChildren) > 0 { + err := seq.Exec(ctx, deleteEmptySpaceEdgeRowsQuery, spaceID) + if err != nil { + return err + } + } + if len(children) == 0 { + return nil + } + query, params := massInsertSpaceChildBuilder.Build([1]any{spaceID}, children) + return seq.Exec(ctx, query, params) +} + +func (seq *SpaceEdgeQuery) SetParents(ctx context.Context, childID id.RoomID, parents []SpaceParentEntry, removedParents []id.RoomID, clear bool) error { + if clear { + err := seq.Exec(ctx, clearSpaceParentsQuery, childID) + if err != nil { + return err + } + } + if len(removedParents) > 0 { + err := seq.Exec(ctx, deleteEmptySpaceEdgeRowsQuery) + if err != nil { + return err + } + } + if len(parents) == 0 { + return nil + } + query, params := massInsertSpaceParentBuilder.Build([1]any{childID}, parents) + return seq.Exec(ctx, query, params) +} + +func (seq *SpaceEdgeQuery) RevalidateAllChildrenOfParentValidity(ctx context.Context, spaceID id.RoomID) error { + return seq.Exec(ctx, revalidateAllParentsPointingAtSpaceQuery, spaceID) +} + +func (seq *SpaceEdgeQuery) RevalidateAllParentsOfRoomValidity(ctx context.Context, childID id.RoomID) error { + return seq.Exec(ctx, revalidateAllParentsOfRoomQuery, childID) +} + +func (seq *SpaceEdgeQuery) RevalidateSpecificParentValidity(ctx context.Context, spaceID, childID id.RoomID) error { + return seq.Exec(ctx, revalidateSpecificParentQuery, spaceID, childID) +} + +func (seq *SpaceEdgeQuery) RecalculateAllChildDepths(ctx context.Context) error { + return seq.Exec(ctx, recalculateAllSpaceChildDepths) +} + +func (seq *SpaceEdgeQuery) GetAll(ctx context.Context, spaceID id.RoomID) (map[id.RoomID][]*SpaceEdge, error) { + edges := make(map[id.RoomID][]*SpaceEdge) + err := seq.QueryManyIter(ctx, getAllSpaceChildren, spaceID).Iter(func(edge *SpaceEdge) (bool, error) { + edges[edge.SpaceID] = append(edges[edge.SpaceID], edge) + edge.SpaceID = "" + if !edge.ParentValidated { + edge.ParentEventRowID = 0 + edge.Canonical = false + } + return true, nil + }) + return edges, err +} + +type SpaceEdge struct { + SpaceID id.RoomID `json:"space_id,omitempty"` + ChildID id.RoomID `json:"child_id"` + Depth int `json:"-"` + + ChildEventRowID EventRowID `json:"child_event_rowid,omitempty"` + Order string `json:"order,omitempty"` + Suggested bool `json:"suggested,omitempty"` + + ParentEventRowID EventRowID `json:"parent_event_rowid,omitempty"` + Canonical bool `json:"canonical,omitempty"` + ParentValidated bool `json:"-"` +} + +func (se *SpaceEdge) Scan(row dbutil.Scannable) (*SpaceEdge, error) { + var childRowID, parentRowID sql.NullInt64 + err := row.Scan( + &se.SpaceID, &se.ChildID, &se.Depth, + &childRowID, &se.Order, &se.Suggested, + &parentRowID, &se.Canonical, &se.ParentValidated, + ) + if err != nil { + return nil, err + } + se.ChildEventRowID = EventRowID(childRowID.Int64) + se.ParentEventRowID = EventRowID(parentRowID.Int64) + return se, nil +} diff --git a/pkg/hicli/database/upgrades/00-latest-revision.sql b/pkg/hicli/database/upgrades/00-latest-revision.sql index af2d8b9..36ca532 100644 --- a/pkg/hicli/database/upgrades/00-latest-revision.sql +++ b/pkg/hicli/database/upgrades/00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v9 (compatible with v5+): Latest revision +-- v0 -> v10 (compatible with v10+): Latest revision CREATE TABLE account ( user_id TEXT NOT NULL PRIMARY KEY, device_id TEXT NOT NULL, @@ -10,6 +10,7 @@ CREATE TABLE account ( CREATE TABLE room ( room_id TEXT NOT NULL PRIMARY KEY, + room_type TEXT, creation_content TEXT, tombstone_content TEXT, @@ -35,7 +36,7 @@ CREATE TABLE room ( CONSTRAINT room_preview_event_fkey FOREIGN KEY (preview_event_rowid) REFERENCES event (rowid) ON DELETE SET NULL ) STRICT; -CREATE INDEX room_type_idx ON room (creation_content ->> 'type'); +CREATE INDEX room_type_idx ON room (room_type); CREATE INDEX room_sorting_timestamp_idx ON room (sorting_timestamp DESC); CREATE INDEX room_preview_idx ON room (preview_event_rowid); -- CREATE INDEX room_sorting_timestamp_idx ON room (unread_notifications > 0); @@ -278,3 +279,25 @@ CREATE TABLE receipt ( CONSTRAINT receipt_room_fkey FOREIGN KEY (room_id) REFERENCES room (room_id) ON DELETE CASCADE -- note: there's no foreign key on event ID because receipts could point at events that are too far in history. ) STRICT; + +CREATE TABLE space_edge ( + space_id TEXT NOT NULL, + child_id TEXT NOT NULL, + depth INTEGER, + + -- m.space.child fields + child_event_rowid INTEGER, + "order" TEXT NOT NULL DEFAULT '', + suggested INTEGER NOT NULL DEFAULT false CHECK ( suggested IN (false, true) ), + -- m.space.parent fields + parent_event_rowid INTEGER, + canonical INTEGER NOT NULL DEFAULT false CHECK ( canonical IN (false, true) ), + parent_validated INTEGER NOT NULL DEFAULT false CHECK ( parent_validated IN (false, true) ), + + PRIMARY KEY (space_id, child_id), + CONSTRAINT space_edge_child_event_fkey FOREIGN KEY (child_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE, + CONSTRAINT space_edge_parent_event_fkey FOREIGN KEY (parent_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE, + CONSTRAINT space_edge_child_event_unique UNIQUE (child_event_rowid), + CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid) +) STRICT; +CREATE INDEX space_edge_child_idx ON space_edge (child_id); diff --git a/pkg/hicli/database/upgrades/10-spaces.sql b/pkg/hicli/database/upgrades/10-spaces.sql new file mode 100644 index 0000000..a2ef780 --- /dev/null +++ b/pkg/hicli/database/upgrades/10-spaces.sql @@ -0,0 +1,113 @@ +-- v10 (compatible with v10+): Add support for spaces +ALTER TABLE room ADD COLUMN room_type TEXT; +UPDATE room SET room_type=COALESCE(creation_content->>'$.type', ''); +DROP INDEX room_type_idx; +CREATE INDEX room_type_idx ON room (room_type); + +CREATE TABLE space_edge ( + space_id TEXT NOT NULL, + child_id TEXT NOT NULL, + depth INTEGER, + + -- m.space.child fields + child_event_rowid INTEGER, + "order" TEXT NOT NULL DEFAULT '', + suggested INTEGER NOT NULL DEFAULT false CHECK ( suggested IN (false, true) ), + -- m.space.parent fields + parent_event_rowid INTEGER, + canonical INTEGER NOT NULL DEFAULT false CHECK ( canonical IN (false, true) ), + parent_validated INTEGER NOT NULL DEFAULT false CHECK ( parent_validated IN (false, true) ), + + PRIMARY KEY (space_id, child_id), + CONSTRAINT space_edge_child_event_fkey FOREIGN KEY (child_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE, + CONSTRAINT space_edge_parent_event_fkey FOREIGN KEY (parent_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE, + CONSTRAINT space_edge_child_event_unique UNIQUE (child_event_rowid), + CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid) +) STRICT; +CREATE INDEX space_edge_child_idx ON space_edge (child_id); + +INSERT INTO space_edge (space_id, child_id, child_event_rowid, "order", suggested) +SELECT + event.room_id, + event.state_key, + event.rowid, + CASE WHEN typeof(content->>'$.order')='TEXT' THEN content->>'$.order' ELSE '' END, + CASE WHEN json_type(content, '$.suggested') IN ('true', 'false') THEN content->>'$.suggested' ELSE false END +FROM current_state + INNER JOIN event ON current_state.event_rowid = event.rowid + LEFT JOIN room ON current_state.room_id = room.room_id +WHERE type = 'm.space.child' + AND json_array_length(event.content, '$.via') > 0 + AND event.state_key LIKE '!%' + AND (room.room_id IS NULL OR room.room_type = 'm.space'); + +INSERT INTO space_edge (space_id, child_id, parent_event_rowid, canonical) +SELECT + event.state_key, + event.room_id, + event.rowid, + CASE WHEN json_type(content, '$.canonical') IN ('true', 'false') THEN content->>'$.canonical' ELSE false END +FROM current_state + INNER JOIN event ON current_state.event_rowid = event.rowid + LEFT JOIN room ON event.state_key = room.room_id +WHERE type = 'm.space.parent' + AND json_array_length(event.content, '$.via') > 0 + AND event.state_key LIKE '!%' + AND (room.room_id IS NULL OR room.room_type = 'm.space') +ON CONFLICT (space_id, child_id) DO UPDATE + SET parent_event_rowid = excluded.parent_event_rowid, + canonical = excluded.canonical; + +UPDATE space_edge +SET parent_validated=(SELECT EXISTS( + SELECT 1 + FROM room + INNER JOIN current_state cs ON cs.room_id = room.room_id AND cs.event_type = 'm.room.power_levels' AND cs.state_key = '' + INNER JOIN event pls ON cs.event_rowid = pls.rowid + INNER JOIN event edgeevt ON space_edge.parent_event_rowid = edgeevt.rowid + WHERE room.room_id = space_edge.space_id + AND room.room_type = 'm.space' + AND COALESCE( + ( + SELECT value + FROM json_each(pls.content, '$.users') + WHERE key=edgeevt.sender AND type='integer' + ), + pls.content->>'$.users_default', + 0 + ) >= COALESCE( + pls.content->>'$.events."m.space.child"', + pls.content->>'$.state_default', + 50 + ) +)) +WHERE parent_event_rowid IS NOT NULL; + +WITH RECURSIVE + top_level_spaces AS ( + SELECT space_id + FROM (SELECT DISTINCT(space_id) FROM space_edge) outeredge + WHERE NOT EXISTS( + SELECT 1 + FROM space_edge inneredge + INNER JOIN room ON inneredge.space_id = room.room_id + WHERE inneredge.child_id=outeredge.space_id + AND (inneredge.child_event_rowid IS NOT NULL OR inneredge.parent_validated) + ) + ), + children AS ( + SELECT space_id, child_id, 1 AS depth, space_id AS path + FROM space_edge + WHERE space_id IN top_level_spaces AND (child_event_rowid IS NOT NULL OR parent_validated) + UNION + SELECT se.space_id, se.child_id, c.depth+1, c.path || se.space_id + FROM space_edge se + INNER JOIN children c ON se.space_id=c.child_id + WHERE instr(c.path, se.space_id)=0 + AND c.depth < 10 + AND (child_event_rowid IS NOT NULL OR parent_validated) + ) +UPDATE space_edge +SET depth = c.depth +FROM children c +WHERE space_edge.space_id = c.space_id AND space_edge.child_id = c.child_id; diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go index 2bd8534..c15c8b1 100644 --- a/pkg/hicli/events.go +++ b/pkg/hicli/events.go @@ -37,6 +37,7 @@ type SyncComplete struct { Rooms map[id.RoomID]*SyncRoom `json:"rooms"` LeftRooms []id.RoomID `json:"left_rooms"` InvitedRooms []*database.InvitedRoom `json:"invited_rooms"` + SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"` } func (c *SyncComplete) IsEmpty() bool { diff --git a/pkg/hicli/init.go b/pkg/hicli/init.go index 5c02292..a50023d 100644 --- a/pkg/hicli/init.go +++ b/pkg/hicli/init.go @@ -91,11 +91,21 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[* } return } + payload.SpaceEdges, err = h.DB.SpaceEdge.GetAll(ctx, "") + if err != nil { + if ctx.Err() == nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get space edges to send to client") + } + return + } payload.ClearState = true } if payload.InvitedRooms == nil { payload.InvitedRooms = make([]*database.InvitedRoom, 0) } + if payload.SpaceEdges == nil { + payload.SpaceEdges = make(map[id.RoomID][]*database.SpaceEdge) + } for _, room := range rooms { if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp { break diff --git a/pkg/hicli/paginate.go b/pkg/hicli/paginate.go index ecb2ec5..542ff5b 100644 --- a/pkg/hicli/paginate.go +++ b/pkg/hicli/paginate.go @@ -121,13 +121,14 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe if err != nil { return fmt.Errorf("failed to save events: %w", err) } + sdc := &spaceDataCollector{} for i := range currentStateEntries { currentStateEntries[i].EventRowID = dbEvts[i].RowID if mediaReferenceEntries[i] != nil { mediaReferenceEntries[i].EventRowID = dbEvts[i].RowID } if evts[i].Type != event.StateMember { - processImportantEvent(ctx, evts[i], room, updatedRoom) + processImportantEvent(ctx, evts[i], room, updatedRoom, dbEvts[i].RowID, sdc) } } err = h.DB.Media.AddMany(ctx, mediaCacheEntries) @@ -146,6 +147,10 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe return fmt.Errorf("failed to save current state entries: %w", err) } roomChanged := updatedRoom.CheckChangesAndCopyInto(room) + err = sdc.Apply(ctx, room, h.DB.SpaceEdge) + if err != nil { + return err + } if roomChanged { err = h.DB.Room.Upsert(ctx, updatedRoom) if err != nil { @@ -168,6 +173,8 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe InvitedRooms: make([]*database.InvitedRoom, 0), AccountData: make(map[event.Type]*database.AccountData), LeftRooms: make([]id.RoomID, 0), + // TODO dispatch space edge changes if something changed? (fairly unlikely though) + SpaceEdges: make(map[id.RoomID][]*database.SpaceEdge), }) } } diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go index e46532a..a591e91 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -667,6 +667,7 @@ func (h *HiClient) processStateAndTimeline( updatedRoom.LazyLoadSummary = summary heroesChanged = true } + sdc := &spaceDataCollector{} decryptionQueue := make(map[id.SessionID]*database.SessionRequest) allNewEvents := make([]*database.Event, 0, len(state.Events)+len(timeline.Events)) addedEvents := make(map[database.EventRowID]struct{}) @@ -750,7 +751,7 @@ func (h *HiClient) processStateAndTimeline( if err != nil { return -1, fmt.Errorf("failed to save current state event ID %s for %s/%s: %w", evt.ID, evt.Type.Type, *evt.StateKey, err) } - processImportantEvent(ctx, evt, room, updatedRoom) + processImportantEvent(ctx, evt, room, updatedRoom, dbEvt.RowID, sdc) } allNewEvents = append(allNewEvents, dbEvt) addedEvents[dbEvt.RowID] = struct{}{} @@ -932,6 +933,10 @@ func (h *HiClient) processStateAndTimeline( return fmt.Errorf("failed to save room data: %w", err) } } + err = sdc.Apply(ctx, room, h.DB.SpaceEdge) + if err != nil { + return err + } // TODO why is *old* unread count sometimes zero when processing the read receipt that is making it zero? if roomChanged || len(accountData) > 0 || len(newOwnReceipts) > 0 || len(receipts) > 0 || len(timelineRowTuples) > 0 || len(allNewEvents) > 0 { for _, receipt := range receipts { @@ -1033,13 +1038,101 @@ func intPtrEqual(a, b *int) bool { return *a == *b } -func processImportantEvent(ctx context.Context, evt *event.Event, existingRoomData, updatedRoom *database.Room) (roomDataChanged bool) { +type spaceDataCollector struct { + Children []database.SpaceChildEntry + Parents []database.SpaceParentEntry + RemovedChildren []id.RoomID + RemovedParents []id.RoomID + PowerLevelChanged bool + IsFullState bool +} + +func (sdc *spaceDataCollector) Collect(evt *event.Event, rowID database.EventRowID) { + switch evt.Type { + case event.StatePowerLevels: + sdc.PowerLevelChanged = true + case event.StateCreate: + sdc.IsFullState = true + case event.StateSpaceChild: + content := evt.Content.AsSpaceChild() + if len(content.Via) == 0 { + sdc.RemovedChildren = append(sdc.RemovedChildren, id.RoomID(*evt.StateKey)) + } else { + sdc.Children = append(sdc.Children, database.SpaceChildEntry{ + ChildID: id.RoomID(*evt.StateKey), + EventRowID: rowID, + Order: content.Order, + Suggested: content.Suggested, + }) + } + case event.StateSpaceParent: + content := evt.Content.AsSpaceParent() + if len(content.Via) == 0 { + sdc.RemovedParents = append(sdc.RemovedParents, id.RoomID(*evt.StateKey)) + } else { + sdc.Parents = append(sdc.Parents, database.SpaceParentEntry{ + ParentID: id.RoomID(*evt.StateKey), + EventRowID: rowID, + Canonical: content.Canonical, + }) + } + } +} + +func (sdc *spaceDataCollector) Apply(ctx context.Context, room *database.Room, seq *database.SpaceEdgeQuery) error { + if room.CreationContent == nil || room.CreationContent.Type != event.RoomTypeSpace { + sdc.Children = nil + sdc.RemovedChildren = nil + sdc.PowerLevelChanged = false + } + if len(sdc.Children) == 0 && len(sdc.RemovedChildren) == 0 && + len(sdc.Parents) == 0 && len(sdc.RemovedParents) == 0 && + !sdc.PowerLevelChanged { + return nil + } + return seq.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error { + if len(sdc.Children) > 0 || len(sdc.RemovedChildren) > 0 { + err := seq.SetChildren(ctx, room.ID, sdc.Children, sdc.RemovedChildren, sdc.IsFullState) + if err != nil { + return fmt.Errorf("failed to set space children: %w", err) + } + } + if len(sdc.Parents) > 0 || len(sdc.RemovedParents) > 0 { + err := seq.SetParents(ctx, room.ID, sdc.Parents, sdc.RemovedParents, sdc.IsFullState) + if err != nil { + return fmt.Errorf("failed to set space parents: %w", err) + } + if len(sdc.Parents) > 0 { + err = seq.RevalidateAllParentsOfRoomValidity(ctx, room.ID) + if err != nil { + return fmt.Errorf("failed to revalidate own parent references: %w", err) + } + } + } + if sdc.PowerLevelChanged { + err := seq.RevalidateAllChildrenOfParentValidity(ctx, room.ID) + if err != nil { + return fmt.Errorf("failed to revalidate child parent references to self: %w", err) + } + } + return nil + }) +} + +func processImportantEvent( + ctx context.Context, + evt *event.Event, + existingRoomData, updatedRoom *database.Room, + rowID database.EventRowID, + sdc *spaceDataCollector, +) (roomDataChanged bool) { if evt.StateKey == nil { return } switch evt.Type { case event.StateCreate, event.StateTombstone, event.StateRoomName, event.StateCanonicalAlias, - event.StateRoomAvatar, event.StateTopic, event.StateEncryption: + event.StateRoomAvatar, event.StateTopic, event.StateEncryption, + event.StateSpaceChild, event.StateSpaceParent, event.StatePowerLevels: if *evt.StateKey != "" { return } @@ -1047,6 +1140,7 @@ func processImportantEvent(ctx context.Context, evt *event.Event, existingRoomDa return } err := evt.Content.ParseRaw(evt.Type) + sdc.Collect(evt, rowID) if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { zerolog.Ctx(ctx).Warn().Err(err). Stringer("event_type", &evt.Type). diff --git a/pkg/hicli/syncwrap.go b/pkg/hicli/syncwrap.go index 8492da2..5b34f9b 100644 --- a/pkg/hicli/syncwrap.go +++ b/pkg/hicli/syncwrap.go @@ -38,6 +38,7 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)), InvitedRooms: make([]*database.InvitedRoom, 0, len(resp.Rooms.Invite)), LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)), + SpaceEdges: make(map[id.RoomID][]*database.SpaceEdge), }}) err := c.preProcessSyncResponse(ctx, resp, since) if err != nil { From 2b206bb32f15e6c16d3c19c68ff4977a847dd390 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 28 Dec 2024 18:51:39 +0200 Subject: [PATCH 09/16] hicli/database: don't store space depth --- pkg/hicli/database/space.go | 57 ++++++------------- .../database/upgrades/00-latest-revision.sql | 1 - pkg/hicli/database/upgrades/10-spaces.sql | 30 ---------- 3 files changed, 16 insertions(+), 72 deletions(-) diff --git a/pkg/hicli/database/space.go b/pkg/hicli/database/space.go index 8ff6710..1139c69 100644 --- a/pkg/hicli/database/space.go +++ b/pkg/hicli/database/space.go @@ -16,43 +16,23 @@ import ( const ( getAllSpaceChildren = ` - SELECT space_id, child_id, depth, child_event_rowid, "order", suggested, parent_event_rowid, canonical, parent_validated + SELECT space_id, child_id, child_event_rowid, "order", suggested, parent_event_rowid, canonical, parent_validated FROM space_edge - WHERE (space_id = $1 OR $1 = '') AND depth IS NOT NULL AND (child_event_rowid IS NOT NULL OR parent_validated) - ORDER BY depth, space_id, "order", child_id + -- This check should be redundant thanks to parent_validated and validation before insert for children + --INNER JOIN room ON space_id = room.room_id AND room.room_type = 'm.space' + WHERE (space_id = $1 OR $1 = '') AND (child_event_rowid IS NOT NULL OR parent_validated) + ORDER BY space_id, "order", child_id ` - // language=sqlite - for some reason GoLand doesn't auto-detect SQL when using WITH RECURSIVE - recalculateAllSpaceChildDepths = ` - UPDATE space_edge SET depth = NULL; - WITH RECURSIVE - top_level_spaces AS ( - SELECT space_id - FROM (SELECT DISTINCT(space_id) FROM space_edge) outeredge - INNER JOIN room ON outeredge.space_id = room.room_id AND room.room_type = 'm.space' - WHERE NOT EXISTS( - SELECT 1 - FROM space_edge inneredge - INNER JOIN room ON inneredge.space_id = room.room_id - WHERE inneredge.child_id=outeredge.space_id - AND (inneredge.child_event_rowid IS NOT NULL OR inneredge.parent_validated) - ) - ), - children AS ( - SELECT space_id, child_id, 1 AS depth, space_id AS path - FROM space_edge - WHERE space_id IN top_level_spaces AND (child_event_rowid IS NOT NULL OR parent_validated) - UNION - SELECT se.space_id, se.child_id, c.depth+1, c.path || se.space_id - FROM space_edge se - INNER JOIN children c ON se.space_id = c.child_id - WHERE instr(c.path, se.space_id) = 0 - AND c.depth < 10 - AND (child_event_rowid IS NOT NULL OR parent_validated) - ) - UPDATE space_edge - SET depth = c.depth - FROM children c - WHERE space_edge.space_id = c.space_id AND space_edge.child_id = c.child_id; + getTopLevelSpaces = ` + SELECT space_id + FROM (SELECT DISTINCT(space_id) FROM space_edge) outeredge + WHERE NOT EXISTS( + SELECT 1 + FROM space_edge inneredge + INNER JOIN room ON inneredge.space_id = room.room_id + WHERE inneredge.child_id = outeredge.space_id + AND (inneredge.child_event_rowid IS NOT NULL OR inneredge.parent_validated) + ) ` revalidateAllParents = ` UPDATE space_edge @@ -202,10 +182,6 @@ func (seq *SpaceEdgeQuery) RevalidateSpecificParentValidity(ctx context.Context, return seq.Exec(ctx, revalidateSpecificParentQuery, spaceID, childID) } -func (seq *SpaceEdgeQuery) RecalculateAllChildDepths(ctx context.Context) error { - return seq.Exec(ctx, recalculateAllSpaceChildDepths) -} - func (seq *SpaceEdgeQuery) GetAll(ctx context.Context, spaceID id.RoomID) (map[id.RoomID][]*SpaceEdge, error) { edges := make(map[id.RoomID][]*SpaceEdge) err := seq.QueryManyIter(ctx, getAllSpaceChildren, spaceID).Iter(func(edge *SpaceEdge) (bool, error) { @@ -223,7 +199,6 @@ func (seq *SpaceEdgeQuery) GetAll(ctx context.Context, spaceID id.RoomID) (map[i type SpaceEdge struct { SpaceID id.RoomID `json:"space_id,omitempty"` ChildID id.RoomID `json:"child_id"` - Depth int `json:"-"` ChildEventRowID EventRowID `json:"child_event_rowid,omitempty"` Order string `json:"order,omitempty"` @@ -237,7 +212,7 @@ type SpaceEdge struct { func (se *SpaceEdge) Scan(row dbutil.Scannable) (*SpaceEdge, error) { var childRowID, parentRowID sql.NullInt64 err := row.Scan( - &se.SpaceID, &se.ChildID, &se.Depth, + &se.SpaceID, &se.ChildID, &childRowID, &se.Order, &se.Suggested, &parentRowID, &se.Canonical, &se.ParentValidated, ) diff --git a/pkg/hicli/database/upgrades/00-latest-revision.sql b/pkg/hicli/database/upgrades/00-latest-revision.sql index 36ca532..2df49d2 100644 --- a/pkg/hicli/database/upgrades/00-latest-revision.sql +++ b/pkg/hicli/database/upgrades/00-latest-revision.sql @@ -283,7 +283,6 @@ CREATE TABLE receipt ( CREATE TABLE space_edge ( space_id TEXT NOT NULL, child_id TEXT NOT NULL, - depth INTEGER, -- m.space.child fields child_event_rowid INTEGER, diff --git a/pkg/hicli/database/upgrades/10-spaces.sql b/pkg/hicli/database/upgrades/10-spaces.sql index a2ef780..429973c 100644 --- a/pkg/hicli/database/upgrades/10-spaces.sql +++ b/pkg/hicli/database/upgrades/10-spaces.sql @@ -7,7 +7,6 @@ CREATE INDEX room_type_idx ON room (room_type); CREATE TABLE space_edge ( space_id TEXT NOT NULL, child_id TEXT NOT NULL, - depth INTEGER, -- m.space.child fields child_event_rowid INTEGER, @@ -82,32 +81,3 @@ SET parent_validated=(SELECT EXISTS( ) )) WHERE parent_event_rowid IS NOT NULL; - -WITH RECURSIVE - top_level_spaces AS ( - SELECT space_id - FROM (SELECT DISTINCT(space_id) FROM space_edge) outeredge - WHERE NOT EXISTS( - SELECT 1 - FROM space_edge inneredge - INNER JOIN room ON inneredge.space_id = room.room_id - WHERE inneredge.child_id=outeredge.space_id - AND (inneredge.child_event_rowid IS NOT NULL OR inneredge.parent_validated) - ) - ), - children AS ( - SELECT space_id, child_id, 1 AS depth, space_id AS path - FROM space_edge - WHERE space_id IN top_level_spaces AND (child_event_rowid IS NOT NULL OR parent_validated) - UNION - SELECT se.space_id, se.child_id, c.depth+1, c.path || se.space_id - FROM space_edge se - INNER JOIN children c ON se.space_id=c.child_id - WHERE instr(c.path, se.space_id)=0 - AND c.depth < 10 - AND (child_event_rowid IS NOT NULL OR parent_validated) - ) -UPDATE space_edge -SET depth = c.depth -FROM children c -WHERE space_edge.space_id = c.space_id AND space_edge.child_id = c.child_id; From 2ea80dac6f823e49f93cab6d3a7b0f0b7b12d741 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 28 Dec 2024 18:58:49 +0200 Subject: [PATCH 10/16] web/statestore: allow sync event fields to be null --- pkg/hicli/init.go | 26 ++++++-------------------- pkg/hicli/paginate.go | 15 ++------------- pkg/hicli/syncwrap.go | 1 - web/src/api/statestore/main.ts | 14 +++++++------- web/src/api/statestore/room.ts | 12 ++++++------ web/src/api/types/hievents.ts | 22 ++++++++++++---------- web/src/api/types/hitypes.ts | 12 ++++++++++++ 7 files changed, 45 insertions(+), 57 deletions(-) diff --git a/pkg/hicli/init.go b/pkg/hicli/init.go index a50023d..3c2b40f 100644 --- a/pkg/hicli/init.go +++ b/pkg/hicli/init.go @@ -14,12 +14,9 @@ import ( func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) *SyncRoom { syncRoom := &SyncRoom{ - Meta: room, - Events: make([]*database.Event, 0, 2), - Timeline: make([]database.TimelineRowTuple, 0), - State: map[event.Type]map[string]database.EventRowID{}, - Notifications: make([]SyncNotification, 0), - Receipts: make(map[id.EventID][]*database.Receipt), + Meta: room, + Events: make([]*database.Event, 0, 2), + State: map[event.Type]map[string]database.EventRowID{}, } ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID) if err != nil { @@ -27,7 +24,6 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) if ctx.Err() != nil { return nil } - syncRoom.AccountData = make(map[event.Type]*database.AccountData) } else { syncRoom.AccountData = make(map[event.Type]*database.AccountData, len(ad)) for _, data := range ad { @@ -79,9 +75,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[* return } payload := SyncComplete{ - Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)-1), - LeftRooms: make([]id.RoomID, 0), - AccountData: make(map[event.Type]*database.AccountData), + Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)), } if i == 0 { payload.InvitedRooms, err = h.DB.InvitedRoom.GetAll(ctx) @@ -91,6 +85,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[* } return } + // TODO include space rooms in first batch too? payload.SpaceEdges, err = h.DB.SpaceEdge.GetAll(ctx, "") if err != nil { if ctx.Err() == nil { @@ -100,12 +95,6 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[* } payload.ClearState = true } - if payload.InvitedRooms == nil { - payload.InvitedRooms = make([]*database.InvitedRoom, 0) - } - if payload.SpaceEdges == nil { - payload.SpaceEdges = make(map[id.RoomID][]*database.SpaceEdge) - } for _, room := range rooms { if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp { break @@ -127,10 +116,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[* return } payload := SyncComplete{ - Rooms: make(map[id.RoomID]*SyncRoom), - InvitedRooms: make([]*database.InvitedRoom, 0), - LeftRooms: make([]id.RoomID, 0), - AccountData: make(map[event.Type]*database.AccountData, len(ad)), + AccountData: make(map[event.Type]*database.AccountData, len(ad)), } for _, data := range ad { payload.AccountData[event.Type{Type: data.Type, Class: event.AccountDataEventType}] = data diff --git a/pkg/hicli/paginate.go b/pkg/hicli/paginate.go index 542ff5b..f41ceee 100644 --- a/pkg/hicli/paginate.go +++ b/pkg/hicli/paginate.go @@ -147,6 +147,7 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe return fmt.Errorf("failed to save current state entries: %w", err) } roomChanged := updatedRoom.CheckChangesAndCopyInto(room) + // TODO dispatch space edge changes if something changed? (fairly unlikely though) err = sdc.Apply(ctx, room, h.DB.SpaceEdge) if err != nil { return err @@ -160,21 +161,9 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe h.EventHandler(&SyncComplete{ Rooms: map[id.RoomID]*SyncRoom{ roomID: { - Meta: room, - Timeline: make([]database.TimelineRowTuple, 0), - State: make(map[event.Type]map[string]database.EventRowID), - AccountData: make(map[event.Type]*database.AccountData), - Events: make([]*database.Event, 0), - Reset: false, - Notifications: make([]SyncNotification, 0), - Receipts: make(map[id.EventID][]*database.Receipt), + Meta: room, }, }, - InvitedRooms: make([]*database.InvitedRoom, 0), - AccountData: make(map[event.Type]*database.AccountData), - LeftRooms: make([]id.RoomID, 0), - // TODO dispatch space edge changes if something changed? (fairly unlikely though) - SpaceEdges: make(map[id.RoomID][]*database.SpaceEdge), }) } } diff --git a/pkg/hicli/syncwrap.go b/pkg/hicli/syncwrap.go index 5b34f9b..8492da2 100644 --- a/pkg/hicli/syncwrap.go +++ b/pkg/hicli/syncwrap.go @@ -38,7 +38,6 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)), InvitedRooms: make([]*database.InvitedRoom, 0, len(resp.Rooms.Invite)), LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)), - SpaceEdges: make(map[id.RoomID][]*database.SpaceEdge), }}) err := c.preProcessSyncResponse(ctx, resp, since) if err != nil { diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index f4332f8..1bfde16 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -122,7 +122,7 @@ export class StateStore { entry.meta.unread_highlights !== oldEntry.meta.current.unread_highlights || entry.meta.marked_unread !== oldEntry.meta.current.marked_unread || entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid || - entry.events.findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1 + (entry.events ?? []).findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1 } #makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null { @@ -165,7 +165,7 @@ export class StateStore { } const resyncRoomList = this.roomList.current.length === 0 const changedRoomListEntries = new Map() - for (const data of sync.invited_rooms) { + for (const data of sync.invited_rooms ?? []) { const room = new InvitedRoomStore(data, this) this.inviteRooms.set(room.room_id, room) if (!resyncRoomList) { @@ -176,7 +176,7 @@ export class StateStore { } } const hasInvites = this.inviteRooms.size > 0 - for (const [roomID, data] of Object.entries(sync.rooms)) { + for (const [roomID, data] of Object.entries(sync.rooms ?? {})) { let isNewRoom = false let room = this.rooms.get(roomID) if (!room) { @@ -203,7 +203,7 @@ export class StateStore { } } - if (window.Notification?.permission === "granted" && !focused.current) { + if (window.Notification?.permission === "granted" && !focused.current && data.notifications) { for (const notification of data.notifications) { this.showNotification(room, notification.event_rowid, notification.sound) } @@ -212,7 +212,7 @@ export class StateStore { this.switchRoom?.(roomID) } } - for (const ad of Object.values(sync.account_data)) { + for (const ad of Object.values(sync.account_data ?? {})) { if (ad.type === "io.element.recent_emoji") { this.#frequentlyUsedEmoji = null } else if (ad.type === "fi.mau.gomuks.preferences") { @@ -222,7 +222,7 @@ export class StateStore { this.accountData.set(ad.type, ad.content) this.accountDataSubs.notify(ad.type) } - for (const roomID of sync.left_rooms) { + for (const roomID of sync.left_rooms ?? []) { if (this.activeRoomID === roomID) { this.switchRoom?.(null) } @@ -233,7 +233,7 @@ export class StateStore { let updatedRoomList: RoomListEntry[] | undefined if (resyncRoomList) { updatedRoomList = this.inviteRooms.values().toArray() - updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms) + updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms ?? {}) .map(entry => this.#makeRoomListEntry(entry)) .filter(entry => entry !== null)) updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp) diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 20b4ae3..7419a55 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -390,7 +390,7 @@ export class RoomStateStore { } else { this.meta.emit(sync.meta) } - for (const ad of Object.values(sync.account_data)) { + for (const ad of Object.values(sync.account_data ?? {})) { if (ad.type === "fi.mau.gomuks.preferences") { this.serverPreferenceCache = ad.content this.preferenceSub.notify() @@ -398,10 +398,10 @@ export class RoomStateStore { this.accountData.set(ad.type, ad.content) this.accountDataSubs.notify(ad.type) } - for (const evt of sync.events) { + for (const evt of sync.events ?? []) { this.applyEvent(evt) } - for (const [evtType, changedEvts] of Object.entries(sync.state)) { + for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) { let stateMap = this.state.get(evtType) if (!stateMap) { stateMap = new Map() @@ -414,9 +414,9 @@ export class RoomStateStore { this.stateSubs.notify(evtType) } if (sync.reset) { - this.timeline = sync.timeline + this.timeline = sync.timeline ?? [] this.pendingEvents.splice(0, this.pendingEvents.length) - } else { + } else if (sync.timeline) { this.timeline.push(...sync.timeline) } if (sync.meta.unread_notifications === 0 && sync.meta.unread_highlights === 0) { @@ -426,7 +426,7 @@ export class RoomStateStore { this.openNotifications.clear() } this.notifyTimelineSubscribers() - for (const [evtID, receipts] of Object.entries(sync.receipts)) { + for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) { this.applyReceipts(receipts, evtID, false) } } diff --git a/web/src/api/types/hievents.ts b/web/src/api/types/hievents.ts index f05296e..f7d85d3 100644 --- a/web/src/api/types/hievents.ts +++ b/web/src/api/types/hievents.ts @@ -19,6 +19,7 @@ import { DBReceipt, DBRoom, DBRoomAccountData, + DBSpaceEdge, EventRowID, RawDBEvent, TimelineRowTuple, @@ -71,13 +72,13 @@ export interface ImageAuthTokenEvent extends BaseRPCCommand { export interface SyncRoom { meta: DBRoom - timeline: TimelineRowTuple[] - events: RawDBEvent[] - state: Record> + timeline: TimelineRowTuple[] | null + events: RawDBEvent[] | null + state: Record> | null reset: boolean - notifications: SyncNotification[] - account_data: Record - receipts: Record + notifications: SyncNotification[] | null + account_data: Record | null + receipts: Record | null } export interface SyncNotification { @@ -86,10 +87,11 @@ export interface SyncNotification { } export interface SyncCompleteData { - rooms: Record - invited_rooms: DBInvitedRoom[] - left_rooms: RoomID[] - account_data: Record + rooms: Record | null + invited_rooms: DBInvitedRoom[] | null + left_rooms: RoomID[] | null + account_data: Record | null + space_edges: Record[]> | null since?: string clear_state?: boolean } diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index 7796e82..a859a47 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -71,6 +71,18 @@ export interface DBRoom { prev_batch: string } +export interface DBSpaceEdge { + space_id: RoomID + child_id: RoomID + + child_event_rowid?: EventRowID + order?: string + suggested?: true + + parent_event_rowid?: EventRowID + canonical?: true +} + //eslint-disable-next-line @typescript-eslint/no-explicit-any export type UnknownEventContent = Record From 5483b077c752f739d84424dd67dc77bb18b9d356 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 28 Dec 2024 19:13:21 +0200 Subject: [PATCH 11/16] hicli/init: send spaces in first payload --- pkg/hicli/database/room.go | 5 +++ pkg/hicli/database/space.go | 13 ++++++- pkg/hicli/events.go | 15 ++++---- pkg/hicli/init.go | 65 +++++++++++++++++++++++++---------- web/src/api/types/hievents.ts | 1 + 5 files changed, 72 insertions(+), 27 deletions(-) diff --git a/pkg/hicli/database/room.go b/pkg/hicli/database/room.go index 234f00f..a27de72 100644 --- a/pkg/hicli/database/room.go +++ b/pkg/hicli/database/room.go @@ -27,6 +27,7 @@ const ( FROM room ` getRoomsBySortingTimestampQuery = getRoomBaseQuery + `WHERE sorting_timestamp < $1 AND sorting_timestamp > 0 ORDER BY sorting_timestamp DESC LIMIT $2` + getRoomsByTypeQuery = getRoomBaseQuery + `WHERE room_type = $1` getRoomByIDQuery = getRoomBaseQuery + `WHERE room_id = $1` ensureRoomExistsQuery = ` INSERT INTO room (room_id) VALUES ($1) @@ -96,6 +97,10 @@ func (rq *RoomQuery) GetBySortTS(ctx context.Context, maxTS time.Time, limit int return rq.QueryMany(ctx, getRoomsBySortingTimestampQuery, maxTS.UnixMilli(), limit) } +func (rq *RoomQuery) GetAllSpaces(ctx context.Context) ([]*Room, error) { + return rq.QueryMany(ctx, getRoomsByTypeQuery, event.RoomTypeSpace) +} + func (rq *RoomQuery) Upsert(ctx context.Context, room *Room) error { return rq.Exec(ctx, upsertRoomFromSyncQuery, room.sqlVariables()...) } diff --git a/pkg/hicli/database/space.go b/pkg/hicli/database/space.go index 1139c69..b0b86ef 100644 --- a/pkg/hicli/database/space.go +++ b/pkg/hicli/database/space.go @@ -26,13 +26,18 @@ const ( getTopLevelSpaces = ` SELECT space_id FROM (SELECT DISTINCT(space_id) FROM space_edge) outeredge + LEFT JOIN room_account_data ON + room_account_data.user_id = $1 + AND room_account_data.room_id = outeredge.space_id + AND room_account_data.type = 'org.matrix.msc3230.space_order' WHERE NOT EXISTS( SELECT 1 FROM space_edge inneredge INNER JOIN room ON inneredge.space_id = room.room_id WHERE inneredge.child_id = outeredge.space_id AND (inneredge.child_event_rowid IS NOT NULL OR inneredge.parent_validated) - ) + ) AND EXISTS(SELECT 1 FROM room WHERE room_id = space_id AND room_type = 'm.space') + ORDER BY room_account_data.content->>'$.order' NULLS LAST, space_id ` revalidateAllParents = ` UPDATE space_edge @@ -196,6 +201,12 @@ func (seq *SpaceEdgeQuery) GetAll(ctx context.Context, spaceID id.RoomID) (map[i return edges, err } +var roomIDScanner = dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID]) + +func (seq *SpaceEdgeQuery) GetTopLevelIDs(ctx context.Context, userID id.UserID) ([]id.RoomID, error) { + return roomIDScanner.NewRowIter(seq.GetDB().Query(ctx, getTopLevelSpaces, userID)).AsList() +} + type SpaceEdge struct { SpaceID id.RoomID `json:"space_id,omitempty"` ChildID id.RoomID `json:"child_id"` diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go index c15c8b1..e45a4e8 100644 --- a/pkg/hicli/events.go +++ b/pkg/hicli/events.go @@ -31,13 +31,14 @@ type SyncNotification struct { } type SyncComplete struct { - Since *string `json:"since,omitempty"` - ClearState bool `json:"clear_state,omitempty"` - AccountData map[event.Type]*database.AccountData `json:"account_data"` - Rooms map[id.RoomID]*SyncRoom `json:"rooms"` - LeftRooms []id.RoomID `json:"left_rooms"` - InvitedRooms []*database.InvitedRoom `json:"invited_rooms"` - SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"` + Since *string `json:"since,omitempty"` + ClearState bool `json:"clear_state,omitempty"` + AccountData map[event.Type]*database.AccountData `json:"account_data"` + Rooms map[id.RoomID]*SyncRoom `json:"rooms"` + LeftRooms []id.RoomID `json:"left_rooms"` + InvitedRooms []*database.InvitedRoom `json:"invited_rooms"` + SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"` + TopLevelSpaces []id.RoomID `json:"top_level_spaces"` } func (c *SyncComplete) IsEmpty() bool { diff --git a/pkg/hicli/init.go b/pkg/hicli/init.go index 3c2b40f..5b08de5 100644 --- a/pkg/hicli/init.go +++ b/pkg/hicli/init.go @@ -66,6 +66,49 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*SyncComplete] { return func(yield func(*SyncComplete) bool) { maxTS := time.Now().Add(1 * time.Hour) + { + spaces, err := h.DB.Room.GetAllSpaces(ctx) + if err != nil { + if ctx.Err() == nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get initial spaces to send to client") + } + return + } + payload := SyncComplete{ + Rooms: make(map[id.RoomID]*SyncRoom, len(spaces)), + } + for _, room := range spaces { + payload.Rooms[room.ID] = h.getInitialSyncRoom(ctx, room) + if ctx.Err() != nil { + return + } + } + payload.TopLevelSpaces, err = h.DB.SpaceEdge.GetTopLevelIDs(ctx, h.Account.UserID) + if err != nil { + if ctx.Err() == nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get top-level space IDs to send to client") + } + return + } + payload.SpaceEdges, err = h.DB.SpaceEdge.GetAll(ctx, "") + if err != nil { + if ctx.Err() == nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get space edges to send to client") + } + return + } + payload.InvitedRooms, err = h.DB.InvitedRoom.GetAll(ctx) + if err != nil { + if ctx.Err() == nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get invited rooms to send to client") + } + return + } + payload.ClearState = true + if !yield(&payload) { + return + } + } for i := 0; ; i++ { rooms, err := h.DB.Room.GetBySortTS(ctx, maxTS, batchSize) if err != nil { @@ -77,24 +120,6 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[* payload := SyncComplete{ Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)), } - if i == 0 { - payload.InvitedRooms, err = h.DB.InvitedRoom.GetAll(ctx) - if err != nil { - if ctx.Err() == nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get invited rooms to send to client") - } - return - } - // TODO include space rooms in first batch too? - payload.SpaceEdges, err = h.DB.SpaceEdge.GetAll(ctx, "") - if err != nil { - if ctx.Err() == nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get space edges to send to client") - } - return - } - payload.ClearState = true - } for _, room := range rooms { if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp { break @@ -105,7 +130,9 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[* return } } - if !yield(&payload) || len(rooms) < batchSize { + if !yield(&payload) { + return + } else if len(rooms) < batchSize { break } } diff --git a/web/src/api/types/hievents.ts b/web/src/api/types/hievents.ts index f7d85d3..7c64596 100644 --- a/web/src/api/types/hievents.ts +++ b/web/src/api/types/hievents.ts @@ -92,6 +92,7 @@ export interface SyncCompleteData { left_rooms: RoomID[] | null account_data: Record | null space_edges: Record[]> | null + top_level_spaces: RoomID[] | null since?: string clear_state?: boolean } From 5a8139685dc28c3d67cc37064980edb233874ea8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 28 Dec 2024 21:05:16 +0200 Subject: [PATCH 12/16] web/roomlist: add space bar Fixes #518 --- web/index.html | 11 ++++ web/src/api/statestore/main.ts | 53 +++++++++++++-- web/src/api/statestore/space.ts | 109 +++++++++++++++++++++++++++++++ web/src/api/types/hievents.ts | 2 +- web/src/api/types/hitypes.ts | 2 +- web/src/icons/home.svg | 1 + web/src/ui/MainScreen.css | 2 +- web/src/ui/MainScreen.tsx | 2 +- web/src/ui/roomlist/RoomList.css | 46 ++++++++++++- web/src/ui/roomlist/RoomList.tsx | 58 +++++++++++----- web/src/ui/roomlist/Space.tsx | 40 ++++++++++++ web/src/util/eventdispatcher.ts | 11 ++-- 12 files changed, 302 insertions(+), 35 deletions(-) create mode 100644 web/src/api/statestore/space.ts create mode 100644 web/src/icons/home.svg create mode 100644 web/src/ui/roomlist/Space.tsx diff --git a/web/index.html b/web/index.html index 40252d6..483ad69 100644 --- a/web/index.html +++ b/web/index.html @@ -12,5 +12,16 @@
+ + + + + + + diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 1bfde16..71db297 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -39,6 +39,7 @@ import { } from "../types" import { InvitedRoomStore } from "./invitedroom.ts" import { RoomStateStore } from "./room.ts" +import { RoomListFilter, SpaceEdgeStore } from "./space.ts" export interface RoomListEntry { room_id: RoomID @@ -72,7 +73,10 @@ export class StateStore { readonly rooms: Map = new Map() readonly inviteRooms: Map = new Map() readonly roomList = new NonNullCachedEventDispatcher([]) - currentRoomListFilter: string = "" + readonly topLevelSpaces = new NonNullCachedEventDispatcher([]) + readonly spaceEdges: Map = new Map() + currentRoomListQuery: string = "" + currentRoomListFilter: RoomListFilter | null = null readonly accountData: Map = new Map() readonly accountDataSubs = new MultiSubscribable() readonly emojiRoomsSub = new Subscribable() @@ -89,11 +93,25 @@ export class StateStore { activeRoomIsPreview: boolean = false imageAuthToken?: string - getFilteredRoomList(): RoomListEntry[] { - if (!this.currentRoomListFilter) { - return this.roomList.current + #roomListFilterFunc = (entry: RoomListEntry) => { + if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) { + return false + } else if (this.currentRoomListFilter && !this.currentRoomListFilter.include(entry)) { + return false } - return this.roomList.current.filter(entry => entry.search_name.includes(this.currentRoomListFilter)) + return true + } + + get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null { + if (!this.currentRoomListFilter && !this.currentRoomListQuery) { + return null + } + return this.#roomListFilterFunc + } + + getFilteredRoomList(): RoomListEntry[] { + const fn = this.roomListFilterFunc + return fn ? this.roomList.current.filter(fn) : this.roomList.current } #shouldHideRoom(entry: SyncRoom): boolean { @@ -259,6 +277,12 @@ export class StateStore { if (updatedRoomList) { this.roomList.emit(updatedRoomList) } + for (const [spaceID, children] of Object.entries(sync.space_edges ?? {})) { + this.getSpaceStore(spaceID, true).children = children + } + if (sync.top_level_spaces) { + this.topLevelSpaces.emit(sync.top_level_spaces) + } } invalidateEmojiPackKeyCache() { @@ -324,6 +348,20 @@ export class StateStore { return this.#watchedRoomEmojiPacks ?? {} } + getSpaceStore(spaceID: RoomID, force: true): SpaceEdgeStore + getSpaceStore(spaceID: RoomID): SpaceEdgeStore | null + getSpaceStore(spaceID: RoomID, force?: true): SpaceEdgeStore | null { + let store = this.spaceEdges.get(spaceID) + if (!store) { + if (!force && this.rooms.get(spaceID)?.meta.current.creation_content?.type !== "m.space") { + return null + } + store = new SpaceEdgeStore(spaceID, this) + this.spaceEdges.set(spaceID, store) + } + return store + } + get frequentlyUsedEmoji(): Map { if (this.#frequentlyUsedEmoji === null) { const emojiData = this.accountData.get("io.element.recent_emoji") @@ -433,9 +471,12 @@ export class StateStore { clear() { this.rooms.clear() this.inviteRooms.clear() + this.spaceEdges.clear() this.roomList.emit([]) + this.topLevelSpaces.emit([]) this.accountData.clear() - this.currentRoomListFilter = "" + this.currentRoomListQuery = "" + this.currentRoomListFilter = null this.#frequentlyUsedEmoji = null this.#emojiPackKeys = null this.#watchedRoomEmojiPacks = null diff --git a/web/src/api/statestore/space.ts b/web/src/api/statestore/space.ts new file mode 100644 index 0000000..f3a3fc0 --- /dev/null +++ b/web/src/api/statestore/space.ts @@ -0,0 +1,109 @@ +// 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 { RoomListEntry, StateStore } from "@/api/statestore/main.ts" +import { DBSpaceEdge, RoomID } from "@/api/types" + +export interface RoomListFilter { + id: unknown + include(room: RoomListEntry): boolean +} + +export class SpaceEdgeStore { + #children: DBSpaceEdge[] = [] + #childRooms: Set = new Set() + #flattenedRooms: Set = new Set() + #childSpaces: Set = new Set() + readonly #parentSpaces: Set = new Set() + + constructor(public id: RoomID, private parent: StateStore) { + } + + addParent(parent: SpaceEdgeStore) { + this.#parentSpaces.add(parent) + } + + removeParent(parent: SpaceEdgeStore) { + this.#parentSpaces.delete(parent) + } + + include(room: RoomListEntry) { + return this.#flattenedRooms.has(room.room_id) + } + + get children() { + return this.#children + } + + #updateFlattened(recalculate: boolean, added: Set) { + if (recalculate) { + let flattened = new Set(this.#childRooms) + for (const space of this.#childSpaces) { + flattened = flattened.union(space.#flattenedRooms) + } + this.#flattenedRooms = flattened + } else if (added.size > 50) { + this.#flattenedRooms = this.#flattenedRooms.union(added) + } else if (added.size > 0) { + for (const room of added) { + this.#flattenedRooms.add(room) + } + } + } + + #notifyParentsOfChange(recalculate: boolean, added: Set, stack: WeakSet) { + if (stack.has(this)) { + return + } + stack.add(this) + for (const parent of this.#parentSpaces) { + parent.#updateFlattened(recalculate, added) + parent.#notifyParentsOfChange(recalculate, added, stack) + } + stack.delete(this) + } + + set children(newChildren: DBSpaceEdge[]) { + const newChildRooms = new Set() + const newChildSpaces = new Set() + for (const child of newChildren) { + const spaceStore = this.parent.getSpaceStore(child.child_id) + if (spaceStore) { + newChildSpaces.add(spaceStore) + spaceStore.addParent(this) + } else { + newChildRooms.add(child.child_id) + } + } + for (const space of this.#childSpaces) { + if (!newChildSpaces.has(space)) { + space.removeParent(this) + } + } + const addedRooms = newChildRooms.difference(this.#childRooms) + const removedRooms = this.#childRooms.difference(newChildRooms) + this.#children = newChildren + this.#childRooms = newChildRooms + this.#childSpaces = newChildSpaces + if (this.#childSpaces.size > 0) { + this.#updateFlattened(removedRooms.size > 0, addedRooms) + } else { + this.#flattenedRooms = newChildRooms + } + if (this.#parentSpaces.size > 0) { + this.#notifyParentsOfChange(removedRooms.size > 0, addedRooms, new WeakSet()) + } + } +} diff --git a/web/src/api/types/hievents.ts b/web/src/api/types/hievents.ts index 7c64596..125eb57 100644 --- a/web/src/api/types/hievents.ts +++ b/web/src/api/types/hievents.ts @@ -91,7 +91,7 @@ export interface SyncCompleteData { invited_rooms: DBInvitedRoom[] | null left_rooms: RoomID[] | null account_data: Record | null - space_edges: Record[]> | null + space_edges: Record | null top_level_spaces: RoomID[] | null since?: string clear_state?: boolean diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index a859a47..521a9dd 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -72,7 +72,7 @@ export interface DBRoom { } export interface DBSpaceEdge { - space_id: RoomID + // space_id: RoomID child_id: RoomID child_event_rowid?: EventRowID diff --git a/web/src/icons/home.svg b/web/src/icons/home.svg new file mode 100644 index 0000000..cc29681 --- /dev/null +++ b/web/src/icons/home.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/MainScreen.css b/web/src/ui/MainScreen.css index bfe308d..4e9bc21 100644 --- a/web/src/ui/MainScreen.css +++ b/web/src/ui/MainScreen.css @@ -1,5 +1,5 @@ main.matrix-main { - --room-list-width: 300px; + --room-list-width: 350px; --right-panel-width: 300px; position: fixed; diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 63173ed..05f8b59 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -318,7 +318,7 @@ const MainScreen = () => { }, [context, client]) useEffect(() => context.keybindings.listen(), [context]) const [roomListWidth, resizeHandle1] = useResizeHandle( - 300, 48, Math.min(900, window.innerWidth * 0.4), + 350, 96, Math.min(900, window.innerWidth * 0.4), "roomListWidth", { className: "room-list-resizer" }, ) const [rightPanelWidth, resizeHandle2] = useResizeHandle( diff --git a/web/src/ui/roomlist/RoomList.css b/web/src/ui/roomlist/RoomList.css index 933fba2..ca6504a 100644 --- a/web/src/ui/roomlist/RoomList.css +++ b/web/src/ui/roomlist/RoomList.css @@ -5,14 +5,53 @@ div.room-list-wrapper { box-sizing: border-box; overflow: hidden; scrollbar-color: var(--room-list-scrollbar-color); - display: flex; - flex-direction: column; + display: grid; + grid-template: + "spacebar search" 3.5rem + "spacebar roomlist" 1fr + / 3rem 1fr; } div.room-list { background-color: var(--room-list-background-overlay); overflow-y: auto; - flex: 1; + grid-area: roomlist; +} + +div.space-bar { + background-color: var(--space-list-background-overlay); + grid-area: spacebar; + overflow: auto; + scrollbar-width: none; + + > div.space-entry { + width: 2rem; + height: 2rem; + padding: .25rem; + margin: .25rem; + border-radius: .25rem; + cursor: var(--clickable-cursor); + + &:hover, &:focus { + background-color: var(--room-list-entry-hover-color); + } + + &.active { + background-color: var(--room-list-entry-selected-color); + } + + > svg { + width: 100%; + height: 100%; + } + + > img.avatar { + border-radius: 0; + clip-path: url(#squircle); + width: 100%; + height: 100%; + } + } } div.room-search-wrapper { @@ -21,6 +60,7 @@ div.room-search-wrapper { align-items: center; height: 3.5rem; background-color: var(--room-list-search-background-overlay); + grid-area: search; > input { padding: 0 0 0 1rem; diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx index 93eb074..324c144 100644 --- a/web/src/ui/roomlist/RoomList.tsx +++ b/web/src/ui/roomlist/RoomList.tsx @@ -13,7 +13,8 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { use, useRef, useState } from "react" +import React, { use, useCallback, useRef, useState } from "react" +import type { RoomListFilter } from "@/api/statestore/space.ts" import type { RoomID } from "@/api/types" import { useEventAsState } from "@/util/eventdispatcher.ts" import reverseMap from "@/util/reversemap.ts" @@ -22,7 +23,9 @@ import ClientContext from "../ClientContext.ts" import MainScreenContext from "../MainScreenContext.ts" import { keyToString } from "../keybindings.ts" import Entry from "./Entry.tsx" +import Space from "./Space.tsx" import CloseIcon from "@/icons/close.svg?react" +import HomeIcon from "@/icons/home.svg?react" import SearchIcon from "@/icons/search.svg?react" import "./RoomList.css" @@ -34,20 +37,27 @@ const RoomList = ({ activeRoomID }: RoomListProps) => { const client = use(ClientContext)! const mainScreen = use(MainScreenContext) const roomList = useEventAsState(client.store.roomList) - const roomFilterRef = useRef(null) - const [roomFilter, setRoomFilter] = useState("") - const [realRoomFilter, setRealRoomFilter] = useState("") + const spaces = useEventAsState(client.store.topLevelSpaces) + const searchInputRef = useRef(null) + const [query, directSetQuery] = useState("") + const [space, directSetSpace] = useState(null) - const updateRoomFilter = (evt: React.ChangeEvent) => { - setRoomFilter(evt.target.value) - client.store.currentRoomListFilter = toSearchableString(evt.target.value) - setRealRoomFilter(client.store.currentRoomListFilter) + const setQuery = (evt: React.ChangeEvent) => { + client.store.currentRoomListQuery = toSearchableString(evt.target.value) + directSetQuery(evt.target.value) } + const setSpace = useCallback((space: RoomListFilter | null) => { + directSetSpace(space) + client.store.currentRoomListFilter = space + }, [client]) + const onClickSpace = useCallback((evt: React.MouseEvent) => { + const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!) + setSpace(store) + }, [setSpace, client]) const clearQuery = () => { - setRoomFilter("") - client.store.currentRoomListFilter = "" - setRealRoomFilter("") - roomFilterRef.current?.focus() + client.store.currentRoomListQuery = "" + directSetQuery("") + searchInputRef.current?.focus() } const onKeyDown = (evt: React.KeyboardEvent) => { const key = keyToString(evt) @@ -64,28 +74,40 @@ const RoomList = ({ activeRoomID }: RoomListProps) => { } } + const roomListFilter = client.store.roomListFilterFunc return
-
+
+
setSpace(null)}> + +
+ {spaces.map(roomID => )} +
{reverseMap(roomList, room =>
-
setSpace(null)}> - -
+ + + {spaces.map(roomID => Date: Sun, 29 Dec 2024 14:55:18 +0200 Subject: [PATCH 14/16] web/roomlist: add pseudo-space for space orphans --- web/src/api/statestore/main.ts | 14 +++++++++++--- web/src/api/statestore/space.ts | 26 ++++++++++++++++++++------ web/src/ui/roomlist/RoomList.tsx | 15 ++++++++++++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 71db297..724754a 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 } from "./space.ts" +import { RoomListFilter, SpaceEdgeStore, SpaceOrphansSpace } from "./space.ts" export interface RoomListEntry { room_id: RoomID @@ -75,6 +75,7 @@ export class StateStore { readonly roomList = new NonNullCachedEventDispatcher([]) readonly topLevelSpaces = new NonNullCachedEventDispatcher([]) readonly spaceEdges: Map = new Map() + readonly spaceOrphans = new SpaceOrphansSpace(this) currentRoomListQuery: string = "" currentRoomListFilter: RoomListFilter | null = null readonly accountData: Map = new Map() @@ -277,11 +278,18 @@ export class StateStore { if (updatedRoomList) { this.roomList.emit(updatedRoomList) } - for (const [spaceID, children] of Object.entries(sync.space_edges ?? {})) { - this.getSpaceStore(spaceID, true).children = children + if (sync.space_edges) { + // Ensure all space stores exist first + for (const spaceID of Object.keys(sync.space_edges)) { + this.getSpaceStore(spaceID, true) + } + for (const [spaceID, children] of Object.entries(sync.space_edges ?? {})) { + this.getSpaceStore(spaceID, true).children = children + } } if (sync.top_level_spaces) { this.topLevelSpaces.emit(sync.top_level_spaces) + this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id })) } } diff --git a/web/src/api/statestore/space.ts b/web/src/api/statestore/space.ts index 22d354d..1815533 100644 --- a/web/src/api/statestore/space.ts +++ b/web/src/api/statestore/space.ts @@ -44,11 +44,11 @@ export class SpaceEdgeStore implements RoomListFilter { constructor(public id: RoomID, private parent: StateStore) { } - addParent(parent: SpaceEdgeStore) { + #addParent(parent: SpaceEdgeStore) { this.#parentSpaces.add(parent) } - removeParent(parent: SpaceEdgeStore) { + #removeParent(parent: SpaceEdgeStore) { this.#parentSpaces.delete(parent) } @@ -95,28 +95,42 @@ export class SpaceEdgeStore implements RoomListFilter { const spaceStore = this.parent.getSpaceStore(child.child_id) if (spaceStore) { newChildSpaces.add(spaceStore) - spaceStore.addParent(this) + spaceStore.#addParent(this) } else { newChildRooms.add(child.child_id) } } for (const space of this.#childSpaces) { if (!newChildSpaces.has(space)) { - space.removeParent(this) + space.#removeParent(this) } } const addedRooms = newChildRooms.difference(this.#childRooms) const removedRooms = this.#childRooms.difference(newChildRooms) + const didAddChildren = newChildSpaces.difference(this.#childSpaces).size > 0 + const recalculateFlattened = removedRooms.size > 0 || didAddChildren this.#children = newChildren this.#childRooms = newChildRooms this.#childSpaces = newChildSpaces if (this.#childSpaces.size > 0) { - this.#updateFlattened(removedRooms.size > 0, addedRooms) + this.#updateFlattened(recalculateFlattened, addedRooms) } else { this.#flattenedRooms = newChildRooms } if (this.#parentSpaces.size > 0) { - this.#notifyParentsOfChange(removedRooms.size > 0, addedRooms, new WeakSet()) + this.#notifyParentsOfChange(recalculateFlattened, addedRooms, new WeakSet()) } } } + +export class SpaceOrphansSpace extends SpaceEdgeStore { + static id = "fi.mau.gomuks.space_orphans" + + constructor(parent: StateStore) { + super(SpaceOrphansSpace.id, parent) + } + + include(room: RoomListEntry): boolean { + return !super.include(room) && !room.dm_user_id + } +} diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx index 3289526..587ac8d 100644 --- a/web/src/ui/roomlist/RoomList.tsx +++ b/web/src/ui/roomlist/RoomList.tsx @@ -75,6 +75,12 @@ const RoomList = ({ activeRoomID }: RoomListProps) => { } const roomListFilter = client.store.roomListFilterFunc + const pseudoSpaces = [ + null, + DirectChatSpace, + UnreadsSpace, + client.store.spaceOrphans, + ] return
{
- - - + {pseudoSpaces.map(pseudoSpace => )} {spaces.map(roomID => Date: Sun, 29 Dec 2024 15:28:29 +0200 Subject: [PATCH 15/16] media: use rect instead of circle in fallback avatar Normal avatars already have border-radius. The raw svg being a rectangle allows space squircles to work properly too. --- pkg/gomuks/media.go | 2 +- web/src/api/media.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gomuks/media.go b/pkg/gomuks/media.go index c6d847f..fe03c96 100644 --- a/pkg/gomuks/media.go +++ b/pkg/gomuks/media.go @@ -125,7 +125,7 @@ func (new *noErrorWriter) Write(p []byte) (n int, err error) { // note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts const fallbackAvatarTemplate = ` - + %s diff --git a/web/src/api/media.ts b/web/src/api/media.ts index 5c97ac1..33ad9ea 100644 --- a/web/src/api/media.ts +++ b/web/src/api/media.ts @@ -54,7 +54,7 @@ export const getUserColor = (userID: UserID) => { // note: this should stay in sync with fallbackAvatarTemplate in cmd/gomuks.media.go function makeFallbackAvatar(backgroundColor: string, fallbackCharacter: string): string { return "data:image/svg+xml," + encodeURIComponent(` - + ${escapeHTMLChar(fallbackCharacter)} From 7a8d29b6de95758a12ba5a0cfd681c596b28330d Mon Sep 17 00:00:00 2001 From: Derry Tutt <82726593+everypizza1@users.noreply.github.com> Date: Sun, 29 Dec 2024 11:12:47 -0700 Subject: [PATCH 16/16] web/composer: fix streched custom emojis in autocomplete (#565) --- web/src/ui/composer/Autocompleter.css | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/ui/composer/Autocompleter.css b/web/src/ui/composer/Autocompleter.css index fb8b4bd..571532c 100644 --- a/web/src/ui/composer/Autocompleter.css +++ b/web/src/ui/composer/Autocompleter.css @@ -35,6 +35,7 @@ div.autocompletions { > img { width: 1.5rem; height: 1.5rem; + object-fit: contain; } } }