Compare commits
135 commits
tulir/virt
...
main
Author | SHA1 | Date | |
---|---|---|---|
d5666f9f00 | |||
338c7d9adc | |||
7c5ab68cf2 | |||
![]() |
295d1f156e | ||
![]() |
c312a2b523 | ||
![]() |
99b3fd0e5e | ||
![]() |
c48f24d2de | ||
![]() |
696687f60c | ||
![]() |
4262b5abfa | ||
![]() |
87ec9d60a5 | ||
![]() |
9c17ce001d | ||
![]() |
0f2263ce8d | ||
![]() |
92d3ab64bf | ||
![]() |
746e26fbc1 | ||
![]() |
3041fb18e3 | ||
![]() |
ede1c92906 | ||
![]() |
0ed5dfcc12 | ||
![]() |
7f80301276 | ||
![]() |
3f4333003d | ||
![]() |
218481f3a4 | ||
![]() |
ef05bc71f9 | ||
![]() |
86843d61f6 | ||
![]() |
6b9f6bebd5 | ||
![]() |
aee4cff572 | ||
![]() |
678940618f | ||
![]() |
6425f68c88 | ||
![]() |
0db18f1e94 | ||
![]() |
fbad48129b | ||
![]() |
b3b255e71c | ||
![]() |
5da85acfe0 | ||
![]() |
f79678a87f | ||
![]() |
23f2699909 | ||
![]() |
6e8dd0e591 | ||
![]() |
9a88df0cc1 | ||
![]() |
3ef311d9d6 | ||
![]() |
508355f2bf | ||
![]() |
d234981604 | ||
![]() |
e20f3161a2 | ||
![]() |
c78ef2e97b | ||
![]() |
d093ea2f90 | ||
![]() |
e6242a9c37 | ||
![]() |
aeabda449d | ||
![]() |
5aacc3424d | ||
![]() |
d06ed8637c | ||
![]() |
1ffb44fc27 | ||
![]() |
83ea1c12ad | ||
![]() |
014c63f0e7 | ||
![]() |
91fa59d5ba | ||
![]() |
b48c285f5c | ||
![]() |
7168683a4b | ||
![]() |
4247e17160 | ||
![]() |
62d8d06129 | ||
![]() |
1ba4d532c9 | ||
![]() |
bbce3df381 | ||
![]() |
2203c18e15 | ||
![]() |
bdae0c416f | ||
![]() |
b7cc6aff86 | ||
![]() |
5d41b49462 | ||
![]() |
548c8a9a94 | ||
![]() |
f9a8d3e042 | ||
![]() |
7ed0f2633c | ||
![]() |
4a728e187c | ||
![]() |
c210f696cc | ||
![]() |
0c3d3686e4 | ||
![]() |
bd2ece9ff2 | ||
![]() |
a14f01a3ec | ||
![]() |
4885dab2e1 | ||
![]() |
42a97c8c1b | ||
![]() |
4074e2ca68 | ||
![]() |
4fc9b88ec6 | ||
![]() |
a9e459b448 | ||
![]() |
7c664c2700 | ||
![]() |
c228b7f183 | ||
![]() |
5c27592b8c | ||
![]() |
db709896d6 | ||
![]() |
d1fd7f576a | ||
![]() |
ce60eb8a94 | ||
![]() |
289b428644 | ||
![]() |
2461cad4f2 | ||
![]() |
8ba9279cc7 | ||
![]() |
3075156884 | ||
![]() |
7df4d7c6f9 | ||
![]() |
4060383efa | ||
![]() |
14c9291c8d | ||
![]() |
36ad528124 | ||
![]() |
2665956654 | ||
![]() |
1e22e62a9a | ||
![]() |
717f2989a8 | ||
![]() |
9fd50a6ae3 | ||
![]() |
947a853bae | ||
![]() |
6d12e6e009 | ||
![]() |
1b5467cf0e | ||
![]() |
66c850717a | ||
![]() |
2c489fa582 | ||
![]() |
e8c8a44f38 | ||
![]() |
23fb7db2b9 | ||
![]() |
e11c398a57 | ||
![]() |
19a4913a3f | ||
![]() |
0da4dede52 | ||
![]() |
97add30a39 | ||
![]() |
c2ab65e5c0 | ||
![]() |
15238b66f9 | ||
![]() |
ce728417e5 | ||
![]() |
b7f939f480 | ||
![]() |
865b2e4fdf | ||
![]() |
fabf3404af | ||
![]() |
9cff332671 | ||
![]() |
4649689b72 | ||
![]() |
5bb28d3216 | ||
![]() |
f94d84b044 | ||
![]() |
9e63da1b6b | ||
![]() |
d4fc883736 | ||
![]() |
5ab60cb816 | ||
![]() |
40e7d63453 | ||
![]() |
b4ad603ea3 | ||
![]() |
31bc7a6f24 | ||
![]() |
5b5df65f39 | ||
![]() |
bdc823742e | ||
![]() |
158745b7a0 | ||
![]() |
cb08f43535 | ||
![]() |
a1a006bf6b | ||
![]() |
f766b786ee | ||
![]() |
5d25d839f8 | ||
![]() |
39cb5f28a0 | ||
![]() |
ac6f2713e5 | ||
![]() |
d8f0a82ffc | ||
![]() |
3c3e2456e2 | ||
![]() |
021236592f | ||
![]() |
c3899d0b50 | ||
![]() |
7f94bbf39e | ||
![]() |
8c9925959a | ||
![]() |
ddf20b34d2 | ||
![]() |
6d1c5f6277 | ||
![]() |
8b7d0fe6b6 | ||
![]() |
59e1b760d6 |
11
.forgeo/workflows
Normal file
|
@ -0,0 +1,11 @@
|
|||
on: [push]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/cascading-pr@v1.0.1
|
||||
with:
|
||||
GOPATH="$CI_PROJECT_DIR/.cache"
|
||||
GOCACHE="$CI_PROJECT_DIR/.cache/build"
|
||||
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
|
||||
GO_LDFLAGS="-s -w -linkmode external -extldflags -static -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
|
7
.github/workflows/go.yml
vendored
|
@ -2,13 +2,16 @@ name: Go
|
|||
|
||||
on: [push, pull_request]
|
||||
|
||||
env:
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: ["1.23"]
|
||||
name: Lint Go ${{ matrix.go-version == '1.23' && '(latest)' || '(old)' }}
|
||||
go-version: ["1.23", "1.24"]
|
||||
name: Lint Go ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
|
@ -13,6 +13,9 @@ cache:
|
|||
paths:
|
||||
- .cache
|
||||
|
||||
variables:
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
frontend:
|
||||
image: node:22-alpine
|
||||
stage: frontend
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates jq curl
|
||||
RUN apk add --no-cache ca-certificates jq curl ffmpeg
|
||||
|
||||
ARG EXECUTABLE=./gomuks
|
||||
COPY $EXECUTABLE /usr/bin/gomuks
|
||||
|
|
19
README.md
|
@ -1,18 +1,7 @@
|
|||
# gomuks
|
||||

|
||||
[](LICENSE)
|
||||
[](https://github.com/tulir/gomuks/releases)
|
||||
[](https://mau.dev/tulir/gomuks/pipelines)
|
||||
# nyxmuks
|
||||
|
||||
A Matrix client written in Go using [mautrix](https://github.com/mautrix/go).
|
||||
Soft fork of Tulir's Gomuks.
|
||||
|
||||
This branch contains gomuks web. For legacy gomuks terminal, see the
|
||||
[master branch](https://github.com/tulir/gomuks/tree/master). The new
|
||||
version will get a terminal frontend in the future. See also:
|
||||
<https://github.com/tulir/gomuks/issues/476>.
|
||||
# why?
|
||||
|
||||
## Docs
|
||||
For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/).
|
||||
|
||||
## Discussion
|
||||
Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net)
|
||||
Gomuks is adding unneccesary features, and the developer is acting maliciously. This fork aims to remove a few features that seem to be made with a malicious intent, like the "un-redact" button.
|
|
@ -2,39 +2,41 @@ module go.mau.fi/gomuks/desktop
|
|||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.3
|
||||
toolchain go1.23.5
|
||||
|
||||
require github.com/wailsapp/wails/v3 v3.0.0-alpha.7
|
||||
require github.com/wailsapp/wails/v3 v3.0.0-alpha.9
|
||||
|
||||
require (
|
||||
go.mau.fi/gomuks v0.3.1
|
||||
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
|
||||
go.mau.fi/gomuks v0.4.0
|
||||
go.mau.fi/util v0.8.6
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.0.0 // indirect
|
||||
github.com/adrg/xdg v0.5.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/buckket/go-blurhash v1.1.0 // indirect
|
||||
github.com/chzyer/readline v1.5.1 // indirect
|
||||
github.com/cloudflare/circl v1.3.7 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/cloudflare/circl v1.3.8 // indirect
|
||||
github.com/coder/websocket v1.8.13 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.2.5 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/ebitengine/purego v0.4.0-alpha.4 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.5.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.11.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.12.0 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/uuid v1.4.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
|
@ -42,41 +44,43 @@ require (
|
|||
github.com/leaanthony/u v1.1.0 // indirect
|
||||
github.com/lmittmann/tint v1.0.4 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/rs/zerolog v1.33.0 // indirect
|
||||
github.com/samber/lo v1.38.1 // indirect
|
||||
github.com/sergi/go-diff v1.2.0 // indirect
|
||||
github.com/skeema/knownhosts v1.2.1 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.2.2 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.15 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.19 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
go.mau.fi/webp v0.2.0 // indirect
|
||||
go.mau.fi/zeroconfig v0.1.3 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
|
||||
golang.org/x/image v0.23.0 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/tools v0.28.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/image v0.25.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 // indirect
|
||||
mvdan.cc/xurls/v2 v2.5.0 // indirect
|
||||
maunium.net/go/mautrix v0.23.2 // indirect
|
||||
mvdan.cc/xurls/v2 v2.6.0 // indirect
|
||||
)
|
||||
|
||||
replace go.mau.fi/gomuks => ../
|
||||
|
|
155
desktop/go.sum
|
@ -1,5 +1,5 @@
|
|||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
|
@ -7,12 +7,14 @@ github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ
|
|||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
|
||||
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
|
||||
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
|
@ -31,39 +33,41 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk
|
|||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
|
||||
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
|
||||
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
|
||||
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
|
||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
|
||||
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
|
||||
github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
|
||||
github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
|
||||
github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
|
||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
|
||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
||||
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
|
||||
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
|
||||
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
|
||||
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
|
||||
github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
|
||||
github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
|
||||
github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
|
||||
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
|
@ -71,8 +75,8 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
|
|||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
|
@ -98,18 +102,19 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
|
|||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
|
||||
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
|
||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
|
||||
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||
|
@ -121,8 +126,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
|||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
|
||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
|
@ -130,11 +135,11 @@ github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
|||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ=
|
||||
github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
|
||||
github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
|
||||
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
|
@ -145,23 +150,26 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
|||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
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/wailsapp/go-webview2 v1.0.15 h1:IeQFoWmsHp32y64I41J+Zod3SopjHs918KSO4jUqEnY=
|
||||
github.com/wailsapp/go-webview2 v1.0.15/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
|
||||
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
|
||||
github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.7 h1:LNX2EnbxTEYJYICJT8UkuzoGVNalRizTNGBY47endmk=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.7/go.mod h1:lBz4zedFxreJBoVpMe9u89oo4IE3IlyHJg5rOWnGNR0=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.9 h1:b8CfRrhPno8Fra0xFp4Ifyj+ogmXBc35rsQWvcrHtsI=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.9/go.mod h1:dSv6s722nSWaUyUiapAM1DHc5HKggNGY1a79shO85/g=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
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/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
|
||||
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
|
||||
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
|
||||
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE=
|
||||
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
||||
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/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
@ -169,16 +177,17 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
|||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
|
||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
|
@ -187,15 +196,14 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
@ -208,20 +216,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
|
@ -229,28 +238,30 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 h1:3aZCApUF3wlboN1BcAX6u6MGhju4OpsmhyfvxdO6tO0=
|
||||
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI=
|
||||
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
|
||||
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
|
||||
maunium.net/go/mautrix v0.23.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg=
|
||||
maunium.net/go/mautrix v0.23.2/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=
|
||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||
|
|
38
go.mod
|
@ -2,14 +2,15 @@ module go.mau.fi/gomuks
|
|||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.4
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0
|
||||
github.com/alecthomas/chroma/v2 v2.15.0
|
||||
github.com/buckket/go-blurhash v1.1.0
|
||||
github.com/chzyer/readline v1.5.1
|
||||
github.com/coder/websocket v1.8.12
|
||||
github.com/gabriel-vasile/mimetype v1.4.7
|
||||
github.com/coder/websocket v1.8.13
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/gabriel-vasile/mimetype v1.4.8
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
|
@ -17,29 +18,30 @@ require (
|
|||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
github.com/yuin/goldmark v1.7.8
|
||||
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
|
||||
go.mau.fi/util v0.8.6
|
||||
go.mau.fi/webp v0.2.0
|
||||
go.mau.fi/zeroconfig v0.1.3
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/text v0.21.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/image v0.25.0
|
||||
golang.org/x/net v0.37.0
|
||||
golang.org/x/text v0.23.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mauflag v1.0.0
|
||||
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5
|
||||
mvdan.cc/xurls/v2 v2.5.0
|
||||
maunium.net/go/mautrix v0.23.2
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
)
|
||||
|
|
75
go.sum
|
@ -2,10 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
|
||||
|
@ -16,30 +16,34 @@ github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI
|
|||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
|
||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
|
@ -57,32 +61,37 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
|||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
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/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
|
||||
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
|
||||
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
|
||||
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE=
|
||||
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
||||
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/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
|
||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
|
@ -91,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=
|
||||
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
||||
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
||||
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 h1:3aZCApUF3wlboN1BcAX6u6MGhju4OpsmhyfvxdO6tO0=
|
||||
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI=
|
||||
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
|
||||
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
|
||||
maunium.net/go/mautrix v0.23.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg=
|
||||
maunium.net/go/mautrix v0.23.2/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=
|
||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||
|
|
|
@ -54,7 +54,7 @@ func NewEventBuffer(maxSize int) *EventBuffer {
|
|||
}
|
||||
}
|
||||
|
||||
func (eb *EventBuffer) HicliEventHandler(evt any) {
|
||||
func (eb *EventBuffer) Push(evt any) {
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to marshal event %T: %w", evt, err))
|
||||
|
|
|
@ -34,6 +34,8 @@ import (
|
|||
type Config struct {
|
||||
Web WebConfig `yaml:"web"`
|
||||
Matrix MatrixConfig `yaml:"matrix"`
|
||||
Push PushConfig `yaml:"push"`
|
||||
Media MediaConfig `yaml:"media"`
|
||||
Logging zeroconfig.Config `yaml:"logging"`
|
||||
}
|
||||
|
||||
|
@ -41,6 +43,14 @@ type MatrixConfig struct {
|
|||
DisableHTTP2 bool `yaml:"disable_http2"`
|
||||
}
|
||||
|
||||
type PushConfig struct {
|
||||
FCMGateway string `yaml:"fcm_gateway"`
|
||||
}
|
||||
|
||||
type MediaConfig struct {
|
||||
ThumbnailSize int `yaml:"thumbnail_size"`
|
||||
}
|
||||
|
||||
type WebConfig struct {
|
||||
ListenAddress string `yaml:"listen_address"`
|
||||
Username string `yaml:"username"`
|
||||
|
@ -69,6 +79,9 @@ func makeDefaultConfig() Config {
|
|||
Matrix: MatrixConfig{
|
||||
DisableHTTP2: false,
|
||||
},
|
||||
Media: MediaConfig{
|
||||
ThumbnailSize: 120,
|
||||
},
|
||||
Logging: zeroconfig.Config{
|
||||
MinLevel: ptr.Ptr(zerolog.DebugLevel),
|
||||
Writers: []zeroconfig.WriterConfig{{
|
||||
|
@ -121,6 +134,14 @@ func (gmx *Gomuks) LoadConfig() error {
|
|||
gmx.Config.Web.EventBufferSize = 512
|
||||
changed = true
|
||||
}
|
||||
if gmx.Config.Push.FCMGateway == "" {
|
||||
gmx.Config.Push.FCMGateway = "https://push.gomuks.app"
|
||||
changed = true
|
||||
}
|
||||
if gmx.Config.Media.ThumbnailSize == 0 {
|
||||
gmx.Config.Media.ThumbnailSize = 120
|
||||
changed = true
|
||||
}
|
||||
if len(gmx.Config.Web.OriginPatterns) == 0 {
|
||||
gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"}
|
||||
changed = true
|
||||
|
|
|
@ -34,6 +34,7 @@ import (
|
|||
"go.mau.fi/util/dbutil"
|
||||
"go.mau.fi/util/exerrors"
|
||||
"go.mau.fi/util/exzerolog"
|
||||
"go.mau.fi/util/ptr"
|
||||
"golang.org/x/net/http2"
|
||||
|
||||
"go.mau.fi/gomuks/pkg/hicli"
|
||||
|
@ -171,7 +172,7 @@ func (gmx *Gomuks) StartClient() {
|
|||
nil,
|
||||
gmx.Log.With().Str("component", "hicli").Logger(),
|
||||
[]byte("meow"),
|
||||
gmx.EventBuffer.HicliEventHandler,
|
||||
gmx.HandleEvent,
|
||||
)
|
||||
gmx.Client.LogoutFunc = gmx.Logout
|
||||
httpClient := gmx.Client.Client.Client
|
||||
|
@ -197,6 +198,14 @@ func (gmx *Gomuks) StartClient() {
|
|||
gmx.Log.Info().Stringer("user_id", userID).Msg("Client started")
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) HandleEvent(evt any) {
|
||||
gmx.EventBuffer.Push(evt)
|
||||
syncComplete, ok := evt.(*hicli.SyncComplete)
|
||||
if ok && ptr.Val(syncComplete.Since) != "" {
|
||||
go gmx.SendPushNotifications(syncComplete)
|
||||
}
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) Stop() {
|
||||
gmx.stopOnce.Do(func() {
|
||||
close(gmx.stopChan)
|
||||
|
|
110
pkg/gomuks/keys.go
Normal file
|
@ -0,0 +1,110 @@
|
|||
// 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/>.
|
||||
|
||||
package gomuks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/rs/zerolog/hlog"
|
||||
"go.mau.fi/util/dbutil"
|
||||
"go.mau.fi/util/exhttp"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func (gmx *Gomuks) ExportKeys(w http.ResponseWriter, r *http.Request) {
|
||||
found, correct := gmx.doBasicAuth(r)
|
||||
if !found || !correct {
|
||||
hlog.FromRequest(r).Debug().Msg("Requesting credentials for key export request")
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
hlog.FromRequest(r).Err(err).Msg("Failed to parse form")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("Failed to parse form data\n"))
|
||||
return
|
||||
}
|
||||
roomID := id.RoomID(r.PathValue("room_id"))
|
||||
var sessions dbutil.RowIter[*crypto.InboundGroupSession]
|
||||
filename := "gomuks-keys.txt"
|
||||
if roomID == "" {
|
||||
sessions = gmx.Client.CryptoStore.GetAllGroupSessions(r.Context())
|
||||
} else {
|
||||
filename = fmt.Sprintf("gomuks-keys-%s.txt", roomID)
|
||||
sessions = gmx.Client.CryptoStore.GetGroupSessionsForRoom(r.Context(), roomID)
|
||||
}
|
||||
export, err := crypto.ExportKeysIter(r.FormValue("passphrase"), sessions)
|
||||
if errors.Is(err, crypto.ErrNoSessionsForExport) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte("No keys found\n"))
|
||||
return
|
||||
} else if err != nil {
|
||||
hlog.FromRequest(r).Err(err).Msg("Failed to export keys")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Failed to export keys (see logs for more details)\n"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(export)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(export)
|
||||
}
|
||||
|
||||
var badMultipartForm = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.BAD_FORM_DATA", Err: "Failed to parse form data", StatusCode: http.StatusBadRequest}
|
||||
|
||||
func (gmx *Gomuks) ImportKeys(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseMultipartForm(5 * 1024 * 1024)
|
||||
if err != nil {
|
||||
badMultipartForm.Write(w)
|
||||
return
|
||||
}
|
||||
export, _, err := r.FormFile("export")
|
||||
if err != nil {
|
||||
badMultipartForm.WithMessage("Failed to get export file from form: %w", err).Write(w)
|
||||
return
|
||||
}
|
||||
exportData, err := io.ReadAll(export)
|
||||
if err != nil {
|
||||
badMultipartForm.WithMessage("Failed to read export file: %w", err).Write(w)
|
||||
return
|
||||
}
|
||||
importedCount, totalCount, err := gmx.Client.Crypto.ImportKeys(r.Context(), r.FormValue("passphrase"), exportData)
|
||||
if err != nil {
|
||||
hlog.FromRequest(r).Err(err).Msg("Failed to import keys")
|
||||
mautrix.MUnknown.WithMessage("Failed to import keys: %w", err).Write(w)
|
||||
return
|
||||
}
|
||||
hlog.FromRequest(r).Info().
|
||||
Int("imported_count", importedCount).
|
||||
Int("total_count", totalCount).
|
||||
Msg("Successfully imported keys")
|
||||
exhttp.WriteJSONResponse(w, http.StatusOK, map[string]int{
|
||||
"imported": importedCount,
|
||||
"total": totalCount,
|
||||
})
|
||||
}
|
|
@ -36,16 +36,17 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/hlog"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"go.mau.fi/util/exhttp"
|
||||
"go.mau.fi/util/ffmpeg"
|
||||
"go.mau.fi/util/jsontime"
|
||||
"go.mau.fi/util/ptr"
|
||||
"go.mau.fi/util/random"
|
||||
cwebp "go.mau.fi/webp"
|
||||
_ "golang.org/x/image/webp"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/attachment"
|
||||
"maunium.net/go/mautrix/event"
|
||||
|
@ -59,7 +60,7 @@ var ErrBadGateway = mautrix.RespError{
|
|||
StatusCode: http.StatusBadGateway,
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWriter, r *http.Request, entry *database.Media, force bool) bool {
|
||||
func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWriter, r *http.Request, entry *database.Media, force, useThumbnail bool) bool {
|
||||
if !entry.UseCache() {
|
||||
if force {
|
||||
mautrix.MNotFound.WithMessage("Media not found in cache").Write(w)
|
||||
|
@ -67,11 +68,12 @@ func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWr
|
|||
}
|
||||
return false
|
||||
}
|
||||
etag := entry.ETag(useThumbnail)
|
||||
if entry.Error != nil {
|
||||
w.Header().Set("Mau-Cached-Error", "true")
|
||||
entry.Error.Write(w)
|
||||
return true
|
||||
} else if r.Header.Get("If-None-Match") == entry.ETag() {
|
||||
} else if etag != "" && r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return true
|
||||
} else if entry.MimeType != "" && r.URL.Query().Has("fallback") && !isAllowedAvatarMime(entry.MimeType) {
|
||||
|
@ -79,7 +81,43 @@ func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWr
|
|||
return true
|
||||
}
|
||||
log := zerolog.Ctx(ctx)
|
||||
cacheFile, err := os.Open(gmx.cacheEntryToPath(entry.Hash[:]))
|
||||
hash := entry.Hash
|
||||
if useThumbnail {
|
||||
if entry.ThumbnailError != "" {
|
||||
log.Debug().Str(zerolog.ErrorFieldName, entry.ThumbnailError).Msg("Returning cached thumbnail error")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
if entry.ThumbnailHash == nil {
|
||||
err := gmx.generateAvatarThumbnail(entry, gmx.Config.Media.ThumbnailSize)
|
||||
if errors.Is(err, os.ErrNotExist) && !force {
|
||||
return false
|
||||
} else if err != nil {
|
||||
log.Err(err).Msg("Failed to generate avatar thumbnail")
|
||||
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return true
|
||||
} else {
|
||||
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, nil)
|
||||
}
|
||||
}
|
||||
hash = entry.ThumbnailHash
|
||||
}
|
||||
cacheFile, err := os.Open(gmx.cacheEntryToPath(hash[:]))
|
||||
if useThumbnail && errors.Is(err, os.ErrNotExist) {
|
||||
err = gmx.generateAvatarThumbnail(entry, gmx.Config.Media.ThumbnailSize)
|
||||
if errors.Is(err, os.ErrNotExist) && !force {
|
||||
return false
|
||||
} else if err != nil {
|
||||
log.Err(err).Msg("Failed to generate avatar thumbnail")
|
||||
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return true
|
||||
} else {
|
||||
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, nil)
|
||||
cacheFile, err = os.Open(gmx.cacheEntryToPath(hash[:]))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) && !force {
|
||||
return false
|
||||
|
@ -91,7 +129,7 @@ func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWr
|
|||
defer func() {
|
||||
_ = cacheFile.Close()
|
||||
}()
|
||||
cacheEntryToHeaders(w, entry)
|
||||
cacheEntryToHeaders(w, entry, useThumbnail)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err = io.Copy(w, cacheFile)
|
||||
if err != nil {
|
||||
|
@ -105,13 +143,76 @@ func (gmx *Gomuks) cacheEntryToPath(hash []byte) string {
|
|||
return filepath.Join(gmx.CacheDir, "media", hashPath[0:2], hashPath[2:4], hashPath[4:])
|
||||
}
|
||||
|
||||
func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media) {
|
||||
w.Header().Set("Content-Type", entry.MimeType)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10))
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName}))
|
||||
func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media, thumbnail bool) {
|
||||
if thumbnail {
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(entry.ThumbnailSize, 10))
|
||||
w.Header().Set("Content-Disposition", "inline; filename=thumbnail.webp")
|
||||
} else {
|
||||
w.Header().Set("Content-Type", entry.MimeType)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10))
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName}))
|
||||
}
|
||||
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; media-src 'self';")
|
||||
w.Header().Set("Cache-Control", "max-age=2592000, immutable")
|
||||
w.Header().Set("ETag", entry.ETag())
|
||||
w.Header().Set("ETag", entry.ETag(thumbnail))
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) saveMediaCacheEntryWithThumbnail(ctx context.Context, entry *database.Media, err error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
entry.ThumbnailError = err.Error()
|
||||
}
|
||||
err = gmx.Client.DB.Media.Put(ctx, entry)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to save cache entry after generating thumbnail")
|
||||
}
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) generateAvatarThumbnail(entry *database.Media, size int) error {
|
||||
cacheFile, err := os.Open(gmx.cacheEntryToPath(entry.Hash[:]))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open full file: %w", err)
|
||||
}
|
||||
img, _, err := image.Decode(cacheFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(gmx.TempDir, "thumbnail-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tempFile.Close()
|
||||
_ = os.Remove(tempFile.Name())
|
||||
}()
|
||||
thumbnailImage := imaging.Thumbnail(img, size, size, imaging.Lanczos)
|
||||
fileHasher := sha256.New()
|
||||
wrappedWriter := io.MultiWriter(fileHasher, tempFile)
|
||||
err = cwebp.Encode(wrappedWriter, thumbnailImage, &cwebp.Options{Quality: 80})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode thumbnail: %w", err)
|
||||
}
|
||||
fileInfo, err := tempFile.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat temporary file: %w", err)
|
||||
}
|
||||
entry.ThumbnailHash = (*[32]byte)(fileHasher.Sum(nil))
|
||||
entry.ThumbnailError = ""
|
||||
entry.ThumbnailSize = fileInfo.Size()
|
||||
cachePath := gmx.cacheEntryToPath(entry.ThumbnailHash[:])
|
||||
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
err = os.Rename(tempFile.Name(), cachePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to rename temporary file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type noErrorWriter struct {
|
||||
|
@ -191,6 +292,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
encrypted, _ := strconv.ParseBool(query.Get("encrypted"))
|
||||
useThumbnail := query.Get("thumbnail") == "avatar"
|
||||
|
||||
logVal := zerolog.Ctx(r.Context()).With().
|
||||
Stringer("mxc_uri", mxc).
|
||||
|
@ -211,7 +313,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false) {
|
||||
if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false, useThumbnail) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -301,8 +403,8 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
|
|||
cacheEntry.Size = resp.ContentLength
|
||||
fileHasher := sha256.New()
|
||||
wrappedReader := io.TeeReader(reader, fileHasher)
|
||||
if cacheEntry.Size > 0 && cacheEntry.EncFile == nil {
|
||||
cacheEntryToHeaders(w, cacheEntry)
|
||||
if cacheEntry.Size > 0 && cacheEntry.EncFile == nil && !useThumbnail {
|
||||
cacheEntryToHeaders(w, cacheEntry, useThumbnail)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
wrappedReader = io.TeeReader(wrappedReader, &noErrorWriter{w})
|
||||
w = nil
|
||||
|
@ -342,7 +444,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
if w != nil {
|
||||
gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, true)
|
||||
gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, true, useThumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
258
pkg/gomuks/push.go
Normal file
|
@ -0,0 +1,258 @@
|
|||
// 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/>.
|
||||
|
||||
package gomuks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.mau.fi/util/jsontime"
|
||||
"go.mau.fi/util/ptr"
|
||||
"go.mau.fi/util/random"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/gomuks/pkg/hicli"
|
||||
"go.mau.fi/gomuks/pkg/hicli/database"
|
||||
)
|
||||
|
||||
type PushNotification struct {
|
||||
Dismiss []PushDismiss `json:"dismiss,omitempty"`
|
||||
OrigMessages []*PushNewMessage `json:"-"`
|
||||
RawMessages []json.RawMessage `json:"messages,omitempty"`
|
||||
ImageAuth string `json:"image_auth,omitempty"`
|
||||
ImageAuthExpiry *jsontime.UnixMilli `json:"image_auth_expiry,omitempty"`
|
||||
HasImportant bool `json:"-"`
|
||||
}
|
||||
|
||||
type PushDismiss struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
}
|
||||
|
||||
var pushClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 5,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) SendPushNotifications(sync *hicli.SyncComplete) {
|
||||
var ctx context.Context
|
||||
var push PushNotification
|
||||
for _, room := range sync.Rooms {
|
||||
if room.DismissNotifications && len(push.Dismiss) < 10 {
|
||||
push.Dismiss = append(push.Dismiss, PushDismiss{RoomID: room.Meta.ID})
|
||||
}
|
||||
for _, notif := range room.Notifications {
|
||||
if ctx == nil {
|
||||
ctx = gmx.Log.With().
|
||||
Str("action", "send push notification").
|
||||
Logger().WithContext(context.Background())
|
||||
}
|
||||
msg := gmx.formatPushNotificationMessage(ctx, notif)
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
msgJSON, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).
|
||||
Int64("event_rowid", int64(notif.RowID)).
|
||||
Stringer("event_id", notif.Event.ID).
|
||||
Msg("Failed to marshal push notification")
|
||||
continue
|
||||
} else if len(msgJSON) > 1500 {
|
||||
// This should not happen as long as formatPushNotificationMessage doesn't return too long messages
|
||||
zerolog.Ctx(ctx).Error().
|
||||
Int64("event_rowid", int64(notif.RowID)).
|
||||
Stringer("event_id", notif.Event.ID).
|
||||
Msg("Push notification too long")
|
||||
continue
|
||||
}
|
||||
push.RawMessages = append(push.RawMessages, msgJSON)
|
||||
push.OrigMessages = append(push.OrigMessages, msg)
|
||||
}
|
||||
}
|
||||
if len(push.Dismiss) == 0 && len(push.RawMessages) == 0 {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = gmx.Log.With().
|
||||
Str("action", "send push notification").
|
||||
Logger().WithContext(context.Background())
|
||||
}
|
||||
pushRegs, err := gmx.Client.DB.PushRegistration.GetAll(ctx)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get push registrations")
|
||||
return
|
||||
}
|
||||
if len(push.RawMessages) > 0 {
|
||||
exp := time.Now().Add(24 * time.Hour)
|
||||
push.ImageAuth = gmx.generateImageToken(24 * time.Hour)
|
||||
push.ImageAuthExpiry = ptr.Ptr(jsontime.UM(exp))
|
||||
}
|
||||
for notif := range push.Split {
|
||||
gmx.SendPushNotification(ctx, pushRegs, notif)
|
||||
}
|
||||
}
|
||||
|
||||
func (pn *PushNotification) Split(yield func(*PushNotification) bool) {
|
||||
const maxSize = 2000
|
||||
currentSize := 0
|
||||
offset := 0
|
||||
hasSound := false
|
||||
for i, msg := range pn.RawMessages {
|
||||
if len(msg) >= maxSize {
|
||||
// This is already checked in SendPushNotifications, so this should never happen
|
||||
panic("push notification message too long")
|
||||
}
|
||||
if currentSize+len(msg) > maxSize {
|
||||
yield(&PushNotification{
|
||||
Dismiss: pn.Dismiss,
|
||||
RawMessages: pn.RawMessages[offset:i],
|
||||
ImageAuth: pn.ImageAuth,
|
||||
HasImportant: hasSound,
|
||||
})
|
||||
offset = i
|
||||
currentSize = 0
|
||||
hasSound = false
|
||||
}
|
||||
currentSize += len(msg)
|
||||
hasSound = hasSound || pn.OrigMessages[i].Sound
|
||||
}
|
||||
yield(&PushNotification{
|
||||
Dismiss: pn.Dismiss,
|
||||
RawMessages: pn.RawMessages[offset:],
|
||||
ImageAuth: pn.ImageAuth,
|
||||
HasImportant: hasSound,
|
||||
})
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*database.PushRegistration, notif *PushNotification) {
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Bool("important", notif.HasImportant).
|
||||
Int("message_count", len(notif.RawMessages)).
|
||||
Int("dismiss_count", len(notif.Dismiss)).
|
||||
Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
rawPayload, err := json.Marshal(notif)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to marshal push notification")
|
||||
return
|
||||
} else if base64.StdEncoding.EncodedLen(len(rawPayload)) >= 4000 {
|
||||
log.Error().Msg("Generated push payload too long")
|
||||
return
|
||||
}
|
||||
for _, reg := range pushRegs {
|
||||
devicePayload := rawPayload
|
||||
encrypted := false
|
||||
if reg.Encryption.Key != nil {
|
||||
var err error
|
||||
devicePayload, err = encryptPush(rawPayload, reg.Encryption.Key)
|
||||
if err != nil {
|
||||
log.Err(err).Str("device_id", reg.DeviceID).Msg("Failed to encrypt push payload")
|
||||
continue
|
||||
}
|
||||
encrypted = true
|
||||
}
|
||||
switch reg.Type {
|
||||
case database.PushTypeFCM:
|
||||
if !encrypted {
|
||||
log.Warn().
|
||||
Str("device_id", reg.DeviceID).
|
||||
Msg("FCM push registration doesn't have encryption key")
|
||||
continue
|
||||
}
|
||||
var token string
|
||||
err = json.Unmarshal(reg.Data, &token)
|
||||
if err != nil {
|
||||
log.Err(err).Str("device_id", reg.DeviceID).Msg("Failed to unmarshal FCM token")
|
||||
continue
|
||||
}
|
||||
gmx.SendFCMPush(ctx, token, devicePayload, notif.HasImportant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func encryptPush(payload, key []byte) ([]byte, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("encryption key must be 32 bytes long")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM cipher: %w", err)
|
||||
}
|
||||
iv := random.Bytes(12)
|
||||
encrypted := make([]byte, 12, 12+len(payload))
|
||||
copy(encrypted, iv)
|
||||
return gcm.Seal(encrypted, iv, payload, nil), nil
|
||||
}
|
||||
|
||||
type PushRequest struct {
|
||||
Token string `json:"token"`
|
||||
Payload []byte `json:"payload"`
|
||||
HighPriority bool `json:"high_priority"`
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) SendFCMPush(ctx context.Context, token string, payload []byte, highPriority bool) {
|
||||
wrappedPayload, _ := json.Marshal(&PushRequest{
|
||||
Token: token,
|
||||
Payload: payload,
|
||||
HighPriority: highPriority,
|
||||
})
|
||||
url := fmt.Sprintf("%s/_gomuks/push/fcm", gmx.Config.Push.FCMGateway)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wrappedPayload))
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to create push request")
|
||||
return
|
||||
}
|
||||
resp, err := pushClient.Do(req)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).Str("push_token", token).Msg("Failed to send push request")
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
zerolog.Ctx(ctx).Error().
|
||||
Int("status", resp.StatusCode).
|
||||
Str("push_token", token).
|
||||
Msg("Non-200 status while sending push request")
|
||||
} else {
|
||||
zerolog.Ctx(ctx).Trace().
|
||||
Int("status", resp.StatusCode).
|
||||
Str("push_token", token).
|
||||
Msg("Sent push request")
|
||||
}
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
171
pkg/gomuks/pushmessage.go
Normal file
|
@ -0,0 +1,171 @@
|
|||
// 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/>.
|
||||
|
||||
package gomuks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.mau.fi/util/jsontime"
|
||||
"go.mau.fi/util/ptr"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/gomuks/pkg/hicli"
|
||||
"go.mau.fi/gomuks/pkg/hicli/database"
|
||||
)
|
||||
|
||||
type PushNewMessage struct {
|
||||
Timestamp jsontime.UnixMilli `json:"timestamp"`
|
||||
EventID id.EventID `json:"event_id"`
|
||||
EventRowID database.EventRowID `json:"event_rowid"`
|
||||
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
RoomName string `json:"room_name"`
|
||||
RoomAvatar string `json:"room_avatar,omitempty"`
|
||||
Sender NotificationUser `json:"sender"`
|
||||
Self NotificationUser `json:"self"`
|
||||
|
||||
Text string `json:"text"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Mention bool `json:"mention,omitempty"`
|
||||
Reply bool `json:"reply,omitempty"`
|
||||
Sound bool `json:"sound,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationUser struct {
|
||||
ID id.UserID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
func getAvatarLinkForNotification(name, ident string, uri id.ContentURIString) string {
|
||||
parsed := uri.ParseOrIgnore()
|
||||
if !parsed.IsValid() {
|
||||
return ""
|
||||
}
|
||||
var fallbackChar rune
|
||||
if name == "" {
|
||||
fallbackChar, _ = utf8.DecodeRuneInString(ident[1:])
|
||||
} else {
|
||||
fallbackChar, _ = utf8.DecodeRuneInString(name)
|
||||
}
|
||||
return fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false&fallback=%s", parsed.Homeserver, parsed.FileID, url.QueryEscape(string(fallbackChar)))
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) getNotificationUser(ctx context.Context, roomID id.RoomID, userID id.UserID) (user NotificationUser) {
|
||||
user = NotificationUser{ID: userID, Name: userID.Localpart()}
|
||||
memberEvt, err := gmx.Client.DB.CurrentState.Get(ctx, roomID, event.StateMember, userID.String())
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).Stringer("of_user_id", userID).Msg("Failed to get member event")
|
||||
return
|
||||
} else if memberEvt == nil {
|
||||
return
|
||||
}
|
||||
var memberContent event.MemberEventContent
|
||||
_ = json.Unmarshal(memberEvt.Content, &memberContent)
|
||||
if memberContent.Displayname != "" {
|
||||
user.Name = memberContent.Displayname
|
||||
}
|
||||
if len(user.Name) > 50 {
|
||||
user.Name = user.Name[:50] + "…"
|
||||
}
|
||||
if memberContent.AvatarURL != "" {
|
||||
user.Avatar = getAvatarLinkForNotification(memberContent.Displayname, userID.String(), memberContent.AvatarURL)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) formatPushNotificationMessage(ctx context.Context, notif hicli.SyncNotification) *PushNewMessage {
|
||||
evtType := notif.Event.Type
|
||||
rawContent := notif.Event.Content
|
||||
if evtType == event.EventEncrypted.Type {
|
||||
evtType = notif.Event.DecryptedType
|
||||
rawContent = notif.Event.Decrypted
|
||||
}
|
||||
if evtType != event.EventMessage.Type && evtType != event.EventSticker.Type {
|
||||
return nil
|
||||
}
|
||||
var content event.MessageEventContent
|
||||
err := json.Unmarshal(rawContent, &content)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Warn().Err(err).
|
||||
Stringer("event_id", notif.Event.ID).
|
||||
Msg("Failed to unmarshal message content to format push notification")
|
||||
return nil
|
||||
}
|
||||
var roomAvatar, image string
|
||||
if notif.Room.Avatar != nil {
|
||||
avatarIdent := notif.Room.ID.String()
|
||||
if ptr.Val(notif.Room.DMUserID) != "" {
|
||||
avatarIdent = notif.Room.DMUserID.String()
|
||||
}
|
||||
roomAvatar = getAvatarLinkForNotification(ptr.Val(notif.Room.Name), avatarIdent, notif.Room.Avatar.CUString())
|
||||
}
|
||||
roomName := ptr.Val(notif.Room.Name)
|
||||
if roomName == "" {
|
||||
roomName = "Unnamed room"
|
||||
}
|
||||
if len(roomName) > 50 {
|
||||
roomName = roomName[:50] + "…"
|
||||
}
|
||||
text := content.Body
|
||||
if len(text) > 400 {
|
||||
text = text[:350] + "[…]"
|
||||
}
|
||||
if content.MsgType == event.MsgImage || evtType == event.EventSticker.Type {
|
||||
if content.File != nil && content.File.URL != "" {
|
||||
parsed := content.File.URL.ParseOrIgnore()
|
||||
if len(content.File.URL) < 255 && parsed.IsValid() {
|
||||
image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=true", parsed.Homeserver, parsed.FileID)
|
||||
}
|
||||
} else if content.URL != "" {
|
||||
parsed := content.URL.ParseOrIgnore()
|
||||
if len(content.URL) < 255 && parsed.IsValid() {
|
||||
image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false", parsed.Homeserver, parsed.FileID)
|
||||
}
|
||||
}
|
||||
if content.FileName == "" || content.FileName == content.Body {
|
||||
text = "Sent a photo"
|
||||
}
|
||||
} else if content.MsgType.IsMedia() {
|
||||
if content.FileName == "" || content.FileName == content.Body {
|
||||
text = "Sent a file: " + text
|
||||
}
|
||||
}
|
||||
return &PushNewMessage{
|
||||
Timestamp: notif.Event.Timestamp,
|
||||
EventID: notif.Event.ID,
|
||||
EventRowID: notif.Event.RowID,
|
||||
|
||||
RoomID: notif.Room.ID,
|
||||
RoomName: roomName,
|
||||
RoomAvatar: roomAvatar,
|
||||
Sender: gmx.getNotificationUser(ctx, notif.Room.ID, notif.Event.Sender),
|
||||
Self: gmx.getNotificationUser(ctx, notif.Room.ID, gmx.Client.Account.UserID),
|
||||
|
||||
Text: text,
|
||||
Image: image,
|
||||
Mention: content.Mentions.Has(gmx.Client.Account.UserID),
|
||||
Reply: content.RelatesTo.GetNonFallbackReplyTo() != "",
|
||||
Sound: notif.Sound,
|
||||
}
|
||||
}
|
|
@ -53,6 +53,9 @@ func (gmx *Gomuks) CreateAPIRouter() http.Handler {
|
|||
api.HandleFunc("GET /sso", gmx.HandleSSOComplete)
|
||||
api.HandleFunc("POST /sso", gmx.PrepareSSO)
|
||||
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
|
||||
api.HandleFunc("POST /keys/export", gmx.ExportKeys)
|
||||
api.HandleFunc("POST /keys/export/{room_id}", gmx.ExportKeys)
|
||||
api.HandleFunc("POST /keys/import", gmx.ImportKeys)
|
||||
api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
|
||||
return exhttp.ApplyMiddleware(
|
||||
api,
|
||||
|
@ -190,10 +193,10 @@ func (gmx *Gomuks) generateToken() (string, time.Time) {
|
|||
}), expiry
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) generateImageToken() string {
|
||||
func (gmx *Gomuks) generateImageToken(expiry time.Duration) string {
|
||||
return gmx.signToken(tokenData{
|
||||
Username: gmx.Config.Web.Username,
|
||||
Expiry: jsontime.U(time.Now().Add(1 * time.Hour)),
|
||||
Expiry: jsontime.U(time.Now().Add(expiry)),
|
||||
ImageOnly: true,
|
||||
})
|
||||
}
|
||||
|
@ -206,16 +209,26 @@ func (gmx *Gomuks) signToken(td any) string {
|
|||
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum)
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) {
|
||||
func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter, created, jsonOutput bool) {
|
||||
token, expiry := gmx.generateToken()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "gomuks_auth",
|
||||
Value: token,
|
||||
Expires: expiry,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
if !jsonOutput {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "gomuks_auth",
|
||||
Value: token,
|
||||
Expires: expiry,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
if created {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if jsonOutput {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"token": token})
|
||||
}
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -223,32 +236,42 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
jsonOutput := r.URL.Query().Get("output") == "json"
|
||||
allowPrompt := r.URL.Query().Get("no_prompt") != "true"
|
||||
authCookie, err := r.Cookie("gomuks_auth")
|
||||
if err == nil && gmx.validateAuth(authCookie.Value, false) {
|
||||
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
|
||||
gmx.writeTokenCookie(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else if username, password, ok := r.BasicAuth(); !ok {
|
||||
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
gmx.writeTokenCookie(w, false, jsonOutput)
|
||||
} else if found, correct := gmx.doBasicAuth(r); found && correct {
|
||||
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
|
||||
gmx.writeTokenCookie(w, true, jsonOutput)
|
||||
} else {
|
||||
usernameHash := sha256.Sum256([]byte(username))
|
||||
expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username))
|
||||
usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:])
|
||||
passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil
|
||||
if usernameCorrect && passwordCorrect {
|
||||
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
|
||||
gmx.writeTokenCookie(w)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if !found {
|
||||
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
|
||||
} else {
|
||||
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials")
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
if allowPrompt {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) doBasicAuth(r *http.Request) (found, correct bool) {
|
||||
var username, password string
|
||||
username, password, found = r.BasicAuth()
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
usernameHash := sha256.Sum256([]byte(username))
|
||||
expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username))
|
||||
usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:])
|
||||
passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil
|
||||
correct = passwordCorrect && usernameCorrect
|
||||
return
|
||||
}
|
||||
|
||||
func isImageFetch(header http.Header) bool {
|
||||
return header.Get("Sec-Fetch-Site") == "cross-site" &&
|
||||
header.Get("Sec-Fetch-Mode") == "no-cors" &&
|
||||
|
|
|
@ -148,7 +148,7 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
|||
sendImageAuthToken := func() {
|
||||
err := writeCmd(ctx, conn, &hicli.JSONCommand{
|
||||
Command: "image_auth_token",
|
||||
Data: exerrors.Must(json.Marshal(gmx.generateImageToken())),
|
||||
Data: exerrors.Must(json.Marshal(gmx.generateImageToken(1 * time.Hour))),
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to write image auth token message")
|
||||
|
|
|
@ -17,17 +17,18 @@ import (
|
|||
type Database struct {
|
||||
*dbutil.Database
|
||||
|
||||
Account *AccountQuery
|
||||
AccountData *AccountDataQuery
|
||||
Room *RoomQuery
|
||||
InvitedRoom *InvitedRoomQuery
|
||||
Event *EventQuery
|
||||
CurrentState *CurrentStateQuery
|
||||
Timeline *TimelineQuery
|
||||
SessionRequest *SessionRequestQuery
|
||||
Receipt *ReceiptQuery
|
||||
Media *MediaQuery
|
||||
SpaceEdge *SpaceEdgeQuery
|
||||
Account *AccountQuery
|
||||
AccountData *AccountDataQuery
|
||||
Room *RoomQuery
|
||||
InvitedRoom *InvitedRoomQuery
|
||||
Event *EventQuery
|
||||
CurrentState *CurrentStateQuery
|
||||
Timeline *TimelineQuery
|
||||
SessionRequest *SessionRequestQuery
|
||||
Receipt *ReceiptQuery
|
||||
Media *MediaQuery
|
||||
SpaceEdge *SpaceEdgeQuery
|
||||
PushRegistration *PushRegistrationQuery
|
||||
}
|
||||
|
||||
func New(rawDB *dbutil.Database) *Database {
|
||||
|
@ -36,17 +37,18 @@ func New(rawDB *dbutil.Database) *Database {
|
|||
return &Database{
|
||||
Database: rawDB,
|
||||
|
||||
Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
|
||||
AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
|
||||
Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
|
||||
InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
|
||||
Event: &EventQuery{QueryHelper: eventQH},
|
||||
CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
|
||||
Timeline: &TimelineQuery{QueryHelper: eventQH},
|
||||
SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
|
||||
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
|
||||
Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
|
||||
SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)},
|
||||
Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
|
||||
AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
|
||||
Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
|
||||
InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
|
||||
Event: &EventQuery{QueryHelper: eventQH},
|
||||
CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
|
||||
Timeline: &TimelineQuery{QueryHelper: eventQH},
|
||||
SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
|
||||
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
|
||||
Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
|
||||
SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)},
|
||||
PushRegistration: &PushRegistrationQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newPushRegistration)},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -85,3 +87,7 @@ func newAccount(_ *dbutil.QueryHelper[*Account]) *Account {
|
|||
func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
|
||||
return &SpaceEdge{}
|
||||
}
|
||||
|
||||
func newPushRegistration(_ *dbutil.QueryHelper[*PushRegistration]) *PushRegistration {
|
||||
return &PushRegistration{}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,11 @@ const (
|
|||
getEventByID = getEventBaseQuery + `WHERE event_id = $1`
|
||||
getEventByTransactionID = getEventBaseQuery + `WHERE transaction_id = $1`
|
||||
getFailedEventsByMegolmSessionID = getEventBaseQuery + `WHERE room_id = $1 AND megolm_session_id = $2 AND decryption_error IS NOT NULL`
|
||||
insertEventBaseQuery = `
|
||||
getRelatedEventsQuery = getEventBaseQuery + `
|
||||
WHERE room_id = $1 AND relates_to = $2 AND ($3 = '' OR relation_type = $3)
|
||||
ORDER BY timestamp ASC
|
||||
`
|
||||
insertEventBaseQuery = `
|
||||
INSERT INTO event (
|
||||
room_id, event_id, sender, type, state_key, timestamp, content, decrypted, decrypted_type,
|
||||
unsigned, local_content, transaction_id, redacted_by, relates_to, relation_type,
|
||||
|
@ -112,6 +116,10 @@ func (eq *EventQuery) GetByRowID(ctx context.Context, rowID EventRowID) (*Event,
|
|||
return eq.QueryOne(ctx, getEventByRowID, rowID)
|
||||
}
|
||||
|
||||
func (eq *EventQuery) GetRelatedEvents(ctx context.Context, roomID id.RoomID, eventID id.EventID, relationType event.RelationType) ([]*Event, error) {
|
||||
return eq.QueryMany(ctx, getRelatedEventsQuery, roomID, eventID, relationType)
|
||||
}
|
||||
|
||||
func (eq *EventQuery) GetByRowIDs(ctx context.Context, rowIDs ...EventRowID) ([]*Event, error) {
|
||||
query, params := buildMultiEventGetFunction(nil, rowIDs, getManyEventsByRowID)
|
||||
return eq.QueryMany(ctx, query, params...)
|
||||
|
|
|
@ -23,24 +23,27 @@ import (
|
|||
|
||||
const (
|
||||
insertMediaQuery = `
|
||||
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (mxc) DO NOTHING
|
||||
`
|
||||
upsertMediaQuery = `
|
||||
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (mxc) DO UPDATE
|
||||
SET enc_file = COALESCE(excluded.enc_file, media.enc_file),
|
||||
file_name = COALESCE(excluded.file_name, media.file_name),
|
||||
mime_type = COALESCE(excluded.mime_type, media.mime_type),
|
||||
size = COALESCE(excluded.size, media.size),
|
||||
hash = COALESCE(excluded.hash, media.hash),
|
||||
error = excluded.error
|
||||
file_name = COALESCE(excluded.file_name, media.file_name),
|
||||
mime_type = COALESCE(excluded.mime_type, media.mime_type),
|
||||
size = COALESCE(excluded.size, media.size),
|
||||
hash = COALESCE(excluded.hash, media.hash),
|
||||
error = excluded.error,
|
||||
thumbnail_size = COALESCE(excluded.thumbnail_size, media.thumbnail_size),
|
||||
thumbnail_hash = COALESCE(excluded.thumbnail_hash, media.thumbnail_hash),
|
||||
thumbnail_error = excluded.thumbnail_error
|
||||
WHERE excluded.error IS NULL OR media.hash IS NULL
|
||||
`
|
||||
getMediaQuery = `
|
||||
SELECT mxc, enc_file, file_name, mime_type, size, hash, error
|
||||
SELECT mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error
|
||||
FROM media
|
||||
WHERE mxc = $1
|
||||
`
|
||||
|
@ -137,9 +140,22 @@ type Media struct {
|
|||
Size int64
|
||||
Hash *[32]byte
|
||||
Error *MediaError
|
||||
|
||||
ThumbnailError string
|
||||
ThumbnailSize int64
|
||||
ThumbnailHash *[32]byte
|
||||
}
|
||||
|
||||
func (m *Media) ETag() string {
|
||||
func (m *Media) ETag(thumbnail bool) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if thumbnail {
|
||||
if m.ThumbnailHash == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(`"%x"`, m.ThumbnailHash)
|
||||
}
|
||||
if m.Hash == nil {
|
||||
return ""
|
||||
}
|
||||
|
@ -151,14 +167,18 @@ func (m *Media) UseCache() bool {
|
|||
}
|
||||
|
||||
func (m *Media) sqlVariables() []any {
|
||||
var hash []byte
|
||||
var hash, thumbnailHash []byte
|
||||
if m.Hash != nil {
|
||||
hash = m.Hash[:]
|
||||
}
|
||||
if m.ThumbnailHash != nil {
|
||||
thumbnailHash = m.ThumbnailHash[:]
|
||||
}
|
||||
return []any{
|
||||
&m.MXC, dbutil.JSONPtr(m.EncFile),
|
||||
dbutil.StrPtr(m.FileName), dbutil.StrPtr(m.MimeType), dbutil.NumPtr(m.Size),
|
||||
hash, dbutil.JSONPtr(m.Error),
|
||||
dbutil.NumPtr(m.ThumbnailSize), thumbnailHash, dbutil.StrPtr(m.ThumbnailError),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -172,19 +192,27 @@ var safeMimes = []string{
|
|||
}
|
||||
|
||||
func (m *Media) Scan(row dbutil.Scannable) (*Media, error) {
|
||||
var mimeType, fileName sql.NullString
|
||||
var size sql.NullInt64
|
||||
var hash []byte
|
||||
err := row.Scan(&m.MXC, dbutil.JSON{Data: &m.EncFile}, &fileName, &mimeType, &size, &hash, dbutil.JSON{Data: &m.Error})
|
||||
var mimeType, fileName, thumbnailError sql.NullString
|
||||
var size, thumbnailSize sql.NullInt64
|
||||
var hash, thumbnailHash []byte
|
||||
err := row.Scan(
|
||||
&m.MXC, dbutil.JSON{Data: &m.EncFile}, &fileName, &mimeType, &size,
|
||||
&hash, dbutil.JSON{Data: &m.Error}, &thumbnailSize, &thumbnailHash, &thumbnailError,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.MimeType = mimeType.String
|
||||
m.FileName = fileName.String
|
||||
m.Size = size.Int64
|
||||
m.ThumbnailSize = thumbnailSize.Int64
|
||||
m.ThumbnailError = thumbnailError.String
|
||||
if len(hash) == 32 {
|
||||
m.Hash = (*[32]byte)(hash)
|
||||
}
|
||||
if len(thumbnailHash) == 32 {
|
||||
m.ThumbnailHash = (*[32]byte)(thumbnailHash)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
|
78
pkg/hicli/database/pushregistration.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
// Copyright (c) 2025 Tulir Asokan
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
"go.mau.fi/util/jsontime"
|
||||
)
|
||||
|
||||
const (
|
||||
getNonExpiredPushTargets = `
|
||||
SELECT device_id, type, data, encryption, expiration
|
||||
FROM push_registration
|
||||
WHERE expiration > $1
|
||||
`
|
||||
putPushRegistration = `
|
||||
INSERT INTO push_registration (device_id, type, data, encryption, expiration)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (device_id) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
data = EXCLUDED.data,
|
||||
encryption = EXCLUDED.encryption,
|
||||
expiration = EXCLUDED.expiration
|
||||
`
|
||||
)
|
||||
|
||||
type PushRegistrationQuery struct {
|
||||
*dbutil.QueryHelper[*PushRegistration]
|
||||
}
|
||||
|
||||
func (prq *PushRegistrationQuery) Put(ctx context.Context, reg *PushRegistration) error {
|
||||
return prq.Exec(ctx, putPushRegistration, reg.sqlVariables()...)
|
||||
}
|
||||
|
||||
func (seq *PushRegistrationQuery) GetAll(ctx context.Context) ([]*PushRegistration, error) {
|
||||
return seq.QueryMany(ctx, getNonExpiredPushTargets, time.Now().Unix())
|
||||
}
|
||||
|
||||
type PushType string
|
||||
|
||||
const (
|
||||
PushTypeFCM PushType = "fcm"
|
||||
)
|
||||
|
||||
type EncryptionKey struct {
|
||||
Key []byte `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
type PushRegistration struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Type PushType `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Encryption EncryptionKey `json:"encryption"`
|
||||
Expiration jsontime.Unix `json:"expiration"`
|
||||
}
|
||||
|
||||
func (pe *PushRegistration) Scan(row dbutil.Scannable) (*PushRegistration, error) {
|
||||
err := row.Scan(&pe.DeviceID, &pe.Type, (*[]byte)(&pe.Data), dbutil.JSON{Data: &pe.Encryption}, &pe.Expiration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pe, nil
|
||||
}
|
||||
|
||||
func (pe *PushRegistration) sqlVariables() []any {
|
||||
if pe.Expiration.IsZero() {
|
||||
pe.Expiration = jsontime.U(time.Now().Add(7 * 24 * time.Hour))
|
||||
}
|
||||
return []interface{}{pe.DeviceID, pe.Type, unsafeJSONString(pe.Data), dbutil.JSON{Data: &pe.Encryption}, pe.Expiration}
|
||||
}
|
|
@ -21,7 +21,8 @@ import (
|
|||
|
||||
const (
|
||||
getRoomBaseQuery = `
|
||||
SELECT room_id, creation_content, tombstone_content, name, name_quality, avatar, explicit_avatar, topic, canonical_alias,
|
||||
SELECT room_id, creation_content, tombstone_content, name, name_quality,
|
||||
avatar, explicit_avatar, dm_user_id, topic, canonical_alias,
|
||||
lazy_load_summary, encryption_event, has_member_list, preview_event_rowid, sorting_timestamp,
|
||||
unread_highlights, unread_notifications, unread_messages, marked_unread, prev_batch
|
||||
FROM room
|
||||
|
@ -42,18 +43,19 @@ const (
|
|||
name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END,
|
||||
avatar = COALESCE($6, room.avatar),
|
||||
explicit_avatar = CASE WHEN $6 IS NOT NULL THEN $7 ELSE room.explicit_avatar END,
|
||||
topic = COALESCE($8, room.topic),
|
||||
canonical_alias = COALESCE($9, room.canonical_alias),
|
||||
lazy_load_summary = COALESCE($10, room.lazy_load_summary),
|
||||
encryption_event = COALESCE($11, room.encryption_event),
|
||||
has_member_list = room.has_member_list OR $12,
|
||||
preview_event_rowid = COALESCE($13, room.preview_event_rowid),
|
||||
sorting_timestamp = COALESCE($14, room.sorting_timestamp),
|
||||
unread_highlights = COALESCE($15, room.unread_highlights),
|
||||
unread_notifications = COALESCE($16, room.unread_notifications),
|
||||
unread_messages = COALESCE($17, room.unread_messages),
|
||||
marked_unread = COALESCE($18, room.marked_unread),
|
||||
prev_batch = COALESCE($19, room.prev_batch)
|
||||
dm_user_id = COALESCE($8, room.dm_user_id),
|
||||
topic = COALESCE($9, room.topic),
|
||||
canonical_alias = COALESCE($10, room.canonical_alias),
|
||||
lazy_load_summary = COALESCE($11, room.lazy_load_summary),
|
||||
encryption_event = COALESCE($12, room.encryption_event),
|
||||
has_member_list = room.has_member_list OR $13,
|
||||
preview_event_rowid = COALESCE($14, room.preview_event_rowid),
|
||||
sorting_timestamp = COALESCE($15, room.sorting_timestamp),
|
||||
unread_highlights = COALESCE($16, room.unread_highlights),
|
||||
unread_notifications = COALESCE($17, room.unread_notifications),
|
||||
unread_messages = COALESCE($18, room.unread_messages),
|
||||
marked_unread = COALESCE($19, room.marked_unread),
|
||||
prev_batch = COALESCE($20, room.prev_batch)
|
||||
WHERE room_id = $1
|
||||
`
|
||||
setRoomPrevBatchQuery = `
|
||||
|
@ -78,7 +80,7 @@ const (
|
|||
AND (type IN ('m.room.message', 'm.sticker')
|
||||
OR (type = 'm.room.encrypted'
|
||||
AND decrypted_type IN ('m.room.message', 'm.sticker')))
|
||||
AND relation_type <> 'm.replace'
|
||||
AND (relation_type IS NULL OR relation_type <> 'm.replace')
|
||||
AND redacted_by IS NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
|
@ -130,6 +132,9 @@ func (rq *RoomQuery) UpdatePreviewIfLaterOnTimeline(ctx context.Context, roomID
|
|||
|
||||
func (rq *RoomQuery) RecalculatePreview(ctx context.Context, roomID id.RoomID) (rowID EventRowID, err error) {
|
||||
err = rq.GetDB().QueryRow(ctx, recalculateRoomPreviewEventQuery, roomID).Scan(&rowID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -153,6 +158,7 @@ type Room struct {
|
|||
NameQuality NameQuality `json:"name_quality"`
|
||||
Avatar *id.ContentURI `json:"avatar,omitempty"`
|
||||
ExplicitAvatar bool `json:"explicit_avatar"`
|
||||
DMUserID *id.UserID `json:"dm_user_id,omitempty"`
|
||||
Topic *string `json:"topic,omitempty"`
|
||||
CanonicalAlias *id.RoomAlias `json:"canonical_alias,omitempty"`
|
||||
|
||||
|
@ -188,6 +194,10 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) {
|
|||
other.ExplicitAvatar = r.ExplicitAvatar
|
||||
hasChanges = true
|
||||
}
|
||||
if r.DMUserID != nil {
|
||||
other.DMUserID = r.DMUserID
|
||||
hasChanges = true
|
||||
}
|
||||
if r.Topic != nil {
|
||||
other.Topic = r.Topic
|
||||
hasChanges = true
|
||||
|
@ -208,7 +218,7 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) {
|
|||
hasChanges = true
|
||||
other.HasMemberList = true
|
||||
}
|
||||
if r.PreviewEventRowID > other.PreviewEventRowID {
|
||||
if r.PreviewEventRowID != 0 {
|
||||
other.PreviewEventRowID = r.PreviewEventRowID
|
||||
hasChanges = true
|
||||
}
|
||||
|
@ -250,6 +260,7 @@ func (r *Room) Scan(row dbutil.Scannable) (*Room, error) {
|
|||
&r.NameQuality,
|
||||
&r.Avatar,
|
||||
&r.ExplicitAvatar,
|
||||
&r.DMUserID,
|
||||
&r.Topic,
|
||||
&r.CanonicalAlias,
|
||||
dbutil.JSON{Data: &r.LazyLoadSummary},
|
||||
|
@ -281,6 +292,7 @@ func (r *Room) sqlVariables() []any {
|
|||
r.NameQuality,
|
||||
r.Avatar,
|
||||
r.ExplicitAvatar,
|
||||
r.DMUserID,
|
||||
r.Topic,
|
||||
r.CanonicalAlias,
|
||||
dbutil.JSONPtr(r.LazyLoadSummary),
|
||||
|
|
|
@ -39,6 +39,7 @@ const (
|
|||
`
|
||||
getCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1`
|
||||
getCurrentRoomStateWithoutMembersQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND type<>'m.room.member'`
|
||||
getCurrentRoomStateMembersQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND type='m.room.member'`
|
||||
getManyCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE (cs.room_id, cs.event_type, cs.state_key) IN (%s)`
|
||||
getCurrentStateEventQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND cs.event_type = $2 AND cs.state_key = $3`
|
||||
)
|
||||
|
@ -118,3 +119,7 @@ func (csq *CurrentStateQuery) GetAll(ctx context.Context, roomID id.RoomID) ([]*
|
|||
func (csq *CurrentStateQuery) GetAllExceptMembers(ctx context.Context, roomID id.RoomID) ([]*Event, error) {
|
||||
return csq.QueryMany(ctx, getCurrentRoomStateWithoutMembersQuery, roomID)
|
||||
}
|
||||
|
||||
func (csq *CurrentStateQuery) GetMembers(ctx context.Context, roomID id.RoomID) ([]*Event, error) {
|
||||
return csq.QueryMany(ctx, getCurrentRoomStateMembersQuery, roomID)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
-- v0 -> v10 (compatible with v10+): Latest revision
|
||||
-- v0 -> v13 (compatible with v10+): Latest revision
|
||||
CREATE TABLE account (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
|
@ -18,6 +18,7 @@ CREATE TABLE room (
|
|||
name_quality INTEGER NOT NULL DEFAULT 0,
|
||||
avatar TEXT,
|
||||
explicit_avatar INTEGER NOT NULL DEFAULT 0,
|
||||
dm_user_id TEXT,
|
||||
topic TEXT,
|
||||
canonical_alias TEXT,
|
||||
lazy_load_summary TEXT,
|
||||
|
@ -211,13 +212,17 @@ BEGIN
|
|||
END;
|
||||
|
||||
CREATE TABLE media (
|
||||
mxc TEXT NOT NULL PRIMARY KEY,
|
||||
enc_file TEXT,
|
||||
file_name TEXT,
|
||||
mime_type TEXT,
|
||||
size INTEGER,
|
||||
hash BLOB,
|
||||
error TEXT
|
||||
mxc TEXT NOT NULL PRIMARY KEY,
|
||||
enc_file TEXT,
|
||||
file_name TEXT,
|
||||
mime_type TEXT,
|
||||
size INTEGER,
|
||||
hash BLOB,
|
||||
error TEXT,
|
||||
|
||||
thumbnail_size INTEGER,
|
||||
thumbnail_hash BLOB,
|
||||
thumbnail_error TEXT
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE media_reference (
|
||||
|
@ -300,3 +305,13 @@ CREATE TABLE space_edge (
|
|||
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
|
||||
) STRICT;
|
||||
CREATE INDEX space_edge_child_idx ON space_edge (child_id);
|
||||
|
||||
CREATE TABLE push_registration (
|
||||
device_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
encryption TEXT NOT NULL,
|
||||
expiration INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (device_id)
|
||||
) STRICT;
|
||||
|
|
19
pkg/hicli/database/upgrades/11-dm-user-id.sql
Normal file
|
@ -0,0 +1,19 @@
|
|||
-- v11 (compatible with v10+): Store direct chat user ID in database
|
||||
ALTER TABLE room ADD COLUMN dm_user_id TEXT;
|
||||
WITH dm_user_ids AS (
|
||||
SELECT room_id, value
|
||||
FROM room
|
||||
INNER JOIN json_each(lazy_load_summary, '$."m.heroes"')
|
||||
WHERE value NOT IN (SELECT value FROM json_each((
|
||||
SELECT event.content
|
||||
FROM current_state cs
|
||||
INNER JOIN event ON cs.event_rowid = event.rowid
|
||||
WHERE cs.room_id=room.room_id AND cs.event_type='io.element.functional_members' AND cs.state_key=''
|
||||
), '$.service_members'))
|
||||
GROUP BY room_id
|
||||
HAVING COUNT(*) = 1
|
||||
)
|
||||
UPDATE room
|
||||
SET dm_user_id=value
|
||||
FROM dm_user_ids du
|
||||
WHERE room.room_id=du.room_id;
|
10
pkg/hicli/database/upgrades/12-push-registrations.sql
Normal file
|
@ -0,0 +1,10 @@
|
|||
-- v12 (compatible with v10+): Add table for push registrations
|
||||
CREATE TABLE push_registration (
|
||||
device_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
encryption TEXT NOT NULL,
|
||||
expiration INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (device_id)
|
||||
) STRICT;
|
4
pkg/hicli/database/upgrades/13-media-thumbnails.sql
Normal file
|
@ -0,0 +1,4 @@
|
|||
-- v13 (compatible with v10+): Add columns for media thumbnails
|
||||
ALTER TABLE media ADD COLUMN thumbnail_size INTEGER;
|
||||
ALTER TABLE media ADD COLUMN thumbnail_hash BLOB;
|
||||
ALTER TABLE media ADD COLUMN thumbnail_error TEXT;
|
|
@ -7,6 +7,8 @@
|
|||
package hicli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"go.mau.fi/util/jsontime"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
@ -15,19 +17,31 @@ import (
|
|||
)
|
||||
|
||||
type SyncRoom struct {
|
||||
Meta *database.Room `json:"meta"`
|
||||
Timeline []database.TimelineRowTuple `json:"timeline"`
|
||||
State map[event.Type]map[string]database.EventRowID `json:"state"`
|
||||
AccountData map[event.Type]*database.AccountData `json:"account_data"`
|
||||
Events []*database.Event `json:"events"`
|
||||
Reset bool `json:"reset"`
|
||||
Notifications []SyncNotification `json:"notifications"`
|
||||
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
|
||||
Meta *database.Room `json:"meta"`
|
||||
Timeline []database.TimelineRowTuple `json:"timeline"`
|
||||
State map[event.Type]map[string]database.EventRowID `json:"state"`
|
||||
AccountData map[event.Type]*database.AccountData `json:"account_data"`
|
||||
Events []*database.Event `json:"events"`
|
||||
Reset bool `json:"reset"`
|
||||
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
|
||||
|
||||
DismissNotifications bool `json:"dismiss_notifications"`
|
||||
Notifications []SyncNotification `json:"notifications"`
|
||||
}
|
||||
|
||||
type SyncNotification struct {
|
||||
RowID database.EventRowID `json:"event_rowid"`
|
||||
Sound bool `json:"sound"`
|
||||
RowID database.EventRowID `json:"event_rowid"`
|
||||
Sound bool `json:"sound"`
|
||||
Highlight bool `json:"highlight"`
|
||||
Event *database.Event `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 {
|
||||
|
@ -39,6 +53,18 @@ type SyncComplete struct {
|
|||
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
|
||||
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
|
||||
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
|
||||
|
||||
ToDevice []*SyncToDevice `json:"to_device,omitempty"`
|
||||
}
|
||||
|
||||
func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) {
|
||||
for _, room := range c.Rooms {
|
||||
for _, notif := range room.Notifications {
|
||||
if !yield(notif) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SyncComplete) IsEmpty() bool {
|
||||
|
|
|
@ -50,6 +50,8 @@ type HiClient struct {
|
|||
syncErrors int
|
||||
lastSync time.Time
|
||||
|
||||
ToDeviceInSync atomic.Bool
|
||||
|
||||
EventHandler func(evt any)
|
||||
LogoutFunc func(context.Context) error
|
||||
|
||||
|
|
|
@ -101,7 +101,7 @@ func main() {
|
|||
resp, err := cli.Send(ctx, id.RoomID(fields[1]), event.EventMessage, &event.MessageEventContent{
|
||||
Body: strings.Join(fields[2:], " "),
|
||||
MsgType: event.MsgText,
|
||||
})
|
||||
}, false, false)
|
||||
_, _ = fmt.Fprintln(rl, err)
|
||||
_, _ = fmt.Fprintf(rl, "%+v\n", resp)
|
||||
}
|
||||
|
|
|
@ -414,24 +414,42 @@ var HTMLSanitizerImgSrcTemplate = "mxc://%s/%s"
|
|||
|
||||
func writeImg(w *strings.Builder, attr []html.Attribute) id.ContentURI {
|
||||
src, alt, title, isCustomEmoji, width, height := parseImgAttributes(attr)
|
||||
mxc := id.ContentURIString(src).ParseOrIgnore()
|
||||
if !mxc.IsValid() {
|
||||
w.WriteString("<span")
|
||||
writeAttribute(w, "class", "hicli-inline-img-fallback hicli-invalid-inline-img")
|
||||
w.WriteString(">")
|
||||
writeEscapedString(w, alt)
|
||||
w.WriteString("</span>")
|
||||
return id.ContentURI{}
|
||||
}
|
||||
url := fmt.Sprintf(HTMLSanitizerImgSrcTemplate, mxc.Homeserver, mxc.FileID)
|
||||
|
||||
w.WriteString("<a")
|
||||
writeAttribute(w, "class", "hicli-inline-img-fallback hicli-mxc-url")
|
||||
writeAttribute(w, "title", title)
|
||||
writeAttribute(w, "style", "display: none;")
|
||||
writeAttribute(w, "target", "_blank")
|
||||
writeAttribute(w, "data-mxc", mxc.String())
|
||||
writeAttribute(w, "href", url)
|
||||
w.WriteString(">")
|
||||
writeEscapedString(w, alt)
|
||||
w.WriteString("</a>")
|
||||
|
||||
w.WriteString("<img")
|
||||
writeAttribute(w, "alt", alt)
|
||||
if title != "" {
|
||||
writeAttribute(w, "title", title)
|
||||
}
|
||||
mxc := id.ContentURIString(src).ParseOrIgnore()
|
||||
if !mxc.IsValid() {
|
||||
return id.ContentURI{}
|
||||
}
|
||||
writeAttribute(w, "src", fmt.Sprintf(HTMLSanitizerImgSrcTemplate, mxc.Homeserver, mxc.FileID))
|
||||
writeAttribute(w, "src", url)
|
||||
writeAttribute(w, "loading", "lazy")
|
||||
if isCustomEmoji {
|
||||
writeAttribute(w, "class", "hicli-custom-emoji")
|
||||
writeAttribute(w, "class", "hicli-inline-img hicli-custom-emoji")
|
||||
} else if cWidth, cHeight, sizeOK := calculateMediaSize(width, height); sizeOK {
|
||||
writeAttribute(w, "class", "hicli-sized-inline-img")
|
||||
writeAttribute(w, "class", "hicli-inline-img hicli-sized-inline-img")
|
||||
writeAttribute(w, "style", fmt.Sprintf("width: %.2fpx; height: %.2fpx;", cWidth, cHeight))
|
||||
} else {
|
||||
writeAttribute(w, "class", "hicli-sizeless-inline-img")
|
||||
writeAttribute(w, "class", "hicli-inline-img hicli-sizeless-inline-img")
|
||||
}
|
||||
return mxc
|
||||
}
|
||||
|
|
|
@ -120,8 +120,11 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
|||
payload := SyncComplete{
|
||||
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)),
|
||||
}
|
||||
for _, room := range rooms {
|
||||
for roomIdx, room := range rooms {
|
||||
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
|
||||
if roomIdx == 0 {
|
||||
batchSize *= 2
|
||||
}
|
||||
break
|
||||
}
|
||||
maxTS = room.SortingTimestamp.Time
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
"maunium.net/go/mautrix/pushrules"
|
||||
|
||||
"go.mau.fi/gomuks/pkg/hicli/database"
|
||||
)
|
||||
|
@ -46,7 +47,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
|||
})
|
||||
case "send_event":
|
||||
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
|
||||
return h.Send(ctx, params.RoomID, params.EventType, params.Content)
|
||||
return h.Send(ctx, params.RoomID, params.EventType, params.Content, params.DisableEncryption, params.Synchronous)
|
||||
})
|
||||
case "resend_event":
|
||||
return unmarshalAndCall(req.Data, func(params *resendEventParams) (*database.Event, error) {
|
||||
|
@ -64,7 +65,31 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
|||
})
|
||||
case "set_state":
|
||||
return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) {
|
||||
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content)
|
||||
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content, mautrix.ReqSendEvent{
|
||||
UnstableDelay: time.Duration(params.DelayMS) * time.Millisecond,
|
||||
})
|
||||
})
|
||||
case "update_delayed_event":
|
||||
return unmarshalAndCall(req.Data, func(params *updateDelayedEventParams) (*mautrix.RespUpdateDelayedEvent, error) {
|
||||
return h.Client.UpdateDelayedEvent(ctx, &mautrix.ReqUpdateDelayedEvent{
|
||||
DelayID: params.DelayID,
|
||||
Action: params.Action,
|
||||
})
|
||||
})
|
||||
case "set_membership":
|
||||
return unmarshalAndCall(req.Data, func(params *setMembershipParams) (any, error) {
|
||||
switch params.Action {
|
||||
case "invite":
|
||||
return h.Client.InviteUser(ctx, params.RoomID, &mautrix.ReqInviteUser{UserID: params.UserID, Reason: params.Reason})
|
||||
case "kick":
|
||||
return h.Client.KickUser(ctx, params.RoomID, &mautrix.ReqKickUser{UserID: params.UserID, Reason: params.Reason})
|
||||
case "ban":
|
||||
return h.Client.BanUser(ctx, params.RoomID, &mautrix.ReqBanUser{UserID: params.UserID, Reason: params.Reason})
|
||||
case "unban":
|
||||
return h.Client.UnbanUser(ctx, params.RoomID, &mautrix.ReqUnbanUser{UserID: params.UserID, Reason: params.Reason})
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown action %q", params.Action)
|
||||
}
|
||||
})
|
||||
case "set_account_data":
|
||||
return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) {
|
||||
|
@ -86,6 +111,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
|||
return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) {
|
||||
return h.Client.GetProfile(ctx, params.UserID)
|
||||
})
|
||||
case "set_profile_field":
|
||||
return unmarshalAndCall(req.Data, func(params *setProfileFieldParams) (bool, error) {
|
||||
return true, h.Client.UnstableSetProfileField(ctx, params.Field, params.Value)
|
||||
})
|
||||
case "get_mutual_rooms":
|
||||
return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) {
|
||||
return h.GetMutualRooms(ctx, params.UserID)
|
||||
|
@ -104,12 +133,15 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
|||
})
|
||||
case "get_event":
|
||||
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
|
||||
if params.Unredact {
|
||||
return h.GetUnredactedEvent(ctx, params.RoomID, params.EventID)
|
||||
}
|
||||
return h.GetEvent(ctx, params.RoomID, params.EventID)
|
||||
})
|
||||
//case "get_events_by_rowids":
|
||||
// return unmarshalAndCall(req.Data, func(params *getEventsByRowIDsParams) ([]*database.Event, error) {
|
||||
// return h.GetEventsByRowIDs(ctx, params.RowIDs)
|
||||
// })
|
||||
case "get_related_events":
|
||||
return unmarshalAndCall(req.Data, func(params *getRelatedEventsParams) ([]*database.Event, error) {
|
||||
return h.DB.Event.GetRelatedEvents(ctx, params.RoomID, params.EventID, params.RelationType)
|
||||
})
|
||||
case "get_room_state":
|
||||
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
|
||||
return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch)
|
||||
|
@ -145,14 +177,35 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
|||
return unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) {
|
||||
return h.Client.LeaveRoom(ctx, params.RoomID, &mautrix.ReqLeave{Reason: params.Reason})
|
||||
})
|
||||
case "create_room":
|
||||
return unmarshalAndCall(req.Data, func(params *mautrix.ReqCreateRoom) (*mautrix.RespCreateRoom, error) {
|
||||
return h.Client.CreateRoom(ctx, params)
|
||||
})
|
||||
case "mute_room":
|
||||
return unmarshalAndCall(req.Data, func(params *muteRoomParams) (bool, error) {
|
||||
if params.Muted {
|
||||
return true, h.Client.PutPushRule(ctx, "global", pushrules.RoomRule, string(params.RoomID), &mautrix.ReqPutPushRule{
|
||||
Actions: []pushrules.PushActionType{},
|
||||
})
|
||||
} else {
|
||||
return false, h.Client.DeletePushRule(ctx, "global", pushrules.RoomRule, string(params.RoomID))
|
||||
}
|
||||
})
|
||||
case "ensure_group_session_shared":
|
||||
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
|
||||
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":
|
||||
return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
|
||||
return h.Client.ResolveAlias(ctx, params.Alias)
|
||||
})
|
||||
case "request_openid_token":
|
||||
return h.Client.RequestOpenIDToken(ctx)
|
||||
case "logout":
|
||||
if h.LogoutFunc == nil {
|
||||
return nil, errors.New("logout not supported")
|
||||
|
@ -195,6 +248,18 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
|||
}
|
||||
return cli.GetLoginFlows(ctx)
|
||||
})
|
||||
case "register_push":
|
||||
return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
|
||||
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:
|
||||
return nil, fmt.Errorf("unknown command %q", req.Command)
|
||||
}
|
||||
|
@ -224,9 +289,11 @@ type sendMessageParams struct {
|
|||
}
|
||||
|
||||
type sendEventParams struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
EventType event.Type `json:"type"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
EventType event.Type `json:"type"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
DisableEncryption bool `json:"disable_encryption"`
|
||||
Synchronous bool `json:"synchronous"`
|
||||
}
|
||||
|
||||
type resendEventParams struct {
|
||||
|
@ -250,6 +317,19 @@ type sendStateEventParams struct {
|
|||
EventType event.Type `json:"type"`
|
||||
StateKey string `json:"state_key"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
DelayMS int `json:"delay_ms"`
|
||||
}
|
||||
|
||||
type updateDelayedEventParams struct {
|
||||
DelayID string `json:"delay_id"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type setMembershipParams struct {
|
||||
Action string `json:"action"`
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
UserID id.UserID `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type setAccountDataParams struct {
|
||||
|
@ -273,14 +353,23 @@ type getProfileParams struct {
|
|||
UserID id.UserID `json:"user_id"`
|
||||
}
|
||||
|
||||
type getEventParams struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
EventID id.EventID `json:"event_id"`
|
||||
type setProfileFieldParams struct {
|
||||
Field string `json:"field"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
//type getEventsByRowIDsParams struct {
|
||||
// RowIDs []database.EventRowID `json:"row_ids"`
|
||||
//}
|
||||
type getEventParams struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
EventID id.EventID `json:"event_id"`
|
||||
Unredact bool `json:"unredact"`
|
||||
}
|
||||
|
||||
type getRelatedEventsParams struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
EventID id.EventID `json:"event_id"`
|
||||
|
||||
RelationType event.RelationType `json:"relation_type"`
|
||||
}
|
||||
|
||||
type getRoomStateParams struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
|
@ -297,6 +386,12 @@ type ensureGroupSessionSharedParams struct {
|
|||
RoomID id.RoomID `json:"room_id"`
|
||||
}
|
||||
|
||||
type sendToDeviceParams struct {
|
||||
*mautrix.ReqSendToDevice
|
||||
EventType event.Type `json:"event_type"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
}
|
||||
|
||||
type resolveAliasParams struct {
|
||||
Alias id.RoomAlias `json:"alias"`
|
||||
}
|
||||
|
@ -345,3 +440,8 @@ type getReceiptsParams struct {
|
|||
RoomID id.RoomID `json:"room_id"`
|
||||
EventIDs []id.EventID `json:"event_ids"`
|
||||
}
|
||||
|
||||
type muteRoomParams struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
Muted bool `json:"muted"`
|
||||
}
|
||||
|
|
|
@ -22,37 +22,6 @@ import (
|
|||
|
||||
var ErrPaginationAlreadyInProgress = errors.New("pagination is already in progress")
|
||||
|
||||
/*func (h *HiClient) GetEventsByRowIDs(ctx context.Context, rowIDs []database.EventRowID) ([]*database.Event, error) {
|
||||
events, err := h.DB.Event.GetByRowIDs(ctx, rowIDs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if len(events) == 0 {
|
||||
return events, nil
|
||||
}
|
||||
firstRoomID := events[0].RoomID
|
||||
allInSameRoom := true
|
||||
for _, evt := range events {
|
||||
h.ReprocessExistingEvent(ctx, evt)
|
||||
if evt.RoomID != firstRoomID {
|
||||
allInSameRoom = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allInSameRoom {
|
||||
err = h.DB.Event.FillLastEditRowIDs(ctx, firstRoomID, events)
|
||||
if err != nil {
|
||||
return events, fmt.Errorf("failed to fill last edit row IDs: %w", err)
|
||||
}
|
||||
err = h.DB.Event.FillReactionCounts(ctx, firstRoomID, events)
|
||||
if err != nil {
|
||||
return events, fmt.Errorf("failed to fill reaction counts: %w", err)
|
||||
}
|
||||
} else {
|
||||
// TODO slow path where events are collected and filling is done one room at a time?
|
||||
}
|
||||
return events, nil
|
||||
}*/
|
||||
|
||||
func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*database.Event, error) {
|
||||
if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
|
||||
return nil, fmt.Errorf("failed to get event from database: %w", err)
|
||||
|
@ -66,6 +35,26 @@ func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.Ev
|
|||
}
|
||||
}
|
||||
|
||||
func (h *HiClient) GetUnredactedEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*database.Event, error) {
|
||||
if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
|
||||
return nil, fmt.Errorf("failed to get event from database: %w", err)
|
||||
// TODO this check doesn't handle events which keep some fields on redaction
|
||||
} else if evt != nil && len(evt.Content) > 2 {
|
||||
h.ReprocessExistingEvent(ctx, evt)
|
||||
return evt, nil
|
||||
} else if serverEvt, err := h.Client.GetUnredactedEventContent(ctx, roomID, eventID); err != nil {
|
||||
return nil, fmt.Errorf("failed to get event from server: %w", err)
|
||||
} else if redactedServerEvt, err := h.Client.GetEvent(ctx, roomID, eventID); err != nil {
|
||||
return nil, fmt.Errorf("failed to get redacted event from server: %w", err)
|
||||
// TODO this check will have false positives on actually empty events
|
||||
} else if len(serverEvt.Content.VeryRaw) == 2 {
|
||||
return nil, fmt.Errorf("server didn't return content")
|
||||
} else {
|
||||
serverEvt.Unsigned.RedactedBecause = redactedServerEvt.Unsigned.RedactedBecause
|
||||
return h.processEvent(ctx, serverEvt, nil, nil, false)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fetchMembers, refetch, dispatchEvt bool) error {
|
||||
var evts []*event.Event
|
||||
if refetch {
|
||||
|
@ -279,6 +268,7 @@ func (h *HiClient) GetReceipts(ctx context.Context, roomID id.RoomID, eventIDs [
|
|||
|
||||
func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit int) (*PaginationResponse, error) {
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
defer cancel(context.Canceled)
|
||||
h.paginationInterrupterLock.Lock()
|
||||
if _, alreadyPaginating := h.paginationInterrupter[roomID]; alreadyPaginating {
|
||||
h.paginationInterrupterLock.Unlock()
|
||||
|
@ -306,12 +296,12 @@ func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit i
|
|||
if resp.End == "" {
|
||||
resp.End = database.PrevBatchPaginationComplete
|
||||
}
|
||||
if resp.End == database.PrevBatchPaginationComplete || len(resp.Chunk) == 0 {
|
||||
if len(resp.Chunk) == 0 {
|
||||
err = h.DB.Room.SetPrevBatch(ctx, room.ID, resp.End)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set prev_batch: %w", err)
|
||||
}
|
||||
return &PaginationResponse{Events: events, HasMore: resp.End != ""}, nil
|
||||
return &PaginationResponse{Events: events, HasMore: resp.End != database.PrevBatchPaginationComplete}, nil
|
||||
}
|
||||
wakeupSessionRequests := false
|
||||
err = h.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
|
||||
|
@ -376,5 +366,5 @@ func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit i
|
|||
if err == nil && wakeupSessionRequests {
|
||||
h.WakeupRequestQueue()
|
||||
}
|
||||
return &PaginationResponse{Events: events, HasMore: true}, err
|
||||
return &PaginationResponse{Events: events, HasMore: resp.End != database.PrevBatchPaginationComplete}, err
|
||||
}
|
||||
|
|
|
@ -71,6 +71,13 @@ func (h *HiClient) SendMessage(
|
|||
relatesTo *event.RelatesTo,
|
||||
mentions *event.Mentions,
|
||||
) (*database.Event, error) {
|
||||
if text == "/discardsession" {
|
||||
err := h.CryptoStore.RemoveOutboundGroupSession(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("outbound megolm session successfully discarded")
|
||||
}
|
||||
var unencrypted bool
|
||||
if strings.HasPrefix(text, "/unencrypted ") {
|
||||
text = strings.TrimPrefix(text, "/unencrypted ")
|
||||
|
@ -90,7 +97,7 @@ func (h *HiClient) SendMessage(
|
|||
if !json.Valid(content) {
|
||||
return nil, fmt.Errorf("invalid JSON in /raw command")
|
||||
}
|
||||
return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "", unencrypted)
|
||||
return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "", unencrypted, false)
|
||||
} else if strings.HasPrefix(text, "/rawstate ") {
|
||||
parts := strings.SplitN(text, " ", 4)
|
||||
if len(parts) < 4 || len(parts[1]) == 0 {
|
||||
|
@ -154,12 +161,18 @@ func (h *HiClient) SendMessage(
|
|||
Body: "",
|
||||
MsgType: contentCopy.MsgType,
|
||||
URL: contentCopy.URL,
|
||||
GeoURI: contentCopy.GeoURI,
|
||||
NewContent: &contentCopy,
|
||||
RelatesTo: relatesTo,
|
||||
}
|
||||
if contentCopy.File != nil {
|
||||
content.URL = contentCopy.File.URL
|
||||
}
|
||||
if extra != nil {
|
||||
extra = map[string]any{
|
||||
"m.new_content": extra,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content.RelatesTo = relatesTo
|
||||
}
|
||||
|
@ -169,7 +182,7 @@ func (h *HiClient) SendMessage(
|
|||
content.MsgType = ""
|
||||
evtType = event.EventSticker
|
||||
}
|
||||
return h.send(ctx, roomID, evtType, &event.Content{Parsed: content, Raw: extra}, origText, unencrypted)
|
||||
return h.send(ctx, roomID, evtType, &event.Content{Parsed: content, Raw: extra}, origText, unencrypted, false)
|
||||
}
|
||||
|
||||
func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error {
|
||||
|
@ -213,6 +226,7 @@ func (h *HiClient) SetState(
|
|||
evtType event.Type,
|
||||
stateKey string,
|
||||
content any,
|
||||
extra ...mautrix.ReqSendEvent,
|
||||
) (id.EventID, error) {
|
||||
room, err := h.DB.Room.Get(ctx, roomID)
|
||||
if err != nil {
|
||||
|
@ -220,10 +234,14 @@ func (h *HiClient) SetState(
|
|||
} else if room == nil {
|
||||
return "", fmt.Errorf("unknown room")
|
||||
}
|
||||
resp, err := h.Client.SendStateEvent(ctx, room.ID, evtType, stateKey, content)
|
||||
resp, err := h.Client.SendStateEvent(ctx, room.ID, evtType, stateKey, content, extra...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.UnstableDelayID != "" {
|
||||
// Mildly hacky, but it's fine'
|
||||
return id.EventID(resp.UnstableDelayID), nil
|
||||
}
|
||||
return resp.EventID, nil
|
||||
}
|
||||
|
||||
|
@ -232,8 +250,14 @@ func (h *HiClient) Send(
|
|||
roomID id.RoomID,
|
||||
evtType event.Type,
|
||||
content any,
|
||||
disableEncryption bool,
|
||||
synchronous bool,
|
||||
) (*database.Event, error) {
|
||||
return h.send(ctx, roomID, evtType, content, "", false)
|
||||
if evtType == event.EventRedaction {
|
||||
// TODO implement
|
||||
return nil, fmt.Errorf("redaction is not supported")
|
||||
}
|
||||
return h.send(ctx, roomID, evtType, content, "", disableEncryption, synchronous)
|
||||
}
|
||||
|
||||
func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, error) {
|
||||
|
@ -252,7 +276,7 @@ func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, e
|
|||
return nil, fmt.Errorf("unknown room")
|
||||
}
|
||||
dbEvt.SendError = ""
|
||||
go h.actuallySend(context.WithoutCancel(ctx), room, dbEvt, event.Type{Type: dbEvt.Type, Class: event.MessageEventType})
|
||||
go h.actuallySend(context.WithoutCancel(ctx), room, dbEvt, event.Type{Type: dbEvt.Type, Class: event.MessageEventType}, false)
|
||||
return dbEvt, nil
|
||||
}
|
||||
|
||||
|
@ -263,6 +287,7 @@ func (h *HiClient) send(
|
|||
content any,
|
||||
overrideEditSource string,
|
||||
disableEncryption bool,
|
||||
synchronous bool,
|
||||
) (*database.Event, error) {
|
||||
room, err := h.DB.Room.Get(ctx, roomID)
|
||||
if err != nil {
|
||||
|
@ -321,11 +346,15 @@ func (h *HiClient) send(
|
|||
zerolog.Ctx(ctx).Err(err).Msg("Failed to stop typing while sending message")
|
||||
}
|
||||
}()
|
||||
go h.actuallySend(ctx, room, dbEvt, evtType)
|
||||
if synchronous {
|
||||
h.actuallySend(ctx, room, dbEvt, evtType, true)
|
||||
} else {
|
||||
go h.actuallySend(ctx, room, dbEvt, evtType, false)
|
||||
}
|
||||
return dbEvt, nil
|
||||
}
|
||||
|
||||
func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt *database.Event, evtType event.Type) {
|
||||
func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt *database.Event, evtType event.Type, synchronous bool) {
|
||||
var err error
|
||||
defer func() {
|
||||
if dbEvt.SendError != "" {
|
||||
|
@ -335,10 +364,12 @@ func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt
|
|||
Msg("Failed to update send error in database after sending failed")
|
||||
}
|
||||
}
|
||||
h.EventHandler(&SendComplete{
|
||||
Event: dbEvt,
|
||||
Error: err,
|
||||
})
|
||||
if !synchronous {
|
||||
h.EventHandler(&SendComplete{
|
||||
Event: dbEvt,
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
}()
|
||||
if dbEvt.Decrypted != nil && len(dbEvt.Content) <= 2 {
|
||||
var encryptedContent *event.EncryptedEventContent
|
||||
|
@ -411,6 +442,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 {
|
||||
if room.HasMemberList {
|
||||
return nil
|
||||
|
@ -473,6 +516,9 @@ func (h *HiClient) shouldShareKeysToInvitedUsers(ctx context.Context, roomID id.
|
|||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get history visibility event")
|
||||
return false
|
||||
} else if historyVisibility == nil {
|
||||
zerolog.Ctx(ctx).Warn().Msg("History visibility event not found")
|
||||
return false
|
||||
}
|
||||
mautrixEvt := historyVisibility.AsRawMautrix()
|
||||
err = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)
|
||||
|
|
|
@ -66,6 +66,9 @@ func (h *HiClient) markSyncOK() {
|
|||
|
||||
func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
|
||||
log := zerolog.Ctx(ctx)
|
||||
listenToDevice := h.ToDeviceInSync.Load()
|
||||
var syncTD []*SyncToDevice
|
||||
|
||||
postponedToDevices := resp.ToDevice.Events[:0]
|
||||
for _, evt := range resp.ToDevice.Events {
|
||||
evt.Type.Class = event.ToDeviceEventType
|
||||
|
@ -80,19 +83,80 @@ func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.Res
|
|||
|
||||
switch content := evt.Content.Parsed.(type) {
|
||||
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:
|
||||
h.Crypto.HandleRoomKeyWithheld(ctx, content)
|
||||
default:
|
||||
// TODO move this check to mautrix-go?
|
||||
if evt.Sender == h.Account.UserID && content.Code == event.RoomKeyWithheldUnavailable {
|
||||
log.Debug().Any("withheld_content", content).Msg("Ignoring m.unavailable megolm session withheld event")
|
||||
} else {
|
||||
h.Crypto.HandleRoomKeyWithheld(ctx, content)
|
||||
}
|
||||
case *event.SecretRequestEventContent, *event.RoomKeyRequestEventContent:
|
||||
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
|
||||
if len(syncTD) > 0 {
|
||||
ctx.Value(syncContextKey).(*syncContext).evt.ToDevice = syncTD
|
||||
}
|
||||
h.Crypto.MarkOlmHashSavePoint(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HiClient) maybeDiscardOutboundSession(ctx context.Context, newMembership event.Membership, evt *event.Event) bool {
|
||||
var prevMembership event.Membership = "unknown"
|
||||
if evt.Unsigned.PrevContent != nil {
|
||||
prevMembership = event.Membership(gjson.GetBytes(evt.Unsigned.PrevContent.VeryRaw, "membership").Str)
|
||||
}
|
||||
if prevMembership == "unknown" || prevMembership == "" {
|
||||
cs, err := h.DB.CurrentState.Get(ctx, evt.RoomID, event.StateMember, h.Account.UserID.String())
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Warn().Err(err).
|
||||
Stringer("room_id", evt.RoomID).
|
||||
Str("user_id", evt.GetStateKey()).
|
||||
Msg("Failed to get previous membership")
|
||||
return false
|
||||
}
|
||||
prevMembership = event.Membership(gjson.GetBytes(cs.Content, "membership").Str)
|
||||
}
|
||||
if prevMembership == newMembership ||
|
||||
(prevMembership == event.MembershipInvite && newMembership == event.MembershipJoin && h.shouldShareKeysToInvitedUsers(ctx, evt.RoomID)) ||
|
||||
(prevMembership == event.MembershipJoin && newMembership == event.MembershipInvite) ||
|
||||
(prevMembership == event.MembershipBan && newMembership == event.MembershipLeave) ||
|
||||
(prevMembership == event.MembershipLeave && newMembership == event.MembershipBan) {
|
||||
return false
|
||||
}
|
||||
zerolog.Ctx(ctx).Debug().
|
||||
Stringer("room_id", evt.RoomID).
|
||||
Str("user_id", evt.GetStateKey()).
|
||||
Str("prev_membership", string(prevMembership)).
|
||||
Str("new_membership", string(newMembership)).
|
||||
Msg("Got membership state change, invalidating group session in room")
|
||||
err := h.CryptoStore.RemoveOutboundGroupSession(ctx, evt.RoomID)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Warn().Stringer("room_id", evt.RoomID).Msg("Failed to invalidate outbound group session")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *HiClient) postProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) {
|
||||
h.Crypto.HandleOTKCounts(ctx, &resp.DeviceOTKCount)
|
||||
go h.asyncPostProcessSyncResponse(ctx, resp, since)
|
||||
|
@ -297,6 +361,10 @@ func (h *HiClient) processSyncLeftRoom(ctx context.Context, roomID id.RoomID, ro
|
|||
if err != nil {
|
||||
return fmt.Errorf("failed to delete invited room: %w", err)
|
||||
}
|
||||
err = h.CryptoStore.RemoveOutboundGroupSession(ctx, roomID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove outbound group session: %w", err)
|
||||
}
|
||||
payload := ctx.Value(syncContextKey).(*syncContext).evt
|
||||
payload.LeftRooms = append(payload.LeftRooms, roomID)
|
||||
return nil
|
||||
|
@ -530,7 +598,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
const CurrentHTMLSanitizerVersion = 8
|
||||
const CurrentHTMLSanitizerVersion = 10
|
||||
|
||||
func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) {
|
||||
if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) ||
|
||||
|
@ -572,6 +640,14 @@ func (h *HiClient) processEvent(
|
|||
return dbEvt, nil
|
||||
}
|
||||
}
|
||||
if evt.StateKey != nil && evt.Unsigned.PrevContent == nil && evt.Unsigned.ReplacesState != "" {
|
||||
replacesState, err := h.DB.Event.GetByID(ctx, evt.Unsigned.ReplacesState)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get prev content for %s from %s: %w", evt.ID, evt.Unsigned.ReplacesState, err)
|
||||
} else if replacesState != nil {
|
||||
evt.Unsigned.PrevContent = &event.Content{VeryRaw: replacesState.Content}
|
||||
}
|
||||
}
|
||||
dbEvt := database.MautrixToEvent(evt)
|
||||
contentWithoutFallback := removeReplyFallback(evt)
|
||||
if contentWithoutFallback != nil {
|
||||
|
@ -580,7 +656,7 @@ func (h *HiClient) processEvent(
|
|||
}
|
||||
var decryptionErr error
|
||||
var decryptedMautrixEvt *event.Event
|
||||
if evt.Type == event.EventEncrypted && dbEvt.RedactedBy == "" {
|
||||
if evt.Type == event.EventEncrypted && (dbEvt.RedactedBy == "" || len(dbEvt.Content) > 2) {
|
||||
decryptedMautrixEvt, decryptionErr = h.decryptEventInto(ctx, evt, dbEvt)
|
||||
} else if evt.Type == event.EventRedaction {
|
||||
if evt.Redacts != "" && gjson.GetBytes(evt.Content.VeryRaw, "redacts").Str != evt.Redacts.String() {
|
||||
|
@ -709,12 +785,13 @@ func (h *HiClient) processStateAndTimeline(
|
|||
return fmt.Errorf("failed to get relation target of redaction target: %w", err)
|
||||
}
|
||||
}
|
||||
if updatedRoom.PreviewEventRowID == dbEvt.RowID {
|
||||
if updatedRoom.PreviewEventRowID == dbEvt.RowID || (updatedRoom.PreviewEventRowID == 0 && room.PreviewEventRowID == dbEvt.RowID) {
|
||||
updatedRoom.PreviewEventRowID = 0
|
||||
recalculatePreviewEvent = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
megolmSessionDiscarded := false
|
||||
processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) {
|
||||
evt.RoomID = room.ID
|
||||
dbEvt, err := h.processEvent(ctx, evt, summary, decryptionQueue, evt.Unsigned.TransactionID != "")
|
||||
|
@ -724,8 +801,11 @@ func (h *HiClient) processStateAndTimeline(
|
|||
if isUnread {
|
||||
if dbEvt.UnreadType.Is(database.UnreadTypeNotify) && h.firstSyncReceived {
|
||||
newNotifications = append(newNotifications, SyncNotification{
|
||||
RowID: dbEvt.RowID,
|
||||
Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound),
|
||||
RowID: dbEvt.RowID,
|
||||
Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound),
|
||||
Highlight: dbEvt.UnreadType.Is(database.UnreadTypeHighlight),
|
||||
Event: dbEvt,
|
||||
Room: room,
|
||||
})
|
||||
}
|
||||
newUnreadCounts.AddOne(dbEvt.UnreadType)
|
||||
|
@ -744,6 +824,9 @@ func (h *HiClient) processStateAndTimeline(
|
|||
if summary != nil && slices.Contains(summary.Heroes, id.UserID(*evt.StateKey)) {
|
||||
heroesChanged = true
|
||||
}
|
||||
if !megolmSessionDiscarded && room.EncryptionEvent != nil {
|
||||
megolmSessionDiscarded = h.maybeDiscardOutboundSession(ctx, membership, evt)
|
||||
}
|
||||
} else if evt.Type == event.StateElementFunctionalMembers {
|
||||
heroesChanged = true
|
||||
}
|
||||
|
@ -886,18 +969,20 @@ func (h *HiClient) processStateAndTimeline(
|
|||
updatedRoom.PreviewEventRowID, err = h.DB.Room.RecalculatePreview(ctx, room.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to recalculate preview event: %w", err)
|
||||
}
|
||||
_, err = addOldEvent(updatedRoom.PreviewEventRowID, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get preview event: %w", err)
|
||||
} else if updatedRoom.PreviewEventRowID != 0 {
|
||||
_, err = addOldEvent(updatedRoom.PreviewEventRowID, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get preview event: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Calculate name from participants if participants changed and current name was generated from participants, or if the room name was unset
|
||||
if (heroesChanged && updatedRoom.NameQuality <= database.NameQualityParticipants) || updatedRoom.NameQuality == database.NameQualityNil {
|
||||
name, dmAvatarURL, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
|
||||
name, dmAvatarURL, dmUserID, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to calculate room name: %w", err)
|
||||
}
|
||||
updatedRoom.DMUserID = &dmUserID
|
||||
updatedRoom.Name = &name
|
||||
updatedRoom.NameQuality = database.NameQualityParticipants
|
||||
if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar {
|
||||
|
@ -923,6 +1008,7 @@ func (h *HiClient) processStateAndTimeline(
|
|||
} else {
|
||||
updatedRoom.UnreadCounts.Add(newUnreadCounts)
|
||||
}
|
||||
dismissNotifications := room.UnreadNotifications > 0 && updatedRoom.UnreadNotifications == 0 && len(newNotifications) == 0
|
||||
if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) {
|
||||
updatedRoom.PrevBatch = timeline.PrevBatch
|
||||
}
|
||||
|
@ -943,14 +1029,16 @@ func (h *HiClient) processStateAndTimeline(
|
|||
receipt.RoomID = ""
|
||||
}
|
||||
ctx.Value(syncContextKey).(*syncContext).evt.Rooms[room.ID] = &SyncRoom{
|
||||
Meta: room,
|
||||
Timeline: timelineRowTuples,
|
||||
AccountData: accountData,
|
||||
State: changedState,
|
||||
Reset: timeline.Limited,
|
||||
Events: allNewEvents,
|
||||
Notifications: newNotifications,
|
||||
Receipts: receiptMap,
|
||||
Meta: room,
|
||||
Timeline: timelineRowTuples,
|
||||
AccountData: accountData,
|
||||
State: changedState,
|
||||
Reset: timeline.Limited,
|
||||
Events: allNewEvents,
|
||||
Receipts: receiptMap,
|
||||
|
||||
Notifications: newNotifications,
|
||||
DismissNotifications: dismissNotifications,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
@ -966,15 +1054,15 @@ func joinMemberNames(names []string, totalCount int) string {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, error) {
|
||||
func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, id.UserID, error) {
|
||||
var primaryAvatarURL id.ContentURI
|
||||
if summary == nil || len(summary.Heroes) == 0 {
|
||||
return "Empty room", primaryAvatarURL, nil
|
||||
return "Empty room", primaryAvatarURL, "", nil
|
||||
}
|
||||
var functionalMembers []id.UserID
|
||||
functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "")
|
||||
if err != nil {
|
||||
return "", primaryAvatarURL, fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err)
|
||||
return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err)
|
||||
} else if functionalMembersEvt != nil {
|
||||
mautrixEvt := functionalMembersEvt.AsRawMautrix()
|
||||
_ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)
|
||||
|
@ -990,16 +1078,21 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
|
|||
} else if summary.InvitedMemberCount != nil {
|
||||
memberCount = *summary.InvitedMemberCount
|
||||
}
|
||||
var dmUserID id.UserID
|
||||
for _, hero := range summary.Heroes {
|
||||
if slices.Contains(functionalMembers, hero) {
|
||||
// TODO save member count so push rule evaluation would use the subtracted one?
|
||||
memberCount--
|
||||
continue
|
||||
} else if len(members) >= 5 {
|
||||
break
|
||||
}
|
||||
if dmUserID == "" {
|
||||
dmUserID = hero
|
||||
}
|
||||
heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String())
|
||||
if err != nil {
|
||||
return "", primaryAvatarURL, fmt.Errorf("failed to get %s's member event: %w", hero, err)
|
||||
return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s's member event: %w", hero, err)
|
||||
} else if heroEvt == nil {
|
||||
leftMembers = append(leftMembers, hero.String())
|
||||
continue
|
||||
|
@ -1015,19 +1108,28 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
|
|||
}
|
||||
if membership == "join" || membership == "invite" {
|
||||
members = append(members, name)
|
||||
dmUserID = hero
|
||||
} else {
|
||||
leftMembers = append(leftMembers, name)
|
||||
}
|
||||
}
|
||||
if len(members)+len(leftMembers) > 1 || !primaryAvatarURL.IsValid() {
|
||||
if !primaryAvatarURL.IsValid() {
|
||||
primaryAvatarURL = id.ContentURI{}
|
||||
}
|
||||
if len(members) > 0 {
|
||||
return joinMemberNames(members, memberCount), primaryAvatarURL, nil
|
||||
if len(members) > 1 {
|
||||
primaryAvatarURL = id.ContentURI{}
|
||||
dmUserID = ""
|
||||
}
|
||||
return joinMemberNames(members, memberCount), primaryAvatarURL, dmUserID, nil
|
||||
} else if len(leftMembers) > 0 {
|
||||
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, nil
|
||||
if len(leftMembers) > 1 {
|
||||
primaryAvatarURL = id.ContentURI{}
|
||||
dmUserID = ""
|
||||
}
|
||||
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, "", nil
|
||||
} else {
|
||||
return "Empty room", primaryAvatarURL, nil
|
||||
return "Empty room", primaryAvatarURL, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -69,7 +69,7 @@ func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration,
|
|||
c.syncErrors++
|
||||
delay := 1 * time.Second
|
||||
if c.syncErrors > 5 {
|
||||
delay = max(time.Duration(c.syncErrors)*time.Second, 30*time.Second)
|
||||
delay = min(time.Duration(c.syncErrors)*time.Second, 30*time.Second)
|
||||
}
|
||||
c.markSyncErrored(err, false)
|
||||
c.Log.Err(err).Dur("retry_in", delay).Msg("Sync failed")
|
||||
|
|
|
@ -73,8 +73,9 @@ export default tseslint.config(
|
|||
"one-var-declaration-per-line": ["error", "initializations"],
|
||||
"quotes": ["error", "double", {allowTemplateLiterals: true}],
|
||||
"semi": ["error", "never"],
|
||||
"curly": ["error", "all"],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"max-len": ["warn", 120],
|
||||
"max-len": ["error", 120],
|
||||
"space-before-function-paren": ["error", {
|
||||
"anonymous": "never",
|
||||
"named": "never",
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-gomuks="true">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link id="favicon" rel="icon" type="image/png" href="gomuks.png"/>
|
||||
<link rel="manifest" href="manifest.json"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, interactive-widget=resizes-content"/>
|
||||
<title>gomuks web</title>
|
||||
<!-- etag placeholder -->
|
||||
</head>
|
||||
|
|
1631
web/package-lock.json
generated
|
@ -13,15 +13,16 @@
|
|||
"dependencies": {
|
||||
"@wailsio/runtime": "^3.0.0-alpha.29",
|
||||
"blurhash": "^2.0.5",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"katex": "^0.16.11",
|
||||
"leaflet": "^1.9.4",
|
||||
"matrix-widget-api": "^1.13.1",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"react": "^19.0.0",
|
||||
"react-blurhash": "^0.3.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-spinners": "^0.15.0",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
"virtua": "^0.39.2"
|
||||
"unhomoglyph": "^1.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.11.1",
|
||||
|
@ -38,7 +39,7 @@
|
|||
"globals": "^15.9.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.7.0",
|
||||
"vite": "^5.4.8",
|
||||
"vite": "^6.0.9",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,15 +16,17 @@
|
|||
import type { MouseEvent } from "react"
|
||||
import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
|
||||
import RPCClient, { SendMessageParams } from "./rpc.ts"
|
||||
import { RoomStateStore, StateStore } from "./statestore"
|
||||
import { RoomStateStore, StateStore, WidgetListener } from "./statestore"
|
||||
import type {
|
||||
ClientState,
|
||||
ElementRecentEmoji,
|
||||
EventID,
|
||||
EventType,
|
||||
GomuksAndroidMessageToWeb,
|
||||
ImagePackRooms,
|
||||
RPCEvent,
|
||||
RawDBEvent,
|
||||
RelationType,
|
||||
RoomID,
|
||||
RoomStateGUID,
|
||||
SyncStatus,
|
||||
|
@ -39,6 +41,7 @@ export default class Client {
|
|||
#stateRequests: RoomStateGUID[] = []
|
||||
#stateRequestPromise: Promise<void> | null = null
|
||||
#gcInterval: number | undefined
|
||||
#toDeviceRequested = false
|
||||
|
||||
constructor(readonly rpc: RPCClient) {
|
||||
this.rpc.event.listen(this.#handleEvent)
|
||||
|
@ -71,6 +74,74 @@ export default class Client {
|
|||
this.requestNotificationPermission()
|
||||
}
|
||||
|
||||
async #reallyStartAndroid(signal: AbortSignal) {
|
||||
const androidListener = async (evt: CustomEventInit<string>) => {
|
||||
const evtData = JSON.parse(evt.detail ?? "{}") as GomuksAndroidMessageToWeb
|
||||
switch (evtData.type) {
|
||||
case "register_push":
|
||||
await this.rpc.registerPush({
|
||||
type: "fcm",
|
||||
device_id: evtData.device_id,
|
||||
data: evtData.token,
|
||||
encryption: evtData.encryption,
|
||||
expiration: evtData.expiration,
|
||||
})
|
||||
return
|
||||
case "auth":
|
||||
try {
|
||||
const resp = await fetch("_gomuks/auth?no_prompt=true", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: evtData.authorization,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (!resp.ok && !signal.aborted) {
|
||||
console.error("Failed to authenticate:", resp.status, resp.statusText)
|
||||
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
|
||||
detail: {
|
||||
event: "auth_fail",
|
||||
error: `${resp.statusText || resp.status}`,
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to authenticate:", err)
|
||||
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
|
||||
detail: {
|
||||
event: "auth_fail",
|
||||
error: `${err}`.replace(/^Error: /, ""),
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
console.log("Successfully authenticated, connecting to websocket")
|
||||
this.rpc.start()
|
||||
return
|
||||
}
|
||||
}
|
||||
const unsubscribeConnect = this.rpc.connect.listen(evt => {
|
||||
if (!evt.connected) {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
|
||||
detail: { event: "connected" },
|
||||
}))
|
||||
})
|
||||
window.addEventListener("GomuksAndroidMessageToWeb", androidListener)
|
||||
signal.addEventListener("abort", () => {
|
||||
unsubscribeConnect()
|
||||
window.removeEventListener("GomuksAndroidMessageToWeb", androidListener)
|
||||
})
|
||||
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
|
||||
detail: { event: "ready" },
|
||||
}))
|
||||
}
|
||||
|
||||
requestNotificationPermission = (evt?: MouseEvent) => {
|
||||
window.Notification?.requestPermission().then(permission => {
|
||||
console.log("Notification permission:", permission)
|
||||
|
@ -84,9 +155,29 @@ export default class Client {
|
|||
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 {
|
||||
const abort = new AbortController()
|
||||
this.#reallyStart(abort.signal)
|
||||
if (window.gomuksAndroid) {
|
||||
this.#reallyStartAndroid(abort.signal)
|
||||
} else {
|
||||
this.#reallyStart(abort.signal)
|
||||
}
|
||||
this.#gcInterval = setInterval(() => {
|
||||
console.log("Garbage collection completed:", this.store.doGarbageCollection())
|
||||
}, window.gcSettings.interval)
|
||||
|
@ -148,20 +239,42 @@ export default class Client {
|
|||
})
|
||||
}
|
||||
|
||||
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) {
|
||||
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID, unredact?: boolean) {
|
||||
if (typeof room === "string") {
|
||||
room = this.store.rooms.get(room)
|
||||
}
|
||||
if (!room || room.eventsByID.has(eventID) || room.requestedEvents.has(eventID)) {
|
||||
if (!room || (!unredact && room.eventsByID.has(eventID)) ||room.requestedEvents.has(eventID)) {
|
||||
return
|
||||
}
|
||||
room.requestedEvents.add(eventID)
|
||||
this.rpc.getEvent(room.roomID, eventID).then(
|
||||
evt => room.applyEvent(evt),
|
||||
err => console.error(`Failed to fetch event ${eventID}`, err),
|
||||
this.rpc.getEvent(room.roomID, eventID, unredact).then(
|
||||
evt => {
|
||||
room.applyEvent(evt, false, unredact)
|
||||
if (unredact) {
|
||||
room.notifyTimelineSubscribers()
|
||||
}
|
||||
},
|
||||
err => {
|
||||
console.error(`Failed to fetch event ${eventID}`, err)
|
||||
if (unredact) {
|
||||
room.requestedEvents.delete(eventID)
|
||||
window.alert(`Failed to get unredacted content: ${err}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async getRelatedEvents(room: RoomStateStore | RoomID | undefined, eventID: EventID, relationType?: RelationType) {
|
||||
if (typeof room === "string") {
|
||||
room = this.store.rooms.get(room)
|
||||
}
|
||||
if (!room) {
|
||||
return []
|
||||
}
|
||||
const events = await this.rpc.getRelatedEvents(room.roomID, eventID, relationType)
|
||||
return events.map(evt => room.getOrApplyEvent(evt))
|
||||
}
|
||||
|
||||
async pinMessage(room: RoomStateStore, evtID: EventID, wantPinned: boolean) {
|
||||
const pinnedEvents = room.getPinnedEvents()
|
||||
const currentlyPinned = pinnedEvents.includes(evtID)
|
||||
|
@ -196,12 +309,14 @@ export default class Client {
|
|||
}
|
||||
}
|
||||
|
||||
async sendEvent(roomID: RoomID, type: EventType, content: unknown): Promise<void> {
|
||||
async sendEvent(
|
||||
roomID: RoomID, type: EventType, content: unknown, disableEncryption: boolean = false,
|
||||
): Promise<void> {
|
||||
const room = this.store.rooms.get(roomID)
|
||||
if (!room) {
|
||||
throw new Error("Room not found")
|
||||
}
|
||||
const dbEvent = await this.rpc.sendEvent(roomID, type, content)
|
||||
const dbEvent = await this.rpc.sendEvent(roomID, type, content, disableEncryption)
|
||||
this.#handleOutgoingEvent(dbEvent, room)
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
// 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 { parseMXC } from "@/util/validation.ts"
|
||||
import { ContentURI, LazyLoadSummary, RoomID, UserID, UserProfile } from "./types"
|
||||
import { ContentURI, RoomID, UserID, UserProfile } from "./types"
|
||||
|
||||
export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => {
|
||||
const [server, mediaID] = parseMXC(mxc)
|
||||
|
@ -78,36 +78,56 @@ function getFallbackCharacter(from: unknown, idx: number): string {
|
|||
return Array.from(from.slice(0, (idx + 1) * 2))[idx]?.toUpperCase().toWellFormed() ?? ""
|
||||
}
|
||||
|
||||
export const getAvatarURL = (userID: UserID, content?: UserProfile | null): string | undefined => {
|
||||
export const getAvatarURL = (
|
||||
userID: UserID,
|
||||
content?: UserProfile | null,
|
||||
thumbnail = false,
|
||||
forceFallback = false,
|
||||
): string | undefined => {
|
||||
const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1)
|
||||
const backgroundColor = getUserColor(userID)
|
||||
const [server, mediaID] = parseMXC(content?.avatar_url)
|
||||
if (!mediaID) {
|
||||
const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url)
|
||||
if (!mediaID || forceFallback) {
|
||||
return makeFallbackAvatar(backgroundColor, fallbackCharacter)
|
||||
}
|
||||
const encrypted = !!content?.avatar_file
|
||||
const fallback = `${backgroundColor}:${fallbackCharacter}`
|
||||
return `_gomuks/media/${server}/${mediaID}?encrypted=false&fallback=${encodeURIComponent(fallback)}`
|
||||
const url = `_gomuks/media/${server}/${mediaID}?encrypted=${encrypted}&fallback=${encodeURIComponent(fallback)}`
|
||||
return thumbnail ? `${url}&thumbnail=avatar` : url
|
||||
}
|
||||
|
||||
export const getAvatarThumbnailURL = (
|
||||
userID: UserID,
|
||||
content?: UserProfile | null,
|
||||
forceFallback = false,
|
||||
): string | undefined => {
|
||||
return getAvatarURL(userID, content, true, forceFallback)
|
||||
}
|
||||
|
||||
interface RoomForAvatarURL {
|
||||
room_id: RoomID
|
||||
name?: string
|
||||
dm_user_id?: UserID
|
||||
lazy_load_summary?: LazyLoadSummary
|
||||
avatar?: ContentURI
|
||||
avatar_url?: ContentURI
|
||||
}
|
||||
|
||||
export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: ContentURI): string | undefined => {
|
||||
let dmUserID: UserID | undefined
|
||||
if ("dm_user_id" in room) {
|
||||
dmUserID = room.dm_user_id
|
||||
} else if ("lazy_load_summary" in room) {
|
||||
dmUserID = room.lazy_load_summary?.["m.heroes"]?.length === 1
|
||||
? room.lazy_load_summary["m.heroes"][0] : undefined
|
||||
}
|
||||
return getAvatarURL(dmUserID ?? room.room_id, {
|
||||
export const getRoomAvatarURL = (
|
||||
room: RoomForAvatarURL,
|
||||
avatarOverride?: ContentURI,
|
||||
thumbnail = false,
|
||||
forceFallback = false,
|
||||
): string | undefined => {
|
||||
return getAvatarURL(room.dm_user_id ?? room.room_id, {
|
||||
displayname: room.name,
|
||||
avatar_url: avatarOverride ?? room.avatar ?? room.avatar_url,
|
||||
})
|
||||
}, thumbnail, forceFallback)
|
||||
}
|
||||
|
||||
export const getRoomAvatarThumbnailURL = (
|
||||
room: RoomForAvatarURL,
|
||||
avatarOverride?: ContentURI,
|
||||
forceFallback = false,
|
||||
): string | undefined => {
|
||||
return getRoomAvatarURL(room, avatarOverride, true, forceFallback)
|
||||
}
|
||||
|
|
|
@ -17,11 +17,13 @@ import { CachedEventDispatcher, EventDispatcher } from "../util/eventdispatcher.
|
|||
import { CancellablePromise } from "../util/promise.ts"
|
||||
import type {
|
||||
ClientWellKnown,
|
||||
DBPushRegistration,
|
||||
EventID,
|
||||
EventRowID,
|
||||
EventType,
|
||||
JSONValue,
|
||||
LoginFlowsResponse,
|
||||
LoginRequest,
|
||||
MembershipAction,
|
||||
Mentions,
|
||||
MessageEventContent,
|
||||
PaginationResponse,
|
||||
|
@ -31,8 +33,14 @@ import type {
|
|||
RawDBEvent,
|
||||
ReceiptType,
|
||||
RelatesTo,
|
||||
RelationType,
|
||||
ReqCreateRoom,
|
||||
ResolveAliasResponse,
|
||||
RespCreateRoom,
|
||||
RespMediaConfig,
|
||||
RespOpenIDToken,
|
||||
RespRoomJoin,
|
||||
RespTurnServer,
|
||||
RoomAlias,
|
||||
RoomID,
|
||||
RoomStateGUID,
|
||||
|
@ -142,8 +150,14 @@ export default abstract class RPCClient {
|
|||
return this.request("send_message", params)
|
||||
}
|
||||
|
||||
sendEvent(room_id: RoomID, type: EventType, content: unknown): Promise<RawDBEvent> {
|
||||
return this.request("send_event", { room_id, type, content })
|
||||
sendEvent(
|
||||
room_id: RoomID,
|
||||
type: EventType,
|
||||
content: unknown,
|
||||
disable_encryption: boolean = false,
|
||||
synchronous: boolean = false,
|
||||
): Promise<RawDBEvent> {
|
||||
return this.request("send_event", { room_id, type, content, disable_encryption, synchronous })
|
||||
}
|
||||
|
||||
resendEvent(transaction_id: string): Promise<RawDBEvent> {
|
||||
|
@ -160,8 +174,17 @@ export default abstract class RPCClient {
|
|||
|
||||
setState(
|
||||
room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>,
|
||||
extra: { delay_ms?: number } = {},
|
||||
): Promise<EventID> {
|
||||
return this.request("set_state", { room_id, type, state_key, content })
|
||||
return this.request("set_state", { room_id, type, state_key, content, ...extra })
|
||||
}
|
||||
|
||||
updateDelayedEvent(delay_id: string, action: string): Promise<void> {
|
||||
return this.request("update_delayed_event", { delay_id, action })
|
||||
}
|
||||
|
||||
setMembership(room_id: RoomID, user_id: UserID, action: MembershipAction, reason?: string): Promise<void> {
|
||||
return this.request("set_membership", { room_id, user_id, action, reason })
|
||||
}
|
||||
|
||||
setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise<boolean> {
|
||||
|
@ -180,6 +203,10 @@ export default abstract class RPCClient {
|
|||
return this.request("get_profile", { user_id })
|
||||
}
|
||||
|
||||
setProfileField(field: string, value: JSONValue): Promise<boolean> {
|
||||
return this.request("set_profile_field", { field, value })
|
||||
}
|
||||
|
||||
getMutualRooms(user_id: UserID): Promise<RoomID[]> {
|
||||
return this.request("get_mutual_rooms", { user_id })
|
||||
}
|
||||
|
@ -196,6 +223,14 @@ export default abstract class RPCClient {
|
|||
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[]> {
|
||||
return this.request("get_specific_room_state", { keys })
|
||||
}
|
||||
|
@ -206,12 +241,12 @@ export default abstract class RPCClient {
|
|||
return this.request("get_room_state", { room_id, include_members, fetch_members, refetch })
|
||||
}
|
||||
|
||||
getEvent(room_id: RoomID, event_id: EventID): Promise<RawDBEvent> {
|
||||
return this.request("get_event", { room_id, event_id })
|
||||
getEvent(room_id: RoomID, event_id: EventID, unredact?: boolean): Promise<RawDBEvent> {
|
||||
return this.request("get_event", { room_id, event_id, unredact })
|
||||
}
|
||||
|
||||
getEventsByRowIDs(row_ids: EventRowID[]): Promise<RawDBEvent[]> {
|
||||
return this.request("get_events_by_row_ids", { row_ids })
|
||||
getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise<RawDBEvent[]> {
|
||||
return this.request("get_related_events", { room_id, event_id, relation_type })
|
||||
}
|
||||
|
||||
paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise<PaginationResponse> {
|
||||
|
@ -234,6 +269,14 @@ export default abstract class RPCClient {
|
|||
return this.request("leave_room", { room_id, reason })
|
||||
}
|
||||
|
||||
createRoom(request: ReqCreateRoom): Promise<RespCreateRoom> {
|
||||
return this.request("create_room", request)
|
||||
}
|
||||
|
||||
muteRoom(room_id: RoomID, muted: boolean): Promise<boolean> {
|
||||
return this.request("mute_room", { room_id, muted })
|
||||
}
|
||||
|
||||
resolveAlias(alias: RoomAlias): Promise<ResolveAliasResponse> {
|
||||
return this.request("resolve_alias", { alias })
|
||||
}
|
||||
|
@ -257,4 +300,24 @@ export default abstract class RPCClient {
|
|||
verify(recovery_key: string): Promise<boolean> {
|
||||
return this.request("verify", { recovery_key })
|
||||
}
|
||||
|
||||
requestOpenIDToken(): Promise<RespOpenIDToken> {
|
||||
return this.request("request_openid_token", {})
|
||||
}
|
||||
|
||||
registerPush(reg: DBPushRegistration): Promise<boolean> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./main.ts"
|
||||
export * from "./room.ts"
|
||||
export * from "./hooks.ts"
|
||||
export * from "./space.ts"
|
||||
|
|
|
@ -45,6 +45,7 @@ export class InvitedRoomStore implements RoomListEntry, RoomSummary {
|
|||
readonly invited_by?: UserID
|
||||
readonly inviter_profile?: MemberEventContent
|
||||
readonly is_direct: boolean
|
||||
readonly is_invite = true
|
||||
|
||||
constructor(public readonly meta: DBInvitedRoom, parent: StateStore) {
|
||||
this.room_id = meta.room_id
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// 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 { getAvatarURL } from "@/api/media.ts"
|
||||
import { getAvatarThumbnailURL } from "@/api/media.ts"
|
||||
import { Preferences, getLocalStoragePreferences, getPreferenceProxy } from "@/api/types/preferences"
|
||||
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
||||
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||
|
@ -32,6 +32,7 @@ import {
|
|||
SendCompleteData,
|
||||
SyncCompleteData,
|
||||
SyncRoom,
|
||||
SyncToDevice,
|
||||
TypingEventData,
|
||||
UnknownEventContent,
|
||||
UserID,
|
||||
|
@ -39,7 +40,7 @@ import {
|
|||
} from "../types"
|
||||
import { InvitedRoomStore } from "./invitedroom.ts"
|
||||
import { RoomStateStore } from "./room.ts"
|
||||
import { DirectChatSpace, RoomListFilter, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts"
|
||||
import { DirectChatSpace, RoomListFilter, Space, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts"
|
||||
|
||||
export interface RoomListEntry {
|
||||
room_id: RoomID
|
||||
|
@ -54,6 +55,7 @@ export interface RoomListEntry {
|
|||
unread_notifications: number
|
||||
unread_highlights: number
|
||||
marked_unread: boolean
|
||||
is_invite?: boolean
|
||||
}
|
||||
|
||||
export interface GCSettings {
|
||||
|
@ -61,6 +63,13 @@ export interface GCSettings {
|
|||
lastOpenedCutoff: number,
|
||||
}
|
||||
|
||||
export interface WidgetListener {
|
||||
onTimelineEvent(evt: MemDBEvent): void
|
||||
onStateEvent(evt: MemDBEvent): void
|
||||
onToDeviceEvent(evt: SyncToDevice): void
|
||||
onRoomChange(roomID: RoomID | null): void
|
||||
}
|
||||
|
||||
window.gcSettings ??= {
|
||||
// Run garbage collection every 15 minutes.
|
||||
interval: 15 * 60 * 1000,
|
||||
|
@ -73,6 +82,7 @@ export class StateStore {
|
|||
readonly rooms: Map<RoomID, RoomStateStore> = new Map()
|
||||
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
|
||||
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
|
||||
readonly roomListEntries = new Map<RoomID, RoomListEntry>()
|
||||
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
|
||||
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
|
||||
readonly spaceOrphans = new SpaceOrphansSpace(this)
|
||||
|
@ -97,9 +107,19 @@ export class StateStore {
|
|||
readonly localPreferenceCache: Preferences = getLocalStoragePreferences("global_prefs", this.preferenceSub.notify)
|
||||
serverPreferenceCache: Preferences = {}
|
||||
switchRoom?: (roomID: RoomID | null) => void
|
||||
activeRoomID: RoomID | null = null
|
||||
#activeRoomID: RoomID | null = null
|
||||
activeRoomIsPreview: boolean = false
|
||||
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) => {
|
||||
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
|
||||
|
@ -110,6 +130,39 @@ export class StateStore {
|
|||
return true
|
||||
}
|
||||
|
||||
getSpaceByID(spaceID: string | undefined): RoomListFilter | null {
|
||||
if (!spaceID) {
|
||||
return null
|
||||
}
|
||||
const realSpace = this.spaceEdges.get(spaceID)
|
||||
if (realSpace) {
|
||||
return realSpace
|
||||
}
|
||||
for (const pseudoSpace of this.pseudoSpaces) {
|
||||
if (pseudoSpace.id === spaceID) {
|
||||
return pseudoSpace
|
||||
}
|
||||
}
|
||||
console.warn("Failed to find space", spaceID)
|
||||
return null
|
||||
}
|
||||
|
||||
findMatchingSpace(room: RoomListEntry): Space | null {
|
||||
if (this.spaceOrphans.include(room)) {
|
||||
return this.spaceOrphans
|
||||
}
|
||||
for (const spaceID of this.topLevelSpaces.current) {
|
||||
const space = this.spaceEdges.get(spaceID)
|
||||
if (space?.include(room)) {
|
||||
return space
|
||||
}
|
||||
}
|
||||
if (this.directChatsSpace.include(room)) {
|
||||
return this.directChatsSpace
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
|
||||
if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
|
||||
return null
|
||||
|
@ -169,8 +222,7 @@ export class StateStore {
|
|||
const name = entry.meta.name ?? "Unnamed room"
|
||||
return {
|
||||
room_id: entry.meta.room_id,
|
||||
dm_user_id: entry.meta.lazy_load_summary?.["m.heroes"]?.length === 1
|
||||
? entry.meta.lazy_load_summary["m.heroes"][0] : undefined,
|
||||
dm_user_id: entry.meta.dm_user_id,
|
||||
sorting_timestamp: entry.meta.sorting_timestamp,
|
||||
preview_event,
|
||||
preview_sender,
|
||||
|
@ -204,19 +256,26 @@ export class StateStore {
|
|||
}
|
||||
|
||||
applySync(sync: SyncCompleteData) {
|
||||
let prevActiveRoom: RoomID | null = null
|
||||
if (sync.clear_state && this.rooms.size > 0) {
|
||||
console.info("Clearing state store as sync told to reset and there are rooms in the store")
|
||||
prevActiveRoom = this.activeRoomID
|
||||
this.clear()
|
||||
}
|
||||
const resyncRoomList = this.roomList.current.length === 0
|
||||
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 ?? []) {
|
||||
const room = new InvitedRoomStore(data, this)
|
||||
const oldEntry = this.inviteRooms.get(room.room_id)
|
||||
this.inviteRooms.set(room.room_id, room)
|
||||
if (!resyncRoomList) {
|
||||
changedRoomListEntries.set(room.room_id, room)
|
||||
this.#applyUnreadModification(room, oldEntry)
|
||||
this.#applyUnreadModification(room, this.roomListEntries.get(room.room_id))
|
||||
this.roomListEntries.set(room.room_id, room)
|
||||
}
|
||||
if (this.activeRoomID === room.room_id) {
|
||||
this.switchRoom?.(room.room_id)
|
||||
|
@ -239,8 +298,12 @@ export class StateStore {
|
|||
if (roomListEntryChanged) {
|
||||
const entry = this.#makeRoomListEntry(data, room)
|
||||
changedRoomListEntries.set(roomID, entry)
|
||||
this.#applyUnreadModification(entry, room.roomListEntry)
|
||||
room.roomListEntry = entry
|
||||
this.#applyUnreadModification(entry, this.roomListEntries.get(roomID))
|
||||
if (entry) {
|
||||
this.roomListEntries.set(roomID, entry)
|
||||
} else {
|
||||
this.roomListEntries.delete(roomID)
|
||||
}
|
||||
}
|
||||
if (!resyncRoomList) {
|
||||
// When we join a valid replacement room, hide the tombstoned room.
|
||||
|
@ -278,6 +341,7 @@ export class StateStore {
|
|||
}
|
||||
this.rooms.delete(roomID)
|
||||
changedRoomListEntries.set(roomID, null)
|
||||
this.#applyUnreadModification(null, this.roomListEntries.get(roomID))
|
||||
}
|
||||
|
||||
let updatedRoomList: RoomListEntry[] | undefined
|
||||
|
@ -289,7 +353,7 @@ export class StateStore {
|
|||
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
|
||||
for (const entry of updatedRoomList) {
|
||||
this.#applyUnreadModification(entry, undefined)
|
||||
this.rooms.get(entry.room_id)!.roomListEntry = entry
|
||||
this.roomListEntries.set(entry.room_id, entry)
|
||||
}
|
||||
} else if (changedRoomListEntries.size > 0) {
|
||||
updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id))
|
||||
|
@ -326,6 +390,10 @@ export class StateStore {
|
|||
this.topLevelSpaces.emit(sync.top_level_spaces)
|
||||
this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id }))
|
||||
}
|
||||
if (prevActiveRoom) {
|
||||
// TODO this will fail if the room is not in the top 100 recent rooms
|
||||
this.switchRoom?.(prevActiveRoom)
|
||||
}
|
||||
}
|
||||
|
||||
invalidateEmojiPackKeyCache() {
|
||||
|
@ -431,7 +499,7 @@ export class StateStore {
|
|||
body = body.slice(0, 350) + " […]"
|
||||
}
|
||||
const memberEvt = room.getStateEvent("m.room.member", evt.sender)
|
||||
const icon = `${getAvatarURL(evt.sender, memberEvt?.content)}&image_auth=${this.imageAuthToken}`
|
||||
const icon = `${getAvatarThumbnailURL(evt.sender, memberEvt?.content)}&image_auth=${this.imageAuthToken}`
|
||||
const roomName = room.meta.current.name ?? "Unnamed room"
|
||||
const senderName = memberEvt?.content.displayname ?? evt.sender
|
||||
const title = senderName === roomName ? senderName : `${senderName} (${roomName})`
|
||||
|
@ -515,6 +583,7 @@ export class StateStore {
|
|||
this.rooms.clear()
|
||||
this.inviteRooms.clear()
|
||||
this.spaceEdges.clear()
|
||||
this.pseudoSpaces.forEach(space => space.clearUnreads())
|
||||
this.roomList.emit([])
|
||||
this.topLevelSpaces.emit([])
|
||||
this.accountData.clear()
|
||||
|
|
|
@ -18,7 +18,7 @@ import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
|||
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||
import toSearchableString from "@/util/searchablestring.ts"
|
||||
import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts"
|
||||
import { getDisplayname } from "@/util/validation.ts"
|
||||
import { getDisplayname, getServerName } from "@/util/validation.ts"
|
||||
import {
|
||||
ContentURI,
|
||||
DBReceipt,
|
||||
|
@ -42,7 +42,7 @@ import {
|
|||
UserID,
|
||||
roomStateGUIDToString,
|
||||
} from "../types"
|
||||
import type { RoomListEntry, StateStore } from "./main.ts"
|
||||
import type { StateStore } from "./main.ts"
|
||||
|
||||
function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
|
||||
if (!arr1 || !arr2) {
|
||||
|
@ -70,6 +70,7 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
|
|||
meta1.avatar === meta2.avatar &&
|
||||
meta1.topic === meta2.topic &&
|
||||
meta1.canonical_alias === meta2.canonical_alias &&
|
||||
meta1.dm_user_id === meta2.dm_user_id &&
|
||||
llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) &&
|
||||
meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm &&
|
||||
meta1.has_member_list === meta2.has_member_list
|
||||
|
@ -126,7 +127,6 @@ export class RoomStateStore {
|
|||
readUpToRow = -1
|
||||
hasMoreHistory = true
|
||||
hidden = false
|
||||
roomListEntry: RoomListEntry | undefined | null
|
||||
|
||||
constructor(meta: DBRoom, private parent: StateStore) {
|
||||
this.roomID = meta.room_id
|
||||
|
@ -246,6 +246,35 @@ export class RoomStateStore {
|
|||
return this.#membersCache ?? []
|
||||
}
|
||||
|
||||
getViaServers(): string[] {
|
||||
const ownServerName = getServerName(this.parent.userID)
|
||||
const vias = [ownServerName]
|
||||
const members = this.getMembers()
|
||||
const memberCount = new Map<string, number>()
|
||||
const powerLevels: PowerLevelEventContent = this.getStateEvent("m.room.power_levels", "")?.content ?? {}
|
||||
const usersDefault = powerLevels.users_default ?? 0
|
||||
let powerServer: string | undefined = undefined
|
||||
for (const member of members) {
|
||||
const serverName = getServerName(member.userID)
|
||||
if (serverName !== ownServerName) {
|
||||
if (!powerServer && (powerLevels?.users?.[member.userID] ?? usersDefault) > usersDefault) {
|
||||
powerServer = serverName
|
||||
vias.push(powerServer)
|
||||
}
|
||||
memberCount.set(serverName, (memberCount.get(serverName) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
const servers = Array.from(memberCount.entries())
|
||||
servers.sort(([, a], [, b]) => b - a)
|
||||
for (const [serverName] of servers) {
|
||||
if (serverName !== ownServerName && serverName !== powerServer) {
|
||||
vias.push(serverName)
|
||||
break
|
||||
}
|
||||
}
|
||||
return vias
|
||||
}
|
||||
|
||||
getPinnedEvents(): EventID[] {
|
||||
const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned
|
||||
if (Array.isArray(pinnedList)) {
|
||||
|
@ -315,13 +344,35 @@ export class RoomStateStore {
|
|||
return true
|
||||
}
|
||||
|
||||
applyEvent(evt: RawDBEvent, pending: boolean = false) {
|
||||
getOrApplyEvent(evt: RawDBEvent) {
|
||||
const existing = this.eventsByRowID.get(evt.rowid)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
return this.applyEvent(evt)
|
||||
}
|
||||
|
||||
setViewingRedacted(evt: MemDBEvent, view: boolean) {
|
||||
const modified = {
|
||||
...evt,
|
||||
viewing_redacted: view,
|
||||
}
|
||||
this.eventsByRowID.set(evt.rowid, modified)
|
||||
this.eventsByID.set(evt.event_id, modified)
|
||||
this.eventSubs.notify(evt.event_id)
|
||||
this.notifyTimelineSubscribers()
|
||||
}
|
||||
|
||||
applyEvent(evt: RawDBEvent, pending: boolean = false, viewRedacted: boolean = false) {
|
||||
const memEvt = evt as MemDBEvent
|
||||
memEvt.mem = true
|
||||
memEvt.pending = pending
|
||||
if (pending) {
|
||||
memEvt.timeline_rowid = UNSENT_TIMELINE_ROWID_BASE + memEvt.timestamp
|
||||
}
|
||||
if (viewRedacted) {
|
||||
memEvt.viewing_redacted = true
|
||||
}
|
||||
if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
|
||||
memEvt.type = evt.decrypted_type
|
||||
memEvt.encrypted = evt.content as EncryptedEventContent
|
||||
|
@ -332,20 +383,24 @@ export class RoomStateStore {
|
|||
if (memEvt.last_edit_rowid) {
|
||||
memEvt.last_edit = this.eventsByRowID.get(memEvt.last_edit_rowid)
|
||||
if (memEvt.last_edit) {
|
||||
memEvt.orig_content = memEvt.content
|
||||
memEvt.content = memEvt.last_edit.content["m.new_content"]
|
||||
memEvt.orig_content = memEvt.orig_content ?? memEvt.content
|
||||
memEvt.orig_local_content = memEvt.orig_local_content ?? memEvt.local_content
|
||||
memEvt.content = memEvt.last_edit.content["m.new_content"] ?? memEvt.last_edit.content
|
||||
memEvt.local_content = memEvt.last_edit.local_content
|
||||
}
|
||||
} else if (memEvt.relation_type === "m.replace" && memEvt.relates_to) {
|
||||
const editTarget = this.eventsByID.get(memEvt.relates_to)
|
||||
if (editTarget?.last_edit_rowid === memEvt.rowid && !editTarget.last_edit) {
|
||||
this.eventsByRowID.set(editTarget.rowid, {
|
||||
if (editTarget?.last_edit_rowid === memEvt.rowid) {
|
||||
const modified: MemDBEvent = {
|
||||
...editTarget,
|
||||
last_edit: memEvt,
|
||||
orig_content: editTarget.content,
|
||||
content: memEvt.content["m.new_content"],
|
||||
orig_local_content: editTarget.orig_local_content ?? editTarget.local_content,
|
||||
orig_content: editTarget.orig_content ?? editTarget.content,
|
||||
content: memEvt.content["m.new_content"] ?? memEvt.content,
|
||||
local_content: memEvt.local_content,
|
||||
})
|
||||
}
|
||||
this.eventsByRowID.set(editTarget.rowid, modified)
|
||||
this.eventsByID.set(editTarget.event_id, modified)
|
||||
this.eventSubs.notify(editTarget.event_id)
|
||||
}
|
||||
}
|
||||
|
@ -402,6 +457,8 @@ export class RoomStateStore {
|
|||
for (const evt of sync.events ?? []) {
|
||||
this.applyEvent(evt)
|
||||
}
|
||||
const hasWidgets = this.parent.widgetListeners.size > 0
|
||||
const newState: MemDBEvent[] = []
|
||||
for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
|
||||
let stateMap = this.state.get(evtType)
|
||||
if (!stateMap) {
|
||||
|
@ -411,6 +468,12 @@ export class RoomStateStore {
|
|||
for (const [key, rowID] of Object.entries(changedEvts)) {
|
||||
stateMap.set(key, rowID)
|
||||
this.invalidateStateCaches(evtType, key)
|
||||
if (hasWidgets) {
|
||||
const evt = this.eventsByRowID.get(rowID)
|
||||
if (evt) {
|
||||
newState.push(evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.stateSubs.notify(evtType)
|
||||
}
|
||||
|
@ -430,6 +493,13 @@ export class RoomStateStore {
|
|||
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
|
||||
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) {
|
||||
|
|
|
@ -40,6 +40,10 @@ export abstract class Space implements RoomListFilter {
|
|||
abstract id: string
|
||||
abstract include(room: RoomListEntry): boolean
|
||||
|
||||
clearUnreads() {
|
||||
this.counts.emit(emptyUnreadCounts)
|
||||
}
|
||||
|
||||
applyUnreads(newCounts?: SpaceUnreadCounts | null, oldCounts?: SpaceUnreadCounts | null) {
|
||||
const mergedCounts: SpaceUnreadCounts = {
|
||||
unread_messages: this.counts.current.unread_messages
|
||||
|
|
30
web/src/api/types/android.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
// gomuks - A Matrix client written in Go.
|
||||
// Copyright (C) 2024 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
export interface AndroidRegisterPushEvent {
|
||||
type: "register_push"
|
||||
device_id: string
|
||||
token: string
|
||||
encryption: { key: string }
|
||||
expiration?: number
|
||||
}
|
||||
|
||||
export interface AndroidAuthEvent {
|
||||
type: "auth"
|
||||
authorization: `Bearer ${string}`
|
||||
}
|
||||
|
||||
export type GomuksAndroidMessageToWeb = AndroidRegisterPushEvent | AndroidAuthEvent
|
|
@ -86,6 +86,13 @@ export interface SyncNotification {
|
|||
sound: boolean
|
||||
}
|
||||
|
||||
export interface SyncToDevice {
|
||||
sender: UserID
|
||||
type: EventType
|
||||
content: Record<string, unknown>
|
||||
encrypted: boolean
|
||||
}
|
||||
|
||||
export interface SyncCompleteData {
|
||||
rooms: Record<RoomID, SyncRoom> | null
|
||||
invited_rooms: DBInvitedRoom[] | null
|
||||
|
@ -95,6 +102,7 @@ export interface SyncCompleteData {
|
|||
top_level_spaces: RoomID[] | null
|
||||
since?: string
|
||||
clear_state?: boolean
|
||||
to_device?: SyncToDevice[] | null
|
||||
}
|
||||
|
||||
export interface SyncCompleteEvent extends BaseRPCCommand<SyncCompleteData> {
|
||||
|
|
|
@ -54,6 +54,7 @@ export interface DBRoom {
|
|||
name_quality: RoomNameQuality
|
||||
avatar?: ContentURI
|
||||
explicit_avatar: boolean
|
||||
dm_user_id?: UserID
|
||||
topic?: string
|
||||
canonical_alias?: RoomAlias
|
||||
lazy_load_summary?: LazyLoadSummary
|
||||
|
@ -155,7 +156,9 @@ export interface MemDBEvent extends BaseDBEvent {
|
|||
pending: boolean
|
||||
encrypted?: EncryptedEventContent
|
||||
orig_content?: UnknownEventContent
|
||||
orig_local_content?: LocalContent
|
||||
last_edit?: MemDBEvent
|
||||
viewing_redacted?: boolean
|
||||
}
|
||||
|
||||
export interface DBAccountData {
|
||||
|
@ -283,3 +286,13 @@ export interface ProfileEncryptionInfo {
|
|||
user_trusted: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface DBPushRegistration {
|
||||
device_id: string
|
||||
type: "fcm"
|
||||
data: unknown
|
||||
encryption: { key: string }
|
||||
expiration?: number
|
||||
}
|
||||
|
||||
export type MembershipAction = "invite" | "kick" | "ban" | "unban"
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./mxtypes.ts"
|
||||
export * from "./hitypes.ts"
|
||||
export * from "./hievents.ts"
|
||||
export * from "./android.ts"
|
||||
|
|
|
@ -25,6 +25,14 @@ export type RoomVersion = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" |
|
|||
export type RoomType = "" | "m.space"
|
||||
export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread"
|
||||
|
||||
export type JSONValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JSONValue[]
|
||||
| {[key: string]: JSONValue}
|
||||
|
||||
export interface RoomPredecessor {
|
||||
room_id: RoomID
|
||||
event_id: EventID
|
||||
|
@ -65,9 +73,20 @@ export interface EncryptedEventContent {
|
|||
export interface UserProfile {
|
||||
displayname?: string
|
||||
avatar_url?: ContentURI
|
||||
avatar_file?: EncryptedFile
|
||||
[custom: string]: unknown
|
||||
}
|
||||
|
||||
export interface PronounSet {
|
||||
subject?: string
|
||||
object?: string
|
||||
possessive_determiner?: string
|
||||
possessive_pronoun?: string
|
||||
reflexive?: string
|
||||
summary: string
|
||||
language: string
|
||||
}
|
||||
|
||||
export type Membership = "join" | "leave" | "ban" | "invite" | "knock"
|
||||
|
||||
export interface MemberEventContent extends UserProfile {
|
||||
|
@ -93,6 +112,15 @@ export interface ACLEventContent {
|
|||
deny?: string[]
|
||||
}
|
||||
|
||||
export interface PolicyRuleContent {
|
||||
entity: string
|
||||
reason: string
|
||||
recommendation: string
|
||||
"org.matrix.msc4205.hashes"?: {
|
||||
sha256: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PowerLevelEventContent {
|
||||
users?: Record<UserID, number>
|
||||
users_default?: number
|
||||
|
@ -153,6 +181,10 @@ export interface URLPreview {
|
|||
"og:description"?: string
|
||||
}
|
||||
|
||||
export interface BeeperPerMessageProfile extends UserProfile {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface BaseMessageEventContent {
|
||||
msgtype: string
|
||||
body: string
|
||||
|
@ -165,6 +197,7 @@ export interface BaseMessageEventContent {
|
|||
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string
|
||||
"m.url_previews"?: URLPreview[]
|
||||
"com.beeper.linkpreviews"?: URLPreview[]
|
||||
"com.beeper.per_message_profile"?: BeeperPerMessageProfile
|
||||
}
|
||||
|
||||
export interface TextMessageEventContent extends BaseMessageEventContent {
|
||||
|
@ -188,6 +221,10 @@ export interface ReactionEventContent {
|
|||
"com.beeper.reaction.shortcode"?: string
|
||||
}
|
||||
|
||||
export interface IgnoredUsersEventContent {
|
||||
ignored_users: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface EncryptedFile {
|
||||
url: ContentURI
|
||||
k: string
|
||||
|
@ -279,3 +316,48 @@ export interface RoomSummary {
|
|||
export interface RespRoomJoin {
|
||||
room_id: RoomID
|
||||
}
|
||||
|
||||
export interface RespOpenIDToken {
|
||||
access_token: string
|
||||
expires_in: number
|
||||
matrix_server_name: string
|
||||
token_type: "Bearer"
|
||||
}
|
||||
|
||||
export type RoomVisibility = "public" | "private"
|
||||
export type RoomPreset = "private_chat" | "public_chat" | "trusted_private_chat"
|
||||
|
||||
export interface ReqCreateRoom {
|
||||
visibility?: RoomVisibility
|
||||
room_alias_name?: string
|
||||
name?: string
|
||||
topic?: string
|
||||
invite?: UserID[]
|
||||
preset?: RoomPreset
|
||||
is_direct?: boolean
|
||||
initial_state?: {
|
||||
type: EventType
|
||||
state_key?: string
|
||||
content: Record<string, unknown>
|
||||
}[]
|
||||
room_version?: string
|
||||
creation_content?: Record<string, unknown>
|
||||
power_level_content_override?: Record<string, unknown>
|
||||
"fi.mau.room_id"?: RoomID
|
||||
}
|
||||
|
||||
export interface RespCreateRoom {
|
||||
room_id: RoomID
|
||||
}
|
||||
|
||||
export interface RespTurnServer {
|
||||
username: string
|
||||
password: string
|
||||
ttl: number
|
||||
uris: string[]
|
||||
}
|
||||
|
||||
export interface RespMediaConfig {
|
||||
"m.upload.size": number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
|
|
@ -55,10 +55,22 @@ export const preferences = {
|
|||
}),
|
||||
show_media_previews: new Preference<boolean>({
|
||||
displayName: "Show image and video previews",
|
||||
description: "If disabled, images and videos will only be visible after clicking and will not be downloaded automatically.",
|
||||
description: "If disabled, images and videos will only be visible after clicking and will not be downloaded automatically. This will also disable images in URL previews.",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: true,
|
||||
}),
|
||||
show_inline_images: new Preference<boolean>({
|
||||
displayName: "Show inline images",
|
||||
description: "If disabled, custom emojis and other inline images will not be rendered and the alt attribute will be shown instead.",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: true,
|
||||
}),
|
||||
show_invite_avatars: new Preference<boolean>({
|
||||
displayName: "Show avatars in invites",
|
||||
description: "If disabled, the avatar of the room or inviter will not be shown in the invite view.",
|
||||
allowedContexts: anyGlobalContext,
|
||||
defaultValue: true,
|
||||
}),
|
||||
code_block_line_wrap: new Preference<boolean>({
|
||||
displayName: "Code block line wrap",
|
||||
description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.",
|
||||
|
@ -139,6 +151,12 @@ export const preferences = {
|
|||
allowedContexts: anyContext,
|
||||
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>({
|
||||
displayName: "GIF provider",
|
||||
description: "The service to use to search for GIFs",
|
||||
|
|
1
web/src/icons/block.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="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q54 0 104-17.5t92-50.5L228-676q-33 42-50.5 92T160-480q0 134 93 227t227 93Zm252-124q33-42 50.5-92T800-480q0-134-93-227t-227-93q-54 0-104 17.5T284-732l448 448Z"/></svg>
|
After Width: | Height: | Size: 477 B |
1
web/src/icons/chat.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="M240-400h320v-80H240v80Zm0-120h480v-80H240v80Zm0-120h480v-80H240v80ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H240L80-80Zm126-240h594v-480H160v525l46-45Zm-46 0v-480 480Z"/></svg>
|
After Width: | Height: | Size: 341 B |
1
web/src/icons/door-open.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="M440-440q17 0 28.5-11.5T480-480q0-17-11.5-28.5T440-520q-17 0-28.5 11.5T400-480q0 17 11.5 28.5T440-440ZM280-120v-80l240-40v-445q0-15-9-27t-23-14l-208-34v-80l220 36q44 8 72 41t28 77v512l-320 54Zm-160 0v-80h80v-560q0-34 23.5-57t56.5-23h400q34 0 57 23t23 57v560h80v80H120Zm160-80h400v-560H280v560Z"/></svg>
|
After Width: | Height: | Size: 419 B |
1
web/src/icons/gavel.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="M160-120v-80h480v80H160Zm226-194L160-540l84-86 228 226-86 86Zm254-254L414-796l86-84 226 226-86 86Zm184 408L302-682l56-56 522 522-56 56Z"/></svg>
|
After Width: | Height: | Size: 261 B |
1
web/src/icons/mark-read.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="M694-160 553-302l56-56 85 85 170-170 56 57-226 226ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v280h-80v-280H160v525l46-45h274v80H240L80-80Zm80-240v-480 480Z"/></svg>
|
After Width: | Height: | Size: 300 B |
1
web/src/icons/mark-unread.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="M80-80v-720q0-33 23.5-56.5T160-880h404q-4 20-4 40t4 40H160v525l46-45h594v-324q23-5 43-13.5t37-22.5v360q0 33-23.5 56.5T800-240H240L80-80Zm80-720v480-480Zm600 80q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Z"/></svg>
|
After Width: | Height: | Size: 357 B |
1
web/src/icons/person-add.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="M720-400v-120H600v-80h120v-120h80v120h120v80H800v120h-80Zm-360-80q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-160v-112q0-34 17.5-62.5T104-378q62-31 126-46.5T360-440q66 0 130 15.5T616-378q29 15 46.5 43.5T680-272v112H40Zm80-80h480v-32q0-11-5.5-20T580-306q-54-27-109-40.5T360-360q-56 0-111 13.5T140-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T440-640q0-33-23.5-56.5T360-720q-33 0-56.5 23.5T280-640q0 33 23.5 56.5T360-560Zm0-80Zm0 400Z"/></svg>
|
After Width: | Height: | Size: 604 B |
1
web/src/icons/person-remove.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="M640-520v-80h240v80H640Zm-280 40q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-160v-112q0-34 17.5-62.5T104-378q62-31 126-46.5T360-440q66 0 130 15.5T616-378q29 15 46.5 43.5T680-272v112H40Zm80-80h480v-32q0-11-5.5-20T580-306q-54-27-109-40.5T360-360q-56 0-111 13.5T140-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T440-640q0-33-23.5-56.5T360-720q-33 0-56.5 23.5T280-640q0 33 23.5 56.5T360-560Zm0-80Zm0 400Z"/></svg>
|
After Width: | Height: | Size: 571 B |
1
web/src/icons/restore-trash.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="M440-320h80v-166l64 62 56-56-160-160-160 160 56 56 64-62v166ZM280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520Zm-400 0v520-520Z"/></svg>
|
After Width: | Height: | Size: 332 B |
1
web/src/icons/share.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="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm480-280q17 0 28.5-11.5T720-760q0-17-11.5-28.5T680-800q-17 0-28.5 11.5T640-760q0 17 11.5 28.5T680-720Zm0 520ZM200-480Zm480-280Z"/></svg>
|
After Width: | Height: | Size: 793 B |
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 |
|
@ -86,6 +86,7 @@
|
|||
--timeline-message-gap-small-event: 0;
|
||||
--timeline-sender-name-timestamp-gap: .25rem;
|
||||
--timeline-sender-name-content-gap: 0;
|
||||
--timeline-vertical-padding: 0;
|
||||
--timeline-horizontal-padding: 1.5rem;
|
||||
--timeline-status-size: 4rem;
|
||||
|
||||
|
@ -166,7 +167,6 @@ body {
|
|||
padding: 0;
|
||||
background-color: var(--background-color);
|
||||
line-height: 1.5;
|
||||
font-size: 16px;
|
||||
touch-action: none;
|
||||
color: var(--text-color);
|
||||
min-height: 100vh;
|
||||
|
@ -175,6 +175,7 @@ body {
|
|||
html {
|
||||
touch-action: none;
|
||||
background-color: var(--background-color);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
@ -207,6 +208,8 @@ button, a.button, span.button {
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
/* Buttons sometimes have their own fonts? */
|
||||
font-family: var(--font-stack);
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--button-hover-color);
|
||||
|
|
|
@ -13,10 +13,11 @@
|
|||
//
|
||||
// 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 equal from "fast-deep-equal"
|
||||
import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react"
|
||||
import { SyncLoader } from "react-spinners"
|
||||
import Client from "@/api/client.ts"
|
||||
import { RoomStateStore } from "@/api/statestore"
|
||||
import { RoomListFilter, RoomStateStore } from "@/api/statestore"
|
||||
import type { RoomID } from "@/api/types"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts"
|
||||
|
@ -24,7 +25,7 @@ import ClientContext from "./ClientContext.ts"
|
|||
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
|
||||
import StylePreferences from "./StylePreferences.tsx"
|
||||
import Keybindings from "./keybindings.ts"
|
||||
import { ModalWrapper } from "./modal"
|
||||
import { ModalContext, ModalWrapper, NestableModalContext } from "./modal"
|
||||
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
|
||||
import RoomList from "./roomlist/RoomList.tsx"
|
||||
import RoomPreview, { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
|
||||
|
@ -32,19 +33,6 @@ import RoomView from "./roomview/RoomView.tsx"
|
|||
import { useResizeHandle } from "./util/useResizeHandle.tsx"
|
||||
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 {
|
||||
public keybindings: Keybindings
|
||||
private rightPanelStack: RightPanelProps[] = []
|
||||
|
@ -52,6 +40,7 @@ class ContextFields implements MainScreenContextFields {
|
|||
constructor(
|
||||
private directSetRightPanel: (props: RightPanelProps | null) => void,
|
||||
private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void,
|
||||
private directSetSpace: (space: RoomListFilter | null) => void,
|
||||
private client: Client,
|
||||
) {
|
||||
this.keybindings = new Keybindings(client.store, this)
|
||||
|
@ -63,10 +52,10 @@ class ContextFields implements MainScreenContextFields {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
const isEqual = objectIsEqual(this.currentRightPanel, props)
|
||||
const isEqual = equal(this.currentRightPanel, props)
|
||||
if (isEqual && !pushState) {
|
||||
return
|
||||
}
|
||||
|
@ -80,7 +69,7 @@ class ContextFields implements MainScreenContextFields {
|
|||
} else {
|
||||
this.directSetRightPanel(props)
|
||||
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)
|
||||
if (pushState) {
|
||||
history.go(i - this.rightPanelStack.length)
|
||||
|
@ -95,12 +84,17 @@ class ContextFields implements MainScreenContextFields {
|
|||
}
|
||||
}
|
||||
|
||||
setActiveRoom = (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>, pushState = true) => {
|
||||
setActiveRoom = (
|
||||
roomID: RoomID | null,
|
||||
previewMeta?: Partial<RoomPreviewProps>,
|
||||
toSpace?: RoomListFilter,
|
||||
pushState = true,
|
||||
) => {
|
||||
console.log("Switching to room", roomID)
|
||||
if (roomID) {
|
||||
const room = this.client.store.rooms.get(roomID)
|
||||
if (room) {
|
||||
this.#setActiveRoom(room, pushState)
|
||||
this.#setActiveRoom(room, toSpace, pushState)
|
||||
} else {
|
||||
this.#setPreviewRoom(roomID, pushState, previewMeta)
|
||||
}
|
||||
|
@ -109,6 +103,24 @@ class ContextFields implements MainScreenContextFields {
|
|||
}
|
||||
}
|
||||
|
||||
setSpace = (space: RoomListFilter | null, pushState = true) => {
|
||||
if (space === this.client.store.currentRoomListFilter) {
|
||||
return
|
||||
}
|
||||
console.log("Switching to space", space?.id)
|
||||
this.directSetSpace(space)
|
||||
this.client.store.currentRoomListFilter = space
|
||||
if (pushState) {
|
||||
if (this.client.store.activeRoomID && space) {
|
||||
const entry = this.client.store.roomListEntries.get(this.client.store.activeRoomID)
|
||||
if (entry && !space.include(entry)) {
|
||||
this.setActiveRoom(null)
|
||||
}
|
||||
}
|
||||
history.replaceState({ ...(history.state || {}), space_id: space?.id }, "")
|
||||
}
|
||||
}
|
||||
|
||||
#setPreviewRoom(roomID: RoomID, pushState: boolean, meta?: Partial<RoomPreviewProps>) {
|
||||
const invite = this.client.store.inviteRooms.get(roomID)
|
||||
this.#closeActiveRoom(false)
|
||||
|
@ -120,6 +132,7 @@ class ContextFields implements MainScreenContextFields {
|
|||
room_id: roomID,
|
||||
source_via: meta?.via,
|
||||
source_alias: meta?.alias,
|
||||
space_id: history.state?.space_id,
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
@ -131,10 +144,21 @@ class ContextFields implements MainScreenContextFields {
|
|||
return room.preferences.room_window_title.replace("$room", name!)
|
||||
}
|
||||
|
||||
#setActiveRoom(room: RoomStateStore, pushState: boolean) {
|
||||
#setActiveRoom(room: RoomStateStore, space: RoomListFilter | undefined | null, pushState: boolean) {
|
||||
window.activeRoom = room
|
||||
this.directSetActiveRoom(room)
|
||||
this.directSetRightPanel(null)
|
||||
if (!space && this.client.store.currentRoomListFilter) {
|
||||
const roomListEntry = this.client.store.roomListEntries.get(room.roomID)
|
||||
if (roomListEntry && !this.client.store.currentRoomListFilter.include(roomListEntry)) {
|
||||
space = this.client.store.findMatchingSpace(roomListEntry)
|
||||
}
|
||||
}
|
||||
if (space && space !== this.client.store.currentRoomListFilter) {
|
||||
console.log("Switching to space", space?.id)
|
||||
this.directSetSpace(space)
|
||||
this.client.store.currentRoomListFilter = space
|
||||
}
|
||||
this.rightPanelStack = []
|
||||
this.client.store.activeRoomID = room.roomID
|
||||
this.client.store.activeRoomIsPreview = false
|
||||
|
@ -148,7 +172,7 @@ class ContextFields implements MainScreenContextFields {
|
|||
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
if (pushState) {
|
||||
history.pushState({ room_id: room.roomID }, "")
|
||||
history.pushState({ room_id: room.roomID, space_id: space?.id ?? history.state?.space_id }, "")
|
||||
}
|
||||
let roomNameForTitle = room.meta.current.name
|
||||
if (roomNameForTitle && roomNameForTitle.length > 48) {
|
||||
|
@ -166,7 +190,7 @@ class ContextFields implements MainScreenContextFields {
|
|||
this.client.store.activeRoomIsPreview = false
|
||||
this.keybindings.activeRoom = null
|
||||
if (pushState) {
|
||||
history.pushState({}, "")
|
||||
history.pushState({ space_id: history.state?.space_id }, "")
|
||||
}
|
||||
document.title = this.#getWindowTitle()
|
||||
}
|
||||
|
@ -181,8 +205,9 @@ class ContextFields implements MainScreenContextFields {
|
|||
}
|
||||
|
||||
clickRightPanelOpener = (evt: React.MouseEvent) => {
|
||||
evt.preventDefault()
|
||||
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 })
|
||||
} else if (type === "user") {
|
||||
this.setRightPanel({ type, userID: evt.currentTarget.getAttribute("data-target-user")! })
|
||||
|
@ -197,8 +222,11 @@ class ContextFields implements MainScreenContextFields {
|
|||
|
||||
const SYNC_ERROR_HIDE_DELAY = 30 * 1000
|
||||
|
||||
const handleURLHash = (client: Client) => {
|
||||
const handleURLHash = (client: Client, context: MainScreenContextFields, hashOnly = false) => {
|
||||
if (!location.hash.startsWith("#/uri/")) {
|
||||
if (hashOnly) {
|
||||
return null
|
||||
}
|
||||
if (location.search) {
|
||||
const currentETag = (
|
||||
document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement
|
||||
|
@ -224,7 +252,7 @@ const handleURLHash = (client: Client) => {
|
|||
const uri = parseMatrixURI(decodedURI)
|
||||
if (!uri) {
|
||||
console.error("Invalid matrix URI", decodedURI)
|
||||
return history.state
|
||||
return hashOnly ? null : history.state
|
||||
}
|
||||
console.log("Handling URI", uri)
|
||||
const newURL = new URL(location.href)
|
||||
|
@ -248,7 +276,7 @@ const handleURLHash = (client: Client) => {
|
|||
// TODO loading indicator or something for this?
|
||||
client.rpc.resolveAlias(uri.identifier).then(
|
||||
res => {
|
||||
window.mainScreenContext.setActiveRoom(res.room_id, {
|
||||
context.setActiveRoom(res.room_id, {
|
||||
alias: uri.identifier,
|
||||
via: res.servers.slice(0, 3),
|
||||
})
|
||||
|
@ -258,8 +286,9 @@ const handleURLHash = (client: Client) => {
|
|||
return null
|
||||
} else {
|
||||
console.error("Invalid matrix URI", uri)
|
||||
history.replaceState(history.state, "", newURL.toString())
|
||||
}
|
||||
return history.state
|
||||
return hashOnly ? null : history.state
|
||||
}
|
||||
|
||||
type ActiveRoomType = [RoomStateStore | RoomPreviewProps | null, RoomStateStore | RoomPreviewProps | null]
|
||||
|
@ -279,30 +308,42 @@ const activeRoomReducer = (
|
|||
|
||||
const MainScreen = () => {
|
||||
const [[prevActiveRoom, activeRoom], directSetActiveRoom] = useReducer(activeRoomReducer, [null, null])
|
||||
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
|
||||
const skipNextTransitionRef = useRef(false)
|
||||
const [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(null)
|
||||
const client = use(ClientContext)!
|
||||
const syncStatus = useEventAsState(client.syncStatus)
|
||||
const context = useMemo(
|
||||
() => new ContextFields(directSetRightPanel, directSetActiveRoom, client),
|
||||
() => new ContextFields(directSetRightPanel, directSetActiveRoom, directSetSpace, client),
|
||||
[client],
|
||||
)
|
||||
useEffect(() => {
|
||||
window.mainScreenContext = context
|
||||
const listener = (evt: PopStateEvent) => {
|
||||
const listener = (evt: Pick<PopStateEvent, "state" | "hasUAVisualTransition">) => {
|
||||
skipNextTransitionRef.current = evt.hasUAVisualTransition
|
||||
const roomID = evt.state?.room_id ?? null
|
||||
const spaceID = evt.state?.space_id ?? undefined
|
||||
if (spaceID !== client.store.currentRoomListFilter?.id) {
|
||||
context.setSpace(client.store.getSpaceByID(spaceID), false)
|
||||
}
|
||||
if (roomID !== client.store.activeRoomID) {
|
||||
context.setActiveRoom(roomID, {
|
||||
alias: ensureString(evt.state?.source_alias) || undefined,
|
||||
via: ensureStringArray(evt.state?.source_via),
|
||||
}, false)
|
||||
}, undefined, false)
|
||||
}
|
||||
context.setRightPanel(evt.state?.right_panel ?? null, false)
|
||||
}
|
||||
const hashListener = () => {
|
||||
const state = handleURLHash(client, context, true)
|
||||
if (state !== null) {
|
||||
listener({ state, hasUAVisualTransition: false })
|
||||
}
|
||||
}
|
||||
window.addEventListener("hashchange", hashListener)
|
||||
window.addEventListener("popstate", listener)
|
||||
const initHandle = () => {
|
||||
const state = handleURLHash(client)
|
||||
const state = handleURLHash(client, context)
|
||||
listener({ state } as PopStateEvent)
|
||||
}
|
||||
let cancel = () => {}
|
||||
|
@ -313,6 +354,7 @@ const MainScreen = () => {
|
|||
}
|
||||
return () => {
|
||||
window.removeEventListener("popstate", listener)
|
||||
window.removeEventListener("hashchange", hashListener)
|
||||
cancel()
|
||||
}
|
||||
}, [context, client])
|
||||
|
@ -368,28 +410,31 @@ const MainScreen = () => {
|
|||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [activeRoom, prevActiveRoom])
|
||||
const mainContent = <main className={classNames.join(" ")} style={extraStyle}>
|
||||
<RoomList activeRoomID={activeRoom?.roomID ?? null} space={space}/>
|
||||
{resizeHandle1}
|
||||
{renderedRoom
|
||||
? renderedRoom instanceof RoomStateStore
|
||||
? <RoomView
|
||||
key={renderedRoom.roomID}
|
||||
room={renderedRoom}
|
||||
rightPanel={rightPanel}
|
||||
rightPanelResizeHandle={resizeHandle2}
|
||||
/>
|
||||
: <RoomPreview {...renderedRoom} />
|
||||
: rightPanel && <>
|
||||
<div className="room-view placeholder"/>
|
||||
{resizeHandle2}
|
||||
{rightPanel && <RightPanel {...rightPanel}/>}
|
||||
</>}
|
||||
</main>
|
||||
return <MainScreenContext value={context}>
|
||||
<ModalWrapper>
|
||||
<StylePreferences client={client} activeRoom={activeRealRoom}/>
|
||||
<main className={classNames.join(" ")} style={extraStyle}>
|
||||
<RoomList activeRoomID={activeRoom?.roomID ?? null}/>
|
||||
{resizeHandle1}
|
||||
{renderedRoom
|
||||
? renderedRoom instanceof RoomStateStore
|
||||
? <RoomView
|
||||
key={renderedRoom.roomID}
|
||||
room={renderedRoom}
|
||||
rightPanel={rightPanel}
|
||||
rightPanelResizeHandle={resizeHandle2}
|
||||
/>
|
||||
: <RoomPreview {...renderedRoom} />
|
||||
: rightPanel && <>
|
||||
<div className="room-view placeholder"/>
|
||||
{resizeHandle2}
|
||||
{rightPanel && <RightPanel {...rightPanel}/>}
|
||||
</>}
|
||||
</main>
|
||||
{syncLoader}
|
||||
<ModalWrapper ContextType={ModalContext} historyStateKey="modal">
|
||||
<ModalWrapper ContextType={NestableModalContext} historyStateKey="nestable_modal">
|
||||
<StylePreferences client={client} activeRoom={activeRealRoom}/>
|
||||
{mainContent}
|
||||
{syncLoader}
|
||||
</ModalWrapper>
|
||||
</ModalWrapper>
|
||||
</MainScreenContext>
|
||||
}
|
||||
|
|
|
@ -13,13 +13,15 @@
|
|||
//
|
||||
// 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 { createContext } from "react"
|
||||
import React, { createContext } from "react"
|
||||
import { RoomListFilter } from "@/api/statestore"
|
||||
import type { RoomID } from "@/api/types"
|
||||
import type { RightPanelProps } from "./rightpanel/RightPanel.tsx"
|
||||
import type { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
|
||||
|
||||
export interface MainScreenContextFields {
|
||||
setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>) => void
|
||||
setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>, toSpace?: RoomListFilter) => void
|
||||
setSpace: (space: RoomListFilter | null, pushState?: boolean) => void
|
||||
clickRoom: (evt: React.MouseEvent) => void
|
||||
clearActiveRoom: () => void
|
||||
|
||||
|
@ -32,6 +34,9 @@ const stubContext = {
|
|||
get setActiveRoom(): never {
|
||||
throw new Error("MainScreenContext used outside main screen")
|
||||
},
|
||||
get setSpace(): never {
|
||||
throw new Error("MainScreenContext used outside main screen")
|
||||
},
|
||||
get clickRoom(): never {
|
||||
throw new Error("MainScreenContext used outside main screen")
|
||||
},
|
||||
|
|
|
@ -117,6 +117,15 @@ const StylePreferences = ({ client, activeRoom }: StylePreferencesProps) => {
|
|||
--timeline-status-size: 2rem;
|
||||
}
|
||||
`, [preferences.display_read_receipts])
|
||||
useStyle(() => !preferences.show_inline_images && css`
|
||||
a.hicli-inline-img-fallback {
|
||||
display: inline !important;
|
||||
}
|
||||
|
||||
img.hicli-inline-img {
|
||||
display: none;
|
||||
}
|
||||
`, [preferences.show_inline_images])
|
||||
useAsyncStyle(() => preferences.code_block_theme === "auto" ? `
|
||||
@import url("_gomuks/codeblock/github.css") (prefers-color-scheme: light);
|
||||
@import url("_gomuks/codeblock/github-dark.css") (prefers-color-scheme: dark);
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
// 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 { JSX, RefObject, use, useEffect } from "react"
|
||||
import { getAvatarURL, getMediaURL } from "@/api/media.ts"
|
||||
import { getAvatarThumbnailURL, getMediaURL } from "@/api/media.ts"
|
||||
import { AutocompleteMemberEntry, RoomStateStore, useCustomEmojis } from "@/api/statestore"
|
||||
import { Emoji, emojiToMarkdown, useSortedAndFilteredEmojis } from "@/util/emoji"
|
||||
import { escapeMarkdown } from "@/util/markdown.ts"
|
||||
|
@ -138,7 +138,7 @@ const userFuncs = {
|
|||
<img
|
||||
className="small avatar"
|
||||
loading="lazy"
|
||||
src={getAvatarURL(user.userID, { displayname: user.displayName, avatar_url: user.avatarURL })}
|
||||
src={getAvatarThumbnailURL(user.userID, { displayname: user.displayName, avatar_url: user.avatarURL })}
|
||||
alt=""
|
||||
/>
|
||||
{user.displayName}
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
//
|
||||
// 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 { RefCallback, useState } from "react"
|
||||
import Client from "@/api/client.ts"
|
||||
import { RoomStateStore, usePreference } from "@/api/statestore"
|
||||
import type { MediaMessageEventContent } from "@/api/types"
|
||||
|
@ -27,10 +28,19 @@ export interface ComposerMediaProps {
|
|||
}
|
||||
|
||||
export const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
|
||||
const defaultMaxWidth = 360
|
||||
const paddingAndButtonWidth = 16 + 40
|
||||
const [maxWidth, setMaxWidth] = useState(defaultMaxWidth)
|
||||
const [mediaContent, containerClass, containerStyle] = useMediaContent(
|
||||
content, "m.room.message", { height: 120, width: 360 },
|
||||
content, "m.room.message", { height: 120, width: maxWidth },
|
||||
)
|
||||
return <div className="composer-media">
|
||||
const containerRef: RefCallback<HTMLDivElement> = elem => {
|
||||
setMaxWidth(Math.min(
|
||||
(elem?.getBoundingClientRect().width ?? defaultMaxWidth) - paddingAndButtonWidth,
|
||||
defaultMaxWidth,
|
||||
))
|
||||
}
|
||||
return <div className="composer-media" ref={containerRef}>
|
||||
<div className={`media-container ${containerClass}`} style={containerStyle}>
|
||||
{mediaContent}
|
||||
</div>
|
||||
|
|
|
@ -55,6 +55,7 @@ export interface ComposerState {
|
|||
replyTo: EventID | null
|
||||
silentReply: boolean
|
||||
explicitReplyInThread: boolean
|
||||
startNewThread: boolean
|
||||
uninited?: boolean
|
||||
}
|
||||
|
||||
|
@ -67,6 +68,7 @@ const emptyComposer: ComposerState = {
|
|||
location: null,
|
||||
silentReply: false,
|
||||
explicitReplyInThread: false,
|
||||
startNewThread: false,
|
||||
}
|
||||
const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true }
|
||||
const composerReducer = (
|
||||
|
@ -116,7 +118,7 @@ const MessageComposer = () => {
|
|||
document.execCommand("insertText", false, text)
|
||||
}, [])
|
||||
roomCtx.setReplyTo = useCallback((evt: EventID | null) => {
|
||||
setState({ replyTo: evt, silentReply: false, explicitReplyInThread: false })
|
||||
setState({ replyTo: evt, silentReply: false, explicitReplyInThread: false, startNewThread: false })
|
||||
textInput.current?.focus()
|
||||
}, [])
|
||||
const setSilentReply = useCallback((newVal: boolean | React.MouseEvent) => {
|
||||
|
@ -135,6 +137,14 @@ const MessageComposer = () => {
|
|||
setState(state => ({ explicitReplyInThread: !state.explicitReplyInThread }))
|
||||
}
|
||||
}, [])
|
||||
const setStartNewThread = useCallback((newVal: boolean | React.MouseEvent) => {
|
||||
if (typeof newVal === "boolean") {
|
||||
setState({ startNewThread: newVal })
|
||||
} else {
|
||||
newVal.stopPropagation()
|
||||
setState(state => ({ startNewThread: !state.startNewThread }))
|
||||
}
|
||||
}, [])
|
||||
roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => {
|
||||
if (evt === null) {
|
||||
rawSetEditing(null)
|
||||
|
@ -160,13 +170,14 @@ const MessageComposer = () => {
|
|||
replyTo: null,
|
||||
silentReply: false,
|
||||
explicitReplyInThread: false,
|
||||
startNewThread: false,
|
||||
})
|
||||
textInput.current?.focus()
|
||||
}, [room.roomID])
|
||||
const canSend = Boolean(state.text || state.media || state.location)
|
||||
const onClickSend = (evt: React.FormEvent) => {
|
||||
evt.preventDefault()
|
||||
if (!canSend) {
|
||||
if (!canSend || loadingMedia) {
|
||||
return
|
||||
}
|
||||
doSendMessage(state)
|
||||
|
@ -204,6 +215,10 @@ const MessageComposer = () => {
|
|||
relates_to.rel_type = "m.thread"
|
||||
relates_to.event_id = replyToEvt.content?.["m.relates_to"].event_id
|
||||
relates_to.is_falling_back = !state.explicitReplyInThread
|
||||
} else if (state.startNewThread) {
|
||||
relates_to.rel_type = "m.thread"
|
||||
relates_to.event_id = replyToEvt.event_id
|
||||
relates_to.is_falling_back = true
|
||||
}
|
||||
}
|
||||
let base_content: MessageEventContent | undefined
|
||||
|
@ -553,7 +568,7 @@ const MessageComposer = () => {
|
|||
style.left = style.right
|
||||
delete style.right
|
||||
openModal({
|
||||
content: <div className="event-context-menu" style={style}>
|
||||
content: <div className="context-menu event-context-menu" style={style}>
|
||||
{makeAttachmentButtons(true)}
|
||||
</div>,
|
||||
})
|
||||
|
@ -580,6 +595,8 @@ const MessageComposer = () => {
|
|||
onSetSilent={setSilentReply}
|
||||
isExplicitInThread={state.explicitReplyInThread}
|
||||
onSetExplicitInThread={setExplicitReplyInThread}
|
||||
startNewThread={state.startNewThread}
|
||||
onSetStartNewThread={setStartNewThread}
|
||||
/>}
|
||||
{editing && <ReplyBody
|
||||
room={room}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { JSX, use } from "react"
|
||||
import { PulseLoader } from "react-spinners"
|
||||
import { getAvatarURL } from "@/api/media.ts"
|
||||
import { getAvatarThumbnailURL } from "@/api/media.ts"
|
||||
import { useMultipleRoomMembers, useRoomTyping } from "@/api/statestore"
|
||||
import { humanJoin } from "@/util/join.ts"
|
||||
import { getDisplayname } from "@/util/validation.ts"
|
||||
|
@ -40,7 +40,7 @@ const TypingNotifications = () => {
|
|||
key={sender}
|
||||
className="small avatar"
|
||||
loading="lazy"
|
||||
src={getAvatarURL(sender, member)}
|
||||
src={getAvatarThumbnailURL(sender, member)}
|
||||
alt=""
|
||||
/>)
|
||||
memberNames.push(getDisplayname(sender, member))
|
||||
|
|
|
@ -34,6 +34,7 @@ export function filter(users: AutocompleteMemberEntry[], query: string): Autocom
|
|||
interface filteredUserCache {
|
||||
query: string
|
||||
result: AutocompleteMemberEntry[]
|
||||
slicedResult?: AutocompleteMemberEntry[]
|
||||
}
|
||||
|
||||
export function useFilteredMembers(
|
||||
|
@ -44,15 +45,16 @@ export function useFilteredMembers(
|
|||
if (!query) {
|
||||
prev.current.query = ""
|
||||
prev.current.result = allMembers
|
||||
prev.current.slicedResult = slice && allMembers.length > 100 ? allMembers.slice(0, 100) : undefined
|
||||
} else if (prev.current.query !== query) {
|
||||
prev.current.result = (sort ? filterAndSort : filter)(
|
||||
query.startsWith(prev.current.query) ? prev.current.result : allMembers,
|
||||
query,
|
||||
)
|
||||
if (prev.current.result.length > 100 && slice) {
|
||||
prev.current.result = prev.current.result.slice(0, 100)
|
||||
}
|
||||
prev.current.slicedResult = prev.current.result.length > 100 && slice
|
||||
? prev.current.result.slice(0, 100)
|
||||
: undefined
|
||||
prev.current.query = query
|
||||
}
|
||||
return prev.current.result
|
||||
return prev.current.slicedResult ?? prev.current.result
|
||||
}
|
||||
|
|
|
@ -226,7 +226,7 @@ div.emoji-picker, div.sticker-picker {
|
|||
}
|
||||
|
||||
@media screen and (max-width: 37.5rem) {
|
||||
div.emoji-picker, div.gif-picker {
|
||||
div.emoji-picker, div.gif-picker, div.sticker-picker {
|
||||
inset: 0 0 3rem 0 !important;
|
||||
width: 100%;
|
||||
height: calc(100% - 3rem);
|
||||
|
|
|
@ -13,16 +13,16 @@
|
|||
//
|
||||
// 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 React, { use, useState } from "react"
|
||||
import React, { JSX, use, useState } from "react"
|
||||
import { MemDBEvent } from "@/api/types"
|
||||
import { isMobileDevice } from "@/util/ismobile.ts"
|
||||
import { ModalCloseContext } from "../../modal"
|
||||
import TimelineEvent from "../TimelineEvent.tsx"
|
||||
import { ModalCloseContext } from "../modal"
|
||||
import TimelineEvent from "../timeline/TimelineEvent.tsx"
|
||||
|
||||
interface ConfirmWithMessageProps {
|
||||
evt: MemDBEvent
|
||||
evt?: MemDBEvent
|
||||
title: string
|
||||
description: string
|
||||
description: string | JSX.Element
|
||||
placeholder: string
|
||||
confirmButton: string
|
||||
onConfirm: (reason: string) => void
|
||||
|
@ -40,9 +40,9 @@ const ConfirmWithMessageModal = ({
|
|||
}
|
||||
return <form onSubmit={onConfirmWrapped}>
|
||||
<h3>{title}</h3>
|
||||
<div className="timeline-event-container">
|
||||
{evt && <div className="timeline-event-container">
|
||||
<TimelineEvent evt={evt} prevEvt={null} disableMenu={true} />
|
||||
</div>
|
||||
</div>}
|
||||
<div className="confirm-description">
|
||||
{description}
|
||||
</div>
|
|
@ -15,8 +15,8 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { CSSProperties, use } from "react"
|
||||
import { MemDBEvent } from "@/api/types"
|
||||
import ClientContext from "../../ClientContext.ts"
|
||||
import { RoomContextData } from "../../roomview/roomcontext.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
import { RoomContextData } from "../roomview/roomcontext.ts"
|
||||
import { usePrimaryItems } from "./usePrimaryItems.tsx"
|
||||
import { useSecondaryItems } from "./useSecondaryItems.tsx"
|
||||
import CloseIcon from "@/icons/close.svg?react"
|
||||
|
@ -41,14 +41,14 @@ interface EventContextMenuProps extends BaseEventMenuProps {
|
|||
|
||||
export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
||||
const elements = useSecondaryItems(use(ClientContext)!, roomCtx, evt)
|
||||
return <div style={style} className="event-context-menu extra">{elements}</div>
|
||||
return <div style={style} className="context-menu event-context-menu extra">{elements}</div>
|
||||
}
|
||||
|
||||
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
||||
const client = use(ClientContext)!
|
||||
const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined)
|
||||
const secondary = useSecondaryItems(client, roomCtx, evt)
|
||||
return <div style={style} className="event-context-menu full">
|
||||
return <div style={style} className="context-menu event-context-menu full">
|
||||
{primary}
|
||||
<hr/>
|
||||
{secondary}
|
3
web/src/ui/menu/RoomMenu.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
div.room-list-menu {
|
||||
|
||||
}
|
121
web/src/ui/menu/RoomMenu.tsx
Normal file
|
@ -0,0 +1,121 @@
|
|||
// 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 { CSSProperties, use } from "react"
|
||||
import { RoomListEntry, RoomStateStore, useAccountData } from "@/api/statestore"
|
||||
import { RoomID } from "@/api/types"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
import { ModalCloseContext } from "../modal"
|
||||
import SettingsView from "../settings/SettingsView.tsx"
|
||||
import DoorOpenIcon from "@/icons/door-open.svg?react"
|
||||
import MarkReadIcon from "@/icons/mark-read.svg?react"
|
||||
import MarkUnreadIcon from "@/icons/mark-unread.svg?react"
|
||||
import NotificationsOffIcon from "@/icons/notifications-off.svg?react"
|
||||
import NotificationsIcon from "@/icons/notifications.svg?react"
|
||||
import SettingsIcon from "@/icons/settings.svg?react"
|
||||
import "./RoomMenu.css"
|
||||
|
||||
interface RoomMenuProps {
|
||||
room: RoomStateStore
|
||||
entry: RoomListEntry
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
const hasNotifyingActions = (actions: unknown) => {
|
||||
return Array.isArray(actions) && actions.length > 0 && actions.includes("notify")
|
||||
}
|
||||
|
||||
const MuteButton = ({ roomID }: { roomID: RoomID }) => {
|
||||
const client = use(ClientContext)!
|
||||
const closeModal = use(ModalCloseContext)
|
||||
const roomRules = useAccountData(client.store, "m.push_rules")?.global?.room
|
||||
const pushRule = Array.isArray(roomRules) ? roomRules.find(rule => rule?.rule_id === roomID) : null
|
||||
const muted = pushRule?.enabled === true && !hasNotifyingActions(pushRule.actions)
|
||||
const toggleMute = () => {
|
||||
client.rpc.muteRoom(roomID, !muted).catch(err => {
|
||||
console.error("Failed to mute room", err)
|
||||
window.alert(`Failed to ${muted ? "unmute" : "mute"} room: ${err}`)
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
return <button onClick={toggleMute}>
|
||||
{muted ? <NotificationsIcon/> : <NotificationsOffIcon/>}
|
||||
{muted ? "Unmute" : "Mute"}
|
||||
</button>
|
||||
}
|
||||
|
||||
const MarkReadButton = ({ room }: { room: RoomStateStore }) => {
|
||||
const meta = useEventAsState(room.meta)
|
||||
const client = use(ClientContext)!
|
||||
const closeModal = use(ModalCloseContext)
|
||||
const read = !meta.marked_unread && meta.unread_messages === 0
|
||||
const markRead = () => {
|
||||
const evt = room.eventsByRowID.get(
|
||||
room.timeline[room.timeline.length-1]?.event_rowid ?? meta.preview_event_rowid,
|
||||
)
|
||||
if (!evt) {
|
||||
window.alert("Can't mark room as read: last event not found in cache")
|
||||
return
|
||||
}
|
||||
const rrType = room.preferences.send_read_receipts ? "m.read" : "m.read.private"
|
||||
client.rpc.markRead(room.roomID, evt.event_id, rrType).catch(err => {
|
||||
console.error("Failed to mark room as read", err)
|
||||
window.alert(`Failed to mark room as read: ${err}`)
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
const markUnread = () => {
|
||||
client.rpc.setAccountData("m.marked_unread", { unread: true }, room.roomID).catch(err => {
|
||||
console.error("Failed to mark room as unread", err)
|
||||
window.alert(`Failed to mark room as unread: ${err}`)
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
return <button onClick={read ? markUnread : markRead}>
|
||||
{read ? <MarkUnreadIcon/> : <MarkReadIcon/>}
|
||||
Mark {read ? "unread" : "read"}
|
||||
</button>
|
||||
}
|
||||
|
||||
export const RoomMenu = ({ room, style }: RoomMenuProps) => {
|
||||
const closeModal = use(ModalCloseContext)
|
||||
const client = use(ClientContext)!
|
||||
const openSettings = () => {
|
||||
closeModal()
|
||||
window.openNestableModal({
|
||||
dimmed: true,
|
||||
boxed: true,
|
||||
innerBoxClass: "settings-view",
|
||||
content: <SettingsView room={room} />,
|
||||
})
|
||||
}
|
||||
const leaveRoom = () => {
|
||||
if (!window.confirm(`Really leave ${room.meta.current.name}?`)) {
|
||||
return
|
||||
}
|
||||
client.rpc.leaveRoom(room.roomID).catch(err => {
|
||||
console.error("Failed to leave room", err)
|
||||
window.alert(`Failed to leave room: ${err}`)
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
return <div className="context-menu room-list-menu" style={style}>
|
||||
<MarkReadButton room={room} />
|
||||
<MuteButton roomID={room.roomID}/>
|
||||
<button onClick={openSettings}><SettingsIcon /> Settings</button>
|
||||
<button onClick={leaveRoom}><DoorOpenIcon /> Leave room</button>
|
||||
</div>
|
||||
}
|
65
web/src/ui/menu/ShareModal.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import React, { use, useState } from "react"
|
||||
import { MemDBEvent } from "@/api/types"
|
||||
import { ModalCloseContext } from "@/ui/modal"
|
||||
import TimelineEvent from "@/ui/timeline/TimelineEvent.tsx"
|
||||
import Toggle from "@/ui/util/Toggle.tsx"
|
||||
|
||||
interface ConfirmWithMessageProps {
|
||||
evt: MemDBEvent
|
||||
title: string
|
||||
confirmButton: string
|
||||
onConfirm: (useMatrixTo: boolean, includeEvent: boolean) => void
|
||||
generateLink: (useMatrixTo: boolean, includeEvent: boolean) => string
|
||||
}
|
||||
|
||||
const ShareModal = ({ evt, title, confirmButton, onConfirm, generateLink }: ConfirmWithMessageProps) => {
|
||||
const [useMatrixTo, setUseMatrixTo] = useState(false)
|
||||
const [includeEvent, setIncludeEvent] = useState(true)
|
||||
const closeModal = use(ModalCloseContext)
|
||||
const onConfirmWrapped = (evt: React.FormEvent) => {
|
||||
evt.preventDefault()
|
||||
closeModal()
|
||||
onConfirm(useMatrixTo, includeEvent)
|
||||
}
|
||||
|
||||
const link = generateLink(useMatrixTo, includeEvent)
|
||||
return <form onSubmit={onConfirmWrapped}>
|
||||
<h3>{title}</h3>
|
||||
<div className="timeline-event-container">
|
||||
<TimelineEvent evt={evt} prevEvt={null} disableMenu={true}/>
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Use matrix.to link</td>
|
||||
<td>
|
||||
<Toggle
|
||||
id="useMatrixTo"
|
||||
checked={useMatrixTo}
|
||||
onChange={evt => setUseMatrixTo(evt.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Link to this specific event</td>
|
||||
<td>
|
||||
<Toggle
|
||||
id="shareEvent"
|
||||
checked={includeEvent}
|
||||
onChange={evt => setIncludeEvent(evt.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="output-preview">
|
||||
<span className="no-select">Preview: </span><code>{link}</code>
|
||||
</div>
|
||||
<div className="confirm-buttons">
|
||||
<button type="button" onClick={closeModal}>Cancel</button>
|
||||
<button type="submit">{confirmButton}</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
export default ShareModal
|
|
@ -43,7 +43,7 @@ div.event-fixed-menu {
|
|||
}
|
||||
}
|
||||
|
||||
div.event-context-menu {
|
||||
div.context-menu {
|
||||
position: fixed;
|
||||
background-color: var(--background-color);
|
||||
border-radius: .5rem;
|
||||
|
@ -80,6 +80,10 @@ div.event-context-menu {
|
|||
color: var(--error-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.event-context-menu, &.room-list-menu {
|
||||
width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
div.confirm-message-modal > form {
|
||||
|
@ -101,6 +105,7 @@ div.confirm-message-modal > form {
|
|||
|
||||
> div.timeline-event {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -118,4 +123,14 @@ div.confirm-message-modal > form {
|
|||
padding: .5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
> div.output-preview {
|
||||
> span.no-select {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> code {
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,4 +14,5 @@
|
|||
// 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/>.
|
||||
export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
|
||||
export { RoomMenu } from "./RoomMenu.tsx"
|
||||
export { getModalStyleFromMouse } from "./util.ts"
|
|
@ -18,9 +18,9 @@ import Client from "@/api/client.ts"
|
|||
import { MemDBEvent } from "@/api/types"
|
||||
import { emojiToReactionContent } from "@/util/emoji"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import EmojiPicker from "../../emojipicker/EmojiPicker.tsx"
|
||||
import { ModalCloseContext, ModalContext } from "../../modal"
|
||||
import { RoomContextData } from "../../roomview/roomcontext.ts"
|
||||
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
|
||||
import { ModalCloseContext, ModalContext } from "../modal"
|
||||
import { RoomContextData } from "../roomview/roomcontext.ts"
|
||||
import { EventExtraMenu } from "./EventMenu.tsx"
|
||||
import { getEncryption, getModalStyleFromButton, getPending, getPowerLevels } from "./util.ts"
|
||||
import EditIcon from "@/icons/edit.svg?react"
|
||||
|
@ -79,7 +79,7 @@ export const usePrimaryItems = (
|
|||
.catch(err => window.alert(`Failed to resend message: ${err}`))
|
||||
}
|
||||
const onClickMore = (mevt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const moreMenuHeight = 4 * 40
|
||||
const moreMenuHeight = 5 * 40
|
||||
setForceOpen!(true)
|
||||
openModal({
|
||||
content: <EventExtraMenu
|
|
@ -17,15 +17,18 @@ import { use } from "react"
|
|||
import Client from "@/api/client.ts"
|
||||
import { useRoomState } from "@/api/statestore"
|
||||
import { MemDBEvent } from "@/api/types"
|
||||
import { ModalCloseContext, ModalContext } from "../../modal"
|
||||
import { RoomContext, RoomContextData } from "../../roomview/roomcontext.ts"
|
||||
import JSONView from "../../util/JSONView.tsx"
|
||||
import { ModalCloseContext, ModalContext } from "../modal"
|
||||
import { RoomContext, RoomContextData } from "../roomview/roomcontext.ts"
|
||||
import JSONView from "../util/JSONView.tsx"
|
||||
import ConfirmWithMessageModal from "./ConfirmWithMessageModal.tsx"
|
||||
import ShareModal from "./ShareModal.tsx"
|
||||
import { getPending, getPowerLevels } from "./util.ts"
|
||||
import ViewSourceIcon from "@/icons/code.svg?react"
|
||||
import DeleteIcon from "@/icons/delete.svg?react"
|
||||
import PinIcon from "@/icons/pin.svg?react"
|
||||
import ReportIcon from "@/icons/report.svg?react"
|
||||
import RestoreTrashIcon from "@/icons/restore-trash.svg?react"
|
||||
import ShareIcon from "@/icons/share.svg?react"
|
||||
import UnpinIcon from "@/icons/unpin.svg?react"
|
||||
|
||||
export const useSecondaryItems = (
|
||||
|
@ -40,7 +43,7 @@ export const useSecondaryItems = (
|
|||
openModal({
|
||||
dimmed: true,
|
||||
boxed: true,
|
||||
content: <JSONView data={evt} />,
|
||||
content: <JSONView data={evt}/>,
|
||||
})
|
||||
}
|
||||
const onClickReport = () => {
|
||||
|
@ -83,12 +86,67 @@ export const useSecondaryItems = (
|
|||
</RoomContext>,
|
||||
})
|
||||
}
|
||||
const onClickHideUnredacted = () => {
|
||||
closeModal()
|
||||
roomCtx.store.setViewingRedacted(evt, false)
|
||||
}
|
||||
const onClickUnredact = () => {
|
||||
closeModal()
|
||||
if (Object.entries(evt.content).length > 0) {
|
||||
roomCtx.store.setViewingRedacted(evt, true)
|
||||
} else {
|
||||
client.requestEvent(roomCtx.store, evt.event_id, true)
|
||||
}
|
||||
}
|
||||
const onClickPin = (pin: boolean) => () => {
|
||||
closeModal()
|
||||
client.pinMessage(roomCtx.store, evt.event_id, pin)
|
||||
.catch(err => window.alert(`Failed to ${pin ? "pin" : "unpin"} message: ${err}`))
|
||||
}
|
||||
|
||||
const onClickShareEvent = () => {
|
||||
const generateLink = (useMatrixTo: boolean, includeEvent: boolean) => {
|
||||
const isRoomIDLink = true
|
||||
let generatedURL = useMatrixTo ? "https://matrix.to/#/" : "matrix:roomid/"
|
||||
if (useMatrixTo) {
|
||||
generatedURL += evt.room_id
|
||||
} else {
|
||||
generatedURL += `${evt.room_id.slice(1)}`
|
||||
}
|
||||
if (includeEvent) {
|
||||
if (useMatrixTo) {
|
||||
generatedURL += `/${evt.event_id}`
|
||||
} else {
|
||||
generatedURL += `/e/${evt.event_id.slice(1)}`
|
||||
}
|
||||
}
|
||||
if (isRoomIDLink) {
|
||||
generatedURL += "?" + new URLSearchParams(
|
||||
roomCtx.store.getViaServers().map(server => ["via", server]),
|
||||
).toString()
|
||||
}
|
||||
return generatedURL
|
||||
}
|
||||
openModal({
|
||||
dimmed: true,
|
||||
boxed: true,
|
||||
innerBoxClass: "confirm-message-modal",
|
||||
content: <RoomContext value={roomCtx}>
|
||||
<ShareModal
|
||||
evt={evt}
|
||||
title="Share Message"
|
||||
confirmButton="Copy to clipboard"
|
||||
onConfirm={(useMatrixTo: boolean, includeEvent: boolean) => {
|
||||
navigator.clipboard.writeText(generateLink(useMatrixTo, includeEvent)).catch(
|
||||
err => window.alert(`Failed to copy link: ${err}`),
|
||||
)
|
||||
}}
|
||||
generateLink={generateLink}
|
||||
/>
|
||||
</RoomContext>,
|
||||
})
|
||||
}
|
||||
|
||||
const [isPending, pendingTitle] = getPending(evt)
|
||||
useRoomState(roomCtx.store, "m.room.power_levels", "")
|
||||
// We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes
|
||||
|
@ -101,9 +159,12 @@ export const useSecondaryItems = (
|
|||
const canRedact = !evt.redacted_by
|
||||
&& ownPL >= redactEvtPL
|
||||
&& (evt.sender === client.userID || ownPL >= redactOtherPL)
|
||||
// TODO check server admin status and room PLs
|
||||
const canUnredact = Boolean(evt.redacted_by)
|
||||
|
||||
return <>
|
||||
<button onClick={onClickViewSource}><ViewSourceIcon/>{names && "View source"}</button>
|
||||
<button onClick={onClickShareEvent}><ShareIcon/>{names && "Share"}</button>
|
||||
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
||||
? <button onClick={onClickPin(false)}>
|
||||
<UnpinIcon/>{names && "Unpin message"}
|
|
@ -39,10 +39,10 @@ export const getEncryption = (room: RoomStateStore): boolean =>{
|
|||
export function getModalStyleFromMouse(
|
||||
evt: React.MouseEvent, modalHeight: number, modalWidth = 10 * 16,
|
||||
): CSSProperties {
|
||||
const style: CSSProperties = { right: window.innerWidth - evt.clientX }
|
||||
if (evt.clientX - modalWidth < 4) {
|
||||
delete style.right
|
||||
style.left = "4px"
|
||||
const style: CSSProperties = { left: evt.clientX }
|
||||
if (evt.clientX + modalWidth > window.innerWidth) {
|
||||
delete style.left
|
||||
style.right = "4px"
|
||||
}
|
||||
if (evt.clientY + modalHeight > window.innerHeight) {
|
||||
style.bottom = window.innerHeight - evt.clientY
|
|
@ -19,8 +19,18 @@ div.overlay {
|
|||
overflow: hidden;
|
||||
display: flex;
|
||||
|
||||
&.full-screen-mobile {
|
||||
@media screen and (max-width: 30rem) {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> div.modal-box-inner {
|
||||
overflow: scroll;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ const LightboxWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||
return
|
||||
}
|
||||
params = {
|
||||
src: target.src,
|
||||
src: target.getAttribute("data-full-src") ?? target.src,
|
||||
alt: target.alt,
|
||||
}
|
||||
setParams(params)
|
||||
|
@ -75,6 +75,11 @@ export interface LightboxProps extends LightboxParams {
|
|||
onClose: () => void
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export class Lightbox extends Component<LightboxProps> {
|
||||
translate = { x: 0, y: 0 }
|
||||
zoom = 1
|
||||
|
@ -82,6 +87,9 @@ export class Lightbox extends Component<LightboxProps> {
|
|||
maybePanning = false
|
||||
readonly ref = createRef<HTMLImageElement>()
|
||||
readonly wrapperRef = createRef<HTMLDivElement>()
|
||||
prevTouch1: Point | null = null
|
||||
prevTouch2: Point | null = null
|
||||
prevTouchDist: number | null = null
|
||||
|
||||
get style() {
|
||||
return {
|
||||
|
@ -91,6 +99,14 @@ export class Lightbox extends Component<LightboxProps> {
|
|||
}
|
||||
}
|
||||
|
||||
get orientation(): number {
|
||||
let rot = (this.rotate / 90) % 4
|
||||
if (rot < 0) {
|
||||
rot += 4
|
||||
}
|
||||
return rot
|
||||
}
|
||||
|
||||
close = () => {
|
||||
this.translate = { x: 0, y: 0 }
|
||||
this.rotate = 0
|
||||
|
@ -115,18 +131,55 @@ export class Lightbox extends Component<LightboxProps> {
|
|||
return
|
||||
}
|
||||
evt.preventDefault()
|
||||
const oldZoom = this.zoom
|
||||
const delta = -evt.deltaY / 1000
|
||||
const newDelta = this.zoom + delta * this.zoom
|
||||
this.zoom = Math.min(Math.max(newDelta, 0.01), 10)
|
||||
const zoomDelta = this.zoom - oldZoom
|
||||
this.translate.x += zoomDelta * (this.ref.current.clientWidth / 2 - evt.nativeEvent.offsetX)
|
||||
this.translate.y += zoomDelta * (this.ref.current.clientHeight / 2 - evt.nativeEvent.offsetY)
|
||||
this.#doZoom(-evt.deltaY / 1000, evt.nativeEvent.offsetX, evt.nativeEvent.offsetY, false)
|
||||
const style = this.style
|
||||
this.ref.current.style.translate = style.translate
|
||||
this.ref.current.style.scale = style.scale
|
||||
}
|
||||
|
||||
#getTouchDistance(p1: Point, p2: Point): number {
|
||||
return Math.hypot(p1.x - p2.x, p1.y - p2.y)
|
||||
}
|
||||
|
||||
#getTouchMidpoint(p1: Point, p2: Point): Point {
|
||||
const contentRect = this.ref.current!.getBoundingClientRect()
|
||||
const p1X = p1.x - contentRect.left
|
||||
const p1Y = p1.y - contentRect.top
|
||||
const p2X = p2.x - contentRect.left
|
||||
const p2Y = p2.y - contentRect.top
|
||||
const point = {
|
||||
x: (p1X + p2X) / 2 / this.zoom,
|
||||
y: (p1Y + p2Y) / 2 / this.zoom,
|
||||
}
|
||||
const orientation = this.orientation
|
||||
if (orientation === 1 || orientation === 3) {
|
||||
// This is slightly weird because doZoom will flip the x and y values again,
|
||||
// but maybe the flipped subtraction from clientWidth/Height is important.
|
||||
return { x: point.y, y: point.x }
|
||||
}
|
||||
return point
|
||||
}
|
||||
|
||||
#doZoom(delta: number, offsetX: number, offsetY: number, touch: boolean) {
|
||||
if (!this.ref.current) {
|
||||
return
|
||||
}
|
||||
const oldZoom = this.zoom
|
||||
const newDelta = oldZoom + delta * this.zoom
|
||||
this.zoom = Math.min(Math.max(newDelta, 0.01), 10)
|
||||
const zoomDelta = this.zoom - oldZoom
|
||||
|
||||
const orientation = this.orientation
|
||||
const negateX = !touch && (orientation === 2 || orientation == 3) ? -1 : 1
|
||||
const negateY = !touch && (orientation === 2 || orientation == 1) ? -1 : 1
|
||||
const flipXY = orientation === 1 || orientation === 3
|
||||
|
||||
const deltaX = zoomDelta * (this.ref.current.clientWidth / 2 - offsetX) * negateX
|
||||
const deltaY = zoomDelta * (this.ref.current.clientHeight / 2 - offsetY) * negateY
|
||||
this.translate.x += flipXY ? deltaY : deltaX
|
||||
this.translate.y += flipXY ? deltaX : deltaY
|
||||
}
|
||||
|
||||
onMouseDown = (evt: React.MouseEvent) => {
|
||||
if (evt.buttons === 1) {
|
||||
evt.preventDefault()
|
||||
|
@ -150,6 +203,57 @@ export class Lightbox extends Component<LightboxProps> {
|
|||
this.ref.current.style.cursor = "grabbing"
|
||||
}
|
||||
|
||||
onTouchStart = (evt: React.TouchEvent) => {
|
||||
if (evt.touches.length === 1) {
|
||||
this.maybePanning = true
|
||||
this.prevTouch1 = { x: evt.touches[0].pageX, y: evt.touches[0].pageY }
|
||||
this.prevTouch2 = null
|
||||
} else if (evt.touches.length === 2) {
|
||||
this.prevTouch1 = { x: evt.touches[0].pageX, y: evt.touches[0].pageY }
|
||||
this.prevTouch2 = { x: evt.touches[1].pageX, y: evt.touches[1].pageY }
|
||||
this.prevTouchDist = this.#getTouchDistance(this.prevTouch1, this.prevTouch2)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}
|
||||
|
||||
onTouchEnd = () => {
|
||||
this.prevTouch1 = null
|
||||
this.prevTouch2 = null
|
||||
this.prevTouchDist = null
|
||||
}
|
||||
|
||||
onTouchMove = (evt: React.TouchEvent) => {
|
||||
if (!this.ref.current) {
|
||||
return
|
||||
}
|
||||
if (evt.touches.length > 0 && this.prevTouch1) {
|
||||
this.translate.x += evt.touches[0].pageX - this.prevTouch1.x
|
||||
this.translate.y += evt.touches[0].pageY - this.prevTouch1.y
|
||||
this.prevTouch1 = { x: evt.touches[0].pageX, y: evt.touches[0].pageY }
|
||||
if (evt.touches.length === 1) {
|
||||
this.ref.current.style.translate = this.style.translate
|
||||
this.ref.current.style.cursor = "grabbing"
|
||||
}
|
||||
}
|
||||
if (evt.touches.length > 1 && this.prevTouch1 && this.prevTouch2 && this.prevTouchDist) {
|
||||
this.prevTouch2 = { x: evt.touches[1].pageX, y: evt.touches[1].pageY }
|
||||
const newDist = this.#getTouchDistance(this.prevTouch1, this.prevTouch2)
|
||||
const midpoint = this.#getTouchMidpoint(
|
||||
{ x: evt.touches[0].clientX, y: evt.touches[0].clientY },
|
||||
{ x: evt.touches[1].clientX, y: evt.touches[1].clientY },
|
||||
)
|
||||
this.#doZoom((newDist - this.prevTouchDist) / 100, midpoint.x, midpoint.y, true)
|
||||
this.prevTouchDist = newDist
|
||||
const style = this.style
|
||||
this.ref.current.style.translate = style.translate
|
||||
this.ref.current.style.scale = style.scale
|
||||
}
|
||||
evt.preventDefault()
|
||||
}
|
||||
|
||||
onKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const key = keyToString(evt)
|
||||
if (key === "Escape") {
|
||||
|
@ -189,6 +293,10 @@ export class Lightbox extends Component<LightboxProps> {
|
|||
className="overlay dimmed lightbox"
|
||||
onClick={this.onClick}
|
||||
onMouseMove={isTouchDevice ? undefined : this.onMouseMove}
|
||||
onTouchStart={isTouchDevice ? this.onTouchStart : undefined}
|
||||
onTouchMove={isTouchDevice ? this.onTouchMove : undefined}
|
||||
onTouchEnd={isTouchDevice ? this.onTouchEnd : undefined}
|
||||
onTouchCancel={isTouchDevice ? this.onTouchEnd : undefined}
|
||||
tabIndex={-1}
|
||||
onKeyDown={this.onKeyDown}
|
||||
ref={this.wrapperRef}
|
||||
|
|
|
@ -13,57 +13,74 @@
|
|||
//
|
||||
// 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 React, { JSX, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react"
|
||||
import { ModalCloseContext, ModalContext, ModalState } from "./contexts.ts"
|
||||
import React, { Context, JSX, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react"
|
||||
import ErrorBoundary from "../util/ErrorBoundary.tsx"
|
||||
import { ModalCloseContext, ModalState, openModal } from "./contexts.ts"
|
||||
|
||||
const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
interface ModalWrapperProps {
|
||||
children: React.ReactNode
|
||||
ContextType: Context<openModal>
|
||||
historyStateKey: string
|
||||
}
|
||||
|
||||
const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperProps) => {
|
||||
const [state, setState] = useReducer((prevState: ModalState | null, newState: ModalState | null) => {
|
||||
prevState?.onClose?.()
|
||||
return newState
|
||||
}, null)
|
||||
const onClickWrapper = useCallback((evt?: React.MouseEvent) => {
|
||||
if (evt && evt.target !== evt.currentTarget) {
|
||||
if (evt && (evt.target !== evt.currentTarget || state?.noDismiss)) {
|
||||
return
|
||||
}
|
||||
evt?.stopPropagation()
|
||||
setState(null)
|
||||
if (history.state?.modal) {
|
||||
if (history.state?.[historyStateKey]) {
|
||||
history.back()
|
||||
}
|
||||
}, [])
|
||||
}, [historyStateKey, state])
|
||||
const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (evt.key === "Escape") {
|
||||
if (evt.key === "Escape" && !state?.noDismiss) {
|
||||
setState(null)
|
||||
if (history.state?.modal) {
|
||||
if (history.state?.[historyStateKey]) {
|
||||
history.back()
|
||||
}
|
||||
}
|
||||
evt.stopPropagation()
|
||||
}
|
||||
const openModal = useCallback((newState: ModalState) => {
|
||||
if (!history.state?.modal && newState.captureInput !== false) {
|
||||
history.pushState({ ...(history.state ?? {}), modal: true }, "")
|
||||
if (!history.state?.[historyStateKey] && newState.captureInput !== false) {
|
||||
history.pushState({ ...(history.state ?? {}), [historyStateKey]: true }, "")
|
||||
}
|
||||
setState(newState)
|
||||
}, [])
|
||||
}, [historyStateKey])
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
window.closeModal = onClickWrapper
|
||||
if (historyStateKey === "nestable_modal") {
|
||||
window.openNestableModal = openModal
|
||||
} else {
|
||||
window.openModal = openModal
|
||||
}
|
||||
if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
|
||||
wrapperRef.current.focus()
|
||||
}
|
||||
}, [state])
|
||||
}, [state, onClickWrapper, historyStateKey, openModal])
|
||||
useEffect(() => {
|
||||
window.closeModal = onClickWrapper
|
||||
const listener = (evt: PopStateEvent) => {
|
||||
if (!evt.state?.modal) {
|
||||
if (!evt.state?.[historyStateKey]) {
|
||||
setState(null)
|
||||
}
|
||||
}
|
||||
window.addEventListener("popstate", listener)
|
||||
return () => window.removeEventListener("popstate", listener)
|
||||
}, [])
|
||||
}, [historyStateKey])
|
||||
let modal: JSX.Element | null = null
|
||||
if (state) {
|
||||
let content = <ModalCloseContext value={onClickWrapper}>{state.content}</ModalCloseContext>
|
||||
let content = <ModalCloseContext value={onClickWrapper}>
|
||||
<ErrorBoundary thing="modal">
|
||||
{state.content}
|
||||
</ErrorBoundary>
|
||||
</ModalCloseContext>
|
||||
if (state.boxed) {
|
||||
content = <div className={`modal-box ${state.boxClass ?? ""}`}>
|
||||
<div className={`modal-box-inner ${state.innerBoxClass ?? ""}`}>
|
||||
|
@ -85,10 +102,10 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||
modal = content
|
||||
}
|
||||
}
|
||||
return <ModalContext value={openModal}>
|
||||
return <ContextType value={openModal}>
|
||||
{children}
|
||||
{modal}
|
||||
</ModalContext>
|
||||
</ContextType>
|
||||
}
|
||||
|
||||
export default ModalWrapper
|
||||
|
|
|
@ -33,11 +33,15 @@ export interface ModalState {
|
|||
innerBoxClass?: string
|
||||
onClose?: () => void
|
||||
captureInput?: boolean
|
||||
noDismiss?: boolean
|
||||
}
|
||||
|
||||
type openModal = (state: ModalState) => void
|
||||
export type openModal = (state: ModalState) => void
|
||||
|
||||
export const ModalContext = createContext<openModal>(() =>
|
||||
console.error("Tried to open modal without being inside context"))
|
||||
|
||||
export const NestableModalContext = createContext<openModal>(() =>
|
||||
console.error("Tried to open nestable modal without being inside context"))
|
||||
|
||||
export const ModalCloseContext = createContext<() => void>(() => {})
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
// 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 React, { use, useState } from "react"
|
||||
import { getAvatarURL } from "@/api/media.ts"
|
||||
import { getAvatarThumbnailURL } from "@/api/media.ts"
|
||||
import { MemDBEvent, MemberEventContent } from "@/api/types"
|
||||
import { getDisplayname } from "@/util/validation.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
|
@ -33,7 +33,7 @@ const MemberRow = ({ evt, onClick }: MemberRowProps) => {
|
|||
return <div className="member" data-target-panel="user" data-target-user={userID} onClick={onClick}>
|
||||
<img
|
||||
className="avatar"
|
||||
src={getAvatarURL(userID, content)}
|
||||
src={getAvatarThumbnailURL(userID, content)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
|
@ -50,7 +50,7 @@ const MemberList = () => {
|
|||
roomCtx.store.membersRequested = true
|
||||
use(ClientContext)?.loadRoomState(roomCtx.store.roomID, { omitMembers: false, refetch: false })
|
||||
}
|
||||
const memberEvents = useFilteredMembers(roomCtx?.store, filter)
|
||||
const memberEvents = useFilteredMembers(roomCtx?.store, filter, false, false)
|
||||
if (!roomCtx) {
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -51,9 +51,23 @@ div.right-panel-content.pinned-messages {
|
|||
}
|
||||
}
|
||||
|
||||
div.right-panel-content.user {
|
||||
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 {
|
||||
padding: 1rem;
|
||||
|
||||
div.avatar-container {
|
||||
|
@ -63,7 +77,6 @@ div.right-panel-content.user {
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
margin: 0 auto;
|
||||
|
||||
> img {
|
||||
|
@ -89,6 +102,27 @@ div.right-panel-content.user {
|
|||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div.userid, div.extended-profile, div.devices, div.user-moderation, div.mutual-rooms, div.errors {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
div.extended-profile {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
> input {
|
||||
border: 0;
|
||||
padding: 0; /* Necessary to prevent alignment issues with other cells */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--blockquote-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
|
@ -178,6 +212,25 @@ div.right-panel-content.user {
|
|||
}
|
||||
}
|
||||
|
||||
div.user-moderation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
button.moderation-action {
|
||||
padding: .5rem;
|
||||
width: 100%;
|
||||
gap: .5rem;
|
||||
justify-content: left;
|
||||
|
||||
&.dangerous {
|
||||
color: var(--error-color);
|
||||
}
|
||||
&.positive {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.errors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -13,20 +13,30 @@
|
|||
//
|
||||
// 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 { JSX, use } from "react"
|
||||
import type { UserID } from "@/api/types"
|
||||
import MainScreenContext from "../MainScreenContext.ts"
|
||||
import MainScreenContext, { MainScreenContextFields } from "../MainScreenContext.ts"
|
||||
import ErrorBoundary from "../util/ErrorBoundary.tsx"
|
||||
import ElementCall from "../widget/ElementCall.tsx"
|
||||
import LazyWidget from "../widget/LazyWidget.tsx"
|
||||
import MemberList from "./MemberList.tsx"
|
||||
import PinnedMessages from "./PinnedMessages.tsx"
|
||||
import UserInfo from "./UserInfo.tsx"
|
||||
import WidgetList from "./WidgetList.tsx"
|
||||
import BackIcon from "@/icons/back.svg?react"
|
||||
import CloseIcon from "@/icons/close.svg?react"
|
||||
import "./RightPanel.css"
|
||||
|
||||
export type RightPanelType = "pinned-messages" | "members" | "user"
|
||||
export type RightPanelType = "pinned-messages" | "members" | "widgets" | "widget" | "user" | "element-call"
|
||||
|
||||
interface RightPanelSimpleProps {
|
||||
type: "pinned-messages" | "members"
|
||||
type: "pinned-messages" | "members" | "widgets" | "element-call"
|
||||
}
|
||||
|
||||
interface RightPanelWidgetProps {
|
||||
type: "widget"
|
||||
info: IWidget
|
||||
}
|
||||
|
||||
interface RightPanelUserProps {
|
||||
|
@ -34,25 +44,37 @@ interface RightPanelUserProps {
|
|||
userID: UserID
|
||||
}
|
||||
|
||||
export type RightPanelProps = RightPanelUserProps | RightPanelSimpleProps
|
||||
export type RightPanelProps = RightPanelUserProps | RightPanelWidgetProps | RightPanelSimpleProps
|
||||
|
||||
function getTitle(type: RightPanelType): string {
|
||||
switch (type) {
|
||||
function getTitle(props: RightPanelProps): string {
|
||||
switch (props.type) {
|
||||
case "pinned-messages":
|
||||
return "Pinned Messages"
|
||||
case "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":
|
||||
return "User Info"
|
||||
}
|
||||
}
|
||||
|
||||
function renderRightPanelContent(props: RightPanelProps): JSX.Element | null {
|
||||
function renderRightPanelContent(props: RightPanelProps, mainScreen: MainScreenContextFields): JSX.Element | null {
|
||||
switch (props.type) {
|
||||
case "pinned-messages":
|
||||
return <PinnedMessages />
|
||||
case "members":
|
||||
return <MemberList />
|
||||
case "widgets":
|
||||
return <WidgetList />
|
||||
case "element-call":
|
||||
return <ElementCall onClose={mainScreen.closeRightPanel} />
|
||||
case "widget":
|
||||
return <LazyWidget info={props.info} onClose={mainScreen.closeRightPanel} />
|
||||
case "user":
|
||||
return <UserInfo userID={props.userID} />
|
||||
}
|
||||
|
@ -66,17 +88,24 @@ const RightPanel = (props: RightPanelProps) => {
|
|||
data-target-panel="members"
|
||||
onClick={mainScreen.clickRightPanelOpener}
|
||||
><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">
|
||||
<div className="right-panel-header">
|
||||
<div className="left-side">
|
||||
{backButton}
|
||||
<div className="panel-name">{getTitle(props.type)}</div>
|
||||
<div className="panel-name">{getTitle(props)}</div>
|
||||
</div>
|
||||
<button onClick={mainScreen.closeRightPanel}><CloseIcon/></button>
|
||||
</div>
|
||||
<div className={`right-panel-content ${props.type}`}>
|
||||
{renderRightPanelContent(props)}
|
||||
<ErrorBoundary thing="right panel content">
|
||||
{renderRightPanelContent(props, mainScreen)}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
102
web/src/ui/rightpanel/StartDMButton.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
// 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, useState } from "react"
|
||||
import Client from "@/api/client.ts"
|
||||
import { UserID } from "@/api/types"
|
||||
import MainScreenContext from "../MainScreenContext.ts"
|
||||
import ChatIcon from "@/icons/chat.svg?react"
|
||||
|
||||
const StartDMButton = ({ userID, client }: { userID: UserID; client: Client }) => {
|
||||
const mainScreen = use(MainScreenContext)!
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const findExistingRoom = () => {
|
||||
for (const room of client.store.rooms.values()) {
|
||||
if (room.meta.current.dm_user_id === userID) {
|
||||
return room.roomID
|
||||
}
|
||||
}
|
||||
}
|
||||
const existingRoom = useMemo(findExistingRoom, [userID, client])
|
||||
|
||||
const startDM = async () => {
|
||||
if (existingRoom) {
|
||||
mainScreen.setActiveRoom(existingRoom)
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`Are you sure you want to start a chat with ${userID}?`)) {
|
||||
return
|
||||
}
|
||||
const existingRoomRelookup = findExistingRoom()
|
||||
if (existingRoomRelookup) {
|
||||
mainScreen.setActiveRoom(existingRoomRelookup)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true)
|
||||
|
||||
let shouldEncrypt = false
|
||||
const initialState = []
|
||||
|
||||
try {
|
||||
shouldEncrypt = (await client.rpc.trackUserDevices(userID)).devices.length > 0
|
||||
|
||||
if (shouldEncrypt) {
|
||||
console.log("User has encryption devices, creating encrypted room")
|
||||
initialState.push({
|
||||
type: "m.room.encryption",
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to check user encryption status:", err)
|
||||
}
|
||||
|
||||
// Create the room with encryption if needed
|
||||
const response = await client.rpc.createRoom({
|
||||
is_direct: true,
|
||||
preset: "trusted_private_chat",
|
||||
invite: [userID],
|
||||
initial_state: initialState,
|
||||
})
|
||||
console.log("Created DM room:", response.room_id)
|
||||
|
||||
// FIXME this is a hacky way to work around the room taking time to come down /sync
|
||||
setTimeout(() => {
|
||||
mainScreen.setActiveRoom(response.room_id)
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
console.error("Failed to create DM room:", err)
|
||||
window.alert(`Failed to create DM room: ${err}`)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return <button
|
||||
className="moderation-action positive"
|
||||
onClick={startDM}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<ChatIcon />
|
||||
<span>{existingRoom ? "Go to DM" : isCreating ? "Creating..." : "Create DM"}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
export default StartDMButton
|
119
web/src/ui/rightpanel/UserExtendedProfile.tsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import Client from "@/api/client.ts"
|
||||
import { PronounSet, UserProfile } from "@/api/types"
|
||||
import { ensureArray, ensureString } from "@/util/validation.ts"
|
||||
|
||||
interface ExtendedProfileProps {
|
||||
profile: UserProfile | null
|
||||
refreshProfile: () => void
|
||||
client: Client
|
||||
userID: string
|
||||
}
|
||||
|
||||
interface SetTimezoneProps {
|
||||
tz?: string
|
||||
client: Client
|
||||
refreshProfile: () => void
|
||||
}
|
||||
|
||||
const getCurrentTimezone = () => new Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
const currentTimeAdjusted = (tz: string) => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZoneName: "short",
|
||||
timeZone: tz,
|
||||
}).format(new Date())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const ClockElement = ({ tz }: { tz: string }) => {
|
||||
const cta = currentTimeAdjusted(tz)
|
||||
const isValidTZ = cta !== null
|
||||
const [time, setTime] = useState(cta)
|
||||
useEffect(() => {
|
||||
if (!isValidTZ) {
|
||||
return
|
||||
}
|
||||
let interval: number | undefined
|
||||
const updateTime = () => setTime(currentTimeAdjusted(tz))
|
||||
const timeout = setTimeout(() => {
|
||||
interval = setInterval(updateTime, 1000)
|
||||
updateTime()
|
||||
}, (1001 - Date.now() % 1000))
|
||||
return () => interval ? clearInterval(interval) : clearTimeout(timeout)
|
||||
}, [tz, isValidTZ])
|
||||
|
||||
if (!isValidTZ) {
|
||||
return null
|
||||
}
|
||||
return <>
|
||||
<div title={tz}>Time:</div>
|
||||
<div title={tz}>{time}</div>
|
||||
</>
|
||||
}
|
||||
|
||||
const SetTimeZoneElement = ({ tz, client, refreshProfile }: SetTimezoneProps) => {
|
||||
const zones = Intl.supportedValuesOf("timeZone")
|
||||
const saveTz = (newTz: string) => {
|
||||
if (!zones.includes(newTz)) {
|
||||
return
|
||||
}
|
||||
client.rpc.setProfileField("us.cloke.msc4175.tz", newTz).then(
|
||||
() => refreshProfile(),
|
||||
err => {
|
||||
console.error("Failed to set time zone:", err)
|
||||
window.alert(`Failed to set time zone: ${err}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const defaultValue = tz || getCurrentTimezone()
|
||||
return <>
|
||||
<label htmlFor="userprofile-timezone-input">Set time zone:</label>
|
||||
<input
|
||||
list="timezones"
|
||||
id="userprofile-timezone-input"
|
||||
defaultValue={defaultValue}
|
||||
onKeyDown={evt => evt.key === "Enter" && saveTz(evt.currentTarget.value)}
|
||||
onBlur={evt => evt.currentTarget.value !== defaultValue && saveTz(evt.currentTarget.value)}
|
||||
/>
|
||||
<datalist id="timezones">
|
||||
{zones.map((zone) => <option key={zone} value={zone} />)}
|
||||
</datalist>
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
const UserExtendedProfile = ({ profile, refreshProfile, client, userID }: ExtendedProfileProps)=> {
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
|
||||
const extendedProfileKeys = ["us.cloke.msc4175.tz", "io.fsky.nyx.pronouns"]
|
||||
const hasExtendedProfile = extendedProfileKeys.some((key) => profile[key])
|
||||
if (!hasExtendedProfile && client.userID !== userID) {
|
||||
return null
|
||||
}
|
||||
// Explicitly only return something if the profile has the keys we're looking for.
|
||||
// otherwise there's an ugly and pointless <hr/> for no real reason.
|
||||
|
||||
const pronouns = ensureArray(profile["io.fsky.nyx.pronouns"]) as PronounSet[]
|
||||
const userTimeZone = ensureString(profile["us.cloke.msc4175.tz"])
|
||||
return <div className="extended-profile">
|
||||
{userTimeZone && <ClockElement tz={userTimeZone} />}
|
||||
{userID === client.userID &&
|
||||
<SetTimeZoneElement tz={userTimeZone} client={client} refreshProfile={refreshProfile} />}
|
||||
{pronouns.length > 0 && <>
|
||||
<div>Pronouns:</div>
|
||||
<div>{pronouns.map(pronounSet => ensureString(pronounSet.summary)).join(", ")}</div>
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default UserExtendedProfile
|
55
web/src/ui/rightpanel/UserIgnoreButton.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
// gomuks - A Matrix client written in Go.
|
||||
// Copyright (C) 2025 Nexus Nicholson
|
||||
//
|
||||
// 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 Client from "@/api/client.ts"
|
||||
import { useAccountData } from "@/api/statestore"
|
||||
import { IgnoredUsersEventContent } from "@/api/types"
|
||||
import IgnoreIcon from "@/icons/block.svg?react"
|
||||
|
||||
const UserIgnoreButton = ({ userID, client }: { userID: string; client: Client }) => {
|
||||
const ignoredUsers = useAccountData(client.store, "m.ignored_user_list") as IgnoredUsersEventContent | null
|
||||
|
||||
const isIgnored = Boolean(ignoredUsers?.ignored_users?.[userID])
|
||||
const ignoreUser = () => {
|
||||
if (!window.confirm(`Are you sure you want to ignore ${userID}?`)) {
|
||||
return
|
||||
}
|
||||
const newIgnoredUsers = { ...(ignoredUsers || { ignored_users: {}}) }
|
||||
newIgnoredUsers.ignored_users[userID] = {}
|
||||
client.rpc.setAccountData("m.ignored_user_list", newIgnoredUsers).catch(err => {
|
||||
console.error("Failed to ignore user", err)
|
||||
window.alert(`Failed to ignore ${userID}: ${err}`)
|
||||
})
|
||||
}
|
||||
const unignoreUser = () => {
|
||||
const newIgnoredUsers = { ...(ignoredUsers || { ignored_users: {}}) }
|
||||
delete newIgnoredUsers.ignored_users[userID]
|
||||
client.rpc.setAccountData("m.ignored_user_list", newIgnoredUsers).catch(err => {
|
||||
console.error("Failed to unignore user", err)
|
||||
window.alert(`Failed to unignore ${userID}: ${err}`)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={"moderation-action " + (isIgnored ? "positive" : "dangerous")}
|
||||
onClick={isIgnored ? unignoreUser : ignoreUser}>
|
||||
<IgnoreIcon/>
|
||||
<span>{isIgnored ? "Unignore" : "Ignore"}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserIgnoreButton
|
|
@ -13,18 +13,20 @@
|
|||
//
|
||||
// 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, useEffect, useState } from "react"
|
||||
import { use, useCallback, useEffect, useState } from "react"
|
||||
import { PuffLoader } from "react-spinners"
|
||||
import { getAvatarURL } from "@/api/media.ts"
|
||||
import { useRoomMember } from "@/api/statestore"
|
||||
import { MemberEventContent, UserID, UserProfile } from "@/api/types"
|
||||
import { getLocalpart } from "@/util/validation.ts"
|
||||
import { ensureString, getLocalpart } from "@/util/validation.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
import { LightboxContext } from "../modal"
|
||||
import { RoomContext } from "../roomview/roomcontext.ts"
|
||||
import UserExtendedProfile from "./UserExtendedProfile.tsx"
|
||||
import DeviceList from "./UserInfoDeviceList.tsx"
|
||||
import UserInfoError from "./UserInfoError.tsx"
|
||||
import MutualRooms from "./UserInfoMutualRooms.tsx"
|
||||
import UserModeration from "./UserModeration.tsx"
|
||||
|
||||
interface UserInfoProps {
|
||||
userID: UserID
|
||||
|
@ -38,16 +40,20 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
|||
const member = (memberEvt?.content ?? null) as MemberEventContent | null
|
||||
const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null)
|
||||
const [errors, setErrors] = useState<string[] | null>(null)
|
||||
useEffect(() => {
|
||||
setErrors(null)
|
||||
setGlobalProfile(null)
|
||||
const refreshProfile = useCallback((clearState = false) => {
|
||||
if (clearState) {
|
||||
setErrors(null)
|
||||
setGlobalProfile(null)
|
||||
}
|
||||
client.rpc.getProfile(userID).then(
|
||||
setGlobalProfile,
|
||||
err => setErrors([`${err}`]),
|
||||
)
|
||||
}, [roomCtx, userID, client])
|
||||
|
||||
const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID)
|
||||
}, [userID, client])
|
||||
useEffect(() => refreshProfile(true), [refreshProfile])
|
||||
const displayname = ensureString(member?.displayname)
|
||||
|| ensureString(globalProfile?.displayname)
|
||||
|| getLocalpart(userID)
|
||||
return <>
|
||||
<div className="avatar-container">
|
||||
{member === null && globalProfile === null && errors == null ? <PuffLoader
|
||||
|
@ -56,6 +62,7 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
|||
className="avatar-loader"
|
||||
/> : <img
|
||||
className="avatar"
|
||||
// this is a big avatar (236px by default), use full resolution
|
||||
src={getAvatarURL(userID, member ?? globalProfile)}
|
||||
onClick={openLightbox}
|
||||
alt=""
|
||||
|
@ -63,17 +70,13 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
|||
</div>
|
||||
<div className="displayname" title={displayname}>{displayname}</div>
|
||||
<div className="userid" title={userID}>{userID}</div>
|
||||
<hr/>
|
||||
<UserExtendedProfile profile={globalProfile} refreshProfile={refreshProfile} client={client} userID={userID}/>
|
||||
<DeviceList client={client} room={roomCtx?.store} userID={userID}/>
|
||||
{userID !== client.userID && <>
|
||||
<MutualRooms client={client} userID={userID}/>
|
||||
<hr/>
|
||||
<UserModeration client={client} room={roomCtx?.store} member={memberEvt} userID={userID}/>
|
||||
</>}
|
||||
<DeviceList client={client} room={roomCtx?.store} userID={userID}/>
|
||||
<hr/>
|
||||
{errors?.length ? <>
|
||||
<UserInfoError errors={errors}/>
|
||||
<hr/>
|
||||
</> : null}
|
||||
<UserInfoError errors={errors}/>
|
||||
</>
|
||||
}
|
||||
|
||||
|
|
|
@ -40,8 +40,7 @@ const MutualRooms = ({ client, userID }: MutualRoomsProps) => {
|
|||
}
|
||||
return {
|
||||
room_id: roomID,
|
||||
dm_user_id: roomData.meta.current.lazy_load_summary?.["m.heroes"]?.length === 1
|
||||
? roomData.meta.current.lazy_load_summary["m.heroes"][0] : undefined,
|
||||
dm_user_id: roomData.meta.current.dm_user_id,
|
||||
name: roomData.meta.current.name ?? "Unnamed room",
|
||||
avatar: roomData.meta.current.avatar,
|
||||
search_name: "",
|
||||
|
|
118
web/src/ui/rightpanel/UserModeration.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
// gomuks - A Matrix client written in Go.
|
||||
// Copyright (C) 2025 Nexus Nicholson
|
||||
//
|
||||
// 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 } from "react"
|
||||
import Client from "@/api/client.ts"
|
||||
import { RoomStateStore } from "@/api/statestore"
|
||||
import { MemDBEvent, MembershipAction } from "@/api/types"
|
||||
import ConfirmWithMessageModal from "../menu/ConfirmWithMessageModal.tsx"
|
||||
import { getPowerLevels } from "../menu/util.ts"
|
||||
import { ModalContext } from "../modal"
|
||||
import StartDMButton from "./StartDMButton.tsx"
|
||||
import UserIgnoreButton from "./UserIgnoreButton.tsx"
|
||||
import BanIcon from "@/icons/gavel.svg?react"
|
||||
import InviteIcon from "@/icons/person-add.svg?react"
|
||||
import KickIcon from "@/icons/person-remove.svg?react"
|
||||
|
||||
interface UserModerationProps {
|
||||
userID: string;
|
||||
client: Client;
|
||||
room: RoomStateStore | undefined;
|
||||
member: MemDBEvent | null;
|
||||
}
|
||||
|
||||
const UserModeration = ({ userID, client, member, room }: UserModerationProps) => {
|
||||
const openModal = use(ModalContext)
|
||||
const hasPL = (action: "invite" | "kick" | "ban") => {
|
||||
if (!room) {
|
||||
throw new Error("hasPL called without room")
|
||||
}
|
||||
const [pls, ownPL] = getPowerLevels(room, client)
|
||||
if(action === "invite") {
|
||||
return ownPL >= (pls.invite ?? 0)
|
||||
}
|
||||
const otherUserPL = pls.users?.[userID] ?? pls.users_default ?? 0
|
||||
return ownPL >= (pls[action] ?? 50) && ownPL > otherUserPL
|
||||
}
|
||||
|
||||
const runAction = (action: MembershipAction) => {
|
||||
if (!room) {
|
||||
throw new Error("runAction called without room")
|
||||
}
|
||||
const callback = (reason: string) => {
|
||||
client.rpc.setMembership(room.roomID, userID, action, reason).then(
|
||||
() => console.debug("Actioned", userID),
|
||||
err => {
|
||||
console.error("Failed to action", err)
|
||||
window.alert(`Failed to ${action} ${userID}: ${err}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
const titleCasedAction = action.charAt(0).toUpperCase() + action.slice(1)
|
||||
return () => {
|
||||
openModal({
|
||||
dimmed: true,
|
||||
boxed: true,
|
||||
innerBoxClass: "confirm-message-modal",
|
||||
content: <ConfirmWithMessageModal
|
||||
title={`${titleCasedAction} user`}
|
||||
description={<>Are you sure you want to {action} <code>{userID}</code>?</>}
|
||||
placeholder="Reason (optional)"
|
||||
confirmButton={titleCasedAction}
|
||||
onConfirm={callback}
|
||||
/>,
|
||||
})
|
||||
}
|
||||
}
|
||||
const membership = member?.content.membership || "leave"
|
||||
|
||||
return <div className="user-moderation">
|
||||
<h4>Actions</h4>
|
||||
{!room || room.meta.current.dm_user_id !== userID ? <StartDMButton userID={userID} client={client} /> : null}
|
||||
{room && (["knock", "leave"].includes(membership) || !member) && hasPL("invite") && (
|
||||
<button className="moderation-action positive" onClick={runAction("invite")}>
|
||||
<InviteIcon />
|
||||
<span>{membership === "knock" ? "Accept join request" : "Invite"}</span>
|
||||
</button>
|
||||
)}
|
||||
{room && ["knock", "invite", "join"].includes(membership) && hasPL("kick") && (
|
||||
<button className="moderation-action dangerous" onClick={runAction("kick")}>
|
||||
<KickIcon />
|
||||
<span>{
|
||||
membership === "join"
|
||||
? "Kick"
|
||||
: membership === "invite"
|
||||
? "Revoke invitation"
|
||||
: "Reject join request"
|
||||
}</span>
|
||||
</button>
|
||||
)}
|
||||
{room && membership !== "ban" && hasPL("ban") && (
|
||||
<button className="moderation-action dangerous" onClick={runAction("ban")}>
|
||||
<BanIcon />
|
||||
<span>Ban</span>
|
||||
</button>
|
||||
)}
|
||||
{room && membership === "ban" && hasPL("ban") && (
|
||||
<button className="moderation-action positive" onClick={runAction("unban")}>
|
||||
<BanIcon />
|
||||
<span>Unban</span>
|
||||
</button>
|
||||
)}
|
||||
<UserIgnoreButton userID={userID} client={client} />
|
||||
</div>
|
||||
}
|
||||
|
||||
export default UserModeration
|