diff --git a/desktop/go.mod b/desktop/go.mod index 70137e5..8291c8f 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -75,7 +75,7 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 // indirect + maunium.net/go/mautrix v0.22.2-0.20250101160025-077716a4ec7c // indirect mvdan.cc/xurls/v2 v2.5.0 // indirect ) diff --git a/desktop/go.sum b/desktop/go.sum index ef86ad9..c04dd75 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -250,7 +250,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 h1:3aZCApUF3wlboN1BcAX6u6MGhju4OpsmhyfvxdO6tO0= -maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= +maunium.net/go/mautrix v0.22.2-0.20250101160025-077716a4ec7c h1:rVm1DvtGXvjy46pKmO6HYPDLMT6Wd83D1jtUhbnuPoM= +maunium.net/go/mautrix v0.22.2-0.20250101160025-077716a4ec7c/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= diff --git a/go.mod b/go.mod index bc8de98..a7347ed 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( golang.org/x/text v0.21.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 - maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 + maunium.net/go/mautrix v0.22.2-0.20250101160025-077716a4ec7c mvdan.cc/xurls/v2 v2.5.0 ) diff --git a/go.sum b/go.sum index 1897c4d..51e82a9 100644 --- a/go.sum +++ b/go.sum @@ -91,7 +91,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 h1:3aZCApUF3wlboN1BcAX6u6MGhju4OpsmhyfvxdO6tO0= -maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= +maunium.net/go/mautrix v0.22.2-0.20250101160025-077716a4ec7c h1:rVm1DvtGXvjy46pKmO6HYPDLMT6Wd83D1jtUhbnuPoM= +maunium.net/go/mautrix v0.22.2-0.20250101160025-077716a4ec7c/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= diff --git a/pkg/hicli/database/room.go b/pkg/hicli/database/room.go index a27de72..6e46001 100644 --- a/pkg/hicli/database/room.go +++ b/pkg/hicli/database/room.go @@ -21,7 +21,8 @@ import ( const ( getRoomBaseQuery = ` - SELECT room_id, creation_content, tombstone_content, name, name_quality, avatar, explicit_avatar, topic, canonical_alias, + SELECT room_id, creation_content, tombstone_content, name, name_quality, + avatar, explicit_avatar, dm_user_id, topic, canonical_alias, lazy_load_summary, encryption_event, has_member_list, preview_event_rowid, sorting_timestamp, unread_highlights, unread_notifications, unread_messages, marked_unread, prev_batch FROM room @@ -42,18 +43,19 @@ const ( name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END, avatar = COALESCE($6, room.avatar), explicit_avatar = CASE WHEN $6 IS NOT NULL THEN $7 ELSE room.explicit_avatar END, - topic = COALESCE($8, room.topic), - canonical_alias = COALESCE($9, room.canonical_alias), - lazy_load_summary = COALESCE($10, room.lazy_load_summary), - encryption_event = COALESCE($11, room.encryption_event), - has_member_list = room.has_member_list OR $12, - preview_event_rowid = COALESCE($13, room.preview_event_rowid), - sorting_timestamp = COALESCE($14, room.sorting_timestamp), - unread_highlights = COALESCE($15, room.unread_highlights), - unread_notifications = COALESCE($16, room.unread_notifications), - unread_messages = COALESCE($17, room.unread_messages), - marked_unread = COALESCE($18, room.marked_unread), - prev_batch = COALESCE($19, room.prev_batch) + dm_user_id = COALESCE($8, room.dm_user_id), + topic = COALESCE($9, room.topic), + canonical_alias = COALESCE($10, room.canonical_alias), + lazy_load_summary = COALESCE($11, room.lazy_load_summary), + encryption_event = COALESCE($12, room.encryption_event), + has_member_list = room.has_member_list OR $13, + preview_event_rowid = COALESCE($14, room.preview_event_rowid), + sorting_timestamp = COALESCE($15, room.sorting_timestamp), + unread_highlights = COALESCE($16, room.unread_highlights), + unread_notifications = COALESCE($17, room.unread_notifications), + unread_messages = COALESCE($18, room.unread_messages), + marked_unread = COALESCE($19, room.marked_unread), + prev_batch = COALESCE($20, room.prev_batch) WHERE room_id = $1 ` setRoomPrevBatchQuery = ` @@ -153,6 +155,7 @@ type Room struct { NameQuality NameQuality `json:"name_quality"` Avatar *id.ContentURI `json:"avatar,omitempty"` ExplicitAvatar bool `json:"explicit_avatar"` + DMUserID *id.UserID `json:"dm_user_id,omitempty"` Topic *string `json:"topic,omitempty"` CanonicalAlias *id.RoomAlias `json:"canonical_alias,omitempty"` @@ -188,6 +191,10 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) { other.ExplicitAvatar = r.ExplicitAvatar hasChanges = true } + if r.DMUserID != nil { + other.DMUserID = r.DMUserID + hasChanges = true + } if r.Topic != nil { other.Topic = r.Topic hasChanges = true @@ -250,6 +257,7 @@ func (r *Room) Scan(row dbutil.Scannable) (*Room, error) { &r.NameQuality, &r.Avatar, &r.ExplicitAvatar, + &r.DMUserID, &r.Topic, &r.CanonicalAlias, dbutil.JSON{Data: &r.LazyLoadSummary}, @@ -281,6 +289,7 @@ func (r *Room) sqlVariables() []any { r.NameQuality, r.Avatar, r.ExplicitAvatar, + r.DMUserID, r.Topic, r.CanonicalAlias, dbutil.JSONPtr(r.LazyLoadSummary), diff --git a/pkg/hicli/database/upgrades/00-latest-revision.sql b/pkg/hicli/database/upgrades/00-latest-revision.sql index 2df49d2..97f98d4 100644 --- a/pkg/hicli/database/upgrades/00-latest-revision.sql +++ b/pkg/hicli/database/upgrades/00-latest-revision.sql @@ -1,4 +1,4 @@ --- v0 -> v10 (compatible with v10+): Latest revision +-- v0 -> v11 (compatible with v10+): Latest revision CREATE TABLE account ( user_id TEXT NOT NULL PRIMARY KEY, device_id TEXT NOT NULL, @@ -18,6 +18,7 @@ CREATE TABLE room ( name_quality INTEGER NOT NULL DEFAULT 0, avatar TEXT, explicit_avatar INTEGER NOT NULL DEFAULT 0, + dm_user_id TEXT, topic TEXT, canonical_alias TEXT, lazy_load_summary TEXT, diff --git a/pkg/hicli/database/upgrades/11-dm-user-id.sql b/pkg/hicli/database/upgrades/11-dm-user-id.sql new file mode 100644 index 0000000..3377f0c --- /dev/null +++ b/pkg/hicli/database/upgrades/11-dm-user-id.sql @@ -0,0 +1,19 @@ +-- v11 (compatible with v10+): Store direct chat user ID in database +ALTER TABLE room ADD COLUMN dm_user_id TEXT; +WITH dm_user_ids AS ( + SELECT room_id, value + FROM room + INNER JOIN json_each(lazy_load_summary, '$."m.heroes"') + WHERE value NOT IN (SELECT value FROM json_each(( + SELECT event.content + FROM current_state cs + INNER JOIN event ON cs.event_rowid = event.rowid + WHERE cs.room_id=room.room_id AND cs.event_type='io.element.functional_members' AND cs.state_key='' + ), '$.service_members')) + GROUP BY room_id + HAVING COUNT(*) = 1 +) +UPDATE room +SET dm_user_id=value +FROM dm_user_ids du +WHERE room.room_id=du.room_id; diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 9be033a..2ef0ab4 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -157,6 +157,8 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) { return h.Client.ResolveAlias(ctx, params.Alias) }) + case "request_openid_token": + return h.Client.RequestOpenIDToken(ctx) case "logout": if h.LogoutFunc == nil { return nil, errors.New("logout not supported") diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go index 4813f1b..316da7e 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -894,10 +894,11 @@ func (h *HiClient) processStateAndTimeline( } // Calculate name from participants if participants changed and current name was generated from participants, or if the room name was unset if (heroesChanged && updatedRoom.NameQuality <= database.NameQualityParticipants) || updatedRoom.NameQuality == database.NameQualityNil { - name, dmAvatarURL, err := h.calculateRoomParticipantName(ctx, room.ID, summary) + name, dmAvatarURL, dmUserID, err := h.calculateRoomParticipantName(ctx, room.ID, summary) if err != nil { return fmt.Errorf("failed to calculate room name: %w", err) } + updatedRoom.DMUserID = &dmUserID updatedRoom.Name = &name updatedRoom.NameQuality = database.NameQualityParticipants if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar { @@ -966,15 +967,15 @@ func joinMemberNames(names []string, totalCount int) string { } } -func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, error) { +func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, id.UserID, error) { var primaryAvatarURL id.ContentURI if summary == nil || len(summary.Heroes) == 0 { - return "Empty room", primaryAvatarURL, nil + return "Empty room", primaryAvatarURL, "", nil } var functionalMembers []id.UserID functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "") if err != nil { - return "", primaryAvatarURL, fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err) + return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err) } else if functionalMembersEvt != nil { mautrixEvt := functionalMembersEvt.AsRawMautrix() _ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type) @@ -990,16 +991,21 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R } else if summary.InvitedMemberCount != nil { memberCount = *summary.InvitedMemberCount } + var dmUserID id.UserID for _, hero := range summary.Heroes { if slices.Contains(functionalMembers, hero) { + // TODO save member count so push rule evaluation would use the subtracted one? memberCount-- continue } else if len(members) >= 5 { break } + if dmUserID == "" { + dmUserID = hero + } heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String()) if err != nil { - return "", primaryAvatarURL, fmt.Errorf("failed to get %s's member event: %w", hero, err) + return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s's member event: %w", hero, err) } else if heroEvt == nil { leftMembers = append(leftMembers, hero.String()) continue @@ -1015,19 +1021,28 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R } if membership == "join" || membership == "invite" { members = append(members, name) + dmUserID = hero } else { leftMembers = append(leftMembers, name) } } - if len(members)+len(leftMembers) > 1 || !primaryAvatarURL.IsValid() { + if !primaryAvatarURL.IsValid() { primaryAvatarURL = id.ContentURI{} } if len(members) > 0 { - return joinMemberNames(members, memberCount), primaryAvatarURL, nil + if len(members) > 1 { + primaryAvatarURL = id.ContentURI{} + dmUserID = "" + } + return joinMemberNames(members, memberCount), primaryAvatarURL, dmUserID, nil } else if len(leftMembers) > 0 { - return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, nil + if len(leftMembers) > 1 { + primaryAvatarURL = id.ContentURI{} + dmUserID = "" + } + return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, "", nil } else { - return "Empty room", primaryAvatarURL, nil + return "Empty room", primaryAvatarURL, "", nil } } diff --git a/web/eslint.config.js b/web/eslint.config.js index 7654e8d..41ba85f 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -74,7 +74,7 @@ export default tseslint.config( "quotes": ["error", "double", {allowTemplateLiterals: true}], "semi": ["error", "never"], "comma-dangle": ["error", "always-multiline"], - "max-len": ["warn", 120], + "max-len": ["error", 120], "space-before-function-paren": ["error", { "anonymous": "never", "named": "never", diff --git a/web/src/api/media.ts b/web/src/api/media.ts index 33ad9ea..5e3180d 100644 --- a/web/src/api/media.ts +++ b/web/src/api/media.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { parseMXC } from "@/util/validation.ts" -import { ContentURI, LazyLoadSummary, RoomID, UserID, UserProfile } from "./types" +import { ContentURI, RoomID, UserID, UserProfile } from "./types" export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => { const [server, mediaID] = parseMXC(mxc) @@ -93,20 +93,12 @@ interface RoomForAvatarURL { room_id: RoomID name?: string dm_user_id?: UserID - lazy_load_summary?: LazyLoadSummary avatar?: ContentURI avatar_url?: ContentURI } export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: ContentURI): string | undefined => { - let dmUserID: UserID | undefined - if ("dm_user_id" in room) { - dmUserID = room.dm_user_id - } else if ("lazy_load_summary" in room) { - dmUserID = room.lazy_load_summary?.["m.heroes"]?.length === 1 - ? room.lazy_load_summary["m.heroes"][0] : undefined - } - return getAvatarURL(dmUserID ?? room.room_id, { + return getAvatarURL(room.dm_user_id ?? room.room_id, { displayname: room.name, avatar_url: avatarOverride ?? room.avatar ?? room.avatar_url, }) diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 5975079..9530a8a 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -32,6 +32,7 @@ import type { ReceiptType, RelatesTo, ResolveAliasResponse, + RespOpenIDToken, RespRoomJoin, RoomAlias, RoomID, @@ -261,4 +262,8 @@ export default abstract class RPCClient { verify(recovery_key: string): Promise { return this.request("verify", { recovery_key }) } + + requestOpenIDToken(): Promise { + return this.request("request_openid_token", {}) + } } diff --git a/web/src/api/statestore/index.ts b/web/src/api/statestore/index.ts index 3bbe512..106a3f4 100644 --- a/web/src/api/statestore/index.ts +++ b/web/src/api/statestore/index.ts @@ -1,3 +1,4 @@ export * from "./main.ts" export * from "./room.ts" export * from "./hooks.ts" +export * from "./space.ts" diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 50b07d1..31fe657 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 { DirectChatSpace, RoomListFilter, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts" +import { DirectChatSpace, RoomListFilter, Space, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts" export interface RoomListEntry { room_id: RoomID @@ -73,11 +73,12 @@ export class StateStore { readonly rooms: Map = new Map() readonly inviteRooms: Map = new Map() readonly roomList = new NonNullCachedEventDispatcher([]) + readonly roomListEntries = new Map() readonly topLevelSpaces = new NonNullCachedEventDispatcher([]) readonly spaceEdges: Map = new Map() readonly spaceOrphans = new SpaceOrphansSpace(this) readonly directChatsSpace = new DirectChatSpace() - readonly unreadsSpace = new UnreadsSpace() + readonly unreadsSpace = new UnreadsSpace(this) readonly pseudoSpaces = [ this.spaceOrphans, this.directChatsSpace, @@ -110,6 +111,39 @@ export class StateStore { return true } + getSpaceByID(spaceID: string | undefined): RoomListFilter | null { + if (!spaceID) { + return null + } + const realSpace = this.spaceEdges.get(spaceID) + if (realSpace) { + return realSpace + } + for (const pseudoSpace of this.pseudoSpaces) { + if (pseudoSpace.id === spaceID) { + return pseudoSpace + } + } + console.warn("Failed to find space", spaceID) + return null + } + + findMatchingSpace(room: RoomListEntry): Space | null { + if (this.spaceOrphans.include(room)) { + return this.spaceOrphans + } + for (const spaceID of this.topLevelSpaces.current) { + const space = this.spaceEdges.get(spaceID) + if (space?.include(room)) { + return space + } + } + if (this.directChatsSpace.include(room)) { + return this.directChatsSpace + } + return null + } + get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null { if (!this.currentRoomListFilter && !this.currentRoomListQuery) { return null @@ -169,8 +203,7 @@ 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?.["m.heroes"]?.length === 1 - ? entry.meta.lazy_load_summary["m.heroes"][0] : undefined, + dm_user_id: entry.meta.dm_user_id, sorting_timestamp: entry.meta.sorting_timestamp, preview_event, preview_sender, @@ -212,11 +245,11 @@ export class StateStore { const changedRoomListEntries = new Map() for (const data of sync.invited_rooms ?? []) { const room = new InvitedRoomStore(data, this) - const oldEntry = this.inviteRooms.get(room.room_id) this.inviteRooms.set(room.room_id, room) if (!resyncRoomList) { changedRoomListEntries.set(room.room_id, room) - this.#applyUnreadModification(room, oldEntry) + this.#applyUnreadModification(room, this.roomListEntries.get(room.room_id)) + this.roomListEntries.set(room.room_id, room) } if (this.activeRoomID === room.room_id) { this.switchRoom?.(room.room_id) @@ -239,8 +272,12 @@ export class StateStore { if (roomListEntryChanged) { const entry = this.#makeRoomListEntry(data, room) changedRoomListEntries.set(roomID, entry) - this.#applyUnreadModification(entry, room.roomListEntry) - room.roomListEntry = entry + this.#applyUnreadModification(entry, this.roomListEntries.get(roomID)) + if (entry) { + this.roomListEntries.set(roomID, entry) + } else { + this.roomListEntries.delete(roomID) + } } if (!resyncRoomList) { // When we join a valid replacement room, hide the tombstoned room. @@ -289,7 +326,7 @@ export class StateStore { updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp) for (const entry of updatedRoomList) { this.#applyUnreadModification(entry, undefined) - this.rooms.get(entry.room_id)!.roomListEntry = entry + this.roomListEntries.set(entry.room_id, entry) } } else if (changedRoomListEntries.size > 0) { updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id)) @@ -515,6 +552,7 @@ export class StateStore { this.rooms.clear() this.inviteRooms.clear() this.spaceEdges.clear() + this.pseudoSpaces.forEach(space => space.clearUnreads()) this.roomList.emit([]) this.topLevelSpaces.emit([]) this.accountData.clear() diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 73903a8..5252a3c 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -42,7 +42,7 @@ import { UserID, roomStateGUIDToString, } from "../types" -import type { RoomListEntry, StateStore } from "./main.ts" +import type { StateStore } from "./main.ts" function arraysAreEqual(arr1?: T[], arr2?: T[]): boolean { if (!arr1 || !arr2) { @@ -70,6 +70,7 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean { meta1.avatar === meta2.avatar && meta1.topic === meta2.topic && meta1.canonical_alias === meta2.canonical_alias && + meta1.dm_user_id === meta2.dm_user_id && llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) && meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm && meta1.has_member_list === meta2.has_member_list @@ -126,7 +127,6 @@ export class RoomStateStore { readUpToRow = -1 hasMoreHistory = true hidden = false - roomListEntry: RoomListEntry | undefined | null constructor(meta: DBRoom, private parent: StateStore) { this.roomID = meta.room_id diff --git a/web/src/api/statestore/space.ts b/web/src/api/statestore/space.ts index 8e7eb02..96b37b8 100644 --- a/web/src/api/statestore/space.ts +++ b/web/src/api/statestore/space.ts @@ -40,6 +40,10 @@ export abstract class Space implements RoomListFilter { abstract id: string abstract include(room: RoomListEntry): boolean + clearUnreads() { + this.counts.emit(emptyUnreadCounts) + } + applyUnreads(newCounts?: SpaceUnreadCounts | null, oldCounts?: SpaceUnreadCounts | null) { const mergedCounts: SpaceUnreadCounts = { unread_messages: this.counts.current.unread_messages @@ -79,8 +83,13 @@ export class DirectChatSpace extends Space { export class UnreadsSpace extends Space { id = "fi.mau.gomuks.unreads" + constructor(private parent: StateStore) { + super() + } + include(room: RoomListEntry): boolean { - return Boolean(room.unread_messages + return Boolean(room.room_id === this.parent.activeRoomID + || room.unread_messages || room.unread_notifications || room.unread_highlights || room.marked_unread) diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index 521a9dd..637f38a 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -54,6 +54,7 @@ export interface DBRoom { name_quality: RoomNameQuality avatar?: ContentURI explicit_avatar: boolean + dm_user_id?: UserID topic?: string canonical_alias?: RoomAlias lazy_load_summary?: LazyLoadSummary diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 17f2780..31d9d95 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -288,6 +288,13 @@ export interface RespRoomJoin { room_id: RoomID } +export interface RespOpenIDToken { + access_token: string + expires_in: number + matrix_server_name: string + token_type: "Bearer" +} + export interface PronounSet { subject?: string object?: string diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 05f8b59..e3220a6 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -16,7 +16,7 @@ import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react" import { SyncLoader } from "react-spinners" import Client from "@/api/client.ts" -import { RoomStateStore } from "@/api/statestore" +import { RoomListFilter, RoomStateStore } from "@/api/statestore" import type { RoomID } from "@/api/types" import { useEventAsState } from "@/util/eventdispatcher.ts" import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts" @@ -52,6 +52,7 @@ class ContextFields implements MainScreenContextFields { constructor( private directSetRightPanel: (props: RightPanelProps | null) => void, private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void, + private directSetSpace: (space: RoomListFilter | null) => void, private client: Client, ) { this.keybindings = new Keybindings(client.store, this) @@ -95,12 +96,17 @@ class ContextFields implements MainScreenContextFields { } } - setActiveRoom = (roomID: RoomID | null, previewMeta?: Partial, pushState = true) => { + setActiveRoom = ( + roomID: RoomID | null, + previewMeta?: Partial, + toSpace?: RoomListFilter, + pushState = true, + ) => { console.log("Switching to room", roomID) if (roomID) { const room = this.client.store.rooms.get(roomID) if (room) { - this.#setActiveRoom(room, pushState) + this.#setActiveRoom(room, toSpace, pushState) } else { this.#setPreviewRoom(roomID, pushState, previewMeta) } @@ -109,6 +115,24 @@ class ContextFields implements MainScreenContextFields { } } + setSpace = (space: RoomListFilter | null, pushState = true) => { + if (space === this.client.store.currentRoomListFilter) { + return + } + console.log("Switching to space", space?.id) + this.directSetSpace(space) + this.client.store.currentRoomListFilter = space + if (pushState) { + if (this.client.store.activeRoomID && space) { + const entry = this.client.store.roomListEntries.get(this.client.store.activeRoomID) + if (entry && !space.include(entry)) { + this.setActiveRoom(null) + } + } + history.replaceState({ ...(history.state || {}), space_id: space?.id }, "") + } + } + #setPreviewRoom(roomID: RoomID, pushState: boolean, meta?: Partial) { const invite = this.client.store.inviteRooms.get(roomID) this.#closeActiveRoom(false) @@ -120,6 +144,7 @@ class ContextFields implements MainScreenContextFields { room_id: roomID, source_via: meta?.via, source_alias: meta?.alias, + space_id: history.state?.space_id, }, "") } } @@ -131,10 +156,21 @@ class ContextFields implements MainScreenContextFields { return room.preferences.room_window_title.replace("$room", name!) } - #setActiveRoom(room: RoomStateStore, pushState: boolean) { + #setActiveRoom(room: RoomStateStore, space: RoomListFilter | undefined | null, pushState: boolean) { window.activeRoom = room this.directSetActiveRoom(room) this.directSetRightPanel(null) + if (!space && this.client.store.currentRoomListFilter) { + const roomListEntry = this.client.store.roomListEntries.get(room.roomID) + if (roomListEntry && !this.client.store.currentRoomListFilter.include(roomListEntry)) { + space = this.client.store.findMatchingSpace(roomListEntry) + } + } + if (space && space !== this.client.store.currentRoomListFilter) { + console.log("Switching to space", space?.id) + this.directSetSpace(space) + this.client.store.currentRoomListFilter = space + } this.rightPanelStack = [] this.client.store.activeRoomID = room.roomID this.client.store.activeRoomIsPreview = false @@ -148,7 +184,7 @@ class ContextFields implements MainScreenContextFields { .querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`) ?.scrollIntoView({ block: "nearest" }) if (pushState) { - history.pushState({ room_id: room.roomID }, "") + history.pushState({ room_id: room.roomID, space_id: space?.id ?? history.state?.space_id }, "") } let roomNameForTitle = room.meta.current.name if (roomNameForTitle && roomNameForTitle.length > 48) { @@ -166,7 +202,7 @@ class ContextFields implements MainScreenContextFields { this.client.store.activeRoomIsPreview = false this.keybindings.activeRoom = null if (pushState) { - history.pushState({}, "") + history.pushState({ space_id: history.state?.space_id }, "") } document.title = this.#getWindowTitle() } @@ -197,7 +233,7 @@ class ContextFields implements MainScreenContextFields { const SYNC_ERROR_HIDE_DELAY = 30 * 1000 -const handleURLHash = (client: Client) => { +const handleURLHash = (client: Client, context: MainScreenContextFields) => { if (!location.hash.startsWith("#/uri/")) { if (location.search) { const currentETag = ( @@ -248,7 +284,7 @@ const handleURLHash = (client: Client) => { // TODO loading indicator or something for this? client.rpc.resolveAlias(uri.identifier).then( res => { - window.mainScreenContext.setActiveRoom(res.room_id, { + context.setActiveRoom(res.room_id, { alias: uri.identifier, via: res.servers.slice(0, 3), }) @@ -279,12 +315,13 @@ const activeRoomReducer = ( const MainScreen = () => { const [[prevActiveRoom, activeRoom], directSetActiveRoom] = useReducer(activeRoomReducer, [null, null]) + const [space, directSetSpace] = useState(null) const skipNextTransitionRef = useRef(false) const [rightPanel, directSetRightPanel] = useState(null) const client = use(ClientContext)! const syncStatus = useEventAsState(client.syncStatus) const context = useMemo( - () => new ContextFields(directSetRightPanel, directSetActiveRoom, client), + () => new ContextFields(directSetRightPanel, directSetActiveRoom, directSetSpace, client), [client], ) useEffect(() => { @@ -292,17 +329,21 @@ const MainScreen = () => { const listener = (evt: PopStateEvent) => { skipNextTransitionRef.current = evt.hasUAVisualTransition const roomID = evt.state?.room_id ?? null + const spaceID = evt.state?.space_id ?? undefined + if (spaceID !== client.store.currentRoomListFilter?.id) { + context.setSpace(client.store.getSpaceByID(spaceID), false) + } if (roomID !== client.store.activeRoomID) { context.setActiveRoom(roomID, { alias: ensureString(evt.state?.source_alias) || undefined, via: ensureStringArray(evt.state?.source_via), - }, false) + }, undefined, false) } context.setRightPanel(evt.state?.right_panel ?? null, false) } window.addEventListener("popstate", listener) const initHandle = () => { - const state = handleURLHash(client) + const state = handleURLHash(client, context) listener({ state } as PopStateEvent) } let cancel = () => {} @@ -372,7 +413,7 @@ const MainScreen = () => {
- + {resizeHandle1} {renderedRoom ? renderedRoom instanceof RoomStateStore diff --git a/web/src/ui/MainScreenContext.ts b/web/src/ui/MainScreenContext.ts index 67c0b0b..de71425 100644 --- a/web/src/ui/MainScreenContext.ts +++ b/web/src/ui/MainScreenContext.ts @@ -13,13 +13,15 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { createContext } from "react" +import React, { createContext } from "react" +import { RoomListFilter } from "@/api/statestore" import type { RoomID } from "@/api/types" import type { RightPanelProps } from "./rightpanel/RightPanel.tsx" import type { RoomPreviewProps } from "./roomview/RoomPreview.tsx" export interface MainScreenContextFields { - setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial) => void + setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial, toSpace?: RoomListFilter) => void + setSpace: (space: RoomListFilter | null, pushState?: boolean) => void clickRoom: (evt: React.MouseEvent) => void clearActiveRoom: () => void @@ -32,6 +34,9 @@ const stubContext = { get setActiveRoom(): never { throw new Error("MainScreenContext used outside main screen") }, + get setSpace(): never { + throw new Error("MainScreenContext used outside main screen") + }, get clickRoom(): never { throw new Error("MainScreenContext used outside main screen") }, diff --git a/web/src/ui/modal/Modal.tsx b/web/src/ui/modal/Modal.tsx index 1fc3ab6..4d0baf9 100644 --- a/web/src/ui/modal/Modal.tsx +++ b/web/src/ui/modal/Modal.tsx @@ -60,7 +60,7 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => { } window.addEventListener("popstate", listener) return () => window.removeEventListener("popstate", listener) - }, []) + }, [onClickWrapper]) let modal: JSX.Element | null = null if (state) { let content = {state.content} diff --git a/web/src/ui/rightpanel/UserInfoMutualRooms.tsx b/web/src/ui/rightpanel/UserInfoMutualRooms.tsx index fadd041..1a11b55 100644 --- a/web/src/ui/rightpanel/UserInfoMutualRooms.tsx +++ b/web/src/ui/rightpanel/UserInfoMutualRooms.tsx @@ -40,8 +40,7 @@ const MutualRooms = ({ client, userID }: MutualRoomsProps) => { } return { room_id: roomID, - dm_user_id: roomData.meta.current.lazy_load_summary?.["m.heroes"]?.length === 1 - ? roomData.meta.current.lazy_load_summary["m.heroes"][0] : undefined, + dm_user_id: roomData.meta.current.dm_user_id, 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 index 00ac3a7..bb46319 100644 --- a/web/src/ui/roomlist/FakeSpace.tsx +++ b/web/src/ui/roomlist/FakeSpace.tsx @@ -27,30 +27,33 @@ export interface FakeSpaceProps { space: Space | null setSpace: (space: RoomListFilter | null) => void isActive: boolean - onClickUnread?: (evt: React.MouseEvent | null, space: Space | null) => void + onClickUnread?: (evt: React.MouseEvent, space: Space | null) => void } -const getFakeSpaceIcon = (space: RoomListFilter | null): JSX.Element | null => { +const getFakeSpaceMeta = (space: RoomListFilter | null): [string | undefined, JSX.Element | null] => { switch (space?.id) { case undefined: - return + return ["Home", ] case "fi.mau.gomuks.direct_chats": - return + return ["Direct chats", ] case "fi.mau.gomuks.unreads": - return + return ["Unread chats", ] case "fi.mau.gomuks.space_orphans": - return + return ["Rooms outside spaces", ] default: - return null + return [undefined, null] } } const FakeSpace = ({ space, setSpace, isActive, onClickUnread }: FakeSpaceProps) => { const unreads = useEventAsState(space?.counts) - const onClickUnreadWrapped = onClickUnread ? () => onClickUnread(null, space) : undefined - return
setSpace(space)}> + const onClickUnreadWrapped = onClickUnread + ? (evt: React.MouseEvent) => onClickUnread(evt, space) + : undefined + const [title, icon] = getFakeSpaceMeta(space) + return
setSpace(space)} title={title}> - {getFakeSpaceIcon(space)} + {icon}
} diff --git a/web/src/ui/roomlist/RoomList.css b/web/src/ui/roomlist/RoomList.css index f7f8e33..0d084d2 100644 --- a/web/src/ui/roomlist/RoomList.css +++ b/web/src/ui/roomlist/RoomList.css @@ -119,7 +119,7 @@ div.room-entry { width: 3rem; > img.room-avatar { - padding: 4px; + margin: .25rem; } } @@ -172,6 +172,7 @@ div.room-entry-unreads { justify-content: center; border-radius: var(--unread-count-size); color: var(--unread-counter-text-color); + user-select: none; background-color: var(--unread-counter-message-bg); height: var(--unread-count-size); diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx index 04aff8d..c83be1d 100644 --- a/web/src/ui/roomlist/RoomList.tsx +++ b/web/src/ui/roomlist/RoomList.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import React, { use, useCallback, useRef, useState } from "react" -import { RoomListFilter, Space as SpaceStore, SpaceUnreadCounts } from "@/api/statestore/space.ts" +import { RoomListFilter, Space as SpaceStore, SpaceUnreadCounts } from "@/api/statestore" import type { RoomID } from "@/api/types" import { useEventAsState } from "@/util/eventdispatcher.ts" import reverseMap from "@/util/reversemap.ts" @@ -31,41 +31,37 @@ import "./RoomList.css" interface RoomListProps { activeRoomID: RoomID | null + space: RoomListFilter | null } -const RoomList = ({ activeRoomID }: RoomListProps) => { +const RoomList = ({ activeRoomID, space }: RoomListProps) => { const client = use(ClientContext)! const mainScreen = use(MainScreenContext) const roomList = useEventAsState(client.store.roomList) const spaces = useEventAsState(client.store.topLevelSpaces) const searchInputRef = useRef(null) const [query, directSetQuery] = useState("") - const [space, directSetSpace] = useState(null) 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]) + mainScreen.setSpace(store) + }, [mainScreen, client]) const onClickSpaceUnread = useCallback(( - evt: React.MouseEvent | null, space?: SpaceStore | null, + evt: React.MouseEvent, space?: SpaceStore | null, ) => { - if (evt) { + if (!space) { const targetSpace = evt.currentTarget.closest("div.space-entry")?.getAttribute("data-target-space") if (!targetSpace) { return } space = client.store.getSpaceStore(targetSpace) - } - if (!space) { - return + if (!space) { + return + } } const counts = space.counts.current let wantedField: keyof SpaceUnreadCounts @@ -81,7 +77,8 @@ const RoomList = ({ activeRoomID }: RoomListProps) => { for (let i = client.store.roomList.current.length - 1; i >= 0; i--) { const entry = client.store.roomList.current[i] if (entry[wantedField] > 0 && space.include(entry)) { - mainScreen.setActiveRoom(entry.room_id) + mainScreen.setActiveRoom(entry.room_id, undefined, space) + evt.stopPropagation() break } } @@ -124,11 +121,11 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
- + {client.store.pseudoSpaces.map(pseudoSpace => )} diff --git a/web/src/ui/roomlist/UnreadCount.tsx b/web/src/ui/roomlist/UnreadCount.tsx index 82807c0..4ad48f5 100644 --- a/web/src/ui/roomlist/UnreadCount.tsx +++ b/web/src/ui/roomlist/UnreadCount.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 { SpaceUnreadCounts } from "@/api/statestore/space.ts" +import { SpaceUnreadCounts } from "@/api/statestore" interface UnreadCounts extends SpaceUnreadCounts { marked_unread?: boolean @@ -38,9 +38,9 @@ const UnreadCount = ({ counts, space, onClick }: UnreadCountProps) => { const countIsBig = !space && Boolean(counts.unread_notifications || counts.unread_highlights || counts.marked_unread) let unreadCountDisplay = unreadCount.toString() - if (unreadCount > 999 && countIsBig) { + if (unreadCount > 999 && (countIsBig || space)) { unreadCountDisplay = "99+" - } else if (unreadCount > 9999 && countIsBig) { + } else if (unreadCount > 9999) { unreadCountDisplay = "999+" } const classNames = ["unread-count"] diff --git a/web/src/ui/settings/SettingsView.tsx b/web/src/ui/settings/SettingsView.tsx index 6f9aefc..daf5c44 100644 --- a/web/src/ui/settings/SettingsView.tsx +++ b/web/src/ui/settings/SettingsView.tsx @@ -237,7 +237,9 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta }
{vscodeOpen ?
-
}> + + }> { ) } } + const onClickOpenCSSApp = () => { + client.rpc.requestOpenIDToken().then( + resp => window.open( + `https://css.gomuks.app/login?token=${resp.access_token}&server_name=${resp.matrix_server_name}`, + "_blank", + "noreferrer noopener", + ), + err => window.alert(`Failed to request OpenID token: ${err}`), + ) + } usePreferences(client.store, room) const globalServer = client.store.serverPreferenceCache const globalLocal = client.store.localPreferenceCache @@ -381,6 +393,7 @@ const SettingsView = ({ room }: SettingsViewProps) => {
+ {window.Notification && } diff --git a/web/src/ui/timeline/ReadReceipts.css b/web/src/ui/timeline/ReadReceipts.css index 7e8579d..df23657 100644 --- a/web/src/ui/timeline/ReadReceipts.css +++ b/web/src/ui/timeline/ReadReceipts.css @@ -20,6 +20,7 @@ div.timeline-event > div.read-receipts { > img { margin-left: -.35rem; border: 1px solid var(--background-color); + background-color: var(--background-color); } }