diff --git a/desktop/go.mod b/desktop/go.mod index cef1274..12d6655 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -67,7 +67,7 @@ require ( golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect golang.org/x/image v0.23.0 // indirect golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.32.0 // indirect + golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect @@ -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.1 // indirect + maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce // indirect mvdan.cc/xurls/v2 v2.5.0 // indirect ) diff --git a/desktop/go.sum b/desktop/go.sum index 35bfb03..df55ba0 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -187,8 +187,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= -golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -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.1 h1:2lCM37vmVzZGE0tWD7UOySMtAuC5hq6Pw33KlY2VU/c= -maunium.net/go/mautrix v0.22.1/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= +maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce h1:wkVN87Hlq63YCuUhVYhvSy0qeAUCzbD5quEtd95rxyE= +maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce/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 33d06b4..30504dd 100644 --- a/go.mod +++ b/go.mod @@ -21,11 +21,11 @@ require ( go.mau.fi/zeroconfig v0.1.3 golang.org/x/crypto v0.31.0 golang.org/x/image v0.23.0 - golang.org/x/net v0.32.0 + golang.org/x/net v0.33.0 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.1 + maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce mvdan.cc/xurls/v2 v2.5.0 ) diff --git a/go.sum b/go.sum index 959c6d2..c16c231 100644 --- a/go.sum +++ b/go.sum @@ -73,8 +73,8 @@ golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDP golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= -golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= -golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -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.1 h1:2lCM37vmVzZGE0tWD7UOySMtAuC5hq6Pw33KlY2VU/c= -maunium.net/go/mautrix v0.22.1/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= +maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce h1:wkVN87Hlq63YCuUhVYhvSy0qeAUCzbD5quEtd95rxyE= +maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce/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/database.go b/pkg/hicli/database/database.go index 95e28a7..7299f21 100644 --- a/pkg/hicli/database/database.go +++ b/pkg/hicli/database/database.go @@ -20,6 +20,7 @@ type Database struct { Account AccountQuery AccountData AccountDataQuery Room RoomQuery + InvitedRoom InvitedRoomQuery Event EventQuery CurrentState CurrentStateQuery Timeline TimelineQuery @@ -37,6 +38,7 @@ func New(rawDB *dbutil.Database) *Database { 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}, @@ -58,6 +60,10 @@ func newRoom(_ *dbutil.QueryHelper[*Room]) *Room { return &Room{} } +func newInvitedRoom(_ *dbutil.QueryHelper[*InvitedRoom]) *InvitedRoom { + return &InvitedRoom{} +} + func newReceipt(_ *dbutil.QueryHelper[*Receipt]) *Receipt { return &Receipt{} } diff --git a/pkg/hicli/database/invitedroom.go b/pkg/hicli/database/invitedroom.go new file mode 100644 index 0000000..5be4b27 --- /dev/null +++ b/pkg/hicli/database/invitedroom.go @@ -0,0 +1,73 @@ +// 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" + + "go.mau.fi/util/dbutil" + "go.mau.fi/util/jsontime" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +const ( + getInvitedRoomsQuery = ` + SELECT room_id, received_at, invite_state + FROM invited_room + ORDER BY received_at DESC + ` + deleteInvitedRoomQuery = ` + DELETE FROM invited_room WHERE room_id = $1 + ` + upsertInvitedRoomQuery = ` + INSERT INTO invited_room (room_id, received_at, invite_state) + VALUES ($1, $2, $3) + ON CONFLICT (room_id) DO UPDATE + SET received_at = $2, invite_state = $3 + ` +) + +type InvitedRoomQuery struct { + *dbutil.QueryHelper[*InvitedRoom] +} + +func (irq *InvitedRoomQuery) GetAll(ctx context.Context) ([]*InvitedRoom, error) { + return irq.QueryMany(ctx, getInvitedRoomsQuery) +} + +func (irq *InvitedRoomQuery) Upsert(ctx context.Context, room *InvitedRoom) error { + return irq.Exec(ctx, upsertInvitedRoomQuery, room.sqlVariables()...) +} + +func (irq *InvitedRoomQuery) Delete(ctx context.Context, roomID id.RoomID) error { + return irq.Exec(ctx, deleteInvitedRoomQuery, roomID) +} + +type InvitedRoom struct { + ID id.RoomID `json:"room_id"` + CreatedAt jsontime.UnixMilli `json:"created_at"` + InviteState []*event.Event `json:"invite_state"` +} + +func (r *InvitedRoom) sqlVariables() []any { + return []any{ + r.ID, + dbutil.UnixMilliPtr(r.CreatedAt.Time), + dbutil.JSON{Data: &r.InviteState}, + } +} + +func (r *InvitedRoom) Scan(row dbutil.Scannable) (*InvitedRoom, error) { + var createdAt int64 + err := row.Scan(&r.ID, &createdAt, dbutil.JSON{Data: &r.InviteState}) + if err != nil { + return nil, err + } + r.CreatedAt = jsontime.UMInt(createdAt) + return r, nil +} diff --git a/pkg/hicli/database/room.go b/pkg/hicli/database/room.go index cb386b4..f26ef15 100644 --- a/pkg/hicli/database/room.go +++ b/pkg/hicli/database/room.go @@ -262,7 +262,7 @@ func (r *Room) Scan(row dbutil.Scannable) (*Room, error) { } r.PrevBatch = prevBatch.String r.PreviewEventRowID = EventRowID(previewEventRowID.Int64) - r.SortingTimestamp = jsontime.UM(time.UnixMilli(sortingTimestamp.Int64)) + r.SortingTimestamp = jsontime.UMInt(sortingTimestamp.Int64) return r, nil } diff --git a/pkg/hicli/database/upgrades/00-latest-revision.sql b/pkg/hicli/database/upgrades/00-latest-revision.sql index a79aff1..3b49704 100644 --- a/pkg/hicli/database/upgrades/00-latest-revision.sql +++ b/pkg/hicli/database/upgrades/00-latest-revision.sql @@ -41,6 +41,19 @@ CREATE INDEX room_preview_idx ON room (preview_event_rowid); -- CREATE INDEX room_sorting_timestamp_idx ON room (unread_notifications > 0); -- CREATE INDEX room_sorting_timestamp_idx ON room (unread_messages > 0); +CREATE TABLE invited_room ( + room_id TEXT NOT NULL PRIMARY KEY, + received_at INTEGER NOT NULL, + invite_state TEXT NOT NULL +) STRICT; + +CREATE TRIGGER invited_room_delete_on_room_insert + AFTER INSERT + ON room +BEGIN + DELETE FROM invited_room WHERE room_id = NEW.room_id; +END; + CREATE TABLE account_data ( user_id TEXT NOT NULL, type TEXT NOT NULL, diff --git a/pkg/hicli/database/upgrades/09-invited-rooms.sql b/pkg/hicli/database/upgrades/09-invited-rooms.sql new file mode 100644 index 0000000..49aa750 --- /dev/null +++ b/pkg/hicli/database/upgrades/09-invited-rooms.sql @@ -0,0 +1,13 @@ +-- v9 (compatible with v5+): Add table for invited rooms +CREATE TABLE invited_room ( + room_id TEXT NOT NULL PRIMARY KEY, + received_at INTEGER NOT NULL, + invite_state TEXT NOT NULL +) STRICT; + +CREATE TRIGGER invited_room_delete_on_room_insert + AFTER INSERT + ON room +BEGIN + DELETE FROM invited_room WHERE room_id = NEW.room_id; +END; diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go index bcc9fc4..2bd8534 100644 --- a/pkg/hicli/events.go +++ b/pkg/hicli/events.go @@ -31,15 +31,16 @@ type SyncNotification struct { } type SyncComplete struct { - Since *string `json:"since,omitempty"` - ClearState bool `json:"clear_state,omitempty"` - Rooms map[id.RoomID]*SyncRoom `json:"rooms"` - AccountData map[event.Type]*database.AccountData `json:"account_data"` - LeftRooms []id.RoomID `json:"left_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"` } func (c *SyncComplete) IsEmpty() bool { - return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.AccountData) == 0 + return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.InvitedRooms) == 0 && len(c.AccountData) == 0 } type SyncStatusType string diff --git a/pkg/hicli/init.go b/pkg/hicli/init.go index 2654ee5..5c02292 100644 --- a/pkg/hicli/init.go +++ b/pkg/hicli/init.go @@ -84,8 +84,18 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[* 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) + } for _, room := range rooms { if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp { break @@ -107,9 +117,10 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[* return } payload := SyncComplete{ - Rooms: make(map[id.RoomID]*SyncRoom, 0), - LeftRooms: make([]id.RoomID, 0), - AccountData: make(map[event.Type]*database.AccountData, len(ad)), + 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)), } for _, data := range ad { payload.AccountData[event.Type{Type: data.Type, Class: event.AccountDataEventType}] = data diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 081ba37..0a07c25 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -130,6 +130,21 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) { return h.PaginateServer(ctx, params.RoomID, params.Limit) }) + case "get_room_summary": + return unmarshalAndCall(req.Data, func(params *joinRoomParams) (*mautrix.RespRoomSummary, error) { + return h.Client.GetRoomSummary(ctx, params.RoomIDOrAlias, params.Via...) + }) + case "join_room": + return unmarshalAndCall(req.Data, func(params *joinRoomParams) (*mautrix.RespJoinRoom, error) { + return h.Client.JoinRoom(ctx, params.RoomIDOrAlias, &mautrix.ReqJoinRoom{ + Via: params.Via, + Reason: params.Reason, + }) + }) + case "leave_room": + return unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) { + return h.Client.LeaveRoom(ctx, params.RoomID, &mautrix.ReqLeave{Reason: params.Reason}) + }) case "ensure_group_session_shared": return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) { return true, h.EnsureGroupSessionShared(ctx, params.RoomID) @@ -315,6 +330,17 @@ type paginateParams struct { Limit int `json:"limit"` } +type joinRoomParams struct { + RoomIDOrAlias string `json:"room_id_or_alias"` + Via []string `json:"via"` + Reason string `json:"reason"` +} + +type leaveRoomParams struct { + RoomID id.RoomID `json:"room_id"` + Reason string `json:"reason"` +} + type getReceiptsParams struct { RoomID id.RoomID `json:"room_id"` EventIDs []id.EventID `json:"event_ids"` diff --git a/pkg/hicli/paginate.go b/pkg/hicli/paginate.go index 0169b8d..8c247c4 100644 --- a/pkg/hicli/paginate.go +++ b/pkg/hicli/paginate.go @@ -165,8 +165,9 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe Receipts: make(map[id.EventID][]*database.Receipt), }, }, - AccountData: make(map[event.Type]*database.AccountData), - LeftRooms: make([]id.RoomID, 0), + 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 acd82e8..ccc9d00 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -151,14 +151,20 @@ func (h *HiClient) processSyncResponse(ctx context.Context, resp *mautrix.RespSy } } ctx.Value(syncContextKey).(*syncContext).evt.AccountData = accountData + for roomID, room := range resp.Rooms.Invite { + err = h.processSyncInvitedRoom(ctx, roomID, room) + if err != nil { + return fmt.Errorf("failed to process invited room %s: %w", roomID, err) + } + } for roomID, room := range resp.Rooms.Join { - err := h.processSyncJoinedRoom(ctx, roomID, room) + err = h.processSyncJoinedRoom(ctx, roomID, room) if err != nil { return fmt.Errorf("failed to process joined room %s: %w", roomID, err) } } for roomID, room := range resp.Rooms.Leave { - err := h.processSyncLeftRoom(ctx, roomID, room) + err = h.processSyncLeftRoom(ctx, roomID, room) if err != nil { return fmt.Errorf("failed to process left room %s: %w", roomID, err) } @@ -196,6 +202,27 @@ func (h *HiClient) receiptsToList(content *event.ReceiptEventContent) ([]*databa return receiptList, newOwnReceipts } +func (h *HiClient) processSyncInvitedRoom(ctx context.Context, roomID id.RoomID, room *mautrix.SyncInvitedRoom) error { + ir := &database.InvitedRoom{ + ID: roomID, + CreatedAt: jsontime.UnixMilliNow(), + InviteState: room.State.Events, + } + for _, evt := range room.State.Events { + if evt.Type == event.StateMember && evt.GetStateKey() == h.Account.UserID.String() && evt.Timestamp != 0 { + ir.CreatedAt = jsontime.UM(time.UnixMilli(evt.Timestamp)) + break + } + } + err := h.DB.InvitedRoom.Upsert(ctx, ir) + if err != nil { + return fmt.Errorf("failed to save invited room: %w", err) + } + syncEvt := ctx.Value(syncContextKey).(*syncContext).evt + syncEvt.InvitedRooms = append(syncEvt.InvitedRooms, ir) + return nil +} + func (h *HiClient) processSyncJoinedRoom(ctx context.Context, roomID id.RoomID, room *mautrix.SyncJoinedRoom) error { existingRoomData, err := h.DB.Room.Get(ctx, roomID) if err != nil { @@ -265,6 +292,10 @@ func (h *HiClient) processSyncLeftRoom(ctx context.Context, roomID id.RoomID, ro if err != nil { return fmt.Errorf("failed to delete room: %w", err) } + err = h.DB.InvitedRoom.Delete(ctx, roomID) + if err != nil { + return fmt.Errorf("failed to delete invited room: %w", err) + } payload := ctx.Value(syncContextKey).(*syncContext).evt payload.LeftRooms = append(payload.LeftRooms, roomID) return nil diff --git a/pkg/hicli/syncwrap.go b/pkg/hicli/syncwrap.go index 3a07656..e479e1c 100644 --- a/pkg/hicli/syncwrap.go +++ b/pkg/hicli/syncwrap.go @@ -15,6 +15,8 @@ import ( "github.com/mattn/go-sqlite3" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" + + "go.mau.fi/gomuks/pkg/hicli/database" ) type hiSyncer HiClient @@ -31,9 +33,10 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, c := (*HiClient)(h) c.lastSync = time.Now() ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{ - Since: &since, - Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)), - LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)), + Since: &since, + Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)), + InvitedRooms: make([]*database.InvitedRoom, 0, len(resp.Rooms.Invite)), + LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)), }}) err := c.preProcessSyncResponse(ctx, resp, since) if err != nil { @@ -73,23 +76,23 @@ func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, func (h *hiSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter { if !h.Verified { return &mautrix.Filter{ - Presence: mautrix.FilterPart{ + Presence: &mautrix.FilterPart{ NotRooms: []id.RoomID{"*"}, }, - Room: mautrix.RoomFilter{ + Room: &mautrix.RoomFilter{ NotRooms: []id.RoomID{"*"}, }, } } return &mautrix.Filter{ - Presence: mautrix.FilterPart{ + Presence: &mautrix.FilterPart{ NotRooms: []id.RoomID{"*"}, }, - Room: mautrix.RoomFilter{ - State: mautrix.FilterPart{ + Room: &mautrix.RoomFilter{ + State: &mautrix.FilterPart{ LazyLoadMembers: true, }, - Timeline: mautrix.FilterPart{ + Timeline: &mautrix.FilterPart{ Limit: 100, LazyLoadMembers: true, }, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3cf1d47..fde6577 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -104,6 +104,7 @@ export default class Client { #handleEvent = (ev: RPCEvent) => { if (ev.command === "client_state") { this.state.emit(ev.data) + this.store.userID = ev.data.is_logged_in ? ev.data.user_id : "" } else if (ev.command === "sync_status") { this.syncStatus.emit(ev.data) } else if (ev.command === "init_complete") { diff --git a/web/src/api/media.ts b/web/src/api/media.ts index ca0a0ac..5ece955 100644 --- a/web/src/api/media.ts +++ b/web/src/api/media.ts @@ -13,9 +13,8 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import type { RoomListEntry } from "@/api/statestore" import { parseMXC } from "@/util/validation.ts" -import { ContentURI, DBRoom, UserID, UserProfile } from "./types" +import { ContentURI, LazyLoadSummary, RoomID, UserID, UserProfile } from "./types" export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => { const [server, mediaID] = parseMXC(mxc) @@ -90,7 +89,16 @@ export const getAvatarURL = (userID: UserID, content?: UserProfile | null): stri return `_gomuks/media/${server}/${mediaID}?encrypted=false&fallback=${encodeURIComponent(fallback)}` } -export const getRoomAvatarURL = (room: DBRoom | RoomListEntry, avatarOverride?: ContentURI): string | undefined => { +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 @@ -98,5 +106,8 @@ export const getRoomAvatarURL = (room: DBRoom | RoomListEntry, avatarOverride?: dmUserID = room.lazy_load_summary?.heroes?.length === 1 ? room.lazy_load_summary.heroes[0] : undefined } - return getAvatarURL(dmUserID ?? room.room_id, { displayname: room.name, avatar_url: avatarOverride ?? room.avatar }) + return getAvatarURL(dmUserID ?? 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 98f3f8c..7f9004f 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -32,9 +32,11 @@ import type { ReceiptType, RelatesTo, ResolveAliasResponse, + RespRoomJoin, RoomAlias, RoomID, RoomStateGUID, + RoomSummary, TimelineRowID, UserID, UserProfile, @@ -220,6 +222,18 @@ export default abstract class RPCClient { return this.request("paginate_server", { room_id, limit }) } + getRoomSummary(room_id_or_alias: RoomID | RoomAlias, via?: string[]): Promise { + return this.request("get_room_summary", { room_id_or_alias, via }) + } + + joinRoom(room_id_or_alias: RoomID | RoomAlias, via?: string[], reason?: string): Promise { + return this.request("join_room", { room_id_or_alias, via, reason }) + } + + leaveRoom(room_id: RoomID, reason?: string): Promise> { + return this.request("leave_room", { room_id, reason }) + } + resolveAlias(alias: RoomAlias): Promise { return this.request("resolve_alias", { alias }) } diff --git a/web/src/api/statestore/invitedroom.ts b/web/src/api/statestore/invitedroom.ts new file mode 100644 index 0000000..334944a --- /dev/null +++ b/web/src/api/statestore/invitedroom.ts @@ -0,0 +1,130 @@ +// 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 toSearchableString from "@/util/searchablestring.ts" +import { ensureString, getDisplayname } from "@/util/validation.ts" +import type { + ContentURI, + DBInvitedRoom, JoinRule, + MemberEventContent, Membership, + RoomAlias, + RoomID, + RoomSummary, + RoomType, + RoomVersion, + StrippedStateEvent, + UserID, +} from "../types" +import type { RoomListEntry, StateStore } from "./main.ts" + +export class InvitedRoomStore implements RoomListEntry, RoomSummary { + readonly room_id: RoomID + readonly sorting_timestamp: number + readonly name: string = "" + readonly search_name: string + readonly dm_user_id?: UserID + readonly canonical_alias?: RoomAlias + readonly topic?: string + readonly avatar?: ContentURI + readonly encryption?: "m.megolm.v1.aes-sha2" + readonly room_version?: RoomVersion + readonly join_rules?: JoinRule + readonly invited_by?: UserID + readonly inviter_profile?: MemberEventContent + + constructor(public readonly meta: DBInvitedRoom, parent: StateStore) { + this.room_id = meta.room_id + this.sorting_timestamp = 1000000000000000 + meta.created_at + const members = new Map() + for (const state of this.meta.invite_state) { + if (state.type === "m.room.name") { + this.name = ensureString(state.content.name) + } else if (state.type === "m.room.canonical_alias") { + this.canonical_alias = ensureString(state.content.alias) + } else if (state.type === "m.room.topic") { + this.topic = ensureString(state.content.topic) + } else if (state.type === "m.room.avatar") { + this.avatar = ensureString(state.content.url) + } else if (state.type === "m.room.encryption" && state.content.algorithm === "m.megolm.v1.aes-sha2") { + this.encryption = state.content.algorithm + } else if (state.type === "m.room.create") { + this.room_version = ensureString(state.content.version) as RoomVersion + } else if (state.type === "m.room.member") { + members.set(state.state_key, state) + } else if (state.type === "m.room.join_rules") { + this.join_rules = ensureString(state.content.join_rule) as JoinRule + } + } + this.search_name = toSearchableString(this.name ?? "") + const ownMemberEvt = members.get(parent.userID) + if (ownMemberEvt) { + this.invited_by = ownMemberEvt.sender + this.inviter_profile = members.get(ownMemberEvt.sender)?.content as MemberEventContent + } + if ( + !this.name + && !this.avatar + && !this.topic + && !this.canonical_alias + && this.join_rules === "invite" + && this.invited_by + && ownMemberEvt?.content.is_direct + ) { + this.dm_user_id = this.invited_by + this.name = getDisplayname(this.invited_by, this.inviter_profile) + this.avatar = this.inviter_profile?.avatar_url + } + } + + get membership(): Membership { + return "invite" + } + + get avatar_url(): ContentURI | undefined { + return this.avatar + } + + get num_joined_members(): number { + return 0 + } + + get room_type(): RoomType { + return "" + } + + get world_readable(): boolean { + return false + } + + get guest_can_join(): boolean { + return false + } + + get unread_messages(): number { + return 0 + } + + get unread_notifications(): number { + return 0 + } + + get unread_highlights(): number { + return 1 + } + + get marked_unread(): boolean { + return true + } +} diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 8ce87f2..9ffa945 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -37,6 +37,7 @@ import { UserID, roomStateGUIDToString, } from "../types" +import { InvitedRoomStore } from "./invitedroom.ts" import { RoomStateStore } from "./room.ts" export interface RoomListEntry { @@ -67,7 +68,9 @@ window.gcSettings ??= { } export class StateStore { + userID: UserID = "" readonly rooms: Map = new Map() + readonly inviteRooms: Map = new Map() readonly roomList = new NonNullCachedEventDispatcher([]) currentRoomListFilter: string = "" readonly accountData: Map = new Map() @@ -83,6 +86,7 @@ export class StateStore { serverPreferenceCache: Preferences = {} switchRoom?: (roomID: RoomID | null) => void activeRoomID: RoomID | null = null + activeRoomIsPreview: boolean = false imageAuthToken?: string getFilteredRoomList(): RoomListEntry[] { @@ -161,12 +165,26 @@ export class StateStore { } const resyncRoomList = this.roomList.current.length === 0 const changedRoomListEntries = new Map() + for (const data of sync.invited_rooms) { + const room = new InvitedRoomStore(data, this) + this.inviteRooms.set(room.room_id, room) + if (!resyncRoomList) { + changedRoomListEntries.set(room.room_id, room) + } + if (this.activeRoomID === room.room_id) { + this.switchRoom?.(room.room_id) + } + } + const hasInvites = this.inviteRooms.size > 0 for (const [roomID, data] of Object.entries(sync.rooms)) { let isNewRoom = false let room = this.rooms.get(roomID) if (!room) { room = new RoomStateStore(data.meta, this) this.rooms.set(roomID, room) + if (hasInvites) { + this.inviteRooms.delete(roomID) + } isNewRoom = true } const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room)) @@ -190,6 +208,9 @@ export class StateStore { this.showNotification(room, notification.event_rowid, notification.sound) } } + if (this.activeRoomID === roomID && this.activeRoomIsPreview) { + this.switchRoom?.(roomID) + } } for (const ad of Object.values(sync.account_data)) { if (ad.type === "io.element.recent_emoji") { @@ -211,9 +232,10 @@ export class StateStore { let updatedRoomList: RoomListEntry[] | undefined if (resyncRoomList) { - updatedRoomList = Object.values(sync.rooms) + updatedRoomList = this.inviteRooms.values().toArray() + updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms) .map(entry => this.#makeRoomListEntry(entry)) - .filter(entry => entry !== null) + .filter(entry => entry !== null)) updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp) } else if (changedRoomListEntries.size > 0) { updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id)) @@ -410,6 +432,7 @@ export class StateStore { clear() { this.rooms.clear() + this.inviteRooms.clear() this.roomList.emit([]) this.accountData.clear() this.currentRoomListFilter = "" diff --git a/web/src/api/types/hievents.ts b/web/src/api/types/hievents.ts index 2ce1ee3..f05296e 100644 --- a/web/src/api/types/hievents.ts +++ b/web/src/api/types/hievents.ts @@ -15,6 +15,7 @@ // along with this program. If not, see . import { DBAccountData, + DBInvitedRoom, DBReceipt, DBRoom, DBRoomAccountData, @@ -86,6 +87,7 @@ export interface SyncNotification { export interface SyncCompleteData { rooms: Record + invited_rooms: DBInvitedRoom[] left_rooms: RoomID[] account_data: Record since?: string diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index 0758f5a..200c307 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -74,6 +74,19 @@ export interface DBRoom { //eslint-disable-next-line @typescript-eslint/no-explicit-any export type UnknownEventContent = Record +export interface StrippedStateEvent { + type: EventType + sender: UserID + state_key: string + content: UnknownEventContent +} + +export interface DBInvitedRoom { + room_id: RoomID + created_at: number + invite_state: StrippedStateEvent[] +} + export enum UnreadType { None = 0b0000, Normal = 0b0001, diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index cd3c659..2228124 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -68,8 +68,10 @@ export interface UserProfile { [custom: string]: unknown } +export type Membership = "join" | "leave" | "ban" | "invite" | "knock" + export interface MemberEventContent extends UserProfile { - membership: "join" | "leave" | "ban" | "invite" | "knock" + membership: Membership reason?: string } @@ -235,3 +237,30 @@ export interface ImagePackRooms { export interface ElementRecentEmoji { recent_emoji: [string, number][] } + +export type JoinRule = "public" | "knock" | "restricted" | "knock_restricted" | "invite" | "private" + +export interface RoomSummary { + room_id: RoomID + membership?: Membership + + room_version?: RoomVersion + "im.nheko.summary.room_version"?: RoomVersion + "im.nheko.summary.version"?: RoomVersion + encryption?: "m.megolm.v1.aes-sha2" + "im.nheko.summary.encryption"?: "m.megolm.v1.aes-sha2" + + avatar_url?: ContentURI + canonical_alias?: RoomAlias + guest_can_join: boolean + join_rule?: JoinRule + name?: string + num_joined_members: number + room_type: RoomType + topic?: string + world_readable: boolean +} + +export interface RespRoomJoin { + room_id: RoomID +} diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 3683a55..f0a1b6d 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -19,7 +19,7 @@ import Client from "@/api/client.ts" import { RoomStateStore } from "@/api/statestore" import type { RoomID } from "@/api/types" import { useEventAsState } from "@/util/eventdispatcher.ts" -import { parseMatrixURI } from "@/util/validation.ts" +import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts" import ClientContext from "./ClientContext.ts" import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts" import StylePreferences from "./StylePreferences.tsx" @@ -27,6 +27,7 @@ import Keybindings from "./keybindings.ts" import { ModalWrapper } from "./modal/Modal.tsx" import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx" import RoomList from "./roomlist/RoomList.tsx" +import RoomPreview, { RoomPreviewProps } from "./roomview/RoomPreview.tsx" import RoomView from "./roomview/RoomView.tsx" import { useResizeHandle } from "./util/useResizeHandle.tsx" import "./MainScreen.css" @@ -50,7 +51,7 @@ class ContextFields implements MainScreenContextFields { constructor( private directSetRightPanel: (props: RightPanelProps | null) => void, - private directSetActiveRoom: (room: RoomStateStore | null) => void, + private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void, private client: Client, ) { this.keybindings = new Keybindings(client.store, this) @@ -94,33 +95,73 @@ class ContextFields implements MainScreenContextFields { } } - setActiveRoom = (roomID: RoomID | null, pushState = true) => { + setActiveRoom = (roomID: RoomID | null, previewMeta?: Partial, pushState = true) => { console.log("Switching to room", roomID) - const room = (roomID && this.client.store.rooms.get(roomID)) || null + if (roomID) { + const room = this.client.store.rooms.get(roomID) + if (room) { + this.#setActiveRoom(room, pushState) + } else { + this.#setPreviewRoom(roomID, pushState, previewMeta) + } + } else { + this.#closeActiveRoom(pushState) + } + } + + #setPreviewRoom(roomID: RoomID, pushState: boolean, meta?: Partial) { + const invite = this.client.store.inviteRooms.get(roomID) + this.#closeActiveRoom(false) + this.directSetActiveRoom({ roomID, ...(meta ?? {}), invite }) + this.client.store.activeRoomID = roomID + this.client.store.activeRoomIsPreview = true + if (pushState) { + history.pushState({ + room_id: roomID, + source_via: meta?.via, + source_alias: meta?.alias, + }, "") + } + } + + #setActiveRoom(room: RoomStateStore, pushState: boolean) { window.activeRoom = room this.directSetActiveRoom(room) this.directSetRightPanel(null) this.rightPanelStack = [] - this.client.store.activeRoomID = room?.roomID ?? null + this.client.store.activeRoomID = room.roomID + this.client.store.activeRoomIsPreview = false this.keybindings.activeRoom = room - if (room) { - room.lastOpened = Date.now() - if (!room.stateLoaded) { - this.client.loadRoomState(room.roomID) - .catch(err => console.error("Failed to load room state", err)) - } - document - .querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`) - ?.scrollIntoView({ block: "nearest" }) + room.lastOpened = Date.now() + if (!room.stateLoaded) { + this.client.loadRoomState(room.roomID) + .catch(err => console.error("Failed to load room state", err)) } + document + .querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`) + ?.scrollIntoView({ block: "nearest" }) if (pushState) { - history.pushState({ room_id: roomID }, "") + history.pushState({ room_id: room.roomID }, "") } - let roomNameForTitle = room?.meta.current.name + let roomNameForTitle = room.meta.current.name if (roomNameForTitle && roomNameForTitle.length > 48) { roomNameForTitle = roomNameForTitle.slice(0, 45) + "…" } - document.title = roomNameForTitle ? `${roomNameForTitle} - gomuks web` : "gomuks web" + document.title = `${roomNameForTitle} - gomuks web` + } + + #closeActiveRoom(pushState: boolean) { + window.activeRoom = null + this.directSetActiveRoom(null) + this.directSetRightPanel(null) + this.rightPanelStack = [] + this.client.store.activeRoomID = null + this.client.store.activeRoomIsPreview = false + this.keybindings.activeRoom = null + if (pushState) { + history.pushState({}, "") + } + document.title = "gomuks web" } clickRoom = (evt: React.MouseEvent) => { @@ -192,14 +233,18 @@ const handleURLHash = (client: Client) => { history.replaceState(newState, "", newURL.toString()) return newState } else if (uri.identifier.startsWith("!")) { - const newState = { room_id: uri.identifier } + const newState = { room_id: uri.identifier, source_via: uri.params.getAll("via") } history.replaceState(newState, "", newURL.toString()) return newState } else if (uri.identifier.startsWith("#")) { + history.replaceState(history.state, "", newURL.toString()) // TODO loading indicator or something for this? client.rpc.resolveAlias(uri.identifier).then( res => { - history.pushState({ room_id: res.room_id }, "", newURL.toString()) + window.mainScreenContext.setActiveRoom(res.room_id, { + alias: uri.identifier, + via: res.servers.slice(0, 3), + }) }, err => window.alert(`Failed to resolve room alias ${uri.identifier}: ${err}`), ) @@ -210,9 +255,12 @@ const handleURLHash = (client: Client) => { return history.state } -type ActiveRoomType = [RoomStateStore | null, RoomStateStore | null] +type ActiveRoomType = [RoomStateStore | RoomPreviewProps | null, RoomStateStore | RoomPreviewProps | null] -const activeRoomReducer = (prev: ActiveRoomType, active: RoomStateStore | "clear-animation" | null): ActiveRoomType => { +const activeRoomReducer = ( + prev: ActiveRoomType, + active: RoomStateStore | RoomPreviewProps | "clear-animation" | null, +): ActiveRoomType => { if (active === "clear-animation") { return prev[1] === null ? [null, null] : prev } else if (window.innerWidth > 720 || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { @@ -240,7 +288,10 @@ const MainScreen = () => { skipNextTransitionRef.current = evt.hasUAVisualTransition const roomID = evt.state?.room_id ?? null if (roomID !== client.store.activeRoomID) { - context.setActiveRoom(roomID, false) + context.setActiveRoom(roomID, { + alias: ensureString(evt?.state.source_alias) || undefined, + via: ensureStringArray(evt?.state.source_via), + }, false) } context.setRightPanel(evt.state?.right_panel ?? null, false) } @@ -303,6 +354,7 @@ const MainScreen = () => { Sync failed permanently } + const activeRealRoom = activeRoom instanceof RoomStateStore ? activeRoom : null const renderedRoom = activeRoom ?? prevActiveRoom useEffect(() => { if (prevActiveRoom !== null && activeRoom === null) { @@ -313,17 +365,19 @@ const MainScreen = () => { }, [activeRoom, prevActiveRoom]) return - +
{resizeHandle1} {renderedRoom - ? + ? renderedRoom instanceof RoomStateStore + ? + : : rightPanel && <>
{resizeHandle2} diff --git a/web/src/ui/MainScreenContext.ts b/web/src/ui/MainScreenContext.ts index 8823f4b..67c0b0b 100644 --- a/web/src/ui/MainScreenContext.ts +++ b/web/src/ui/MainScreenContext.ts @@ -16,9 +16,10 @@ import { createContext } from "react" 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) => void + setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial) => void clickRoom: (evt: React.MouseEvent) => void clearActiveRoom: () => void diff --git a/web/src/ui/roomview/RoomPreview.css b/web/src/ui/roomview/RoomPreview.css new file mode 100644 index 0000000..38cb5b7 --- /dev/null +++ b/web/src/ui/roomview/RoomPreview.css @@ -0,0 +1,70 @@ + +div.room-view.preview > div.preview-inner { + width: 100%; + max-height: 100%; + height: auto; + max-width: 30rem; + + display: flex; + flex-direction: column; + align-items: center; + gap: .5rem; + + > img.avatar { + width: 7.5rem; + height: 7.5rem; + border: 1px solid var(--border-color); + } + + > h2.room-name { + margin: 0; + } + + > div.mutual-rooms { + width: 100%; + max-height: 20rem; + overflow: auto; + + > h4 { + margin: .5rem 0 0; + } + } + + > div.error, > div.member-count, > div.inviter-info { + display: flex; + align-items: center; + gap: .25rem; + } + + > div.room-topic { + white-space: pre-wrap; + max-height: 15rem; + overflow: auto; + + @media screen and (max-height: 50rem) { + max-height: 7.5rem; + } + } + + > div.error > svg { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + } + + > div.buttons { + width: 100%; + display: flex; + gap: .25rem; + + > button { + padding: .5rem; + width: 100%; + + &.reject { + border: 2px solid var(--error-color); + padding: .25rem .5rem; + } + } + } +} diff --git a/web/src/ui/roomview/RoomPreview.tsx b/web/src/ui/roomview/RoomPreview.tsx new file mode 100644 index 0000000..9b81ba0 --- /dev/null +++ b/web/src/ui/roomview/RoomPreview.tsx @@ -0,0 +1,129 @@ +// 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 { use, useCallback, useEffect, useState } from "react" +import { ScaleLoader } from "react-spinners" +import { getAvatarURL, getRoomAvatarURL } from "@/api/media.ts" +import { InvitedRoomStore } from "@/api/statestore/invitedroom.ts" +import { RoomID, RoomSummary } from "@/api/types" +import { getDisplayname, getServerName } from "@/util/validation.ts" +import ClientContext from "../ClientContext.ts" +import MainScreenContext from "../MainScreenContext.ts" +import { LightboxContext } from "../modal/Lightbox.tsx" +import MutualRooms from "../rightpanel/UserInfoMutualRooms.tsx" +import ErrorIcon from "@/icons/error.svg?react" +import GroupIcon from "@/icons/group.svg?react" +import "./RoomPreview.css" + +export interface RoomPreviewProps { + roomID: RoomID + via?: string[] + alias?: string + invite?: InvitedRoomStore +} + +const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => { + const client = use(ClientContext)! + const mainScreen = use(MainScreenContext) + const [summary, setSummary] = useState(null) + const [loading, setLoading] = useState(false) + const [buttonClicked, setButtonClicked] = useState(false) + const [error, setError] = useState(null) + const doJoinRoom = useCallback(() => { + let realVia = via + if (!via?.length && invite?.invited_by) { + realVia = [getServerName(invite.invited_by)] + } + setButtonClicked(true) + client.rpc.joinRoom(alias || roomID, alias ? undefined : realVia).then( + () => console.info("Successfully joined", roomID), + err => { + setError(`Failed to join room: ${err}`) + setButtonClicked(false) + }, + ) + }, [client, roomID, via, alias, invite]) + const doRejectInvite = useCallback(() => { + setButtonClicked(true) + client.rpc.leaveRoom(roomID).then( + () => { + console.info("Successfully rejected invite to", roomID) + mainScreen.clearActiveRoom() + }, + err => { + setError(`Failed to reject invite: ${err}`) + setButtonClicked(false) + }, + ) + }, [client, mainScreen, roomID]) + useEffect(() => { + setSummary(null) + setError(null) + setLoading(true) + let realVia = via + if (!via?.length && invite?.invited_by) { + realVia = [getServerName(invite.invited_by)] + } + client.rpc.getRoomSummary(alias || roomID, realVia).then( + setSummary, + err => !invite && setError(`Failed to load room info: ${err}`), + ).finally(() => setLoading(false)) + }, [client, roomID, via, alias, invite]) + const name = summary?.name ?? summary?.canonical_alias ?? invite?.name ?? invite?.canonical_alias ?? alias ?? roomID + const memberCount = summary?.num_joined_members || null + const topic = summary?.topic ?? invite?.topic ?? "" + return
+
+ {invite?.invited_by && !invite.dm_user_id ?
+ + {getDisplayname(invite.invited_by, invite.inviter_profile)} invited you to +
: null} +

{name}

+ + {loading && } + {memberCount &&
{memberCount} members
} +
{topic}
+ {invite?.invited_by && } +
+ {invite && } + +
+ {error &&
+ + {error} +
} +
+
+} + +export default RoomPreview diff --git a/web/src/ui/roomview/RoomView.css b/web/src/ui/roomview/RoomView.css index 3f696f9..49b93f4 100644 --- a/web/src/ui/roomview/RoomView.css +++ b/web/src/ui/roomview/RoomView.css @@ -12,4 +12,10 @@ div.room-view { "typing" auto / 1fr; contain: strict; + + &.preview { + display: flex; + justify-content: center; + align-items: center; + } } diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 9ed400c..c79b63c 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -27,9 +27,9 @@ import ReadReceipts from "./ReadReceipts.tsx" import { ReplyIDBody } from "./ReplyBody.tsx" import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content" import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu" -import ErrorIcon from "../../icons/error.svg?react" -import PendingIcon from "../../icons/pending.svg?react" -import SentIcon from "../../icons/sent.svg?react" +import ErrorIcon from "@/icons/error.svg?react" +import PendingIcon from "@/icons/pending.svg?react" +import SentIcon from "@/icons/sent.svg?react" import "./TimelineEvent.css" export interface TimelineEventProps { diff --git a/web/src/ui/timeline/content/TextMessageBody.tsx b/web/src/ui/timeline/content/TextMessageBody.tsx index e8b0a79..03cfdd2 100644 --- a/web/src/ui/timeline/content/TextMessageBody.tsx +++ b/web/src/ui/timeline/content/TextMessageBody.tsx @@ -34,10 +34,15 @@ function onClickMatrixURI(href: string) { userID: uri.identifier, }) case "!": - return window.mainScreenContext.setActiveRoom(uri.identifier) + return window.mainScreenContext.setActiveRoom(uri.identifier, { + via: uri.params.getAll("via"), + }) case "#": return window.client.rpc.resolveAlias(uri.identifier).then( - res => window.mainScreenContext.setActiveRoom(res.room_id), + res => window.mainScreenContext.setActiveRoom(res.room_id, { + alias: uri.identifier, + via: res.servers.slice(0, 3), + }), err => window.alert(`Failed to resolve room alias ${uri.identifier}: ${err}`), ) } diff --git a/web/src/util/validation.ts b/web/src/util/validation.ts index 187753b..260f94d 100644 --- a/web/src/util/validation.ts +++ b/web/src/util/validation.ts @@ -82,6 +82,11 @@ export function getLocalpart(userID: UserID): string { return idx > 0 ? userID.slice(1, idx) : userID.slice(1) } +export function getServerName(userID: UserID): string { + const idx = userID.indexOf(":") + return userID.slice(idx+1) +} + export function getDisplayname(userID: UserID, profile?: UserProfile | null): string { return profile?.displayname || getLocalpart(userID) } @@ -108,6 +113,10 @@ export function ensureArray(val: unknown): unknown[] { return Array.isArray(val) ? val : [] } -export function ensureStringArray(val: unknown): string[] { - return ensureArray(val).map(ensureString) +export function isString(val: unknown): val is string { + return typeof val === "string" +} + +export function ensureStringArray(val: unknown): string[] { + return ensureArray(val).filter(isString) }