mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/widgets: add initial support
This commit is contained in:
parent
d234981604
commit
508355f2bf
31 changed files with 897 additions and 41 deletions
|
@ -8,7 +8,7 @@ require github.com/wailsapp/wails/v3 v3.0.0-alpha.9
|
||||||
|
|
||||||
require (
|
require (
|
||||||
go.mau.fi/gomuks v0.4.0
|
go.mau.fi/gomuks v0.4.0
|
||||||
go.mau.fi/util v0.8.5
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
@ -79,7 +79,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.23.2-0.20250226205639-b72caa948c18 // indirect
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7 // indirect
|
||||||
mvdan.cc/xurls/v2 v2.6.0 // indirect
|
mvdan.cc/xurls/v2 v2.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -166,8 +166,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
go.mau.fi/util v0.8.5 h1:PwCAAtcfK0XxZ4sdErJyfBMkTEWoQU33aB7QqDDzQRI=
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95 h1:5EfVWWjU2Hte9uE6B/hBgvjnVfBx/7SYDZBnsuo+EBs=
|
||||||
go.mau.fi/util v0.8.5/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
|
||||||
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
||||||
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
|
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
|
||||||
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
||||||
|
@ -261,7 +261,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
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.23.2-0.20250226205639-b72caa948c18 h1:1JVivuS1whIdai/Yurqe1OXiHAarCh0UgR/zh61coiQ=
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7 h1:AeNHqITptzOpmfMxnqmQRw6xN7DUDCgsN00BaPyRd4k=
|
||||||
maunium.net/go/mautrix v0.23.2-0.20250226205639-b72caa948c18/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY=
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7/go.mod h1:IHMaSJh7YIxMrZSDVefS+nLdr3RbeLowsCSa6ibONZ0=
|
||||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||||
|
|
4
go.mod
4
go.mod
|
@ -18,7 +18,7 @@ require (
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
github.com/tidwall/sjson v1.2.5
|
github.com/tidwall/sjson v1.2.5
|
||||||
github.com/yuin/goldmark v1.7.8
|
github.com/yuin/goldmark v1.7.8
|
||||||
go.mau.fi/util v0.8.5
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95
|
||||||
go.mau.fi/webp v0.2.0
|
go.mau.fi/webp v0.2.0
|
||||||
go.mau.fi/zeroconfig v0.1.3
|
go.mau.fi/zeroconfig v0.1.3
|
||||||
golang.org/x/crypto v0.34.0
|
golang.org/x/crypto v0.34.0
|
||||||
|
@ -27,7 +27,7 @@ require (
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/text v0.22.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.23.2-0.20250226205639-b72caa948c18
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7
|
||||||
mvdan.cc/xurls/v2 v2.6.0
|
mvdan.cc/xurls/v2 v2.6.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
8
go.sum
8
go.sum
|
@ -68,8 +68,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
go.mau.fi/util v0.8.5 h1:PwCAAtcfK0XxZ4sdErJyfBMkTEWoQU33aB7QqDDzQRI=
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95 h1:5EfVWWjU2Hte9uE6B/hBgvjnVfBx/7SYDZBnsuo+EBs=
|
||||||
go.mau.fi/util v0.8.5/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
|
||||||
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
||||||
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
|
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
|
||||||
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
||||||
|
@ -100,7 +100,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.23.2-0.20250226205639-b72caa948c18 h1:1JVivuS1whIdai/Yurqe1OXiHAarCh0UgR/zh61coiQ=
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7 h1:AeNHqITptzOpmfMxnqmQRw6xN7DUDCgsN00BaPyRd4k=
|
||||||
maunium.net/go/mautrix v0.23.2-0.20250226205639-b72caa948c18/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY=
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7/go.mod h1:IHMaSJh7YIxMrZSDVefS+nLdr3RbeLowsCSa6ibONZ0=
|
||||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||||
|
|
|
@ -7,6 +7,8 @@
|
||||||
package hicli
|
package hicli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"go.mau.fi/util/jsontime"
|
"go.mau.fi/util/jsontime"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
@ -35,6 +37,13 @@ type SyncNotification struct {
|
||||||
Room *database.Room `json:"-"`
|
Room *database.Room `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SyncToDevice struct {
|
||||||
|
Sender id.UserID `json:"sender"`
|
||||||
|
Type event.Type `json:"type"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
Encrypted bool `json:"encrypted"`
|
||||||
|
}
|
||||||
|
|
||||||
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"`
|
||||||
|
@ -44,6 +53,8 @@ type SyncComplete struct {
|
||||||
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
|
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
|
||||||
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
|
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
|
||||||
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
|
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
|
||||||
|
|
||||||
|
ToDevice []*SyncToDevice `json:"to_device,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) {
|
func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) {
|
||||||
|
|
|
@ -50,6 +50,8 @@ type HiClient struct {
|
||||||
syncErrors int
|
syncErrors int
|
||||||
lastSync time.Time
|
lastSync time.Time
|
||||||
|
|
||||||
|
ToDeviceInSync atomic.Bool
|
||||||
|
|
||||||
EventHandler func(evt any)
|
EventHandler func(evt any)
|
||||||
LogoutFunc func(context.Context) error
|
LogoutFunc func(context.Context) error
|
||||||
|
|
||||||
|
|
|
@ -186,6 +186,11 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
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)
|
||||||
})
|
})
|
||||||
|
case "send_to_device":
|
||||||
|
return unmarshalAndCall(req.Data, func(params *sendToDeviceParams) (*mautrix.RespSendToDevice, error) {
|
||||||
|
params.EventType.Class = event.ToDeviceEventType
|
||||||
|
return h.SendToDevice(ctx, params.EventType, params.ReqSendToDevice, params.Encrypted)
|
||||||
|
})
|
||||||
case "resolve_alias":
|
case "resolve_alias":
|
||||||
return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
|
return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
|
||||||
return h.Client.ResolveAlias(ctx, params.Alias)
|
return h.Client.ResolveAlias(ctx, params.Alias)
|
||||||
|
@ -238,6 +243,14 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
|
return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
|
||||||
return true, h.DB.PushRegistration.Put(ctx, params)
|
return true, h.DB.PushRegistration.Put(ctx, params)
|
||||||
})
|
})
|
||||||
|
case "listen_to_device":
|
||||||
|
return unmarshalAndCall(req.Data, func(listen *bool) (bool, error) {
|
||||||
|
return h.ToDeviceInSync.Swap(*listen), nil
|
||||||
|
})
|
||||||
|
case "get_turn_servers":
|
||||||
|
return h.Client.TurnServer(ctx)
|
||||||
|
case "get_media_config":
|
||||||
|
return h.Client.GetMediaConfig(ctx)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown command %q", req.Command)
|
return nil, fmt.Errorf("unknown command %q", req.Command)
|
||||||
}
|
}
|
||||||
|
@ -357,6 +370,12 @@ type ensureGroupSessionSharedParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sendToDeviceParams struct {
|
||||||
|
*mautrix.ReqSendToDevice
|
||||||
|
EventType event.Type `json:"event_type"`
|
||||||
|
Encrypted bool `json:"encrypted"`
|
||||||
|
}
|
||||||
|
|
||||||
type resolveAliasParams struct {
|
type resolveAliasParams struct {
|
||||||
Alias id.RoomAlias `json:"alias"`
|
Alias id.RoomAlias `json:"alias"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -247,6 +247,10 @@ func (h *HiClient) Send(
|
||||||
content any,
|
content any,
|
||||||
disableEncryption bool,
|
disableEncryption bool,
|
||||||
) (*database.Event, error) {
|
) (*database.Event, error) {
|
||||||
|
if evtType == event.EventRedaction {
|
||||||
|
// TODO implement
|
||||||
|
return nil, fmt.Errorf("redaction is not supported")
|
||||||
|
}
|
||||||
return h.send(ctx, roomID, evtType, content, "", disableEncryption)
|
return h.send(ctx, roomID, evtType, content, "", disableEncryption)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -425,6 +429,18 @@ func (h *HiClient) EnsureGroupSessionShared(ctx context.Context, roomID id.RoomI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HiClient) SendToDevice(ctx context.Context, evtType event.Type, content *mautrix.ReqSendToDevice, encrypt bool) (*mautrix.RespSendToDevice, error) {
|
||||||
|
if encrypt {
|
||||||
|
var err error
|
||||||
|
content, err = h.Crypto.EncryptToDevices(ctx, evtType, content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt: %w", err)
|
||||||
|
}
|
||||||
|
evtType = event.ToDeviceEncrypted
|
||||||
|
}
|
||||||
|
return h.Client.SendToDevice(ctx, evtType, content)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HiClient) loadMembers(ctx context.Context, room *database.Room) error {
|
func (h *HiClient) loadMembers(ctx context.Context, room *database.Room) error {
|
||||||
if room.HasMemberList {
|
if room.HasMemberList {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -66,6 +66,9 @@ func (h *HiClient) markSyncOK() {
|
||||||
|
|
||||||
func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
|
func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
|
||||||
log := zerolog.Ctx(ctx)
|
log := zerolog.Ctx(ctx)
|
||||||
|
listenToDevice := h.ToDeviceInSync.Load()
|
||||||
|
var syncTD []*SyncToDevice
|
||||||
|
|
||||||
postponedToDevices := resp.ToDevice.Events[:0]
|
postponedToDevices := resp.ToDevice.Events[:0]
|
||||||
for _, evt := range resp.ToDevice.Events {
|
for _, evt := range resp.ToDevice.Events {
|
||||||
evt.Type.Class = event.ToDeviceEventType
|
evt.Type.Class = event.ToDeviceEventType
|
||||||
|
@ -80,7 +83,15 @@ func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.Res
|
||||||
|
|
||||||
switch content := evt.Content.Parsed.(type) {
|
switch content := evt.Content.Parsed.(type) {
|
||||||
case *event.EncryptedEventContent:
|
case *event.EncryptedEventContent:
|
||||||
h.Crypto.HandleEncryptedEvent(ctx, evt)
|
unhandledDecrypted := h.Crypto.HandleEncryptedEvent(ctx, evt)
|
||||||
|
if unhandledDecrypted != nil && listenToDevice {
|
||||||
|
syncTD = append(syncTD, &SyncToDevice{
|
||||||
|
Sender: evt.Sender,
|
||||||
|
Type: unhandledDecrypted.Type,
|
||||||
|
Content: unhandledDecrypted.Content.VeryRaw,
|
||||||
|
Encrypted: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
case *event.RoomKeyWithheldEventContent:
|
case *event.RoomKeyWithheldEventContent:
|
||||||
// TODO move this check to mautrix-go?
|
// TODO move this check to mautrix-go?
|
||||||
if evt.Sender == h.Account.UserID && content.Code == event.RoomKeyWithheldUnavailable {
|
if evt.Sender == h.Account.UserID && content.Code == event.RoomKeyWithheldUnavailable {
|
||||||
|
@ -88,11 +99,22 @@ func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.Res
|
||||||
} else {
|
} else {
|
||||||
h.Crypto.HandleRoomKeyWithheld(ctx, content)
|
h.Crypto.HandleRoomKeyWithheld(ctx, content)
|
||||||
}
|
}
|
||||||
default:
|
case *event.SecretRequestEventContent, *event.RoomKeyRequestEventContent:
|
||||||
postponedToDevices = append(postponedToDevices, evt)
|
postponedToDevices = append(postponedToDevices, evt)
|
||||||
|
default:
|
||||||
|
if listenToDevice {
|
||||||
|
syncTD = append(syncTD, &SyncToDevice{
|
||||||
|
Sender: evt.Sender,
|
||||||
|
Type: evt.Type,
|
||||||
|
Content: evt.Content.VeryRaw,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp.ToDevice.Events = postponedToDevices
|
resp.ToDevice.Events = postponedToDevices
|
||||||
|
if len(syncTD) > 0 {
|
||||||
|
ctx.Value(syncContextKey).(*syncContext).evt.ToDevice = syncTD
|
||||||
|
}
|
||||||
h.Crypto.MarkOlmHashSavePoint(ctx)
|
h.Crypto.MarkOlmHashSavePoint(ctx)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
28
web/package-lock.json
generated
28
web/package-lock.json
generated
|
@ -11,8 +11,10 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wailsio/runtime": "^3.0.0-alpha.29",
|
"@wailsio/runtime": "^3.0.0-alpha.29",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
"katex": "^0.16.11",
|
"katex": "^0.16.11",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"matrix-widget-api": "^1.13.1",
|
||||||
"monaco-editor": "^0.52.0",
|
"monaco-editor": "^0.52.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-blurhash": "^0.3.0",
|
"react-blurhash": "^0.3.0",
|
||||||
|
@ -1796,6 +1798,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/events": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/geojson": {
|
"node_modules/@types/geojson": {
|
||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
@ -3314,11 +3322,19 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
|
@ -4386,6 +4402,16 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/matrix-widget-api": {
|
||||||
|
"version": "1.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
|
||||||
|
"integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/events": "^3.0.0",
|
||||||
|
"events": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
|
|
|
@ -13,8 +13,10 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wailsio/runtime": "^3.0.0-alpha.29",
|
"@wailsio/runtime": "^3.0.0-alpha.29",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
"katex": "^0.16.11",
|
"katex": "^0.16.11",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"matrix-widget-api": "^1.13.1",
|
||||||
"monaco-editor": "^0.52.0",
|
"monaco-editor": "^0.52.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-blurhash": "^0.3.0",
|
"react-blurhash": "^0.3.0",
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
import type { MouseEvent } from "react"
|
import type { MouseEvent } from "react"
|
||||||
import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
|
import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
|
||||||
import RPCClient, { SendMessageParams } from "./rpc.ts"
|
import RPCClient, { SendMessageParams } from "./rpc.ts"
|
||||||
import { RoomStateStore, StateStore } from "./statestore"
|
import { RoomStateStore, StateStore, WidgetListener } from "./statestore"
|
||||||
import type {
|
import type {
|
||||||
ClientState,
|
ClientState,
|
||||||
ElementRecentEmoji,
|
ElementRecentEmoji,
|
||||||
|
@ -41,6 +41,7 @@ export default class Client {
|
||||||
#stateRequests: RoomStateGUID[] = []
|
#stateRequests: RoomStateGUID[] = []
|
||||||
#stateRequestPromise: Promise<void> | null = null
|
#stateRequestPromise: Promise<void> | null = null
|
||||||
#gcInterval: number | undefined
|
#gcInterval: number | undefined
|
||||||
|
#toDeviceRequested = false
|
||||||
|
|
||||||
constructor(readonly rpc: RPCClient) {
|
constructor(readonly rpc: RPCClient) {
|
||||||
this.rpc.event.listen(this.#handleEvent)
|
this.rpc.event.listen(this.#handleEvent)
|
||||||
|
@ -154,6 +155,22 @@ export default class Client {
|
||||||
navigator.registerProtocolHandler("matrix", "#/uri/%s")
|
navigator.registerProtocolHandler("matrix", "#/uri/%s")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addWidgetListener(listener: WidgetListener): () => void {
|
||||||
|
this.store.widgetListeners.add(listener)
|
||||||
|
// TODO only request to-device events if there are widgets that need them?
|
||||||
|
if (!this.#toDeviceRequested) {
|
||||||
|
this.#toDeviceRequested = true
|
||||||
|
this.rpc.setListenToDevice(true)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
this.store.widgetListeners.delete(listener)
|
||||||
|
if (this.store.widgetListeners.size === 0 && this.#toDeviceRequested) {
|
||||||
|
this.#toDeviceRequested = false
|
||||||
|
this.rpc.setListenToDevice(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start(): () => void {
|
start(): () => void {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
if (window.gomuksAndroid) {
|
if (window.gomuksAndroid) {
|
||||||
|
|
|
@ -37,8 +37,10 @@ import type {
|
||||||
ReqCreateRoom,
|
ReqCreateRoom,
|
||||||
ResolveAliasResponse,
|
ResolveAliasResponse,
|
||||||
RespCreateRoom,
|
RespCreateRoom,
|
||||||
|
RespMediaConfig,
|
||||||
RespOpenIDToken,
|
RespOpenIDToken,
|
||||||
RespRoomJoin,
|
RespRoomJoin,
|
||||||
|
RespTurnServer,
|
||||||
RoomAlias,
|
RoomAlias,
|
||||||
RoomID,
|
RoomID,
|
||||||
RoomStateGUID,
|
RoomStateGUID,
|
||||||
|
@ -212,6 +214,14 @@ export default abstract class RPCClient {
|
||||||
return this.request("ensure_group_session_shared", { room_id })
|
return this.request("ensure_group_session_shared", { room_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendToDevice(
|
||||||
|
event_type: EventType,
|
||||||
|
messages: { [userId: string]: { [deviceId: string]: object } },
|
||||||
|
encrypted: boolean = false,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.request("send_to_device", { event_type, messages, encrypted })
|
||||||
|
}
|
||||||
|
|
||||||
getSpecificRoomState(keys: RoomStateGUID[]): Promise<RawDBEvent[]> {
|
getSpecificRoomState(keys: RoomStateGUID[]): Promise<RawDBEvent[]> {
|
||||||
return this.request("get_specific_room_state", { keys })
|
return this.request("get_specific_room_state", { keys })
|
||||||
}
|
}
|
||||||
|
@ -289,4 +299,16 @@ export default abstract class RPCClient {
|
||||||
registerPush(reg: DBPushRegistration): Promise<boolean> {
|
registerPush(reg: DBPushRegistration): Promise<boolean> {
|
||||||
return this.request("register_push", reg)
|
return this.request("register_push", reg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTurnServers(): Promise<RespTurnServer> {
|
||||||
|
return this.request("get_turn_servers", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
getMediaConfig(): Promise<RespMediaConfig> {
|
||||||
|
return this.request("get_media_config", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
setListenToDevice(listen: boolean): Promise<void> {
|
||||||
|
return this.request("listen_to_device", listen)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,7 @@ import {
|
||||||
SendCompleteData,
|
SendCompleteData,
|
||||||
SyncCompleteData,
|
SyncCompleteData,
|
||||||
SyncRoom,
|
SyncRoom,
|
||||||
|
SyncToDevice,
|
||||||
TypingEventData,
|
TypingEventData,
|
||||||
UnknownEventContent,
|
UnknownEventContent,
|
||||||
UserID,
|
UserID,
|
||||||
|
@ -61,6 +62,13 @@ export interface GCSettings {
|
||||||
lastOpenedCutoff: number,
|
lastOpenedCutoff: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WidgetListener {
|
||||||
|
onTimelineEvent(evt: MemDBEvent): void
|
||||||
|
onStateEvent(evt: MemDBEvent): void
|
||||||
|
onToDeviceEvent(evt: SyncToDevice): void
|
||||||
|
onRoomChange(roomID: RoomID | null): void
|
||||||
|
}
|
||||||
|
|
||||||
window.gcSettings ??= {
|
window.gcSettings ??= {
|
||||||
// Run garbage collection every 15 minutes.
|
// Run garbage collection every 15 minutes.
|
||||||
interval: 15 * 60 * 1000,
|
interval: 15 * 60 * 1000,
|
||||||
|
@ -98,9 +106,19 @@ export class StateStore {
|
||||||
readonly localPreferenceCache: Preferences = getLocalStoragePreferences("global_prefs", this.preferenceSub.notify)
|
readonly localPreferenceCache: Preferences = getLocalStoragePreferences("global_prefs", this.preferenceSub.notify)
|
||||||
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
|
activeRoomIsPreview: boolean = false
|
||||||
imageAuthToken?: string
|
imageAuthToken?: string
|
||||||
|
readonly widgetListeners: Set<WidgetListener> = new Set()
|
||||||
|
|
||||||
|
get activeRoomID(): RoomID | null {
|
||||||
|
return this.#activeRoomID
|
||||||
|
}
|
||||||
|
|
||||||
|
set activeRoomID(roomID: RoomID | null) {
|
||||||
|
this.#activeRoomID = roomID
|
||||||
|
this.widgetListeners.forEach(listener => listener.onRoomChange(roomID))
|
||||||
|
}
|
||||||
|
|
||||||
#roomListFilterFunc = (entry: RoomListEntry) => {
|
#roomListFilterFunc = (entry: RoomListEntry) => {
|
||||||
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
|
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
|
||||||
|
@ -243,6 +261,11 @@ 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>()
|
||||||
|
if (sync.to_device?.length && this.widgetListeners.size > 0) {
|
||||||
|
for (const listener of this.widgetListeners) {
|
||||||
|
sync.to_device.forEach(listener.onToDeviceEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const data of sync.invited_rooms ?? []) {
|
for (const data of sync.invited_rooms ?? []) {
|
||||||
const room = new InvitedRoomStore(data, this)
|
const room = new InvitedRoomStore(data, this)
|
||||||
this.inviteRooms.set(room.room_id, room)
|
this.inviteRooms.set(room.room_id, room)
|
||||||
|
|
|
@ -457,6 +457,8 @@ export class RoomStateStore {
|
||||||
for (const evt of sync.events ?? []) {
|
for (const evt of sync.events ?? []) {
|
||||||
this.applyEvent(evt)
|
this.applyEvent(evt)
|
||||||
}
|
}
|
||||||
|
const hasWidgets = this.parent.widgetListeners.size > 0
|
||||||
|
const newState: MemDBEvent[] = []
|
||||||
for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
|
for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
|
||||||
let stateMap = this.state.get(evtType)
|
let stateMap = this.state.get(evtType)
|
||||||
if (!stateMap) {
|
if (!stateMap) {
|
||||||
|
@ -466,6 +468,12 @@ export class RoomStateStore {
|
||||||
for (const [key, rowID] of Object.entries(changedEvts)) {
|
for (const [key, rowID] of Object.entries(changedEvts)) {
|
||||||
stateMap.set(key, rowID)
|
stateMap.set(key, rowID)
|
||||||
this.invalidateStateCaches(evtType, key)
|
this.invalidateStateCaches(evtType, key)
|
||||||
|
if (hasWidgets) {
|
||||||
|
const evt = this.eventsByRowID.get(rowID)
|
||||||
|
if (evt) {
|
||||||
|
newState.push(evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.stateSubs.notify(evtType)
|
this.stateSubs.notify(evtType)
|
||||||
}
|
}
|
||||||
|
@ -485,6 +493,13 @@ export class RoomStateStore {
|
||||||
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
|
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
|
||||||
this.applyReceipts(receipts, evtID, false)
|
this.applyReceipts(receipts, evtID, false)
|
||||||
}
|
}
|
||||||
|
if (hasWidgets && ((sync.timeline && sync.timeline.length > 0) || newState.length > 0)) {
|
||||||
|
const evts = sync.timeline?.map(evt => this.eventsByRowID.get(evt.event_rowid)).filter(evt => !!evt)
|
||||||
|
this.parent.widgetListeners.forEach(listener => {
|
||||||
|
evts?.forEach(listener.onTimelineEvent)
|
||||||
|
newState.forEach(listener.onStateEvent)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyState(evt: RawDBEvent) {
|
applyState(evt: RawDBEvent) {
|
||||||
|
|
|
@ -86,6 +86,13 @@ export interface SyncNotification {
|
||||||
sound: boolean
|
sound: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncToDevice {
|
||||||
|
sender: UserID
|
||||||
|
type: EventType
|
||||||
|
content: Record<string, unknown>
|
||||||
|
encrypted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface SyncCompleteData {
|
export interface SyncCompleteData {
|
||||||
rooms: Record<RoomID, SyncRoom> | null
|
rooms: Record<RoomID, SyncRoom> | null
|
||||||
invited_rooms: DBInvitedRoom[] | null
|
invited_rooms: DBInvitedRoom[] | null
|
||||||
|
@ -95,6 +102,7 @@ export interface SyncCompleteData {
|
||||||
top_level_spaces: RoomID[] | null
|
top_level_spaces: RoomID[] | null
|
||||||
since?: string
|
since?: string
|
||||||
clear_state?: boolean
|
clear_state?: boolean
|
||||||
|
to_device?: SyncToDevice[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncCompleteEvent extends BaseRPCCommand<SyncCompleteData> {
|
export interface SyncCompleteEvent extends BaseRPCCommand<SyncCompleteData> {
|
||||||
|
|
|
@ -346,3 +346,15 @@ export interface ReqCreateRoom {
|
||||||
export interface RespCreateRoom {
|
export interface RespCreateRoom {
|
||||||
room_id: RoomID
|
room_id: RoomID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RespTurnServer {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
ttl: number
|
||||||
|
uris: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RespMediaConfig {
|
||||||
|
"m.upload.size": number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
|
@ -139,6 +139,12 @@ export const preferences = {
|
||||||
allowedContexts: anyContext,
|
allowedContexts: anyContext,
|
||||||
defaultValue: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
defaultValue: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
}),
|
}),
|
||||||
|
element_call_base_url: new Preference<string>({
|
||||||
|
displayName: "Element call base URL",
|
||||||
|
description: "The widget base URL for Element calls.",
|
||||||
|
allowedContexts: anyContext,
|
||||||
|
defaultValue: "https://call.element.io",
|
||||||
|
}),
|
||||||
gif_provider: new Preference<GIFProvider>({
|
gif_provider: new Preference<GIFProvider>({
|
||||||
displayName: "GIF provider",
|
displayName: "GIF provider",
|
||||||
description: "The service to use to search for GIFs",
|
description: "The service to use to search for GIFs",
|
||||||
|
|
1
web/src/icons/widgets.svg
Normal file
1
web/src/icons/widgets.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M666-440 440-666l226-226 226 226-226 226Zm-546-80v-320h320v320H120Zm400 400v-320h320v320H520Zm-400 0v-320h320v320H120Zm80-480h160v-160H200v160Zm467 48 113-113-113-113-113 113 113 113Zm-67 352h160v-160H600v160Zm-400 0h160v-160H200v160Zm160-400Zm194-65ZM360-360Zm240 0Z"/></svg>
|
After Width: | Height: | Size: 393 B |
|
@ -13,6 +13,7 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import equal from "fast-deep-equal"
|
||||||
import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react"
|
import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react"
|
||||||
import { SyncLoader } from "react-spinners"
|
import { SyncLoader } from "react-spinners"
|
||||||
import Client from "@/api/client.ts"
|
import Client from "@/api/client.ts"
|
||||||
|
@ -32,19 +33,6 @@ import RoomView from "./roomview/RoomView.tsx"
|
||||||
import { useResizeHandle } from "./util/useResizeHandle.tsx"
|
import { useResizeHandle } from "./util/useResizeHandle.tsx"
|
||||||
import "./MainScreen.css"
|
import "./MainScreen.css"
|
||||||
|
|
||||||
function objectIsEqual(a: RightPanelProps | null, b: RightPanelProps | null): boolean {
|
|
||||||
if (a === null || b === null) {
|
|
||||||
return a === null && b === null
|
|
||||||
}
|
|
||||||
for (const key of Object.keys(a)) {
|
|
||||||
// @ts-expect-error 3:<
|
|
||||||
if (a[key] !== b[key]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
class ContextFields implements MainScreenContextFields {
|
class ContextFields implements MainScreenContextFields {
|
||||||
public keybindings: Keybindings
|
public keybindings: Keybindings
|
||||||
private rightPanelStack: RightPanelProps[] = []
|
private rightPanelStack: RightPanelProps[] = []
|
||||||
|
@ -64,10 +52,10 @@ class ContextFields implements MainScreenContextFields {
|
||||||
}
|
}
|
||||||
|
|
||||||
setRightPanel = (props: RightPanelProps | null, pushState = true) => {
|
setRightPanel = (props: RightPanelProps | null, pushState = true) => {
|
||||||
if ((props?.type === "members" || props?.type === "pinned-messages") && !this.client.store.activeRoomID) {
|
if ((props?.type !== "user") && !this.client.store.activeRoomID) {
|
||||||
props = null
|
props = null
|
||||||
}
|
}
|
||||||
const isEqual = objectIsEqual(this.currentRightPanel, props)
|
const isEqual = equal(this.currentRightPanel, props)
|
||||||
if (isEqual && !pushState) {
|
if (isEqual && !pushState) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -81,7 +69,7 @@ class ContextFields implements MainScreenContextFields {
|
||||||
} else {
|
} else {
|
||||||
this.directSetRightPanel(props)
|
this.directSetRightPanel(props)
|
||||||
for (let i = this.rightPanelStack.length - 1; i >= 0; i--) {
|
for (let i = this.rightPanelStack.length - 1; i >= 0; i--) {
|
||||||
if (objectIsEqual(this.rightPanelStack[i], props)) {
|
if (equal(this.rightPanelStack[i], props)) {
|
||||||
this.rightPanelStack = this.rightPanelStack.slice(0, i + 1)
|
this.rightPanelStack = this.rightPanelStack.slice(0, i + 1)
|
||||||
if (pushState) {
|
if (pushState) {
|
||||||
history.go(i - this.rightPanelStack.length)
|
history.go(i - this.rightPanelStack.length)
|
||||||
|
@ -219,7 +207,7 @@ class ContextFields implements MainScreenContextFields {
|
||||||
clickRightPanelOpener = (evt: React.MouseEvent) => {
|
clickRightPanelOpener = (evt: React.MouseEvent) => {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
const type = evt.currentTarget.getAttribute("data-target-panel")
|
const type = evt.currentTarget.getAttribute("data-target-panel")
|
||||||
if (type === "pinned-messages" || type === "members") {
|
if (type === "pinned-messages" || type === "members" || type === "widgets") {
|
||||||
this.setRightPanel({ type })
|
this.setRightPanel({ type })
|
||||||
} else if (type === "user") {
|
} else if (type === "user") {
|
||||||
this.setRightPanel({ type, userID: evt.currentTarget.getAttribute("data-target-user")! })
|
this.setRightPanel({ type, userID: evt.currentTarget.getAttribute("data-target-user")! })
|
||||||
|
|
|
@ -51,6 +51,22 @@ div.right-panel-content.pinned-messages {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.right-panel-content.widgets {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .5rem;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
padding: .5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div.separator {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
div.right-panel-content.user {
|
div.right-panel-content.user {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
@ -13,21 +13,30 @@
|
||||||
//
|
//
|
||||||
// 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 { IWidget } from "matrix-widget-api"
|
||||||
import { JSX, use } from "react"
|
import { JSX, use } from "react"
|
||||||
import type { UserID } from "@/api/types"
|
import type { UserID } from "@/api/types"
|
||||||
import MainScreenContext from "../MainScreenContext.ts"
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
import ErrorBoundary from "../util/ErrorBoundary.tsx"
|
import ErrorBoundary from "../util/ErrorBoundary.tsx"
|
||||||
|
import ElementCall from "../widget/ElementCall.tsx"
|
||||||
|
import LazyWidget from "../widget/LazyWidget.tsx"
|
||||||
import MemberList from "./MemberList.tsx"
|
import MemberList from "./MemberList.tsx"
|
||||||
import PinnedMessages from "./PinnedMessages.tsx"
|
import PinnedMessages from "./PinnedMessages.tsx"
|
||||||
import UserInfo from "./UserInfo.tsx"
|
import UserInfo from "./UserInfo.tsx"
|
||||||
|
import WidgetList from "./WidgetList.tsx"
|
||||||
import BackIcon from "@/icons/back.svg?react"
|
import BackIcon from "@/icons/back.svg?react"
|
||||||
import CloseIcon from "@/icons/close.svg?react"
|
import CloseIcon from "@/icons/close.svg?react"
|
||||||
import "./RightPanel.css"
|
import "./RightPanel.css"
|
||||||
|
|
||||||
export type RightPanelType = "pinned-messages" | "members" | "user"
|
export type RightPanelType = "pinned-messages" | "members" | "widgets" | "widget" | "user" | "element-call"
|
||||||
|
|
||||||
interface RightPanelSimpleProps {
|
interface RightPanelSimpleProps {
|
||||||
type: "pinned-messages" | "members"
|
type: "pinned-messages" | "members" | "widgets" | "element-call"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RightPanelWidgetProps {
|
||||||
|
type: "widget"
|
||||||
|
info: IWidget
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RightPanelUserProps {
|
interface RightPanelUserProps {
|
||||||
|
@ -35,14 +44,20 @@ interface RightPanelUserProps {
|
||||||
userID: UserID
|
userID: UserID
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RightPanelProps = RightPanelUserProps | RightPanelSimpleProps
|
export type RightPanelProps = RightPanelUserProps | RightPanelWidgetProps | RightPanelSimpleProps
|
||||||
|
|
||||||
function getTitle(type: RightPanelType): string {
|
function getTitle(props: RightPanelProps): string {
|
||||||
switch (type) {
|
switch (props.type) {
|
||||||
case "pinned-messages":
|
case "pinned-messages":
|
||||||
return "Pinned Messages"
|
return "Pinned Messages"
|
||||||
case "members":
|
case "members":
|
||||||
return "Room Members"
|
return "Room Members"
|
||||||
|
case "widgets":
|
||||||
|
return "Widgets in room"
|
||||||
|
case "widget":
|
||||||
|
return props.info.name || "Widget"
|
||||||
|
case "element-call":
|
||||||
|
return "Element Call"
|
||||||
case "user":
|
case "user":
|
||||||
return "User Info"
|
return "User Info"
|
||||||
}
|
}
|
||||||
|
@ -54,6 +69,12 @@ function renderRightPanelContent(props: RightPanelProps): JSX.Element | null {
|
||||||
return <PinnedMessages />
|
return <PinnedMessages />
|
||||||
case "members":
|
case "members":
|
||||||
return <MemberList />
|
return <MemberList />
|
||||||
|
case "widgets":
|
||||||
|
return <WidgetList />
|
||||||
|
case "element-call":
|
||||||
|
return <ElementCall />
|
||||||
|
case "widget":
|
||||||
|
return <LazyWidget info={props.info} />
|
||||||
case "user":
|
case "user":
|
||||||
return <UserInfo userID={props.userID} />
|
return <UserInfo userID={props.userID} />
|
||||||
}
|
}
|
||||||
|
@ -67,12 +88,17 @@ const RightPanel = (props: RightPanelProps) => {
|
||||||
data-target-panel="members"
|
data-target-panel="members"
|
||||||
onClick={mainScreen.clickRightPanelOpener}
|
onClick={mainScreen.clickRightPanelOpener}
|
||||||
><BackIcon/></button>
|
><BackIcon/></button>
|
||||||
|
} else if (props.type === "element-call" || props.type === "widget") {
|
||||||
|
backButton = <button
|
||||||
|
data-target-panel="widgets"
|
||||||
|
onClick={mainScreen.clickRightPanelOpener}
|
||||||
|
><BackIcon/></button>
|
||||||
}
|
}
|
||||||
return <div className="right-panel">
|
return <div className="right-panel">
|
||||||
<div className="right-panel-header">
|
<div className="right-panel-header">
|
||||||
<div className="left-side">
|
<div className="left-side">
|
||||||
{backButton}
|
{backButton}
|
||||||
<div className="panel-name">{getTitle(props.type)}</div>
|
<div className="panel-name">{getTitle(props)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={mainScreen.closeRightPanel}><CloseIcon/></button>
|
<button onClick={mainScreen.closeRightPanel}><CloseIcon/></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
55
web/src/ui/rightpanel/WidgetList.tsx
Normal file
55
web/src/ui/rightpanel/WidgetList.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 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 type { IWidget } from "matrix-widget-api"
|
||||||
|
import { use } from "react"
|
||||||
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
|
import { RoomContext } from "../roomview/roomcontext.ts"
|
||||||
|
|
||||||
|
const WidgetList = () => {
|
||||||
|
const roomCtx = use(RoomContext)
|
||||||
|
const mainScreen = use(MainScreenContext)
|
||||||
|
const widgets = roomCtx?.store.state.get("im.vector.modular.widgets") ?? new Map()
|
||||||
|
const widgetElements = []
|
||||||
|
for (const [stateKey, rowid] of widgets.entries()) {
|
||||||
|
const evt = roomCtx?.store.eventsByRowID.get(rowid)
|
||||||
|
if (!evt || !evt.content.url) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const onClick = () => mainScreen.setRightPanel({
|
||||||
|
type: "widget",
|
||||||
|
info: {
|
||||||
|
id: stateKey,
|
||||||
|
creatorUserId: evt.sender,
|
||||||
|
...evt.content,
|
||||||
|
} as IWidget,
|
||||||
|
})
|
||||||
|
widgetElements.push(<button key={rowid} onClick={onClick}>
|
||||||
|
{evt.content.name || stateKey}
|
||||||
|
</button>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openElementCall = () => {
|
||||||
|
mainScreen.setRightPanel({ type: "element-call" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{widgetElements}
|
||||||
|
<div className="separator" />
|
||||||
|
<button onClick={openElementCall}>Element Call</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WidgetList
|
|
@ -26,6 +26,7 @@ import CodeIcon from "@/icons/code.svg?react"
|
||||||
import PeopleIcon from "@/icons/group.svg?react"
|
import PeopleIcon from "@/icons/group.svg?react"
|
||||||
import PinIcon from "@/icons/pin.svg?react"
|
import PinIcon from "@/icons/pin.svg?react"
|
||||||
import SettingsIcon from "@/icons/settings.svg?react"
|
import SettingsIcon from "@/icons/settings.svg?react"
|
||||||
|
import WidgetIcon from "@/icons/widgets.svg?react"
|
||||||
import "./RoomViewHeader.css"
|
import "./RoomViewHeader.css"
|
||||||
|
|
||||||
interface RoomViewHeaderProps {
|
interface RoomViewHeaderProps {
|
||||||
|
@ -81,6 +82,11 @@ const RoomViewHeader = ({ room }: RoomViewHeaderProps) => {
|
||||||
onClick={mainScreen.clickRightPanelOpener}
|
onClick={mainScreen.clickRightPanelOpener}
|
||||||
title="Room Members"
|
title="Room Members"
|
||||||
><PeopleIcon/></button>
|
><PeopleIcon/></button>
|
||||||
|
<button
|
||||||
|
data-target-panel="widgets"
|
||||||
|
onClick={mainScreen.clickRightPanelOpener}
|
||||||
|
title="Widgets in room"
|
||||||
|
><WidgetIcon/></button>
|
||||||
<button title="Explore room state" onClick={openRoomStateExplorer}><CodeIcon/></button>
|
<button title="Explore room state" onClick={openRoomStateExplorer}><CodeIcon/></button>
|
||||||
<button title="Room Settings" onClick={openSettings}><SettingsIcon/></button>
|
<button title="Room Settings" onClick={openSettings}><SettingsIcon/></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
60
web/src/ui/widget/ElementCall.tsx
Normal file
60
web/src/ui/widget/ElementCall.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 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, useMemo } from "react"
|
||||||
|
import { usePreference } from "@/api/statestore"
|
||||||
|
import ClientContext from "../ClientContext"
|
||||||
|
import { RoomContext } from "../roomview/roomcontext"
|
||||||
|
import LazyWidget from "./LazyWidget"
|
||||||
|
|
||||||
|
const elementCallParams = new URLSearchParams({
|
||||||
|
roomId: "$matrix_room_id",
|
||||||
|
theme: "$org.matrix.msc2873.client_theme",
|
||||||
|
userId: "$matrix_user_id",
|
||||||
|
deviceId: "$org.matrix.msc3819.matrix_device_id",
|
||||||
|
widgetId: "$matrix_widget_id",
|
||||||
|
perParticipantE2EE: "$perParticipantE2EE",
|
||||||
|
baseUrl: "$homeserverBaseURL",
|
||||||
|
intent: "join_existing",
|
||||||
|
hideHeader: "true",
|
||||||
|
confineToRoom: "true",
|
||||||
|
}).toString().replaceAll("%24", "$")
|
||||||
|
|
||||||
|
const ElementCall = () => {
|
||||||
|
const room = use(RoomContext)?.store ?? null
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
const baseURL = usePreference(client.store, room, "element_call_base_url")
|
||||||
|
const widgetInfo = useMemo(() => ({
|
||||||
|
id: `fi.mau.gomuks.call.${crypto.randomUUID().replaceAll("-", "")}`,
|
||||||
|
creatorUserId: client.userID,
|
||||||
|
type: "m.call",
|
||||||
|
url: `${baseURL}/room?${elementCallParams}`,
|
||||||
|
waitForIframeLoad: false,
|
||||||
|
data: {
|
||||||
|
perParticipantE2EE: !!room?.meta.current.encryption_event,
|
||||||
|
// Note: this won't actually work because matrix-js-sdk drops the path prefix for media requests.
|
||||||
|
homeserverBaseURL: new URL(
|
||||||
|
`_gomuks/matrixcompat/${client.store.imageAuthToken}`,
|
||||||
|
window.location.href,
|
||||||
|
).toString(),
|
||||||
|
},
|
||||||
|
}), [room, client, baseURL])
|
||||||
|
if (!room || !client) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return <LazyWidget info={widgetInfo} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ElementCall
|
46
web/src/ui/widget/LazyWidget.tsx
Normal file
46
web/src/ui/widget/LazyWidget.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 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 type { IWidget } from "matrix-widget-api"
|
||||||
|
import { Suspense, lazy, use } from "react"
|
||||||
|
import { GridLoader } from "react-spinners"
|
||||||
|
import ClientContext from "../ClientContext"
|
||||||
|
import { RoomContext } from "../roomview/roomcontext"
|
||||||
|
|
||||||
|
const Widget = lazy(() => import("./widget"))
|
||||||
|
|
||||||
|
const widgetLoader = <div className="widget-container widget-loading">
|
||||||
|
<GridLoader color="var(--primary-color)" size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
export interface LazyWidgetProps {
|
||||||
|
info: IWidget
|
||||||
|
}
|
||||||
|
|
||||||
|
const LazyWidget = ({ info }: LazyWidgetProps) => {
|
||||||
|
const room = use(RoomContext)?.store
|
||||||
|
const client = use(ClientContext)
|
||||||
|
if (!room || !client) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Suspense fallback={widgetLoader}>
|
||||||
|
<Widget info={info} room={room} client={client} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LazyWidget
|
9
web/src/ui/widget/Widget.css
Normal file
9
web/src/ui/widget/Widget.css
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
div.right-panel-content.widget, div.right-panel-content.element-call {
|
||||||
|
overflow: hidden !important;
|
||||||
|
|
||||||
|
> iframe.widget-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
56
web/src/ui/widget/util.ts
Normal file
56
web/src/ui/widget/util.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 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 type { IRoomEvent } from "matrix-widget-api"
|
||||||
|
import type { RoomStateStore } from "@/api/statestore"
|
||||||
|
import type { MemDBEvent } from "@/api/types"
|
||||||
|
|
||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notNull<T>(value: T | null | undefined): value is T {
|
||||||
|
return value !== null && value !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memDBEventToIRoomEvent(evt: MemDBEvent): IRoomEvent {
|
||||||
|
return {
|
||||||
|
type: evt.type,
|
||||||
|
sender: evt.sender,
|
||||||
|
event_id: evt.event_id,
|
||||||
|
room_id: evt.room_id,
|
||||||
|
state_key: evt.state_key,
|
||||||
|
origin_server_ts: evt.timestamp,
|
||||||
|
content: evt.content,
|
||||||
|
unsigned: evt.unsigned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function * iterRoomTimeline(room: RoomStateStore, since: string | undefined) {
|
||||||
|
const tc = room.timelineCache
|
||||||
|
for (let i = tc.length - 1; i >= 0; i--) {
|
||||||
|
const evt = tc[i]!
|
||||||
|
if (evt.event_id === since) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield evt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterEvent(eventType: string, msgtype: string | undefined, stateKey: string | undefined) {
|
||||||
|
return (evt: MemDBEvent) => evt.type === eventType
|
||||||
|
&& (!msgtype || evt.content.msgtype === msgtype)
|
||||||
|
&& (!stateKey || evt.state_key === stateKey)
|
||||||
|
}
|
122
web/src/ui/widget/widget.tsx
Normal file
122
web/src/ui/widget/widget.tsx
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 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 { ClientWidgetApi, IWidget, Widget as WrappedWidget } from "matrix-widget-api"
|
||||||
|
import { memo } from "react"
|
||||||
|
import type Client from "@/api/client"
|
||||||
|
import type { RoomStateStore, WidgetListener } from "@/api/statestore"
|
||||||
|
import type { MemDBEvent, RoomID, SyncToDevice } from "@/api/types"
|
||||||
|
import { getDisplayname } from "@/util/validation"
|
||||||
|
import { memDBEventToIRoomEvent } from "./util"
|
||||||
|
import GomuksWidgetDriver from "./widgetDriver"
|
||||||
|
import "./Widget.css"
|
||||||
|
|
||||||
|
export interface WidgetProps {
|
||||||
|
info: IWidget
|
||||||
|
room: RoomStateStore
|
||||||
|
client: Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO remove this after widgets start using a parameter for it
|
||||||
|
const addLegacyParams = (url: string, widgetID: string) => {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
urlObj.searchParams.set("parentUrl", window.location.href)
|
||||||
|
urlObj.searchParams.set("widgetId", widgetID)
|
||||||
|
return urlObj.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
class WidgetListenerImpl implements WidgetListener {
|
||||||
|
constructor(private api: ClientWidgetApi) {}
|
||||||
|
|
||||||
|
onTimelineEvent = (evt: MemDBEvent) => {
|
||||||
|
this.api.feedEvent(memDBEventToIRoomEvent(evt))
|
||||||
|
.catch(err => console.error("Failed to feed event", memDBEventToIRoomEvent(evt), err))
|
||||||
|
}
|
||||||
|
|
||||||
|
onStateEvent = (evt: MemDBEvent) => {
|
||||||
|
this.api.feedStateUpdate(memDBEventToIRoomEvent(evt))
|
||||||
|
.catch(err => console.error("Failed to feed state update", memDBEventToIRoomEvent(evt), err))
|
||||||
|
}
|
||||||
|
|
||||||
|
onRoomChange = (roomID: RoomID | null) => {
|
||||||
|
this.api.setViewedRoomId(roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
onToDeviceEvent = (evt: SyncToDevice) => {
|
||||||
|
this.api.feedToDevice({
|
||||||
|
sender: evt.sender,
|
||||||
|
type: evt.type,
|
||||||
|
content: evt.content,
|
||||||
|
// Why does this use the IRoomEvent interface??
|
||||||
|
event_id: "",
|
||||||
|
room_id: "",
|
||||||
|
origin_server_ts: 0,
|
||||||
|
unsigned: {},
|
||||||
|
}, evt.encrypted).catch(err => console.error("Failed to feed to-device event", evt, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReactWidget = ({ room, info, client }: WidgetProps) => {
|
||||||
|
const wrappedWidget = new WrappedWidget(info)
|
||||||
|
const driver = new GomuksWidgetDriver(client, room)
|
||||||
|
const widgetURL = addLegacyParams(wrappedWidget.getCompleteUrl({
|
||||||
|
widgetRoomId: room.roomID,
|
||||||
|
currentUserId: client.userID,
|
||||||
|
deviceId: client.state.current?.is_logged_in ? client.state.current.device_id : "",
|
||||||
|
userDisplayName: getDisplayname(client.userID, room.getStateEvent("m.room.member", client.userID)?.content),
|
||||||
|
clientId: "fi.mau.gomuks",
|
||||||
|
clientTheme: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
|
||||||
|
clientLanguage: navigator.language,
|
||||||
|
}), wrappedWidget.id)
|
||||||
|
|
||||||
|
const handleIframe = (iframe: HTMLIFrameElement) => {
|
||||||
|
console.info("Setting up widget API for", iframe)
|
||||||
|
const clientAPI = new ClientWidgetApi(wrappedWidget, iframe, driver)
|
||||||
|
clientAPI.setViewedRoomId(room.roomID)
|
||||||
|
|
||||||
|
clientAPI.on("ready", () => console.info("Widget is ready"))
|
||||||
|
// Suppress unnecessary events to avoid errors
|
||||||
|
const noopReply = (evt: CustomEvent) => {
|
||||||
|
evt.preventDefault()
|
||||||
|
clientAPI.transport.reply(evt.detail, {})
|
||||||
|
}
|
||||||
|
clientAPI.on("action:io.element.join", noopReply)
|
||||||
|
clientAPI.on("action:im.vector.hangup", noopReply)
|
||||||
|
clientAPI.on("action:io.element.device_mute", noopReply)
|
||||||
|
clientAPI.on("action:io.element.tile_layout", noopReply)
|
||||||
|
clientAPI.on("action:io.element.spotlight_layout", noopReply)
|
||||||
|
// TODO handle this one?
|
||||||
|
clientAPI.on("action:io.element.close", noopReply)
|
||||||
|
clientAPI.on("action:set_always_on_screen", noopReply)
|
||||||
|
const removeListener = client.addWidgetListener(new WidgetListenerImpl(clientAPI))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.info("Removing widget API")
|
||||||
|
removeListener()
|
||||||
|
clientAPI.stop()
|
||||||
|
clientAPI.removeAllListeners()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <iframe
|
||||||
|
key={crypto.randomUUID()}
|
||||||
|
ref={handleIframe}
|
||||||
|
src={widgetURL}
|
||||||
|
className="widget-iframe"
|
||||||
|
allow="microphone; camera; fullscreen; encrypted-media; display-capture; screen-wake-lock;"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ReactWidget)
|
270
web/src/ui/widget/widgetDriver.ts
Normal file
270
web/src/ui/widget/widgetDriver.ts
Normal file
|
@ -0,0 +1,270 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 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 {
|
||||||
|
IGetMediaConfigResult,
|
||||||
|
IOpenIDCredentials,
|
||||||
|
IOpenIDUpdate,
|
||||||
|
IRoomAccountData,
|
||||||
|
IRoomEvent,
|
||||||
|
ISendEventDetails,
|
||||||
|
ITurnServer,
|
||||||
|
OpenIDRequestState,
|
||||||
|
SimpleObservable,
|
||||||
|
Symbols,
|
||||||
|
WidgetDriver,
|
||||||
|
} from "matrix-widget-api"
|
||||||
|
import Client from "@/api/client.ts"
|
||||||
|
import { RoomStateStore } from "@/api/statestore"
|
||||||
|
import { EventRowID, RoomID } from "@/api/types"
|
||||||
|
import { filterEvent, isRecord, iterRoomTimeline, memDBEventToIRoomEvent, notNull } from "./util"
|
||||||
|
|
||||||
|
class GomuksWidgetDriver extends WidgetDriver {
|
||||||
|
private openIDToken: IOpenIDCredentials | null = null
|
||||||
|
private openIDExpiry: number | null = null
|
||||||
|
|
||||||
|
constructor(private client: Client, private room: RoomStateStore) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateCapabilities(requested: Set<string>): Promise<Set<string>> {
|
||||||
|
return new Set(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEvent(
|
||||||
|
eventType: string,
|
||||||
|
content: unknown,
|
||||||
|
stateKey: string | null = null,
|
||||||
|
roomID: string | null = null,
|
||||||
|
): Promise<ISendEventDetails> {
|
||||||
|
if (!isRecord(content)) {
|
||||||
|
throw new Error("Content must be an object")
|
||||||
|
}
|
||||||
|
roomID = roomID ?? this.room.roomID
|
||||||
|
if (stateKey) {
|
||||||
|
const eventID = await this.client.rpc.setState(roomID, eventType, stateKey, content)
|
||||||
|
return { eventId: eventID, roomId: roomID }
|
||||||
|
} else {
|
||||||
|
const rawDBEvt = await this.client.rpc.sendEvent(roomID, eventType, content)
|
||||||
|
return { eventId: rawDBEvt.event_id, roomId: rawDBEvt.room_id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// async sendDelayedEvent(
|
||||||
|
// delay: number | null,
|
||||||
|
// parentDelayID: string | null,
|
||||||
|
// eventType: string,
|
||||||
|
// content: unknown,
|
||||||
|
// stateKey: string | null = null,
|
||||||
|
// roomID: string | null = null,
|
||||||
|
// ): Promise<ISendDelayedEventDetails> {
|
||||||
|
// if (!isRecord(content)) {
|
||||||
|
// throw new Error("Content must be an object")
|
||||||
|
// }
|
||||||
|
// throw new Error("Delayed events are not supported")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async updateDelayedEvent(delayID: string, action: UpdateDelayedEventAction): Promise<void> {
|
||||||
|
// throw new Error("Delayed events are not supported")
|
||||||
|
// }
|
||||||
|
|
||||||
|
async sendToDevice(
|
||||||
|
eventType: string,
|
||||||
|
encrypted: boolean,
|
||||||
|
content: { [userId: string]: { [deviceId: string]: object } },
|
||||||
|
): Promise<void> {
|
||||||
|
await this.client.rpc.sendToDevice(eventType, content, encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private readRoomData<T>(
|
||||||
|
roomIDs: RoomID[] | null,
|
||||||
|
reader: (room: RoomStateStore) => T | null,
|
||||||
|
): T[] {
|
||||||
|
if (roomIDs === null || (roomIDs.length === 1 && roomIDs[0] === this.room.roomID)) {
|
||||||
|
const val = reader(this.room)
|
||||||
|
return val ? [val] : []
|
||||||
|
} else if (roomIDs.includes(Symbols.AnyRoom)) {
|
||||||
|
return Array.from(this.client.store.rooms.values().map(reader).filter(notNull))
|
||||||
|
} else {
|
||||||
|
return roomIDs.map(roomID => {
|
||||||
|
const room = this.client.store.rooms.get(roomID)
|
||||||
|
if (!room) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return reader(room)
|
||||||
|
}).filter(notNull)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readRoomTimeline(
|
||||||
|
roomID: string,
|
||||||
|
eventType: string,
|
||||||
|
msgtype: string | undefined,
|
||||||
|
stateKey: string | undefined,
|
||||||
|
limit: number,
|
||||||
|
since: string | undefined,
|
||||||
|
): Promise<IRoomEvent[]> {
|
||||||
|
const room = this.client.store.rooms.get(roomID)
|
||||||
|
if (!room) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (room.timeline.length === 0) {
|
||||||
|
await this.client.loadMoreHistory(roomID)
|
||||||
|
}
|
||||||
|
return iterRoomTimeline(room, since)
|
||||||
|
.filter(filterEvent(eventType, msgtype, stateKey))
|
||||||
|
.take(limit)
|
||||||
|
.map(memDBEventToIRoomEvent)
|
||||||
|
.toArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
async readRoomState(roomID: string, eventType: string, stateKey?: string): Promise<IRoomEvent[]> {
|
||||||
|
const room = this.client.store.rooms.get(roomID)
|
||||||
|
if (!room) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
stateKey === undefined
|
||||||
|
&& eventType === "m.room.member"
|
||||||
|
&& !room.fullMembersLoaded
|
||||||
|
&& !room.membersRequested
|
||||||
|
) {
|
||||||
|
room.membersRequested = true
|
||||||
|
this.client.loadRoomState(room.roomID, { omitMembers: false, refetch: false })
|
||||||
|
}
|
||||||
|
const stateEvts = room.state.get(eventType)
|
||||||
|
if (!stateEvts) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let stateRowIDs: EventRowID[] = []
|
||||||
|
if (stateKey !== undefined) {
|
||||||
|
const stateEvtID = stateEvts.get(stateKey)
|
||||||
|
if (!stateEvtID) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
stateRowIDs = [stateEvtID]
|
||||||
|
} else {
|
||||||
|
stateRowIDs = Array.from(stateEvts.values())
|
||||||
|
}
|
||||||
|
return stateRowIDs.map(rowID => {
|
||||||
|
const evt = room.eventsByRowID.get(rowID)
|
||||||
|
if (!evt) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return memDBEventToIRoomEvent(evt)
|
||||||
|
}).filter(notNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
async readStateEvents(
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string | undefined,
|
||||||
|
limit: number,
|
||||||
|
roomIDs: RoomID[] | null = null,
|
||||||
|
): Promise<IRoomEvent[]> {
|
||||||
|
console.warn(`Deprecated call to readStateEvents(${eventType}, ${stateKey}, ${limit}, ${roomIDs})`)
|
||||||
|
return (await Promise.all(
|
||||||
|
this.readRoomData(roomIDs, room => this.readRoomState(room.roomID, eventType, stateKey)),
|
||||||
|
)).flatMap(evts => evts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async readRoomAccountData(type: string, roomIDs: string[] | null = null): Promise<IRoomAccountData[]> {
|
||||||
|
return this.readRoomData(roomIDs, room => {
|
||||||
|
const content = room.accountData.get(type)
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
room_id: room.roomID,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
|
||||||
|
if (!this.openIDToken || (this.openIDExpiry ?? 0) < Date.now()) {
|
||||||
|
const openID = await this.client.rpc.requestOpenIDToken()
|
||||||
|
if (!openID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.openIDToken = openID
|
||||||
|
this.openIDExpiry = Date.now() + (openID.expires_in / 2) * 1000
|
||||||
|
}
|
||||||
|
observer.update({
|
||||||
|
state: OpenIDRequestState.Allowed,
|
||||||
|
token: this.openIDToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> {
|
||||||
|
const res = await fetch("_gomuks/upload?encrypt=false", {
|
||||||
|
method: "POST",
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
const json = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(json.error)
|
||||||
|
}
|
||||||
|
return { contentUri: json.url }
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(url: string): Promise<{ file: XMLHttpRequestBodyInit }> {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(res.statusText)
|
||||||
|
}
|
||||||
|
return { file: await res.blob() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMediaConfig(): Promise<IGetMediaConfigResult> {
|
||||||
|
return await this.client.rpc.getMediaConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
getKnownRooms(): string[] {
|
||||||
|
return Array.from(this.client.store.rooms.keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigate(uri: string): Promise<void> {
|
||||||
|
if (uri.startsWith("https://matrix.to/")) {
|
||||||
|
const parsedURL = new URL(uri)
|
||||||
|
const parts = parsedURL.hash.split("/")
|
||||||
|
if (parts[1][0] === "#") {
|
||||||
|
uri = `matrix:r/${parts[1].slice(1)}`
|
||||||
|
} else if (parts[1][0] === "!") {
|
||||||
|
if (parts.length >= 4 && parts[3][0] === "$") {
|
||||||
|
uri = `matrix:roomid/${parts[1].slice(1)}/e/${parts[4].slice(1)}`
|
||||||
|
} else {
|
||||||
|
uri = `matrix:roomid/${parts[1].slice(1)}`
|
||||||
|
}
|
||||||
|
} else if (parts[1][0] === "@") {
|
||||||
|
uri = `matrix:u/${parts[1].slice(1)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uri.startsWith("matrix:")) {
|
||||||
|
window.location.hash = `#/uri/${encodeURIComponent(uri)}`
|
||||||
|
} else {
|
||||||
|
throw new Error("Unsupported URI: " + uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async * getTurnServers(): AsyncGenerator<ITurnServer> {
|
||||||
|
const res = await this.client.rpc.getTurnServers()
|
||||||
|
yield res
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: searchUserDirectory, readEventRelations
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GomuksWidgetDriver
|
|
@ -2,7 +2,7 @@ import react from "@vitejs/plugin-react-swc"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
import svgr from "vite-plugin-svgr"
|
import svgr from "vite-plugin-svgr"
|
||||||
|
|
||||||
const splitDeps = ["katex", "leaflet", "monaco-editor"]
|
const splitDeps = ["katex", "leaflet", "monaco-editor", "matrix-widget-api"]
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: "./",
|
base: "./",
|
||||||
|
|
Loading…
Add table
Reference in a new issue