forked from Mirrors/gomuks
parent
60a2e52a0c
commit
da7eb6c583
31 changed files with 754 additions and 79 deletions
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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=
|
||||
|
|
4
go.mod
4
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
|
||||
)
|
||||
|
||||
|
|
8
go.sum
8
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=
|
||||
|
|
|
@ -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{}
|
||||
}
|
||||
|
|
73
pkg/hicli/database/invitedroom.go
Normal file
73
pkg/hicli/database/invitedroom.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
13
pkg/hicli/database/upgrades/09-invited-rooms.sql
Normal file
13
pkg/hicli/database/upgrades/09-invited-rooms.sql
Normal 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;
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -13,9 +13,8 @@
|
|||
//
|
||||
// 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 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,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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<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> {
|
||||
return this.request("resolve_alias", { alias })
|
||||
}
|
||||
|
|
130
web/src/api/statestore/invitedroom.ts
Normal file
130
web/src/api/statestore/invitedroom.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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<RoomID, RoomStateStore> = new Map()
|
||||
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
|
||||
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
|
||||
currentRoomListFilter: string = ""
|
||||
readonly accountData: Map<string, UnknownEventContent> = 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<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)) {
|
||||
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 = ""
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import {
|
||||
DBAccountData,
|
||||
DBInvitedRoom,
|
||||
DBReceipt,
|
||||
DBRoom,
|
||||
DBRoomAccountData,
|
||||
|
@ -86,6 +87,7 @@ export interface SyncNotification {
|
|||
|
||||
export interface SyncCompleteData {
|
||||
rooms: Record<RoomID, SyncRoom>
|
||||
invited_rooms: DBInvitedRoom[]
|
||||
left_rooms: RoomID[]
|
||||
account_data: Record<EventType, DBAccountData>
|
||||
since?: string
|
||||
|
|
|
@ -74,6 +74,19 @@ export interface DBRoom {
|
|||
//eslint-disable-next-line @typescript-eslint/no-explicit-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 {
|
||||
None = 0b0000,
|
||||
Normal = 0b0001,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<RoomPreviewProps>, 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<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
|
||||
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
|
||||
</div>
|
||||
}
|
||||
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 <MainScreenContext value={context}>
|
||||
<ModalWrapper>
|
||||
<StylePreferences client={client} activeRoom={activeRoom}/>
|
||||
<StylePreferences client={client} activeRoom={activeRealRoom}/>
|
||||
<main className={classNames.join(" ")} style={extraStyle}>
|
||||
<RoomList activeRoomID={activeRoom?.roomID ?? null}/>
|
||||
{resizeHandle1}
|
||||
{renderedRoom
|
||||
? <RoomView
|
||||
key={renderedRoom.roomID}
|
||||
room={renderedRoom}
|
||||
rightPanel={rightPanel}
|
||||
rightPanelResizeHandle={resizeHandle2}
|
||||
/>
|
||||
? renderedRoom instanceof RoomStateStore
|
||||
? <RoomView
|
||||
key={renderedRoom.roomID}
|
||||
room={renderedRoom}
|
||||
rightPanel={rightPanel}
|
||||
rightPanelResizeHandle={resizeHandle2}
|
||||
/>
|
||||
: <RoomPreview {...renderedRoom} />
|
||||
: rightPanel && <>
|
||||
<div className="room-view placeholder"/>
|
||||
{resizeHandle2}
|
||||
|
|
|
@ -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<RoomPreviewProps>) => void
|
||||
clickRoom: (evt: React.MouseEvent) => void
|
||||
clearActiveRoom: () => void
|
||||
|
||||
|
|
70
web/src/ui/roomview/RoomPreview.css
Normal file
70
web/src/ui/roomview/RoomPreview.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
129
web/src/ui/roomview/RoomPreview.tsx
Normal file
129
web/src/ui/roomview/RoomPreview.tsx
Normal 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
|
|
@ -12,4 +12,10 @@ div.room-view {
|
|||
"typing" auto
|
||||
/ 1fr;
|
||||
contain: strict;
|
||||
|
||||
&.preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}`),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue