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/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..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) @@ -34,7 +35,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, @@ -95,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 new file mode 100644 index 0000000..b0b86ef --- /dev/null +++ b/pkg/hicli/database/space.go @@ -0,0 +1,236 @@ +// 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, child_event_rowid, "order", suggested, parent_event_rowid, canonical, parent_validated + FROM space_edge + -- 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 + ` + 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 + 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) 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 +} + +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"` + + 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, + &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..2df49d2 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,24 @@ 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, + + -- 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..429973c --- /dev/null +++ b/pkg/hicli/database/upgrades/10-spaces.sql @@ -0,0 +1,83 @@ +-- 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, + + -- 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; diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go index 2bd8534..e45a4e8 100644 --- a/pkg/hicli/events.go +++ b/pkg/hicli/events.go @@ -31,12 +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"` + 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 5c02292..5b08de5 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 { @@ -70,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 { @@ -79,22 +118,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), - } - 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 - } - payload.ClearState = true - } - if payload.InvitedRooms == nil { - payload.InvitedRooms = make([]*database.InvitedRoom, 0) + Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)), } for _, room := range rooms { if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp { @@ -106,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 } } @@ -117,10 +143,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 ecb2ec5..f41ceee 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,11 @@ 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 + } if roomChanged { err = h.DB.Room.Upsert(ctx, updatedRoom) if err != nil { @@ -155,19 +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), }) } } 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/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/media.ts b/web/src/api/media.ts index 5ece955..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)} @@ -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..724754a 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, SpaceOrphansSpace } from "./space.ts" export interface RoomListEntry { room_id: RoomID @@ -72,7 +73,11 @@ 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() + readonly spaceOrphans = new SpaceOrphansSpace(this) + currentRoomListQuery: string = "" + currentRoomListFilter: RoomListFilter | null = null readonly accountData: Map = new Map() readonly accountDataSubs = new MultiSubscribable() readonly emojiRoomsSub = new Subscribable() @@ -89,11 +94,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 { @@ -122,7 +141,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 { @@ -143,8 +162,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, @@ -165,7 +184,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 +195,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 +222,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 +231,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 +241,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 +252,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) @@ -259,6 +278,19 @@ export class StateStore { if (updatedRoomList) { this.roomList.emit(updatedRoomList) } + 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 })) + } } invalidateEmojiPackKeyCache() { @@ -324,6 +356,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 +479,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/room.ts b/web/src/api/statestore/room.ts index 3896de7..7419a55 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 { @@ -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/statestore/space.ts b/web/src/api/statestore/space.ts new file mode 100644 index 0000000..1815533 --- /dev/null +++ b/web/src/api/statestore/space.ts @@ -0,0 +1,136 @@ +// 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: string + include(room: RoomListEntry): boolean +} + +export const DirectChatSpace: RoomListFilter = { + id: "fi.mau.gomuks.direct_chats", + include: room => !!room.dm_user_id, +} + +export const UnreadsSpace: RoomListFilter = { + id: "fi.mau.gomuks.unreads", + include: room => Boolean(room.unread_messages + || room.unread_notifications + || room.unread_highlights + || room.marked_unread), +} + +export class SpaceEdgeStore implements RoomListFilter { + #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) + 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(recalculateFlattened, addedRooms) + } else { + this.#flattenedRooms = newChildRooms + } + if (this.#parentSpaces.size > 0) { + 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/api/types/hievents.ts b/web/src/api/types/hievents.ts index f05296e..125eb57 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,12 @@ 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 + 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 7796e82..521a9dd 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 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/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/icons/notifications-unread.svg b/web/src/icons/notifications-unread.svg new file mode 100644 index 0000000..c96868b --- /dev/null +++ b/web/src/icons/notifications-unread.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/person.svg b/web/src/icons/person.svg new file mode 100644 index 0000000..aa2b620 --- /dev/null +++ b/web/src/icons/person.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/tag.svg b/web/src/icons/tag.svg new file mode 100644 index 0000000..71dadef --- /dev/null +++ b/web/src/icons/tag.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 2ae5194..05f8b59 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) @@ -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/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; } } } 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/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: "", diff --git a/web/src/ui/roomlist/FakeSpace.tsx b/web/src/ui/roomlist/FakeSpace.tsx new file mode 100644 index 0000000..f3ffa53 --- /dev/null +++ b/web/src/ui/roomlist/FakeSpace.tsx @@ -0,0 +1,52 @@ +// 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 { JSX } from "react" +import type { RoomListFilter } from "@/api/statestore/space.ts" +import HomeIcon from "@/icons/home.svg?react" +import NotificationsIcon from "@/icons/notifications.svg?react" +import PersonIcon from "@/icons/person.svg?react" +import TagIcon from "@/icons/tag.svg?react" +import "./RoomList.css" + +export interface FakeSpaceProps { + space: RoomListFilter | null + setSpace: (space: RoomListFilter | null) => void + isActive: boolean +} + +const getFakeSpaceIcon = (space: RoomListFilter | null): JSX.Element | null => { + switch (space?.id) { + case undefined: + return + case "fi.mau.gomuks.direct_chats": + return + case "fi.mau.gomuks.unreads": + return + case "fi.mau.gomuks.space_orphans": + return + default: + return null + } +} + +const FakeSpace = ({ space, setSpace, isActive }: FakeSpaceProps) => { + 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 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..587ac8d 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 { DirectChatSpace, RoomListFilter, UnreadsSpace } from "@/api/statestore/space.ts" import type { RoomID } from "@/api/types" import { useEventAsState } from "@/util/eventdispatcher.ts" import reverseMap from "@/util/reversemap.ts" @@ -22,6 +23,8 @@ import ClientContext from "../ClientContext.ts" import MainScreenContext from "../MainScreenContext.ts" import { keyToString } from "../keybindings.ts" import Entry from "./Entry.tsx" +import FakeSpace from "./FakeSpace.tsx" +import Space from "./Space.tsx" import CloseIcon from "@/icons/close.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,49 @@ const RoomList = ({ activeRoomID }: RoomListProps) => { } } + const roomListFilter = client.store.roomListFilterFunc + const pseudoSpaces = [ + null, + DirectChatSpace, + UnreadsSpace, + client.store.spaceOrphans, + ] return
-
+
+ {pseudoSpaces.map(pseudoSpace => )} + {spaces.map(roomID => )} +
{reverseMap(roomList, room =>