mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
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/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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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
4
go.mod
|
@ -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
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/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=
|
||||||
|
|
|
@ -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{}
|
||||||
}
|
}
|
||||||
|
|
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.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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
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;
|
|
@ -33,13 +33,14 @@ 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
|
||||||
|
|
|
@ -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,7 +117,8 @@ 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),
|
||||||
|
InvitedRooms: make([]*database.InvitedRoom, 0),
|
||||||
LeftRooms: make([]id.RoomID, 0),
|
LeftRooms: make([]id.RoomID, 0),
|
||||||
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
|
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
|
@ -165,6 +165,7 @@ 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),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
InvitedRooms: make([]*database.InvitedRoom, 0),
|
||||||
AccountData: make(map[event.Type]*database.AccountData),
|
AccountData: make(map[event.Type]*database.AccountData),
|
||||||
LeftRooms: make([]id.RoomID, 0),
|
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
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
@ -33,6 +35,7 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync,
|
||||||
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)),
|
||||||
|
InvitedRooms: make([]*database.InvitedRoom, 0, len(resp.Rooms.Invite)),
|
||||||
LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)),
|
LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)),
|
||||||
}})
|
}})
|
||||||
err := c.preProcessSyncResponse(ctx, resp, since)
|
err := c.preProcessSyncResponse(ctx, resp, since)
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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") {
|
||||||
|
|
|
@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
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,
|
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 = ""
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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,16 +95,43 @@ 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)
|
||||||
|
@ -112,15 +140,28 @@ class ContextFields implements MainScreenContextFields {
|
||||||
document
|
document
|
||||||
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
|
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
|
||||||
?.scrollIntoView({ block: "nearest" })
|
?.scrollIntoView({ block: "nearest" })
|
||||||
}
|
|
||||||
if (pushState) {
|
if (pushState) {
|
||||||
history.pushState({ room_id: 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
|
||||||
|
? renderedRoom instanceof RoomStateStore
|
||||||
? <RoomView
|
? <RoomView
|
||||||
key={renderedRoom.roomID}
|
key={renderedRoom.roomID}
|
||||||
room={renderedRoom}
|
room={renderedRoom}
|
||||||
rightPanel={rightPanel}
|
rightPanel={rightPanel}
|
||||||
rightPanelResizeHandle={resizeHandle2}
|
rightPanelResizeHandle={resizeHandle2}
|
||||||
/>
|
/>
|
||||||
|
: <RoomPreview {...renderedRoom} />
|
||||||
: rightPanel && <>
|
: rightPanel && <>
|
||||||
<div className="room-view placeholder"/>
|
<div className="room-view placeholder"/>
|
||||||
{resizeHandle2}
|
{resizeHandle2}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
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
|
"typing" auto
|
||||||
/ 1fr;
|
/ 1fr;
|
||||||
contain: strict;
|
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 { 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 {
|
||||||
|
|
|
@ -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}`),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue