web/widgets: add initial support

This commit is contained in:
Tulir Asokan 2025-03-04 22:54:20 +02:00
parent d234981604
commit 508355f2bf
31 changed files with 897 additions and 41 deletions

View file

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

View file

@ -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
View file

@ -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
View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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) {

View file

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

View file

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

View file

@ -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) {

View file

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

View file

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

View file

@ -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",

View 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

View file

@ -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")! })

View file

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

View file

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

View 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

View file

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

View 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

View 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

View 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
View 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)
}

View 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)

View 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

View file

@ -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: "./",