Merge branch 'main' into nexy7574/extended-profiles

This commit is contained in:
Tulir Asokan 2025-01-03 13:40:58 +02:00
commit fc54faf551
28 changed files with 260 additions and 101 deletions

View file

@ -75,7 +75,7 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // 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 mvdan.cc/xurls/v2 v2.5.0 // indirect
) )

View file

@ -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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.20250101160025-077716a4ec7c h1:rVm1DvtGXvjy46pKmO6HYPDLMT6Wd83D1jtUhbnuPoM=
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= 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 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

2
go.mod
View file

@ -25,7 +25,7 @@ require (
golang.org/x/text v0.21.0 golang.org/x/text v0.21.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 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 mvdan.cc/xurls/v2 v2.5.0
) )

4
go.sum
View file

@ -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= 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 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= 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.20250101160025-077716a4ec7c h1:rVm1DvtGXvjy46pKmO6HYPDLMT6Wd83D1jtUhbnuPoM=
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= 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 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

View file

@ -21,7 +21,8 @@ import (
const ( const (
getRoomBaseQuery = ` 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, lazy_load_summary, encryption_event, has_member_list, preview_event_rowid, sorting_timestamp,
unread_highlights, unread_notifications, unread_messages, marked_unread, prev_batch unread_highlights, unread_notifications, unread_messages, marked_unread, prev_batch
FROM room FROM room
@ -42,18 +43,19 @@ const (
name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END, name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END,
avatar = COALESCE($6, room.avatar), avatar = COALESCE($6, room.avatar),
explicit_avatar = CASE WHEN $6 IS NOT NULL THEN $7 ELSE room.explicit_avatar END, explicit_avatar = CASE WHEN $6 IS NOT NULL THEN $7 ELSE room.explicit_avatar END,
topic = COALESCE($8, room.topic), dm_user_id = COALESCE($8, room.dm_user_id),
canonical_alias = COALESCE($9, room.canonical_alias), topic = COALESCE($9, room.topic),
lazy_load_summary = COALESCE($10, room.lazy_load_summary), canonical_alias = COALESCE($10, room.canonical_alias),
encryption_event = COALESCE($11, room.encryption_event), lazy_load_summary = COALESCE($11, room.lazy_load_summary),
has_member_list = room.has_member_list OR $12, encryption_event = COALESCE($12, room.encryption_event),
preview_event_rowid = COALESCE($13, room.preview_event_rowid), has_member_list = room.has_member_list OR $13,
sorting_timestamp = COALESCE($14, room.sorting_timestamp), preview_event_rowid = COALESCE($14, room.preview_event_rowid),
unread_highlights = COALESCE($15, room.unread_highlights), sorting_timestamp = COALESCE($15, room.sorting_timestamp),
unread_notifications = COALESCE($16, room.unread_notifications), unread_highlights = COALESCE($16, room.unread_highlights),
unread_messages = COALESCE($17, room.unread_messages), unread_notifications = COALESCE($17, room.unread_notifications),
marked_unread = COALESCE($18, room.marked_unread), unread_messages = COALESCE($18, room.unread_messages),
prev_batch = COALESCE($19, room.prev_batch) marked_unread = COALESCE($19, room.marked_unread),
prev_batch = COALESCE($20, room.prev_batch)
WHERE room_id = $1 WHERE room_id = $1
` `
setRoomPrevBatchQuery = ` setRoomPrevBatchQuery = `
@ -153,6 +155,7 @@ type Room struct {
NameQuality NameQuality `json:"name_quality"` NameQuality NameQuality `json:"name_quality"`
Avatar *id.ContentURI `json:"avatar,omitempty"` Avatar *id.ContentURI `json:"avatar,omitempty"`
ExplicitAvatar bool `json:"explicit_avatar"` ExplicitAvatar bool `json:"explicit_avatar"`
DMUserID *id.UserID `json:"dm_user_id,omitempty"`
Topic *string `json:"topic,omitempty"` Topic *string `json:"topic,omitempty"`
CanonicalAlias *id.RoomAlias `json:"canonical_alias,omitempty"` CanonicalAlias *id.RoomAlias `json:"canonical_alias,omitempty"`
@ -188,6 +191,10 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) {
other.ExplicitAvatar = r.ExplicitAvatar other.ExplicitAvatar = r.ExplicitAvatar
hasChanges = true hasChanges = true
} }
if r.DMUserID != nil {
other.DMUserID = r.DMUserID
hasChanges = true
}
if r.Topic != nil { if r.Topic != nil {
other.Topic = r.Topic other.Topic = r.Topic
hasChanges = true hasChanges = true
@ -250,6 +257,7 @@ func (r *Room) Scan(row dbutil.Scannable) (*Room, error) {
&r.NameQuality, &r.NameQuality,
&r.Avatar, &r.Avatar,
&r.ExplicitAvatar, &r.ExplicitAvatar,
&r.DMUserID,
&r.Topic, &r.Topic,
&r.CanonicalAlias, &r.CanonicalAlias,
dbutil.JSON{Data: &r.LazyLoadSummary}, dbutil.JSON{Data: &r.LazyLoadSummary},
@ -281,6 +289,7 @@ func (r *Room) sqlVariables() []any {
r.NameQuality, r.NameQuality,
r.Avatar, r.Avatar,
r.ExplicitAvatar, r.ExplicitAvatar,
r.DMUserID,
r.Topic, r.Topic,
r.CanonicalAlias, r.CanonicalAlias,
dbutil.JSONPtr(r.LazyLoadSummary), dbutil.JSONPtr(r.LazyLoadSummary),

View file

@ -1,4 +1,4 @@
-- v0 -> v10 (compatible with v10+): Latest revision -- v0 -> v11 (compatible with v10+): Latest revision
CREATE TABLE account ( CREATE TABLE account (
user_id TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
@ -18,6 +18,7 @@ CREATE TABLE room (
name_quality INTEGER NOT NULL DEFAULT 0, name_quality INTEGER NOT NULL DEFAULT 0,
avatar TEXT, avatar TEXT,
explicit_avatar INTEGER NOT NULL DEFAULT 0, explicit_avatar INTEGER NOT NULL DEFAULT 0,
dm_user_id TEXT,
topic TEXT, topic TEXT,
canonical_alias TEXT, canonical_alias TEXT,
lazy_load_summary TEXT, lazy_load_summary TEXT,

View file

@ -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;

View file

@ -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 unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
return h.Client.ResolveAlias(ctx, params.Alias) return h.Client.ResolveAlias(ctx, params.Alias)
}) })
case "request_openid_token":
return h.Client.RequestOpenIDToken(ctx)
case "logout": case "logout":
if h.LogoutFunc == nil { if h.LogoutFunc == nil {
return nil, errors.New("logout not supported") return nil, errors.New("logout not supported")

View file

@ -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 // 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 { 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 { if err != nil {
return fmt.Errorf("failed to calculate room name: %w", err) return fmt.Errorf("failed to calculate room name: %w", err)
} }
updatedRoom.DMUserID = &dmUserID
updatedRoom.Name = &name updatedRoom.Name = &name
updatedRoom.NameQuality = database.NameQualityParticipants updatedRoom.NameQuality = database.NameQualityParticipants
if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar { 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 var primaryAvatarURL id.ContentURI
if summary == nil || len(summary.Heroes) == 0 { if summary == nil || len(summary.Heroes) == 0 {
return "Empty room", primaryAvatarURL, nil return "Empty room", primaryAvatarURL, "", nil
} }
var functionalMembers []id.UserID var functionalMembers []id.UserID
functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "") functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "")
if err != nil { 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 { } else if functionalMembersEvt != nil {
mautrixEvt := functionalMembersEvt.AsRawMautrix() mautrixEvt := functionalMembersEvt.AsRawMautrix()
_ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type) _ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)
@ -990,16 +991,21 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
} else if summary.InvitedMemberCount != nil { } else if summary.InvitedMemberCount != nil {
memberCount = *summary.InvitedMemberCount memberCount = *summary.InvitedMemberCount
} }
var dmUserID id.UserID
for _, hero := range summary.Heroes { for _, hero := range summary.Heroes {
if slices.Contains(functionalMembers, hero) { if slices.Contains(functionalMembers, hero) {
// TODO save member count so push rule evaluation would use the subtracted one?
memberCount-- memberCount--
continue continue
} else if len(members) >= 5 { } else if len(members) >= 5 {
break break
} }
if dmUserID == "" {
dmUserID = hero
}
heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String()) heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String())
if err != nil { 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 { } else if heroEvt == nil {
leftMembers = append(leftMembers, hero.String()) leftMembers = append(leftMembers, hero.String())
continue continue
@ -1015,19 +1021,28 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
} }
if membership == "join" || membership == "invite" { if membership == "join" || membership == "invite" {
members = append(members, name) members = append(members, name)
dmUserID = hero
} else { } else {
leftMembers = append(leftMembers, name) leftMembers = append(leftMembers, name)
} }
} }
if len(members)+len(leftMembers) > 1 || !primaryAvatarURL.IsValid() { if !primaryAvatarURL.IsValid() {
primaryAvatarURL = id.ContentURI{} primaryAvatarURL = id.ContentURI{}
} }
if len(members) > 0 { 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 { } 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 { } else {
return "Empty room", primaryAvatarURL, nil return "Empty room", primaryAvatarURL, "", nil
} }
} }

View file

@ -74,7 +74,7 @@ export default tseslint.config(
"quotes": ["error", "double", {allowTemplateLiterals: true}], "quotes": ["error", "double", {allowTemplateLiterals: true}],
"semi": ["error", "never"], "semi": ["error", "never"],
"comma-dangle": ["error", "always-multiline"], "comma-dangle": ["error", "always-multiline"],
"max-len": ["warn", 120], "max-len": ["error", 120],
"space-before-function-paren": ["error", { "space-before-function-paren": ["error", {
"anonymous": "never", "anonymous": "never",
"named": "never", "named": "never",

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { parseMXC } from "@/util/validation.ts" 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 => { export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => {
const [server, mediaID] = parseMXC(mxc) const [server, mediaID] = parseMXC(mxc)
@ -93,20 +93,12 @@ interface RoomForAvatarURL {
room_id: RoomID room_id: RoomID
name?: string name?: string
dm_user_id?: UserID dm_user_id?: UserID
lazy_load_summary?: LazyLoadSummary
avatar?: ContentURI avatar?: ContentURI
avatar_url?: ContentURI avatar_url?: ContentURI
} }
export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: ContentURI): string | undefined => { export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: ContentURI): string | undefined => {
let dmUserID: UserID | undefined return getAvatarURL(room.dm_user_id ?? room.room_id, {
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, {
displayname: room.name, displayname: room.name,
avatar_url: avatarOverride ?? room.avatar ?? room.avatar_url, avatar_url: avatarOverride ?? room.avatar ?? room.avatar_url,
}) })

View file

@ -32,6 +32,7 @@ import type {
ReceiptType, ReceiptType,
RelatesTo, RelatesTo,
ResolveAliasResponse, ResolveAliasResponse,
RespOpenIDToken,
RespRoomJoin, RespRoomJoin,
RoomAlias, RoomAlias,
RoomID, RoomID,
@ -261,4 +262,8 @@ export default abstract class RPCClient {
verify(recovery_key: string): Promise<boolean> { verify(recovery_key: string): Promise<boolean> {
return this.request("verify", { recovery_key }) return this.request("verify", { recovery_key })
} }
requestOpenIDToken(): Promise<RespOpenIDToken> {
return this.request("request_openid_token", {})
}
} }

View file

@ -1,3 +1,4 @@
export * from "./main.ts" export * from "./main.ts"
export * from "./room.ts" export * from "./room.ts"
export * from "./hooks.ts" export * from "./hooks.ts"
export * from "./space.ts"

View file

@ -39,7 +39,7 @@ import {
} from "../types" } from "../types"
import { InvitedRoomStore } from "./invitedroom.ts" import { InvitedRoomStore } from "./invitedroom.ts"
import { RoomStateStore } from "./room.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 { export interface RoomListEntry {
room_id: RoomID room_id: RoomID
@ -73,11 +73,12 @@ export class StateStore {
readonly rooms: Map<RoomID, RoomStateStore> = new Map() readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map() readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([]) readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
readonly roomListEntries = new Map<RoomID, RoomListEntry>()
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([]) readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map() readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
readonly spaceOrphans = new SpaceOrphansSpace(this) readonly spaceOrphans = new SpaceOrphansSpace(this)
readonly directChatsSpace = new DirectChatSpace() readonly directChatsSpace = new DirectChatSpace()
readonly unreadsSpace = new UnreadsSpace() readonly unreadsSpace = new UnreadsSpace(this)
readonly pseudoSpaces = [ readonly pseudoSpaces = [
this.spaceOrphans, this.spaceOrphans,
this.directChatsSpace, this.directChatsSpace,
@ -110,6 +111,39 @@ export class StateStore {
return true 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 { get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
if (!this.currentRoomListFilter && !this.currentRoomListQuery) { if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
return null return null
@ -169,8 +203,7 @@ export class StateStore {
const name = entry.meta.name ?? "Unnamed room" const name = entry.meta.name ?? "Unnamed room"
return { return {
room_id: entry.meta.room_id, room_id: entry.meta.room_id,
dm_user_id: entry.meta.lazy_load_summary?.["m.heroes"]?.length === 1 dm_user_id: entry.meta.dm_user_id,
? entry.meta.lazy_load_summary["m.heroes"][0] : undefined,
sorting_timestamp: entry.meta.sorting_timestamp, sorting_timestamp: entry.meta.sorting_timestamp,
preview_event, preview_event,
preview_sender, preview_sender,
@ -212,11 +245,11 @@ export class StateStore {
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>() const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
for (const data of sync.invited_rooms ?? []) { for (const data of sync.invited_rooms ?? []) {
const room = new InvitedRoomStore(data, this) const room = new InvitedRoomStore(data, this)
const oldEntry = this.inviteRooms.get(room.room_id)
this.inviteRooms.set(room.room_id, room) this.inviteRooms.set(room.room_id, room)
if (!resyncRoomList) { if (!resyncRoomList) {
changedRoomListEntries.set(room.room_id, room) 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) { if (this.activeRoomID === room.room_id) {
this.switchRoom?.(room.room_id) this.switchRoom?.(room.room_id)
@ -239,8 +272,12 @@ export class StateStore {
if (roomListEntryChanged) { if (roomListEntryChanged) {
const entry = this.#makeRoomListEntry(data, room) const entry = this.#makeRoomListEntry(data, room)
changedRoomListEntries.set(roomID, entry) changedRoomListEntries.set(roomID, entry)
this.#applyUnreadModification(entry, room.roomListEntry) this.#applyUnreadModification(entry, this.roomListEntries.get(roomID))
room.roomListEntry = entry if (entry) {
this.roomListEntries.set(roomID, entry)
} else {
this.roomListEntries.delete(roomID)
}
} }
if (!resyncRoomList) { if (!resyncRoomList) {
// When we join a valid replacement room, hide the tombstoned room. // 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) updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
for (const entry of updatedRoomList) { for (const entry of updatedRoomList) {
this.#applyUnreadModification(entry, undefined) this.#applyUnreadModification(entry, undefined)
this.rooms.get(entry.room_id)!.roomListEntry = entry this.roomListEntries.set(entry.room_id, entry)
} }
} else if (changedRoomListEntries.size > 0) { } else if (changedRoomListEntries.size > 0) {
updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id)) updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id))
@ -515,6 +552,7 @@ export class StateStore {
this.rooms.clear() this.rooms.clear()
this.inviteRooms.clear() this.inviteRooms.clear()
this.spaceEdges.clear() this.spaceEdges.clear()
this.pseudoSpaces.forEach(space => space.clearUnreads())
this.roomList.emit([]) this.roomList.emit([])
this.topLevelSpaces.emit([]) this.topLevelSpaces.emit([])
this.accountData.clear() this.accountData.clear()

View file

@ -42,7 +42,7 @@ import {
UserID, UserID,
roomStateGUIDToString, roomStateGUIDToString,
} from "../types" } from "../types"
import type { RoomListEntry, StateStore } from "./main.ts" import type { StateStore } from "./main.ts"
function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean { function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
if (!arr1 || !arr2) { if (!arr1 || !arr2) {
@ -70,6 +70,7 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
meta1.avatar === meta2.avatar && meta1.avatar === meta2.avatar &&
meta1.topic === meta2.topic && meta1.topic === meta2.topic &&
meta1.canonical_alias === meta2.canonical_alias && meta1.canonical_alias === meta2.canonical_alias &&
meta1.dm_user_id === meta2.dm_user_id &&
llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) && llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) &&
meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm && meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm &&
meta1.has_member_list === meta2.has_member_list meta1.has_member_list === meta2.has_member_list
@ -126,7 +127,6 @@ export class RoomStateStore {
readUpToRow = -1 readUpToRow = -1
hasMoreHistory = true hasMoreHistory = true
hidden = false hidden = false
roomListEntry: RoomListEntry | undefined | null
constructor(meta: DBRoom, private parent: StateStore) { constructor(meta: DBRoom, private parent: StateStore) {
this.roomID = meta.room_id this.roomID = meta.room_id

View file

@ -40,6 +40,10 @@ export abstract class Space implements RoomListFilter {
abstract id: string abstract id: string
abstract include(room: RoomListEntry): boolean abstract include(room: RoomListEntry): boolean
clearUnreads() {
this.counts.emit(emptyUnreadCounts)
}
applyUnreads(newCounts?: SpaceUnreadCounts | null, oldCounts?: SpaceUnreadCounts | null) { applyUnreads(newCounts?: SpaceUnreadCounts | null, oldCounts?: SpaceUnreadCounts | null) {
const mergedCounts: SpaceUnreadCounts = { const mergedCounts: SpaceUnreadCounts = {
unread_messages: this.counts.current.unread_messages unread_messages: this.counts.current.unread_messages
@ -79,8 +83,13 @@ export class DirectChatSpace extends Space {
export class UnreadsSpace extends Space { export class UnreadsSpace extends Space {
id = "fi.mau.gomuks.unreads" id = "fi.mau.gomuks.unreads"
constructor(private parent: StateStore) {
super()
}
include(room: RoomListEntry): boolean { 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_notifications
|| room.unread_highlights || room.unread_highlights
|| room.marked_unread) || room.marked_unread)

View file

@ -54,6 +54,7 @@ export interface DBRoom {
name_quality: RoomNameQuality name_quality: RoomNameQuality
avatar?: ContentURI avatar?: ContentURI
explicit_avatar: boolean explicit_avatar: boolean
dm_user_id?: UserID
topic?: string topic?: string
canonical_alias?: RoomAlias canonical_alias?: RoomAlias
lazy_load_summary?: LazyLoadSummary lazy_load_summary?: LazyLoadSummary

View file

@ -288,6 +288,13 @@ export interface RespRoomJoin {
room_id: RoomID room_id: RoomID
} }
export interface RespOpenIDToken {
access_token: string
expires_in: number
matrix_server_name: string
token_type: "Bearer"
}
export interface PronounSet { export interface PronounSet {
subject?: string subject?: string
object?: string object?: string

View file

@ -16,7 +16,7 @@
import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react" import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react"
import { SyncLoader } from "react-spinners" import { SyncLoader } from "react-spinners"
import Client from "@/api/client.ts" import Client from "@/api/client.ts"
import { RoomStateStore } from "@/api/statestore" import { RoomListFilter, RoomStateStore } from "@/api/statestore"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts" import { useEventAsState } from "@/util/eventdispatcher.ts"
import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts" import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts"
@ -52,6 +52,7 @@ class ContextFields implements MainScreenContextFields {
constructor( constructor(
private directSetRightPanel: (props: RightPanelProps | null) => void, private directSetRightPanel: (props: RightPanelProps | null) => void,
private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void, private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void,
private directSetSpace: (space: RoomListFilter | null) => void,
private client: Client, private client: Client,
) { ) {
this.keybindings = new Keybindings(client.store, this) this.keybindings = new Keybindings(client.store, this)
@ -95,12 +96,17 @@ class ContextFields implements MainScreenContextFields {
} }
} }
setActiveRoom = (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>, pushState = true) => { setActiveRoom = (
roomID: RoomID | null,
previewMeta?: Partial<RoomPreviewProps>,
toSpace?: RoomListFilter,
pushState = true,
) => {
console.log("Switching to room", roomID) console.log("Switching to room", roomID)
if (roomID) { if (roomID) {
const room = this.client.store.rooms.get(roomID) const room = this.client.store.rooms.get(roomID)
if (room) { if (room) {
this.#setActiveRoom(room, pushState) this.#setActiveRoom(room, toSpace, pushState)
} else { } else {
this.#setPreviewRoom(roomID, pushState, previewMeta) 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<RoomPreviewProps>) { #setPreviewRoom(roomID: RoomID, pushState: boolean, meta?: Partial<RoomPreviewProps>) {
const invite = this.client.store.inviteRooms.get(roomID) const invite = this.client.store.inviteRooms.get(roomID)
this.#closeActiveRoom(false) this.#closeActiveRoom(false)
@ -120,6 +144,7 @@ class ContextFields implements MainScreenContextFields {
room_id: roomID, room_id: roomID,
source_via: meta?.via, source_via: meta?.via,
source_alias: meta?.alias, 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!) 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 window.activeRoom = room
this.directSetActiveRoom(room) this.directSetActiveRoom(room)
this.directSetRightPanel(null) 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.rightPanelStack = []
this.client.store.activeRoomID = room.roomID this.client.store.activeRoomID = room.roomID
this.client.store.activeRoomIsPreview = false this.client.store.activeRoomIsPreview = false
@ -148,7 +184,7 @@ class ContextFields implements MainScreenContextFields {
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`) .querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
?.scrollIntoView({ block: "nearest" }) ?.scrollIntoView({ block: "nearest" })
if (pushState) { 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 let roomNameForTitle = room.meta.current.name
if (roomNameForTitle && roomNameForTitle.length > 48) { if (roomNameForTitle && roomNameForTitle.length > 48) {
@ -166,7 +202,7 @@ class ContextFields implements MainScreenContextFields {
this.client.store.activeRoomIsPreview = false this.client.store.activeRoomIsPreview = false
this.keybindings.activeRoom = null this.keybindings.activeRoom = null
if (pushState) { if (pushState) {
history.pushState({}, "") history.pushState({ space_id: history.state?.space_id }, "")
} }
document.title = this.#getWindowTitle() document.title = this.#getWindowTitle()
} }
@ -197,7 +233,7 @@ class ContextFields implements MainScreenContextFields {
const SYNC_ERROR_HIDE_DELAY = 30 * 1000 const SYNC_ERROR_HIDE_DELAY = 30 * 1000
const handleURLHash = (client: Client) => { const handleURLHash = (client: Client, context: MainScreenContextFields) => {
if (!location.hash.startsWith("#/uri/")) { if (!location.hash.startsWith("#/uri/")) {
if (location.search) { if (location.search) {
const currentETag = ( const currentETag = (
@ -248,7 +284,7 @@ const handleURLHash = (client: Client) => {
// TODO loading indicator or something for this? // TODO loading indicator or something for this?
client.rpc.resolveAlias(uri.identifier).then( client.rpc.resolveAlias(uri.identifier).then(
res => { res => {
window.mainScreenContext.setActiveRoom(res.room_id, { context.setActiveRoom(res.room_id, {
alias: uri.identifier, alias: uri.identifier,
via: res.servers.slice(0, 3), via: res.servers.slice(0, 3),
}) })
@ -279,12 +315,13 @@ const activeRoomReducer = (
const MainScreen = () => { const MainScreen = () => {
const [[prevActiveRoom, activeRoom], directSetActiveRoom] = useReducer(activeRoomReducer, [null, null]) const [[prevActiveRoom, activeRoom], directSetActiveRoom] = useReducer(activeRoomReducer, [null, null])
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
const skipNextTransitionRef = useRef(false) const skipNextTransitionRef = useRef(false)
const [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(null) const [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(null)
const client = use(ClientContext)! const client = use(ClientContext)!
const syncStatus = useEventAsState(client.syncStatus) const syncStatus = useEventAsState(client.syncStatus)
const context = useMemo( const context = useMemo(
() => new ContextFields(directSetRightPanel, directSetActiveRoom, client), () => new ContextFields(directSetRightPanel, directSetActiveRoom, directSetSpace, client),
[client], [client],
) )
useEffect(() => { useEffect(() => {
@ -292,17 +329,21 @@ const MainScreen = () => {
const listener = (evt: PopStateEvent) => { const listener = (evt: PopStateEvent) => {
skipNextTransitionRef.current = evt.hasUAVisualTransition skipNextTransitionRef.current = evt.hasUAVisualTransition
const roomID = evt.state?.room_id ?? null 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) { if (roomID !== client.store.activeRoomID) {
context.setActiveRoom(roomID, { context.setActiveRoom(roomID, {
alias: ensureString(evt.state?.source_alias) || undefined, alias: ensureString(evt.state?.source_alias) || undefined,
via: ensureStringArray(evt.state?.source_via), via: ensureStringArray(evt.state?.source_via),
}, false) }, undefined, false)
} }
context.setRightPanel(evt.state?.right_panel ?? null, false) context.setRightPanel(evt.state?.right_panel ?? null, false)
} }
window.addEventListener("popstate", listener) window.addEventListener("popstate", listener)
const initHandle = () => { const initHandle = () => {
const state = handleURLHash(client) const state = handleURLHash(client, context)
listener({ state } as PopStateEvent) listener({ state } as PopStateEvent)
} }
let cancel = () => {} let cancel = () => {}
@ -372,7 +413,7 @@ const MainScreen = () => {
<ModalWrapper> <ModalWrapper>
<StylePreferences client={client} activeRoom={activeRealRoom}/> <StylePreferences client={client} activeRoom={activeRealRoom}/>
<main className={classNames.join(" ")} style={extraStyle}> <main className={classNames.join(" ")} style={extraStyle}>
<RoomList activeRoomID={activeRoom?.roomID ?? null}/> <RoomList activeRoomID={activeRoom?.roomID ?? null} space={space}/>
{resizeHandle1} {resizeHandle1}
{renderedRoom {renderedRoom
? renderedRoom instanceof RoomStateStore ? renderedRoom instanceof RoomStateStore

View file

@ -13,13 +13,15 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { createContext } from "react" import React, { createContext } from "react"
import { RoomListFilter } from "@/api/statestore"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import type { RightPanelProps } from "./rightpanel/RightPanel.tsx" import type { RightPanelProps } from "./rightpanel/RightPanel.tsx"
import type { RoomPreviewProps } from "./roomview/RoomPreview.tsx" import type { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
export interface MainScreenContextFields { export interface MainScreenContextFields {
setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>) => void setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>, toSpace?: RoomListFilter) => void
setSpace: (space: RoomListFilter | null, pushState?: boolean) => void
clickRoom: (evt: React.MouseEvent) => void clickRoom: (evt: React.MouseEvent) => void
clearActiveRoom: () => void clearActiveRoom: () => void
@ -32,6 +34,9 @@ const stubContext = {
get setActiveRoom(): never { get setActiveRoom(): never {
throw new Error("MainScreenContext used outside main screen") throw new Error("MainScreenContext used outside main screen")
}, },
get setSpace(): never {
throw new Error("MainScreenContext used outside main screen")
},
get clickRoom(): never { get clickRoom(): never {
throw new Error("MainScreenContext used outside main screen") throw new Error("MainScreenContext used outside main screen")
}, },

View file

@ -60,7 +60,7 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
} }
window.addEventListener("popstate", listener) window.addEventListener("popstate", listener)
return () => window.removeEventListener("popstate", listener) return () => window.removeEventListener("popstate", listener)
}, []) }, [onClickWrapper])
let modal: JSX.Element | null = null let modal: JSX.Element | null = null
if (state) { if (state) {
let content = <ModalCloseContext value={onClickWrapper}>{state.content}</ModalCloseContext> let content = <ModalCloseContext value={onClickWrapper}>{state.content}</ModalCloseContext>

View file

@ -40,8 +40,7 @@ const MutualRooms = ({ client, userID }: MutualRoomsProps) => {
} }
return { return {
room_id: roomID, room_id: roomID,
dm_user_id: roomData.meta.current.lazy_load_summary?.["m.heroes"]?.length === 1 dm_user_id: roomData.meta.current.dm_user_id,
? roomData.meta.current.lazy_load_summary["m.heroes"][0] : undefined,
name: roomData.meta.current.name ?? "Unnamed room", name: roomData.meta.current.name ?? "Unnamed room",
avatar: roomData.meta.current.avatar, avatar: roomData.meta.current.avatar,
search_name: "", search_name: "",

View file

@ -27,30 +27,33 @@ export interface FakeSpaceProps {
space: Space | null space: Space | null
setSpace: (space: RoomListFilter | null) => void setSpace: (space: RoomListFilter | null) => void
isActive: boolean isActive: boolean
onClickUnread?: (evt: React.MouseEvent<HTMLDivElement> | null, space: Space | null) => void onClickUnread?: (evt: React.MouseEvent<HTMLDivElement>, 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) { switch (space?.id) {
case undefined: case undefined:
return <HomeIcon /> return ["Home", <HomeIcon />]
case "fi.mau.gomuks.direct_chats": case "fi.mau.gomuks.direct_chats":
return <PersonIcon /> return ["Direct chats", <PersonIcon />]
case "fi.mau.gomuks.unreads": case "fi.mau.gomuks.unreads":
return <NotificationsIcon /> return ["Unread chats", <NotificationsIcon />]
case "fi.mau.gomuks.space_orphans": case "fi.mau.gomuks.space_orphans":
return <TagIcon /> return ["Rooms outside spaces", <TagIcon />]
default: default:
return null return [undefined, null]
} }
} }
const FakeSpace = ({ space, setSpace, isActive, onClickUnread }: FakeSpaceProps) => { const FakeSpace = ({ space, setSpace, isActive, onClickUnread }: FakeSpaceProps) => {
const unreads = useEventAsState(space?.counts) const unreads = useEventAsState(space?.counts)
const onClickUnreadWrapped = onClickUnread ? () => onClickUnread(null, space) : undefined const onClickUnreadWrapped = onClickUnread
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={() => setSpace(space)}> ? (evt: React.MouseEvent<HTMLDivElement>) => onClickUnread(evt, space)
: undefined
const [title, icon] = getFakeSpaceMeta(space)
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={() => setSpace(space)} title={title}>
<UnreadCount counts={unreads} space={true} onClick={onClickUnreadWrapped} /> <UnreadCount counts={unreads} space={true} onClick={onClickUnreadWrapped} />
{getFakeSpaceIcon(space)} {icon}
</div> </div>
} }

View file

@ -119,7 +119,7 @@ div.room-entry {
width: 3rem; width: 3rem;
> img.room-avatar { > img.room-avatar {
padding: 4px; margin: .25rem;
} }
} }
@ -172,6 +172,7 @@ div.room-entry-unreads {
justify-content: center; justify-content: center;
border-radius: var(--unread-count-size); border-radius: var(--unread-count-size);
color: var(--unread-counter-text-color); color: var(--unread-counter-text-color);
user-select: none;
background-color: var(--unread-counter-message-bg); background-color: var(--unread-counter-message-bg);
height: var(--unread-count-size); height: var(--unread-count-size);

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useCallback, useRef, useState } from "react" 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 type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts" import { useEventAsState } from "@/util/eventdispatcher.ts"
import reverseMap from "@/util/reversemap.ts" import reverseMap from "@/util/reversemap.ts"
@ -31,41 +31,37 @@ import "./RoomList.css"
interface RoomListProps { interface RoomListProps {
activeRoomID: RoomID | null activeRoomID: RoomID | null
space: RoomListFilter | null
} }
const RoomList = ({ activeRoomID }: RoomListProps) => { const RoomList = ({ activeRoomID, space }: RoomListProps) => {
const client = use(ClientContext)! const client = use(ClientContext)!
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
const roomList = useEventAsState(client.store.roomList) const roomList = useEventAsState(client.store.roomList)
const spaces = useEventAsState(client.store.topLevelSpaces) const spaces = useEventAsState(client.store.topLevelSpaces)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const [query, directSetQuery] = useState("") const [query, directSetQuery] = useState("")
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
const setQuery = (evt: React.ChangeEvent<HTMLInputElement>) => { const setQuery = (evt: React.ChangeEvent<HTMLInputElement>) => {
client.store.currentRoomListQuery = toSearchableString(evt.target.value) client.store.currentRoomListQuery = toSearchableString(evt.target.value)
directSetQuery(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<HTMLDivElement>) => { const onClickSpace = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!) const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!)
setSpace(store) mainScreen.setSpace(store)
}, [setSpace, client]) }, [mainScreen, client])
const onClickSpaceUnread = useCallback(( const onClickSpaceUnread = useCallback((
evt: React.MouseEvent<HTMLDivElement> | null, space?: SpaceStore | null, evt: React.MouseEvent<HTMLDivElement>, space?: SpaceStore | null,
) => { ) => {
if (evt) { if (!space) {
const targetSpace = evt.currentTarget.closest("div.space-entry")?.getAttribute("data-target-space") const targetSpace = evt.currentTarget.closest("div.space-entry")?.getAttribute("data-target-space")
if (!targetSpace) { if (!targetSpace) {
return return
} }
space = client.store.getSpaceStore(targetSpace) space = client.store.getSpaceStore(targetSpace)
} if (!space) {
if (!space) { return
return }
} }
const counts = space.counts.current const counts = space.counts.current
let wantedField: keyof SpaceUnreadCounts let wantedField: keyof SpaceUnreadCounts
@ -81,7 +77,8 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
for (let i = client.store.roomList.current.length - 1; i >= 0; i--) { for (let i = client.store.roomList.current.length - 1; i >= 0; i--) {
const entry = client.store.roomList.current[i] const entry = client.store.roomList.current[i]
if (entry[wantedField] > 0 && space.include(entry)) { if (entry[wantedField] > 0 && space.include(entry)) {
mainScreen.setActiveRoom(entry.room_id) mainScreen.setActiveRoom(entry.room_id, undefined, space)
evt.stopPropagation()
break break
} }
} }
@ -124,11 +121,11 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
</button> </button>
</div> </div>
<div className="space-bar"> <div className="space-bar">
<FakeSpace space={null} setSpace={setSpace} isActive={space === null} /> <FakeSpace space={null} setSpace={mainScreen.setSpace} isActive={space === null} />
{client.store.pseudoSpaces.map(pseudoSpace => <FakeSpace {client.store.pseudoSpaces.map(pseudoSpace => <FakeSpace
key={pseudoSpace.id} key={pseudoSpace.id}
space={pseudoSpace} space={pseudoSpace}
setSpace={setSpace} setSpace={mainScreen.setSpace}
onClickUnread={onClickSpaceUnread} onClickUnread={onClickSpaceUnread}
isActive={space?.id === pseudoSpace.id} isActive={space?.id === pseudoSpace.id}
/>)} />)}

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { SpaceUnreadCounts } from "@/api/statestore/space.ts" import { SpaceUnreadCounts } from "@/api/statestore"
interface UnreadCounts extends SpaceUnreadCounts { interface UnreadCounts extends SpaceUnreadCounts {
marked_unread?: boolean marked_unread?: boolean
@ -38,9 +38,9 @@ const UnreadCount = ({ counts, space, onClick }: UnreadCountProps) => {
const countIsBig = !space const countIsBig = !space
&& Boolean(counts.unread_notifications || counts.unread_highlights || counts.marked_unread) && Boolean(counts.unread_notifications || counts.unread_highlights || counts.marked_unread)
let unreadCountDisplay = unreadCount.toString() let unreadCountDisplay = unreadCount.toString()
if (unreadCount > 999 && countIsBig) { if (unreadCount > 999 && (countIsBig || space)) {
unreadCountDisplay = "99+" unreadCountDisplay = "99+"
} else if (unreadCount > 9999 && countIsBig) { } else if (unreadCount > 9999) {
unreadCountDisplay = "999+" unreadCountDisplay = "999+"
} }
const classNames = ["unread-count"] const classNames = ["unread-count"]

View file

@ -237,7 +237,9 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
</span>} </span>}
</div> </div>
{vscodeOpen ? <div className="vscode-wrapper"> {vscodeOpen ? <div className="vscode-wrapper">
<Suspense fallback={<div className="loader"><ScaleLoader width={40} height={80} color="var(--primary-color)"/></div>}> <Suspense fallback={
<div className="loader"><ScaleLoader width={40} height={80} color="var(--primary-color)"/></div>
}>
<Monaco <Monaco
initData={vscodeInitialContentRef.current} initData={vscodeInitialContentRef.current}
onClose={closeVSCode} onClose={closeVSCode}
@ -332,6 +334,16 @@ const SettingsView = ({ room }: SettingsViewProps) => {
) )
} }
} }
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) usePreferences(client.store, room)
const globalServer = client.store.serverPreferenceCache const globalServer = client.store.serverPreferenceCache
const globalLocal = client.store.localPreferenceCache const globalLocal = client.store.localPreferenceCache
@ -381,6 +393,7 @@ const SettingsView = ({ room }: SettingsViewProps) => {
<CustomCSSInput setPref={setPref} room={room} /> <CustomCSSInput setPref={setPref} room={room} />
<AppliedSettingsView room={room} /> <AppliedSettingsView room={room} />
<div className="misc-buttons"> <div className="misc-buttons">
<button onClick={onClickOpenCSSApp}>Sign into css.gomuks.app</button>
{window.Notification && <button onClick={client.requestNotificationPermission}> {window.Notification && <button onClick={client.requestNotificationPermission}>
Request notification permission Request notification permission
</button>} </button>}

View file

@ -20,6 +20,7 @@ div.timeline-event > div.read-receipts {
> img { > img {
margin-left: -.35rem; margin-left: -.35rem;
border: 1px solid var(--background-color); border: 1px solid var(--background-color);
background-color: var(--background-color);
} }
} }