1
0
Fork 0
forked from Mirrors/gomuks

hicli,web: add support for joining rooms

Fixes #503
This commit is contained in:
Tulir Asokan 2024-12-20 16:27:12 +02:00
parent 60a2e52a0c
commit da7eb6c583
31 changed files with 754 additions and 79 deletions

View file

@ -67,7 +67,7 @@ require (
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
golang.org/x/image v0.23.0 // indirect golang.org/x/image v0.23.0 // indirect
golang.org/x/mod v0.22.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/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.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/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.1 // indirect maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce // indirect
mvdan.cc/xurls/v2 v2.5.0 // indirect mvdan.cc/xurls/v2 v2.5.0 // indirect
) )

View file

@ -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.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.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.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= 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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/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.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.1 h1:2lCM37vmVzZGE0tWD7UOySMtAuC5hq6Pw33KlY2VU/c= maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce h1:wkVN87Hlq63YCuUhVYhvSy0qeAUCzbD5quEtd95rxyE=
maunium.net/go/mautrix v0.22.1/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= 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 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=

4
go.mod
View file

@ -21,11 +21,11 @@ require (
go.mau.fi/zeroconfig v0.1.3 go.mau.fi/zeroconfig v0.1.3
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.31.0
golang.org/x/image v0.23.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 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.1 maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce
mvdan.cc/xurls/v2 v2.5.0 mvdan.cc/xurls/v2 v2.5.0
) )

8
go.sum
View file

@ -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/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 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= 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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= 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-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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/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= 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.1 h1:2lCM37vmVzZGE0tWD7UOySMtAuC5hq6Pw33KlY2VU/c= maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce h1:wkVN87Hlq63YCuUhVYhvSy0qeAUCzbD5quEtd95rxyE=
maunium.net/go/mautrix v0.22.1/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= 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 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

@ -20,6 +20,7 @@ type Database struct {
Account AccountQuery Account AccountQuery
AccountData AccountDataQuery AccountData AccountDataQuery
Room RoomQuery Room RoomQuery
InvitedRoom InvitedRoomQuery
Event EventQuery Event EventQuery
CurrentState CurrentStateQuery CurrentState CurrentStateQuery
Timeline TimelineQuery Timeline TimelineQuery
@ -37,6 +38,7 @@ func New(rawDB *dbutil.Database) *Database {
Account: AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)}, Account: AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
AccountData: AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)}, AccountData: AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
Room: RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)}, Room: RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
InvitedRoom: InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
Event: EventQuery{QueryHelper: eventQH}, Event: EventQuery{QueryHelper: eventQH},
CurrentState: CurrentStateQuery{QueryHelper: eventQH}, CurrentState: CurrentStateQuery{QueryHelper: eventQH},
Timeline: TimelineQuery{QueryHelper: eventQH}, Timeline: TimelineQuery{QueryHelper: eventQH},
@ -58,6 +60,10 @@ func newRoom(_ *dbutil.QueryHelper[*Room]) *Room {
return &Room{} return &Room{}
} }
func newInvitedRoom(_ *dbutil.QueryHelper[*InvitedRoom]) *InvitedRoom {
return &InvitedRoom{}
}
func newReceipt(_ *dbutil.QueryHelper[*Receipt]) *Receipt { func newReceipt(_ *dbutil.QueryHelper[*Receipt]) *Receipt {
return &Receipt{} return &Receipt{}
} }

View file

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

View file

@ -262,7 +262,7 @@ func (r *Room) Scan(row dbutil.Scannable) (*Room, error) {
} }
r.PrevBatch = prevBatch.String r.PrevBatch = prevBatch.String
r.PreviewEventRowID = EventRowID(previewEventRowID.Int64) r.PreviewEventRowID = EventRowID(previewEventRowID.Int64)
r.SortingTimestamp = jsontime.UM(time.UnixMilli(sortingTimestamp.Int64)) r.SortingTimestamp = jsontime.UMInt(sortingTimestamp.Int64)
return r, nil return r, nil
} }

View file

@ -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_notifications > 0);
-- CREATE INDEX room_sorting_timestamp_idx ON room (unread_messages > 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 ( CREATE TABLE account_data (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,

View file

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

View file

@ -31,15 +31,16 @@ type SyncNotification struct {
} }
type SyncComplete struct { type SyncComplete struct {
Since *string `json:"since,omitempty"` Since *string `json:"since,omitempty"`
ClearState bool `json:"clear_state,omitempty"` ClearState bool `json:"clear_state,omitempty"`
Rooms map[id.RoomID]*SyncRoom `json:"rooms"` AccountData map[event.Type]*database.AccountData `json:"account_data"`
AccountData map[event.Type]*database.AccountData `json:"account_data"` Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
LeftRooms []id.RoomID `json:"left_rooms"` LeftRooms []id.RoomID `json:"left_rooms"`
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
} }
func (c *SyncComplete) IsEmpty() bool { 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 type SyncStatusType string

View file

@ -84,8 +84,18 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
AccountData: make(map[event.Type]*database.AccountData), AccountData: make(map[event.Type]*database.AccountData),
} }
if i == 0 { 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 payload.ClearState = true
} }
if payload.InvitedRooms == nil {
payload.InvitedRooms = make([]*database.InvitedRoom, 0)
}
for _, room := range rooms { for _, room := range rooms {
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp { if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
break break
@ -107,9 +117,10 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
return return
} }
payload := SyncComplete{ payload := SyncComplete{
Rooms: make(map[id.RoomID]*SyncRoom, 0), Rooms: make(map[id.RoomID]*SyncRoom),
LeftRooms: make([]id.RoomID, 0), InvitedRooms: make([]*database.InvitedRoom, 0),
AccountData: make(map[event.Type]*database.AccountData, len(ad)), LeftRooms: make([]id.RoomID, 0),
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
} }
for _, data := range ad { for _, data := range ad {
payload.AccountData[event.Type{Type: data.Type, Class: event.AccountDataEventType}] = data payload.AccountData[event.Type{Type: data.Type, Class: event.AccountDataEventType}] = data

View file

@ -130,6 +130,21 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) { return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) {
return h.PaginateServer(ctx, params.RoomID, params.Limit) 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": case "ensure_group_session_shared":
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) { return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
return true, h.EnsureGroupSessionShared(ctx, params.RoomID) return true, h.EnsureGroupSessionShared(ctx, params.RoomID)
@ -315,6 +330,17 @@ type paginateParams struct {
Limit int `json:"limit"` 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 { type getReceiptsParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
EventIDs []id.EventID `json:"event_ids"` EventIDs []id.EventID `json:"event_ids"`

View file

@ -165,8 +165,9 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
Receipts: make(map[id.EventID][]*database.Receipt), Receipts: make(map[id.EventID][]*database.Receipt),
}, },
}, },
AccountData: make(map[event.Type]*database.AccountData), InvitedRooms: make([]*database.InvitedRoom, 0),
LeftRooms: make([]id.RoomID, 0), AccountData: make(map[event.Type]*database.AccountData),
LeftRooms: make([]id.RoomID, 0),
}) })
} }
} }

View file

@ -151,14 +151,20 @@ func (h *HiClient) processSyncResponse(ctx context.Context, resp *mautrix.RespSy
} }
} }
ctx.Value(syncContextKey).(*syncContext).evt.AccountData = accountData 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 { for roomID, room := range resp.Rooms.Join {
err := h.processSyncJoinedRoom(ctx, roomID, room) err = h.processSyncJoinedRoom(ctx, roomID, room)
if err != nil { if err != nil {
return fmt.Errorf("failed to process joined room %s: %w", roomID, err) return fmt.Errorf("failed to process joined room %s: %w", roomID, err)
} }
} }
for roomID, room := range resp.Rooms.Leave { for roomID, room := range resp.Rooms.Leave {
err := h.processSyncLeftRoom(ctx, roomID, room) err = h.processSyncLeftRoom(ctx, roomID, room)
if err != nil { if err != nil {
return fmt.Errorf("failed to process left room %s: %w", roomID, err) 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 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 { func (h *HiClient) processSyncJoinedRoom(ctx context.Context, roomID id.RoomID, room *mautrix.SyncJoinedRoom) error {
existingRoomData, err := h.DB.Room.Get(ctx, roomID) existingRoomData, err := h.DB.Room.Get(ctx, roomID)
if err != nil { if err != nil {
@ -265,6 +292,10 @@ func (h *HiClient) processSyncLeftRoom(ctx context.Context, roomID id.RoomID, ro
if err != nil { if err != nil {
return fmt.Errorf("failed to delete room: %w", err) 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 := ctx.Value(syncContextKey).(*syncContext).evt
payload.LeftRooms = append(payload.LeftRooms, roomID) payload.LeftRooms = append(payload.LeftRooms, roomID)
return nil return nil

View file

@ -15,6 +15,8 @@ import (
"github.com/mattn/go-sqlite3" "github.com/mattn/go-sqlite3"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli/database"
) )
type hiSyncer HiClient type hiSyncer HiClient
@ -31,9 +33,10 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync,
c := (*HiClient)(h) c := (*HiClient)(h)
c.lastSync = time.Now() c.lastSync = time.Now()
ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{ ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{
Since: &since, Since: &since,
Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)), Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)),
LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)), InvitedRooms: make([]*database.InvitedRoom, 0, len(resp.Rooms.Invite)),
LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)),
}}) }})
err := c.preProcessSyncResponse(ctx, resp, since) err := c.preProcessSyncResponse(ctx, resp, since)
if err != nil { 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 { func (h *hiSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
if !h.Verified { if !h.Verified {
return &mautrix.Filter{ return &mautrix.Filter{
Presence: mautrix.FilterPart{ Presence: &mautrix.FilterPart{
NotRooms: []id.RoomID{"*"}, NotRooms: []id.RoomID{"*"},
}, },
Room: mautrix.RoomFilter{ Room: &mautrix.RoomFilter{
NotRooms: []id.RoomID{"*"}, NotRooms: []id.RoomID{"*"},
}, },
} }
} }
return &mautrix.Filter{ return &mautrix.Filter{
Presence: mautrix.FilterPart{ Presence: &mautrix.FilterPart{
NotRooms: []id.RoomID{"*"}, NotRooms: []id.RoomID{"*"},
}, },
Room: mautrix.RoomFilter{ Room: &mautrix.RoomFilter{
State: mautrix.FilterPart{ State: &mautrix.FilterPart{
LazyLoadMembers: true, LazyLoadMembers: true,
}, },
Timeline: mautrix.FilterPart{ Timeline: &mautrix.FilterPart{
Limit: 100, Limit: 100,
LazyLoadMembers: true, LazyLoadMembers: true,
}, },

View file

@ -104,6 +104,7 @@ export default class Client {
#handleEvent = (ev: RPCEvent) => { #handleEvent = (ev: RPCEvent) => {
if (ev.command === "client_state") { if (ev.command === "client_state") {
this.state.emit(ev.data) this.state.emit(ev.data)
this.store.userID = ev.data.is_logged_in ? ev.data.user_id : ""
} else if (ev.command === "sync_status") { } else if (ev.command === "sync_status") {
this.syncStatus.emit(ev.data) this.syncStatus.emit(ev.data)
} else if (ev.command === "init_complete") { } else if (ev.command === "init_complete") {

View file

@ -13,9 +13,8 @@
// //
// 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 type { RoomListEntry } from "@/api/statestore"
import { parseMXC } from "@/util/validation.ts" 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 => { export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => {
const [server, mediaID] = parseMXC(mxc) 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)}` 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 let dmUserID: UserID | undefined
if ("dm_user_id" in room) { if ("dm_user_id" in room) {
dmUserID = room.dm_user_id dmUserID = room.dm_user_id
@ -98,5 +106,8 @@ export const getRoomAvatarURL = (room: DBRoom | RoomListEntry, avatarOverride?:
dmUserID = room.lazy_load_summary?.heroes?.length === 1 dmUserID = room.lazy_load_summary?.heroes?.length === 1
? room.lazy_load_summary.heroes[0] : undefined ? 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,
})
} }

View file

@ -32,9 +32,11 @@ import type {
ReceiptType, ReceiptType,
RelatesTo, RelatesTo,
ResolveAliasResponse, ResolveAliasResponse,
RespRoomJoin,
RoomAlias, RoomAlias,
RoomID, RoomID,
RoomStateGUID, RoomStateGUID,
RoomSummary,
TimelineRowID, TimelineRowID,
UserID, UserID,
UserProfile, UserProfile,
@ -220,6 +222,18 @@ export default abstract class RPCClient {
return this.request("paginate_server", { room_id, limit }) return this.request("paginate_server", { room_id, limit })
} }
getRoomSummary(room_id_or_alias: RoomID | RoomAlias, via?: string[]): Promise<RoomSummary> {
return this.request("get_room_summary", { room_id_or_alias, via })
}
joinRoom(room_id_or_alias: RoomID | RoomAlias, via?: string[], reason?: string): Promise<RespRoomJoin> {
return this.request("join_room", { room_id_or_alias, via, reason })
}
leaveRoom(room_id: RoomID, reason?: string): Promise<Record<string, never>> {
return this.request("leave_room", { room_id, reason })
}
resolveAlias(alias: RoomAlias): Promise<ResolveAliasResponse> { resolveAlias(alias: RoomAlias): Promise<ResolveAliasResponse> {
return this.request("resolve_alias", { alias }) return this.request("resolve_alias", { alias })
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
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<UserID, StrippedStateEvent>()
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
}
}

View file

@ -37,6 +37,7 @@ import {
UserID, UserID,
roomStateGUIDToString, roomStateGUIDToString,
} from "../types" } from "../types"
import { InvitedRoomStore } from "./invitedroom.ts"
import { RoomStateStore } from "./room.ts" import { RoomStateStore } from "./room.ts"
export interface RoomListEntry { export interface RoomListEntry {
@ -67,7 +68,9 @@ window.gcSettings ??= {
} }
export class StateStore { export class StateStore {
userID: UserID = ""
readonly rooms: Map<RoomID, RoomStateStore> = new Map() readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([]) readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
currentRoomListFilter: string = "" currentRoomListFilter: string = ""
readonly accountData: Map<string, UnknownEventContent> = new Map() readonly accountData: Map<string, UnknownEventContent> = new Map()
@ -83,6 +86,7 @@ export class StateStore {
serverPreferenceCache: Preferences = {} serverPreferenceCache: Preferences = {}
switchRoom?: (roomID: RoomID | null) => void switchRoom?: (roomID: RoomID | null) => void
activeRoomID: RoomID | null = null activeRoomID: RoomID | null = null
activeRoomIsPreview: boolean = false
imageAuthToken?: string imageAuthToken?: string
getFilteredRoomList(): RoomListEntry[] { getFilteredRoomList(): RoomListEntry[] {
@ -161,12 +165,26 @@ export class StateStore {
} }
const resyncRoomList = this.roomList.current.length === 0 const resyncRoomList = this.roomList.current.length === 0
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>() const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
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)) { for (const [roomID, data] of Object.entries(sync.rooms)) {
let isNewRoom = false let isNewRoom = false
let room = this.rooms.get(roomID) let room = this.rooms.get(roomID)
if (!room) { if (!room) {
room = new RoomStateStore(data.meta, this) room = new RoomStateStore(data.meta, this)
this.rooms.set(roomID, room) this.rooms.set(roomID, room)
if (hasInvites) {
this.inviteRooms.delete(roomID)
}
isNewRoom = true isNewRoom = true
} }
const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room)) const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room))
@ -190,6 +208,9 @@ export class StateStore {
this.showNotification(room, notification.event_rowid, notification.sound) 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)) { for (const ad of Object.values(sync.account_data)) {
if (ad.type === "io.element.recent_emoji") { if (ad.type === "io.element.recent_emoji") {
@ -211,9 +232,10 @@ export class StateStore {
let updatedRoomList: RoomListEntry[] | undefined let updatedRoomList: RoomListEntry[] | undefined
if (resyncRoomList) { if (resyncRoomList) {
updatedRoomList = Object.values(sync.rooms) updatedRoomList = this.inviteRooms.values().toArray()
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms)
.map(entry => this.#makeRoomListEntry(entry)) .map(entry => this.#makeRoomListEntry(entry))
.filter(entry => entry !== null) .filter(entry => entry !== null))
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp) updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
} 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))
@ -410,6 +432,7 @@ export class StateStore {
clear() { clear() {
this.rooms.clear() this.rooms.clear()
this.inviteRooms.clear()
this.roomList.emit([]) this.roomList.emit([])
this.accountData.clear() this.accountData.clear()
this.currentRoomListFilter = "" this.currentRoomListFilter = ""

View file

@ -15,6 +15,7 @@
// 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 { import {
DBAccountData, DBAccountData,
DBInvitedRoom,
DBReceipt, DBReceipt,
DBRoom, DBRoom,
DBRoomAccountData, DBRoomAccountData,
@ -86,6 +87,7 @@ export interface SyncNotification {
export interface SyncCompleteData { export interface SyncCompleteData {
rooms: Record<RoomID, SyncRoom> rooms: Record<RoomID, SyncRoom>
invited_rooms: DBInvitedRoom[]
left_rooms: RoomID[] left_rooms: RoomID[]
account_data: Record<EventType, DBAccountData> account_data: Record<EventType, DBAccountData>
since?: string since?: string

View file

@ -74,6 +74,19 @@ export interface DBRoom {
//eslint-disable-next-line @typescript-eslint/no-explicit-any //eslint-disable-next-line @typescript-eslint/no-explicit-any
export type UnknownEventContent = Record<string, any> export type UnknownEventContent = Record<string, any>
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 { export enum UnreadType {
None = 0b0000, None = 0b0000,
Normal = 0b0001, Normal = 0b0001,

View file

@ -68,8 +68,10 @@ export interface UserProfile {
[custom: string]: unknown [custom: string]: unknown
} }
export type Membership = "join" | "leave" | "ban" | "invite" | "knock"
export interface MemberEventContent extends UserProfile { export interface MemberEventContent extends UserProfile {
membership: "join" | "leave" | "ban" | "invite" | "knock" membership: Membership
reason?: string reason?: string
} }
@ -235,3 +237,30 @@ export interface ImagePackRooms {
export interface ElementRecentEmoji { export interface ElementRecentEmoji {
recent_emoji: [string, number][] 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
}

View file

@ -19,7 +19,7 @@ import Client from "@/api/client.ts"
import { RoomStateStore } from "@/api/statestore" import { 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 { parseMatrixURI } from "@/util/validation.ts" import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts"
import ClientContext from "./ClientContext.ts" import ClientContext from "./ClientContext.ts"
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts" import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
import StylePreferences from "./StylePreferences.tsx" import StylePreferences from "./StylePreferences.tsx"
@ -27,6 +27,7 @@ import Keybindings from "./keybindings.ts"
import { ModalWrapper } from "./modal/Modal.tsx" import { ModalWrapper } from "./modal/Modal.tsx"
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx" import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
import RoomList from "./roomlist/RoomList.tsx" import RoomList from "./roomlist/RoomList.tsx"
import RoomPreview, { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
import RoomView from "./roomview/RoomView.tsx" import RoomView from "./roomview/RoomView.tsx"
import { useResizeHandle } from "./util/useResizeHandle.tsx" import { useResizeHandle } from "./util/useResizeHandle.tsx"
import "./MainScreen.css" import "./MainScreen.css"
@ -50,7 +51,7 @@ class ContextFields implements MainScreenContextFields {
constructor( constructor(
private directSetRightPanel: (props: RightPanelProps | null) => void, private directSetRightPanel: (props: RightPanelProps | null) => void,
private directSetActiveRoom: (room: RoomStateStore | null) => void, private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void,
private client: Client, private client: Client,
) { ) {
this.keybindings = new Keybindings(client.store, this) 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<RoomPreviewProps>, pushState = true) => {
console.log("Switching to room", roomID) 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<RoomPreviewProps>) {
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 window.activeRoom = room
this.directSetActiveRoom(room) this.directSetActiveRoom(room)
this.directSetRightPanel(null) this.directSetRightPanel(null)
this.rightPanelStack = [] this.rightPanelStack = []
this.client.store.activeRoomID = room?.roomID ?? null this.client.store.activeRoomID = room.roomID
this.client.store.activeRoomIsPreview = false
this.keybindings.activeRoom = room this.keybindings.activeRoom = room
if (room) { room.lastOpened = Date.now()
room.lastOpened = Date.now() if (!room.stateLoaded) {
if (!room.stateLoaded) { this.client.loadRoomState(room.roomID)
this.client.loadRoomState(room.roomID) .catch(err => console.error("Failed to load room state", err))
.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" })
} }
document
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
?.scrollIntoView({ block: "nearest" })
if (pushState) { 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) { if (roomNameForTitle && roomNameForTitle.length > 48) {
roomNameForTitle = roomNameForTitle.slice(0, 45) + "…" 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) => { clickRoom = (evt: React.MouseEvent) => {
@ -192,14 +233,18 @@ const handleURLHash = (client: Client) => {
history.replaceState(newState, "", newURL.toString()) history.replaceState(newState, "", newURL.toString())
return newState return newState
} else if (uri.identifier.startsWith("!")) { } 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()) history.replaceState(newState, "", newURL.toString())
return newState return newState
} else if (uri.identifier.startsWith("#")) { } else if (uri.identifier.startsWith("#")) {
history.replaceState(history.state, "", newURL.toString())
// 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 => {
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}`), err => window.alert(`Failed to resolve room alias ${uri.identifier}: ${err}`),
) )
@ -210,9 +255,12 @@ const handleURLHash = (client: Client) => {
return history.state 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") { if (active === "clear-animation") {
return prev[1] === null ? [null, null] : prev return prev[1] === null ? [null, null] : prev
} else if (window.innerWidth > 720 || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { } else if (window.innerWidth > 720 || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
@ -240,7 +288,10 @@ const MainScreen = () => {
skipNextTransitionRef.current = evt.hasUAVisualTransition skipNextTransitionRef.current = evt.hasUAVisualTransition
const roomID = evt.state?.room_id ?? null const roomID = evt.state?.room_id ?? null
if (roomID !== client.store.activeRoomID) { 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) context.setRightPanel(evt.state?.right_panel ?? null, false)
} }
@ -303,6 +354,7 @@ const MainScreen = () => {
Sync failed permanently Sync failed permanently
</div> </div>
} }
const activeRealRoom = activeRoom instanceof RoomStateStore ? activeRoom : null
const renderedRoom = activeRoom ?? prevActiveRoom const renderedRoom = activeRoom ?? prevActiveRoom
useEffect(() => { useEffect(() => {
if (prevActiveRoom !== null && activeRoom === null) { if (prevActiveRoom !== null && activeRoom === null) {
@ -313,17 +365,19 @@ const MainScreen = () => {
}, [activeRoom, prevActiveRoom]) }, [activeRoom, prevActiveRoom])
return <MainScreenContext value={context}> return <MainScreenContext value={context}>
<ModalWrapper> <ModalWrapper>
<StylePreferences client={client} activeRoom={activeRoom}/> <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}/>
{resizeHandle1} {resizeHandle1}
{renderedRoom {renderedRoom
? <RoomView ? renderedRoom instanceof RoomStateStore
key={renderedRoom.roomID} ? <RoomView
room={renderedRoom} key={renderedRoom.roomID}
rightPanel={rightPanel} room={renderedRoom}
rightPanelResizeHandle={resizeHandle2} rightPanel={rightPanel}
/> rightPanelResizeHandle={resizeHandle2}
/>
: <RoomPreview {...renderedRoom} />
: rightPanel && <> : rightPanel && <>
<div className="room-view placeholder"/> <div className="room-view placeholder"/>
{resizeHandle2} {resizeHandle2}

View file

@ -16,9 +16,10 @@
import { createContext } from "react" import { createContext } from "react"
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"
export interface MainScreenContextFields { export interface MainScreenContextFields {
setActiveRoom: (roomID: RoomID | null) => void setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>) => void
clickRoom: (evt: React.MouseEvent) => void clickRoom: (evt: React.MouseEvent) => void
clearActiveRoom: () => void clearActiveRoom: () => void

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
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<RoomSummary | null>(null)
const [loading, setLoading] = useState(false)
const [buttonClicked, setButtonClicked] = useState(false)
const [error, setError] = useState<string | null>(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 <div className="room-view preview">
<div className="preview-inner">
{invite?.invited_by && !invite.dm_user_id ? <div className="inviter-info">
<img
className="small avatar"
onClick={use(LightboxContext)}
src={getAvatarURL(invite.invited_by, invite.inviter_profile)}
alt=""
/>
{getDisplayname(invite.invited_by, invite.inviter_profile)} invited you to
</div> : null}
<h2 className="room-name">{name}</h2>
<img
src={getRoomAvatarURL(invite ?? summary ?? { room_id: roomID })}
className="large avatar"
onClick={use(LightboxContext)}
alt=""
/>
{loading && <ScaleLoader color="var(--primary-color)"/>}
{memberCount && <div className="member-count"><GroupIcon/> {memberCount} members</div>}
<div className="room-topic">{topic}</div>
{invite?.invited_by && <MutualRooms client={client} userID={invite.invited_by} />}
<div className="buttons">
{invite && <button
disabled={buttonClicked}
className="reject"
onClick={doRejectInvite}
>Reject</button>}
<button
disabled={buttonClicked}
className="primary-color-button"
onClick={doJoinRoom}
>{invite ? "Accept" : "Join room"}</button>
</div>
{error && <div className="error">
<ErrorIcon color="var(--error-color)"/>
{error}
</div>}
</div>
</div>
}
export default RoomPreview

View file

@ -12,4 +12,10 @@ div.room-view {
"typing" auto "typing" auto
/ 1fr; / 1fr;
contain: strict; contain: strict;
&.preview {
display: flex;
justify-content: center;
align-items: center;
}
} }

View file

@ -27,9 +27,9 @@ import ReadReceipts from "./ReadReceipts.tsx"
import { ReplyIDBody } from "./ReplyBody.tsx" import { ReplyIDBody } from "./ReplyBody.tsx"
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content" import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu" import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
import ErrorIcon from "../../icons/error.svg?react" import ErrorIcon from "@/icons/error.svg?react"
import PendingIcon from "../../icons/pending.svg?react" import PendingIcon from "@/icons/pending.svg?react"
import SentIcon from "../../icons/sent.svg?react" import SentIcon from "@/icons/sent.svg?react"
import "./TimelineEvent.css" import "./TimelineEvent.css"
export interface TimelineEventProps { export interface TimelineEventProps {

View file

@ -34,10 +34,15 @@ function onClickMatrixURI(href: string) {
userID: uri.identifier, userID: uri.identifier,
}) })
case "!": case "!":
return window.mainScreenContext.setActiveRoom(uri.identifier) return window.mainScreenContext.setActiveRoom(uri.identifier, {
via: uri.params.getAll("via"),
})
case "#": case "#":
return window.client.rpc.resolveAlias(uri.identifier).then( 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}`), err => window.alert(`Failed to resolve room alias ${uri.identifier}: ${err}`),
) )
} }

View file

@ -82,6 +82,11 @@ export function getLocalpart(userID: UserID): string {
return idx > 0 ? userID.slice(1, idx) : userID.slice(1) 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 { export function getDisplayname(userID: UserID, profile?: UserProfile | null): string {
return profile?.displayname || getLocalpart(userID) return profile?.displayname || getLocalpart(userID)
} }
@ -108,6 +113,10 @@ export function ensureArray(val: unknown): unknown[] {
return Array.isArray(val) ? val : [] return Array.isArray(val) ? val : []
} }
export function ensureStringArray(val: unknown): string[] { export function isString(val: unknown): val is string {
return ensureArray(val).map(ensureString) return typeof val === "string"
}
export function ensureStringArray(val: unknown): string[] {
return ensureArray(val).filter(isString)
} }