1
0
Fork 0
forked from Mirrors/gomuks

Compare commits

..

1 commit

Author SHA1 Message Date
Tulir Asokan
c902c941e7 web/timeline: experiment with virtua 2024-12-31 16:13:08 +02:00
147 changed files with 1638 additions and 6205 deletions

View file

@ -1,11 +0,0 @@
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'"

View file

@ -2,16 +2,13 @@ name: Go
on: [push, pull_request] on: [push, pull_request]
env:
GOTOOLCHAIN: local
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
go-version: ["1.23", "1.24"] go-version: ["1.23"]
name: Lint Go ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }} name: Lint Go ${{ matrix.go-version == '1.23' && '(latest)' || '(old)' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View file

@ -13,9 +13,6 @@ cache:
paths: paths:
- .cache - .cache
variables:
GOTOOLCHAIN: local
frontend: frontend:
image: node:22-alpine image: node:22-alpine
stage: frontend stage: frontend

View file

@ -1,6 +1,6 @@
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache ca-certificates jq curl ffmpeg RUN apk add --no-cache ca-certificates jq curl
ARG EXECUTABLE=./gomuks ARG EXECUTABLE=./gomuks
COPY $EXECUTABLE /usr/bin/gomuks COPY $EXECUTABLE /usr/bin/gomuks

View file

@ -1,7 +1,18 @@
# nyxmuks # gomuks
![Languages](https://img.shields.io/github/languages/top/tulir/gomuks.svg)
[![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases)
[![GitLab CI](https://mau.dev/tulir/gomuks/badges/main/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines)
Soft fork of Tulir's Gomuks. A Matrix client written in Go using [mautrix](https://github.com/mautrix/go).
# why? 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>.
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. ## 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)

View file

@ -2,41 +2,39 @@ module go.mau.fi/gomuks/desktop
go 1.23.0 go 1.23.0
toolchain go1.23.5 toolchain go1.23.3
require github.com/wailsapp/wails/v3 v3.0.0-alpha.9 require github.com/wailsapp/wails/v3 v3.0.0-alpha.7
require ( require (
go.mau.fi/gomuks v0.4.0 go.mau.fi/gomuks v0.3.1
go.mau.fi/util v0.8.6 go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
) )
require ( require (
dario.cat/mergo v1.0.1 // indirect dario.cat/mergo v1.0.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
github.com/adrg/xdg v0.5.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/alecthomas/chroma/v2 v2.15.0 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/buckket/go-blurhash v1.1.0 // indirect github.com/buckket/go-blurhash v1.1.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect github.com/chzyer/readline v1.5.1 // indirect
github.com/cloudflare/circl v1.3.8 // indirect github.com/cloudflare/circl v1.3.7 // indirect
github.com/coder/websocket v1.8.13 // indirect github.com/coder/websocket v1.8.12 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.5 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/disintegration/imaging v1.6.2 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/ebitengine/purego v0.4.0-alpha.4 // indirect github.com/ebitengine/purego v0.4.0-alpha.4 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.7 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.0 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-git/go-git/v5 v5.12.0 // indirect github.com/go-git/go-git/v5 v5.11.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.4.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // 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/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect
@ -44,43 +42,41 @@ require (
github.com/leaanthony/u v1.1.0 // indirect github.com/leaanthony/u v1.1.0 // indirect
github.com/lmittmann/tint v1.0.4 // indirect github.com/lmittmann/tint v1.0.4 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // indirect github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/rs/zerolog v1.33.0 // indirect github.com/rs/zerolog v1.33.0 // indirect
github.com/samber/lo v1.38.1 // indirect github.com/samber/lo v1.38.1 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sergi/go-diff v1.2.0 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect github.com/skeema/knownhosts v1.2.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/wailsapp/go-webview2 v1.0.19 // indirect github.com/wailsapp/go-webview2 v1.0.15 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yuin/goldmark v1.7.8 // 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 go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.36.0 // indirect golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
golang.org/x/image v0.25.0 // indirect golang.org/x/image v0.23.0 // indirect
golang.org/x/mod v0.24.0 // indirect golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.37.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.12.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.31.0 // indirect golang.org/x/tools v0.28.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mautrix v0.23.2 // indirect maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 // indirect
mvdan.cc/xurls/v2 v2.6.0 // indirect mvdan.cc/xurls/v2 v2.5.0 // indirect
) )
replace go.mau.fi/gomuks => ../ replace go.mau.fi/gomuks => ../

View file

@ -1,5 +1,5 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@ -7,14 +7,12 @@ 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.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 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
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 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@ -33,41 +31,39 @@ 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 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 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.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.12/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 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/cyphar/filepath-securejoin v0.2.4/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= 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 h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= 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 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
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 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
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 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-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 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 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@ -75,8 +71,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/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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 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= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@ -102,19 +98,18 @@ 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/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 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 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.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.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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs= github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
@ -126,8 +121,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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@ -135,11 +130,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/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 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ=
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -150,26 +145,23 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 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 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 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.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 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU= github.com/wailsapp/go-webview2 v1.0.15 h1:IeQFoWmsHp32y64I41J+Zod3SopjHs918KSO4jUqEnY=
github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/go-webview2 v1.0.15/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= 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/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v3 v3.0.0-alpha.9 h1:b8CfRrhPno8Fra0xFp4Ifyj+ogmXBc35rsQWvcrHtsI= github.com/wailsapp/wails/v3 v3.0.0-alpha.7 h1:LNX2EnbxTEYJYICJT8UkuzoGVNalRizTNGBY47endmk=
github.com/wailsapp/wails/v3 v3.0.0-alpha.9/go.mod h1:dSv6s722nSWaUyUiapAM1DHc5HKggNGY1a79shO85/g= github.com/wailsapp/wails/v3 v3.0.0-alpha.7/go.mod h1:lBz4zedFxreJBoVpMe9u89oo4IE3IlyHJg5rOWnGNR0=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 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/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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
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 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= 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= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -177,17 +169,16 @@ 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.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.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.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@ -196,14 +187,15 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-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-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -216,21 +208,20 @@ 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-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-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.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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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.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.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 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.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -238,30 +229,28 @@ 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.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.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.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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 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-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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 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 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 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.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.23.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg= maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 h1:3aZCApUF3wlboN1BcAX6u6MGhju4OpsmhyfvxdO6tO0=
maunium.net/go/mautrix v0.23.2/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE= maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

38
go.mod
View file

@ -2,15 +2,14 @@ module go.mau.fi/gomuks
go 1.23.0 go 1.23.0
toolchain go1.24.1 toolchain go1.23.4
require ( require (
github.com/alecthomas/chroma/v2 v2.15.0 github.com/alecthomas/chroma/v2 v2.14.0
github.com/buckket/go-blurhash v1.1.0 github.com/buckket/go-blurhash v1.1.0
github.com/chzyer/readline v1.5.1 github.com/chzyer/readline v1.5.1
github.com/coder/websocket v1.8.13 github.com/coder/websocket v1.8.12
github.com/disintegration/imaging v1.6.2 github.com/gabriel-vasile/mimetype v1.4.7
github.com/gabriel-vasile/mimetype v1.4.8
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
github.com/rivo/uniseg v0.4.7 github.com/rivo/uniseg v0.4.7
@ -18,30 +17,29 @@ require (
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5 github.com/tidwall/sjson v1.2.5
github.com/yuin/goldmark v1.7.8 github.com/yuin/goldmark v1.7.8
go.mau.fi/util v0.8.6 go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
go.mau.fi/webp v0.2.0
go.mau.fi/zeroconfig v0.1.3 go.mau.fi/zeroconfig v0.1.3
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.31.0
golang.org/x/image v0.25.0 golang.org/x/image v0.23.0
golang.org/x/net v0.37.0 golang.org/x/net v0.33.0
golang.org/x/text v0.23.0 golang.org/x/text v0.21.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.23.2 maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5
mvdan.cc/xurls/v2 v2.6.0 mvdan.cc/xurls/v2 v2.5.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // indirect github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.28.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
) )

75
go.sum
View file

@ -2,10 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.7.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.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 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/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do= github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
@ -16,34 +16,30 @@ 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/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 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.12/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 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
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/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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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.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.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.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 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs= github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -61,37 +57,32 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 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 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 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.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 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
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 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
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-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
@ -100,7 +91,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.23.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg= maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 h1:3aZCApUF3wlboN1BcAX6u6MGhju4OpsmhyfvxdO6tO0=
maunium.net/go/mautrix v0.23.2/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE= maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

View file

@ -54,7 +54,7 @@ func NewEventBuffer(maxSize int) *EventBuffer {
} }
} }
func (eb *EventBuffer) Push(evt any) { func (eb *EventBuffer) HicliEventHandler(evt any) {
data, err := json.Marshal(evt) data, err := json.Marshal(evt)
if err != nil { if err != nil {
panic(fmt.Errorf("failed to marshal event %T: %w", evt, err)) panic(fmt.Errorf("failed to marshal event %T: %w", evt, err))

View file

@ -34,8 +34,6 @@ import (
type Config struct { type Config struct {
Web WebConfig `yaml:"web"` Web WebConfig `yaml:"web"`
Matrix MatrixConfig `yaml:"matrix"` Matrix MatrixConfig `yaml:"matrix"`
Push PushConfig `yaml:"push"`
Media MediaConfig `yaml:"media"`
Logging zeroconfig.Config `yaml:"logging"` Logging zeroconfig.Config `yaml:"logging"`
} }
@ -43,14 +41,6 @@ type MatrixConfig struct {
DisableHTTP2 bool `yaml:"disable_http2"` DisableHTTP2 bool `yaml:"disable_http2"`
} }
type PushConfig struct {
FCMGateway string `yaml:"fcm_gateway"`
}
type MediaConfig struct {
ThumbnailSize int `yaml:"thumbnail_size"`
}
type WebConfig struct { type WebConfig struct {
ListenAddress string `yaml:"listen_address"` ListenAddress string `yaml:"listen_address"`
Username string `yaml:"username"` Username string `yaml:"username"`
@ -79,9 +69,6 @@ func makeDefaultConfig() Config {
Matrix: MatrixConfig{ Matrix: MatrixConfig{
DisableHTTP2: false, DisableHTTP2: false,
}, },
Media: MediaConfig{
ThumbnailSize: 120,
},
Logging: zeroconfig.Config{ Logging: zeroconfig.Config{
MinLevel: ptr.Ptr(zerolog.DebugLevel), MinLevel: ptr.Ptr(zerolog.DebugLevel),
Writers: []zeroconfig.WriterConfig{{ Writers: []zeroconfig.WriterConfig{{
@ -134,14 +121,6 @@ func (gmx *Gomuks) LoadConfig() error {
gmx.Config.Web.EventBufferSize = 512 gmx.Config.Web.EventBufferSize = 512
changed = true 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 { if len(gmx.Config.Web.OriginPatterns) == 0 {
gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"} gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"}
changed = true changed = true

View file

@ -34,7 +34,6 @@ import (
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog" "go.mau.fi/util/exzerolog"
"go.mau.fi/util/ptr"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"go.mau.fi/gomuks/pkg/hicli" "go.mau.fi/gomuks/pkg/hicli"
@ -172,7 +171,7 @@ func (gmx *Gomuks) StartClient() {
nil, nil,
gmx.Log.With().Str("component", "hicli").Logger(), gmx.Log.With().Str("component", "hicli").Logger(),
[]byte("meow"), []byte("meow"),
gmx.HandleEvent, gmx.EventBuffer.HicliEventHandler,
) )
gmx.Client.LogoutFunc = gmx.Logout gmx.Client.LogoutFunc = gmx.Logout
httpClient := gmx.Client.Client.Client httpClient := gmx.Client.Client.Client
@ -198,14 +197,6 @@ func (gmx *Gomuks) StartClient() {
gmx.Log.Info().Stringer("user_id", userID).Msg("Client started") 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() { func (gmx *Gomuks) Stop() {
gmx.stopOnce.Do(func() { gmx.stopOnce.Do(func() {
close(gmx.stopChan) close(gmx.stopChan)

View file

@ -1,110 +0,0 @@
// 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,
})
}

View file

@ -36,17 +36,16 @@ import (
"strings" "strings"
"github.com/buckket/go-blurhash" "github.com/buckket/go-blurhash"
"github.com/disintegration/imaging"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/hlog" "github.com/rs/zerolog/hlog"
_ "golang.org/x/image/webp"
"go.mau.fi/util/exhttp" "go.mau.fi/util/exhttp"
"go.mau.fi/util/ffmpeg" "go.mau.fi/util/ffmpeg"
"go.mau.fi/util/jsontime" "go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/util/random" "go.mau.fi/util/random"
cwebp "go.mau.fi/webp"
_ "golang.org/x/image/webp"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
@ -60,7 +59,7 @@ var ErrBadGateway = mautrix.RespError{
StatusCode: http.StatusBadGateway, StatusCode: http.StatusBadGateway,
} }
func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWriter, r *http.Request, entry *database.Media, force, useThumbnail bool) bool { func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWriter, r *http.Request, entry *database.Media, force bool) bool {
if !entry.UseCache() { if !entry.UseCache() {
if force { if force {
mautrix.MNotFound.WithMessage("Media not found in cache").Write(w) mautrix.MNotFound.WithMessage("Media not found in cache").Write(w)
@ -68,12 +67,11 @@ func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWr
} }
return false return false
} }
etag := entry.ETag(useThumbnail)
if entry.Error != nil { if entry.Error != nil {
w.Header().Set("Mau-Cached-Error", "true") w.Header().Set("Mau-Cached-Error", "true")
entry.Error.Write(w) entry.Error.Write(w)
return true return true
} else if etag != "" && r.Header.Get("If-None-Match") == etag { } else if r.Header.Get("If-None-Match") == entry.ETag() {
w.WriteHeader(http.StatusNotModified) w.WriteHeader(http.StatusNotModified)
return true return true
} else if entry.MimeType != "" && r.URL.Query().Has("fallback") && !isAllowedAvatarMime(entry.MimeType) { } else if entry.MimeType != "" && r.URL.Query().Has("fallback") && !isAllowedAvatarMime(entry.MimeType) {
@ -81,43 +79,7 @@ func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWr
return true return true
} }
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
hash := entry.Hash cacheFile, err := os.Open(gmx.cacheEntryToPath(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 err != nil {
if errors.Is(err, os.ErrNotExist) && !force { if errors.Is(err, os.ErrNotExist) && !force {
return false return false
@ -129,7 +91,7 @@ func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWr
defer func() { defer func() {
_ = cacheFile.Close() _ = cacheFile.Close()
}() }()
cacheEntryToHeaders(w, entry, useThumbnail) cacheEntryToHeaders(w, entry)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, cacheFile) _, err = io.Copy(w, cacheFile)
if err != nil { if err != nil {
@ -143,76 +105,13 @@ func (gmx *Gomuks) cacheEntryToPath(hash []byte) string {
return filepath.Join(gmx.CacheDir, "media", hashPath[0:2], hashPath[2:4], hashPath[4:]) return filepath.Join(gmx.CacheDir, "media", hashPath[0:2], hashPath[2:4], hashPath[4:])
} }
func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media, thumbnail bool) { func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media) {
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-Type", entry.MimeType)
w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10)) 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-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("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("Cache-Control", "max-age=2592000, immutable")
w.Header().Set("ETag", entry.ETag(thumbnail)) w.Header().Set("ETag", entry.ETag())
}
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 { type noErrorWriter struct {
@ -292,7 +191,6 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
} }
encrypted, _ := strconv.ParseBool(query.Get("encrypted")) encrypted, _ := strconv.ParseBool(query.Get("encrypted"))
useThumbnail := query.Get("thumbnail") == "avatar"
logVal := zerolog.Ctx(r.Context()).With(). logVal := zerolog.Ctx(r.Context()).With().
Stringer("mxc_uri", mxc). Stringer("mxc_uri", mxc).
@ -313,7 +211,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
return return
} }
if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false, useThumbnail) { if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false) {
return return
} }
@ -403,8 +301,8 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
cacheEntry.Size = resp.ContentLength cacheEntry.Size = resp.ContentLength
fileHasher := sha256.New() fileHasher := sha256.New()
wrappedReader := io.TeeReader(reader, fileHasher) wrappedReader := io.TeeReader(reader, fileHasher)
if cacheEntry.Size > 0 && cacheEntry.EncFile == nil && !useThumbnail { if cacheEntry.Size > 0 && cacheEntry.EncFile == nil {
cacheEntryToHeaders(w, cacheEntry, useThumbnail) cacheEntryToHeaders(w, cacheEntry)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
wrappedReader = io.TeeReader(wrappedReader, &noErrorWriter{w}) wrappedReader = io.TeeReader(wrappedReader, &noErrorWriter{w})
w = nil w = nil
@ -444,7 +342,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
return return
} }
if w != nil { if w != nil {
gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, true, useThumbnail) gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, true)
} }
} }

View file

@ -1,258 +0,0 @@
// 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()
}
}

View file

@ -1,171 +0,0 @@
// 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,
}
}

View file

@ -53,9 +53,6 @@ func (gmx *Gomuks) CreateAPIRouter() http.Handler {
api.HandleFunc("GET /sso", gmx.HandleSSOComplete) api.HandleFunc("GET /sso", gmx.HandleSSOComplete)
api.HandleFunc("POST /sso", gmx.PrepareSSO) api.HandleFunc("POST /sso", gmx.PrepareSSO)
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia) 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) api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
return exhttp.ApplyMiddleware( return exhttp.ApplyMiddleware(
api, api,
@ -193,10 +190,10 @@ func (gmx *Gomuks) generateToken() (string, time.Time) {
}), expiry }), expiry
} }
func (gmx *Gomuks) generateImageToken(expiry time.Duration) string { func (gmx *Gomuks) generateImageToken() string {
return gmx.signToken(tokenData{ return gmx.signToken(tokenData{
Username: gmx.Config.Web.Username, Username: gmx.Config.Web.Username,
Expiry: jsontime.U(time.Now().Add(expiry)), Expiry: jsontime.U(time.Now().Add(1 * time.Hour)),
ImageOnly: true, ImageOnly: true,
}) })
} }
@ -209,9 +206,8 @@ func (gmx *Gomuks) signToken(td any) string {
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum) return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum)
} }
func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter, created, jsonOutput bool) { func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) {
token, expiry := gmx.generateToken() token, expiry := gmx.generateToken()
if !jsonOutput {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "gomuks_auth", Name: "gomuks_auth",
Value: token, Value: token,
@ -221,55 +217,36 @@ func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter, created, jsonOutput b
SameSite: http.SameSiteLaxMode, 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) { func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
if gmx.DisableAuth { if gmx.DisableAuth {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
jsonOutput := r.URL.Query().Get("output") == "json"
allowPrompt := r.URL.Query().Get("no_prompt") != "true"
authCookie, err := r.Cookie("gomuks_auth") authCookie, err := r.Cookie("gomuks_auth")
if err == nil && gmx.validateAuth(authCookie.Value, false) { if err == nil && gmx.validateAuth(authCookie.Value, false) {
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie") hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
gmx.writeTokenCookie(w, false, jsonOutput) gmx.writeTokenCookie(w)
} else if found, correct := gmx.doBasicAuth(r); found && correct { w.WriteHeader(http.StatusOK)
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password") } else if username, password, ok := r.BasicAuth(); !ok {
gmx.writeTokenCookie(w, true, jsonOutput)
} else {
if !found {
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request") 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")
}
if allowPrompt {
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
}
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
} } else {
}
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)) usernameHash := sha256.Sum256([]byte(username))
expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username)) expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username))
usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:]) usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:])
passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil
correct = passwordCorrect && usernameCorrect if usernameCorrect && passwordCorrect {
return hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
gmx.writeTokenCookie(w)
w.WriteHeader(http.StatusCreated)
} 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)
}
}
} }
func isImageFetch(header http.Header) bool { func isImageFetch(header http.Header) bool {

View file

@ -148,7 +148,7 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
sendImageAuthToken := func() { sendImageAuthToken := func() {
err := writeCmd(ctx, conn, &hicli.JSONCommand{ err := writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "image_auth_token", Command: "image_auth_token",
Data: exerrors.Must(json.Marshal(gmx.generateImageToken(1 * time.Hour))), Data: exerrors.Must(json.Marshal(gmx.generateImageToken())),
}) })
if err != nil { if err != nil {
log.Err(err).Msg("Failed to write image auth token message") log.Err(err).Msg("Failed to write image auth token message")

View file

@ -28,7 +28,6 @@ type Database struct {
Receipt *ReceiptQuery Receipt *ReceiptQuery
Media *MediaQuery Media *MediaQuery
SpaceEdge *SpaceEdgeQuery SpaceEdge *SpaceEdgeQuery
PushRegistration *PushRegistrationQuery
} }
func New(rawDB *dbutil.Database) *Database { func New(rawDB *dbutil.Database) *Database {
@ -48,7 +47,6 @@ func New(rawDB *dbutil.Database) *Database {
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)}, Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)}, Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)}, SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)},
PushRegistration: &PushRegistrationQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newPushRegistration)},
} }
} }
@ -87,7 +85,3 @@ func newAccount(_ *dbutil.QueryHelper[*Account]) *Account {
func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge { func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
return &SpaceEdge{} return &SpaceEdge{}
} }
func newPushRegistration(_ *dbutil.QueryHelper[*PushRegistration]) *PushRegistration {
return &PushRegistration{}
}

View file

@ -36,10 +36,6 @@ const (
getEventByID = getEventBaseQuery + `WHERE event_id = $1` getEventByID = getEventBaseQuery + `WHERE event_id = $1`
getEventByTransactionID = getEventBaseQuery + `WHERE transaction_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` getFailedEventsByMegolmSessionID = getEventBaseQuery + `WHERE room_id = $1 AND megolm_session_id = $2 AND decryption_error IS NOT NULL`
getRelatedEventsQuery = getEventBaseQuery + `
WHERE room_id = $1 AND relates_to = $2 AND ($3 = '' OR relation_type = $3)
ORDER BY timestamp ASC
`
insertEventBaseQuery = ` insertEventBaseQuery = `
INSERT INTO event ( INSERT INTO event (
room_id, event_id, sender, type, state_key, timestamp, content, decrypted, decrypted_type, room_id, event_id, sender, type, state_key, timestamp, content, decrypted, decrypted_type,
@ -116,10 +112,6 @@ func (eq *EventQuery) GetByRowID(ctx context.Context, rowID EventRowID) (*Event,
return eq.QueryOne(ctx, getEventByRowID, rowID) 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) { func (eq *EventQuery) GetByRowIDs(ctx context.Context, rowIDs ...EventRowID) ([]*Event, error) {
query, params := buildMultiEventGetFunction(nil, rowIDs, getManyEventsByRowID) query, params := buildMultiEventGetFunction(nil, rowIDs, getManyEventsByRowID)
return eq.QueryMany(ctx, query, params...) return eq.QueryMany(ctx, query, params...)

View file

@ -23,27 +23,24 @@ import (
const ( const (
insertMediaQuery = ` insertMediaQuery = `
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error) INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (mxc) DO NOTHING ON CONFLICT (mxc) DO NOTHING
` `
upsertMediaQuery = ` upsertMediaQuery = `
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error) INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (mxc) DO UPDATE ON CONFLICT (mxc) DO UPDATE
SET enc_file = COALESCE(excluded.enc_file, media.enc_file), SET enc_file = COALESCE(excluded.enc_file, media.enc_file),
file_name = COALESCE(excluded.file_name, media.file_name), file_name = COALESCE(excluded.file_name, media.file_name),
mime_type = COALESCE(excluded.mime_type, media.mime_type), mime_type = COALESCE(excluded.mime_type, media.mime_type),
size = COALESCE(excluded.size, media.size), size = COALESCE(excluded.size, media.size),
hash = COALESCE(excluded.hash, media.hash), hash = COALESCE(excluded.hash, media.hash),
error = excluded.error, 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 WHERE excluded.error IS NULL OR media.hash IS NULL
` `
getMediaQuery = ` getMediaQuery = `
SELECT mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error SELECT mxc, enc_file, file_name, mime_type, size, hash, error
FROM media FROM media
WHERE mxc = $1 WHERE mxc = $1
` `
@ -140,22 +137,9 @@ type Media struct {
Size int64 Size int64
Hash *[32]byte Hash *[32]byte
Error *MediaError Error *MediaError
ThumbnailError string
ThumbnailSize int64
ThumbnailHash *[32]byte
} }
func (m *Media) ETag(thumbnail bool) string { func (m *Media) ETag() string {
if m == nil {
return ""
}
if thumbnail {
if m.ThumbnailHash == nil {
return ""
}
return fmt.Sprintf(`"%x"`, m.ThumbnailHash)
}
if m.Hash == nil { if m.Hash == nil {
return "" return ""
} }
@ -167,18 +151,14 @@ func (m *Media) UseCache() bool {
} }
func (m *Media) sqlVariables() []any { func (m *Media) sqlVariables() []any {
var hash, thumbnailHash []byte var hash []byte
if m.Hash != nil { if m.Hash != nil {
hash = m.Hash[:] hash = m.Hash[:]
} }
if m.ThumbnailHash != nil {
thumbnailHash = m.ThumbnailHash[:]
}
return []any{ return []any{
&m.MXC, dbutil.JSONPtr(m.EncFile), &m.MXC, dbutil.JSONPtr(m.EncFile),
dbutil.StrPtr(m.FileName), dbutil.StrPtr(m.MimeType), dbutil.NumPtr(m.Size), dbutil.StrPtr(m.FileName), dbutil.StrPtr(m.MimeType), dbutil.NumPtr(m.Size),
hash, dbutil.JSONPtr(m.Error), hash, dbutil.JSONPtr(m.Error),
dbutil.NumPtr(m.ThumbnailSize), thumbnailHash, dbutil.StrPtr(m.ThumbnailError),
} }
} }
@ -192,27 +172,19 @@ var safeMimes = []string{
} }
func (m *Media) Scan(row dbutil.Scannable) (*Media, error) { func (m *Media) Scan(row dbutil.Scannable) (*Media, error) {
var mimeType, fileName, thumbnailError sql.NullString var mimeType, fileName sql.NullString
var size, thumbnailSize sql.NullInt64 var size sql.NullInt64
var hash, thumbnailHash []byte var hash []byte
err := row.Scan( err := row.Scan(&m.MXC, dbutil.JSON{Data: &m.EncFile}, &fileName, &mimeType, &size, &hash, dbutil.JSON{Data: &m.Error})
&m.MXC, dbutil.JSON{Data: &m.EncFile}, &fileName, &mimeType, &size,
&hash, dbutil.JSON{Data: &m.Error}, &thumbnailSize, &thumbnailHash, &thumbnailError,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
m.MimeType = mimeType.String m.MimeType = mimeType.String
m.FileName = fileName.String m.FileName = fileName.String
m.Size = size.Int64 m.Size = size.Int64
m.ThumbnailSize = thumbnailSize.Int64
m.ThumbnailError = thumbnailError.String
if len(hash) == 32 { if len(hash) == 32 {
m.Hash = (*[32]byte)(hash) m.Hash = (*[32]byte)(hash)
} }
if len(thumbnailHash) == 32 {
m.ThumbnailHash = (*[32]byte)(thumbnailHash)
}
return m, nil return m, nil
} }

View file

@ -1,78 +0,0 @@
// 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}
}

View file

@ -21,8 +21,7 @@ import (
const ( const (
getRoomBaseQuery = ` getRoomBaseQuery = `
SELECT room_id, creation_content, tombstone_content, name, name_quality, SELECT room_id, creation_content, tombstone_content, name, name_quality, avatar, explicit_avatar, topic, canonical_alias,
avatar, explicit_avatar, dm_user_id, topic, canonical_alias,
lazy_load_summary, encryption_event, has_member_list, preview_event_rowid, sorting_timestamp, lazy_load_summary, encryption_event, has_member_list, preview_event_rowid, sorting_timestamp,
unread_highlights, unread_notifications, unread_messages, marked_unread, prev_batch unread_highlights, unread_notifications, unread_messages, marked_unread, prev_batch
FROM room FROM room
@ -43,19 +42,18 @@ const (
name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END, name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END,
avatar = COALESCE($6, room.avatar), avatar = COALESCE($6, room.avatar),
explicit_avatar = CASE WHEN $6 IS NOT NULL THEN $7 ELSE room.explicit_avatar END, explicit_avatar = CASE WHEN $6 IS NOT NULL THEN $7 ELSE room.explicit_avatar END,
dm_user_id = COALESCE($8, room.dm_user_id), topic = COALESCE($8, room.topic),
topic = COALESCE($9, room.topic), canonical_alias = COALESCE($9, room.canonical_alias),
canonical_alias = COALESCE($10, room.canonical_alias), lazy_load_summary = COALESCE($10, room.lazy_load_summary),
lazy_load_summary = COALESCE($11, room.lazy_load_summary), encryption_event = COALESCE($11, room.encryption_event),
encryption_event = COALESCE($12, room.encryption_event), has_member_list = room.has_member_list OR $12,
has_member_list = room.has_member_list OR $13, preview_event_rowid = COALESCE($13, room.preview_event_rowid),
preview_event_rowid = COALESCE($14, room.preview_event_rowid), sorting_timestamp = COALESCE($14, room.sorting_timestamp),
sorting_timestamp = COALESCE($15, room.sorting_timestamp), unread_highlights = COALESCE($15, room.unread_highlights),
unread_highlights = COALESCE($16, room.unread_highlights), unread_notifications = COALESCE($16, room.unread_notifications),
unread_notifications = COALESCE($17, room.unread_notifications), unread_messages = COALESCE($17, room.unread_messages),
unread_messages = COALESCE($18, room.unread_messages), marked_unread = COALESCE($18, room.marked_unread),
marked_unread = COALESCE($19, room.marked_unread), prev_batch = COALESCE($19, room.prev_batch)
prev_batch = COALESCE($20, room.prev_batch)
WHERE room_id = $1 WHERE room_id = $1
` `
setRoomPrevBatchQuery = ` setRoomPrevBatchQuery = `
@ -80,7 +78,7 @@ const (
AND (type IN ('m.room.message', 'm.sticker') AND (type IN ('m.room.message', 'm.sticker')
OR (type = 'm.room.encrypted' OR (type = 'm.room.encrypted'
AND decrypted_type IN ('m.room.message', 'm.sticker'))) AND decrypted_type IN ('m.room.message', 'm.sticker')))
AND (relation_type IS NULL OR relation_type <> 'm.replace') AND relation_type <> 'm.replace'
AND redacted_by IS NULL AND redacted_by IS NULL
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT 1 LIMIT 1
@ -132,9 +130,6 @@ func (rq *RoomQuery) UpdatePreviewIfLaterOnTimeline(ctx context.Context, roomID
func (rq *RoomQuery) RecalculatePreview(ctx context.Context, roomID id.RoomID) (rowID EventRowID, err error) { func (rq *RoomQuery) RecalculatePreview(ctx context.Context, roomID id.RoomID) (rowID EventRowID, err error) {
err = rq.GetDB().QueryRow(ctx, recalculateRoomPreviewEventQuery, roomID).Scan(&rowID) err = rq.GetDB().QueryRow(ctx, recalculateRoomPreviewEventQuery, roomID).Scan(&rowID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
return return
} }
@ -158,7 +153,6 @@ type Room struct {
NameQuality NameQuality `json:"name_quality"` NameQuality NameQuality `json:"name_quality"`
Avatar *id.ContentURI `json:"avatar,omitempty"` Avatar *id.ContentURI `json:"avatar,omitempty"`
ExplicitAvatar bool `json:"explicit_avatar"` ExplicitAvatar bool `json:"explicit_avatar"`
DMUserID *id.UserID `json:"dm_user_id,omitempty"`
Topic *string `json:"topic,omitempty"` Topic *string `json:"topic,omitempty"`
CanonicalAlias *id.RoomAlias `json:"canonical_alias,omitempty"` CanonicalAlias *id.RoomAlias `json:"canonical_alias,omitempty"`
@ -194,10 +188,6 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) {
other.ExplicitAvatar = r.ExplicitAvatar other.ExplicitAvatar = r.ExplicitAvatar
hasChanges = true hasChanges = true
} }
if r.DMUserID != nil {
other.DMUserID = r.DMUserID
hasChanges = true
}
if r.Topic != nil { if r.Topic != nil {
other.Topic = r.Topic other.Topic = r.Topic
hasChanges = true hasChanges = true
@ -218,7 +208,7 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) {
hasChanges = true hasChanges = true
other.HasMemberList = true other.HasMemberList = true
} }
if r.PreviewEventRowID != 0 { if r.PreviewEventRowID > other.PreviewEventRowID {
other.PreviewEventRowID = r.PreviewEventRowID other.PreviewEventRowID = r.PreviewEventRowID
hasChanges = true hasChanges = true
} }
@ -260,7 +250,6 @@ func (r *Room) Scan(row dbutil.Scannable) (*Room, error) {
&r.NameQuality, &r.NameQuality,
&r.Avatar, &r.Avatar,
&r.ExplicitAvatar, &r.ExplicitAvatar,
&r.DMUserID,
&r.Topic, &r.Topic,
&r.CanonicalAlias, &r.CanonicalAlias,
dbutil.JSON{Data: &r.LazyLoadSummary}, dbutil.JSON{Data: &r.LazyLoadSummary},
@ -292,7 +281,6 @@ func (r *Room) sqlVariables() []any {
r.NameQuality, r.NameQuality,
r.Avatar, r.Avatar,
r.ExplicitAvatar, r.ExplicitAvatar,
r.DMUserID,
r.Topic, r.Topic,
r.CanonicalAlias, r.CanonicalAlias,
dbutil.JSONPtr(r.LazyLoadSummary), dbutil.JSONPtr(r.LazyLoadSummary),

View file

@ -39,7 +39,6 @@ const (
` `
getCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1` getCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1`
getCurrentRoomStateWithoutMembersQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND type<>'m.room.member'` 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)` 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` getCurrentStateEventQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND cs.event_type = $2 AND cs.state_key = $3`
) )
@ -119,7 +118,3 @@ func (csq *CurrentStateQuery) GetAll(ctx context.Context, roomID id.RoomID) ([]*
func (csq *CurrentStateQuery) GetAllExceptMembers(ctx context.Context, roomID id.RoomID) ([]*Event, error) { func (csq *CurrentStateQuery) GetAllExceptMembers(ctx context.Context, roomID id.RoomID) ([]*Event, error) {
return csq.QueryMany(ctx, getCurrentRoomStateWithoutMembersQuery, roomID) return csq.QueryMany(ctx, getCurrentRoomStateWithoutMembersQuery, roomID)
} }
func (csq *CurrentStateQuery) GetMembers(ctx context.Context, roomID id.RoomID) ([]*Event, error) {
return csq.QueryMany(ctx, getCurrentRoomStateMembersQuery, roomID)
}

View file

@ -1,4 +1,4 @@
-- v0 -> v13 (compatible with v10+): Latest revision -- v0 -> v10 (compatible with v10+): Latest revision
CREATE TABLE account ( CREATE TABLE account (
user_id TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
@ -18,7 +18,6 @@ CREATE TABLE room (
name_quality INTEGER NOT NULL DEFAULT 0, name_quality INTEGER NOT NULL DEFAULT 0,
avatar TEXT, avatar TEXT,
explicit_avatar INTEGER NOT NULL DEFAULT 0, explicit_avatar INTEGER NOT NULL DEFAULT 0,
dm_user_id TEXT,
topic TEXT, topic TEXT,
canonical_alias TEXT, canonical_alias TEXT,
lazy_load_summary TEXT, lazy_load_summary TEXT,
@ -218,11 +217,7 @@ CREATE TABLE media (
mime_type TEXT, mime_type TEXT,
size INTEGER, size INTEGER,
hash BLOB, hash BLOB,
error TEXT, error TEXT
thumbnail_size INTEGER,
thumbnail_hash BLOB,
thumbnail_error TEXT
) STRICT; ) STRICT;
CREATE TABLE media_reference ( CREATE TABLE media_reference (
@ -305,13 +300,3 @@ CREATE TABLE space_edge (
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid) CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
) STRICT; ) STRICT;
CREATE INDEX space_edge_child_idx ON space_edge (child_id); 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;

View file

@ -1,19 +0,0 @@
-- 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;

View file

@ -1,10 +0,0 @@
-- 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;

View file

@ -1,4 +0,0 @@
-- 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;

View file

@ -7,8 +7,6 @@
package hicli package hicli
import ( import (
"encoding/json"
"go.mau.fi/util/jsontime" "go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@ -23,25 +21,13 @@ type SyncRoom struct {
AccountData map[event.Type]*database.AccountData `json:"account_data"` AccountData map[event.Type]*database.AccountData `json:"account_data"`
Events []*database.Event `json:"events"` Events []*database.Event `json:"events"`
Reset bool `json:"reset"` Reset bool `json:"reset"`
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
DismissNotifications bool `json:"dismiss_notifications"`
Notifications []SyncNotification `json:"notifications"` Notifications []SyncNotification `json:"notifications"`
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
} }
type SyncNotification struct { type SyncNotification struct {
RowID database.EventRowID `json:"event_rowid"` RowID database.EventRowID `json:"event_rowid"`
Sound bool `json:"sound"` 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 { type SyncComplete struct {
@ -53,18 +39,6 @@ type SyncComplete struct {
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"` InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"` SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
TopLevelSpaces []id.RoomID `json:"top_level_spaces"` TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
ToDevice []*SyncToDevice `json:"to_device,omitempty"`
}
func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) {
for _, room := range c.Rooms {
for _, notif := range room.Notifications {
if !yield(notif) {
return
}
}
}
} }
func (c *SyncComplete) IsEmpty() bool { func (c *SyncComplete) IsEmpty() bool {

View file

@ -50,8 +50,6 @@ type HiClient struct {
syncErrors int syncErrors int
lastSync time.Time lastSync time.Time
ToDeviceInSync atomic.Bool
EventHandler func(evt any) EventHandler func(evt any)
LogoutFunc func(context.Context) error LogoutFunc func(context.Context) error

View file

@ -101,7 +101,7 @@ func main() {
resp, err := cli.Send(ctx, id.RoomID(fields[1]), event.EventMessage, &event.MessageEventContent{ resp, err := cli.Send(ctx, id.RoomID(fields[1]), event.EventMessage, &event.MessageEventContent{
Body: strings.Join(fields[2:], " "), Body: strings.Join(fields[2:], " "),
MsgType: event.MsgText, MsgType: event.MsgText,
}, false, false) })
_, _ = fmt.Fprintln(rl, err) _, _ = fmt.Fprintln(rl, err)
_, _ = fmt.Fprintf(rl, "%+v\n", resp) _, _ = fmt.Fprintf(rl, "%+v\n", resp)
} }

View file

@ -414,42 +414,24 @@ var HTMLSanitizerImgSrcTemplate = "mxc://%s/%s"
func writeImg(w *strings.Builder, attr []html.Attribute) id.ContentURI { func writeImg(w *strings.Builder, attr []html.Attribute) id.ContentURI {
src, alt, title, isCustomEmoji, width, height := parseImgAttributes(attr) 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") w.WriteString("<img")
writeAttribute(w, "alt", alt) writeAttribute(w, "alt", alt)
if title != "" { if title != "" {
writeAttribute(w, "title", title) writeAttribute(w, "title", title)
} }
writeAttribute(w, "src", url) mxc := id.ContentURIString(src).ParseOrIgnore()
if !mxc.IsValid() {
return id.ContentURI{}
}
writeAttribute(w, "src", fmt.Sprintf(HTMLSanitizerImgSrcTemplate, mxc.Homeserver, mxc.FileID))
writeAttribute(w, "loading", "lazy") writeAttribute(w, "loading", "lazy")
if isCustomEmoji { if isCustomEmoji {
writeAttribute(w, "class", "hicli-inline-img hicli-custom-emoji") writeAttribute(w, "class", "hicli-custom-emoji")
} else if cWidth, cHeight, sizeOK := calculateMediaSize(width, height); sizeOK { } else if cWidth, cHeight, sizeOK := calculateMediaSize(width, height); sizeOK {
writeAttribute(w, "class", "hicli-inline-img hicli-sized-inline-img") writeAttribute(w, "class", "hicli-sized-inline-img")
writeAttribute(w, "style", fmt.Sprintf("width: %.2fpx; height: %.2fpx;", cWidth, cHeight)) writeAttribute(w, "style", fmt.Sprintf("width: %.2fpx; height: %.2fpx;", cWidth, cHeight))
} else { } else {
writeAttribute(w, "class", "hicli-inline-img hicli-sizeless-inline-img") writeAttribute(w, "class", "hicli-sizeless-inline-img")
} }
return mxc return mxc
} }

View file

@ -120,11 +120,8 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
payload := SyncComplete{ payload := SyncComplete{
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)), Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)),
} }
for roomIdx, room := range rooms { for _, room := range rooms {
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp { if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
if roomIdx == 0 {
batchSize *= 2
}
break break
} }
maxTS = room.SortingTimestamp.Time maxTS = room.SortingTimestamp.Time

View file

@ -17,7 +17,6 @@ import (
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
"go.mau.fi/gomuks/pkg/hicli/database" "go.mau.fi/gomuks/pkg/hicli/database"
) )
@ -47,7 +46,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
}) })
case "send_event": case "send_event":
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) { return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
return h.Send(ctx, params.RoomID, params.EventType, params.Content, params.DisableEncryption, params.Synchronous) return h.Send(ctx, params.RoomID, params.EventType, params.Content)
}) })
case "resend_event": case "resend_event":
return unmarshalAndCall(req.Data, func(params *resendEventParams) (*database.Event, error) { return unmarshalAndCall(req.Data, func(params *resendEventParams) (*database.Event, error) {
@ -65,31 +64,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
}) })
case "set_state": case "set_state":
return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) { return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) {
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content, mautrix.ReqSendEvent{ return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content)
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": case "set_account_data":
return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) { return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) {
@ -111,10 +86,6 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) { return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) {
return h.Client.GetProfile(ctx, params.UserID) 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": case "get_mutual_rooms":
return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) { return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) {
return h.GetMutualRooms(ctx, params.UserID) return h.GetMutualRooms(ctx, params.UserID)
@ -133,15 +104,12 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
}) })
case "get_event": case "get_event":
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) { 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) return h.GetEvent(ctx, params.RoomID, params.EventID)
}) })
case "get_related_events": //case "get_events_by_rowids":
return unmarshalAndCall(req.Data, func(params *getRelatedEventsParams) ([]*database.Event, error) { // return unmarshalAndCall(req.Data, func(params *getEventsByRowIDsParams) ([]*database.Event, error) {
return h.DB.Event.GetRelatedEvents(ctx, params.RoomID, params.EventID, params.RelationType) // return h.GetEventsByRowIDs(ctx, params.RowIDs)
}) // })
case "get_room_state": case "get_room_state":
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) { return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch) return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch)
@ -177,35 +145,14 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) { return unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) {
return h.Client.LeaveRoom(ctx, params.RoomID, &mautrix.ReqLeave{Reason: params.Reason}) 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": case "ensure_group_session_shared":
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) { return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
return true, h.EnsureGroupSessionShared(ctx, params.RoomID) return true, h.EnsureGroupSessionShared(ctx, params.RoomID)
}) })
case "send_to_device":
return unmarshalAndCall(req.Data, func(params *sendToDeviceParams) (*mautrix.RespSendToDevice, error) {
params.EventType.Class = event.ToDeviceEventType
return h.SendToDevice(ctx, params.EventType, params.ReqSendToDevice, params.Encrypted)
})
case "resolve_alias": case "resolve_alias":
return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) { return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
return h.Client.ResolveAlias(ctx, params.Alias) return h.Client.ResolveAlias(ctx, params.Alias)
}) })
case "request_openid_token":
return h.Client.RequestOpenIDToken(ctx)
case "logout": case "logout":
if h.LogoutFunc == nil { if h.LogoutFunc == nil {
return nil, errors.New("logout not supported") return nil, errors.New("logout not supported")
@ -248,18 +195,6 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
} }
return cli.GetLoginFlows(ctx) 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: default:
return nil, fmt.Errorf("unknown command %q", req.Command) return nil, fmt.Errorf("unknown command %q", req.Command)
} }
@ -292,8 +227,6 @@ type sendEventParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
EventType event.Type `json:"type"` EventType event.Type `json:"type"`
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
DisableEncryption bool `json:"disable_encryption"`
Synchronous bool `json:"synchronous"`
} }
type resendEventParams struct { type resendEventParams struct {
@ -317,19 +250,6 @@ type sendStateEventParams struct {
EventType event.Type `json:"type"` EventType event.Type `json:"type"`
StateKey string `json:"state_key"` StateKey string `json:"state_key"`
Content json.RawMessage `json:"content"` 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 { type setAccountDataParams struct {
@ -353,23 +273,14 @@ type getProfileParams struct {
UserID id.UserID `json:"user_id"` UserID id.UserID `json:"user_id"`
} }
type setProfileFieldParams struct {
Field string `json:"field"`
Value any `json:"value"`
}
type getEventParams struct { type getEventParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
EventID id.EventID `json:"event_id"` EventID id.EventID `json:"event_id"`
Unredact bool `json:"unredact"`
} }
type getRelatedEventsParams struct { //type getEventsByRowIDsParams struct {
RoomID id.RoomID `json:"room_id"` // RowIDs []database.EventRowID `json:"row_ids"`
EventID id.EventID `json:"event_id"` //}
RelationType event.RelationType `json:"relation_type"`
}
type getRoomStateParams struct { type getRoomStateParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
@ -386,12 +297,6 @@ type ensureGroupSessionSharedParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
} }
type sendToDeviceParams struct {
*mautrix.ReqSendToDevice
EventType event.Type `json:"event_type"`
Encrypted bool `json:"encrypted"`
}
type resolveAliasParams struct { type resolveAliasParams struct {
Alias id.RoomAlias `json:"alias"` Alias id.RoomAlias `json:"alias"`
} }
@ -440,8 +345,3 @@ type getReceiptsParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
EventIDs []id.EventID `json:"event_ids"` EventIDs []id.EventID `json:"event_ids"`
} }
type muteRoomParams struct {
RoomID id.RoomID `json:"room_id"`
Muted bool `json:"muted"`
}

View file

@ -22,6 +22,37 @@ import (
var ErrPaginationAlreadyInProgress = errors.New("pagination is already in progress") 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) { 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 { if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
return nil, fmt.Errorf("failed to get event from database: %w", err) return nil, fmt.Errorf("failed to get event from database: %w", err)
@ -35,26 +66,6 @@ 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 { func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fetchMembers, refetch, dispatchEvt bool) error {
var evts []*event.Event var evts []*event.Event
if refetch { if refetch {
@ -268,7 +279,6 @@ 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) { func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit int) (*PaginationResponse, error) {
ctx, cancel := context.WithCancelCause(ctx) ctx, cancel := context.WithCancelCause(ctx)
defer cancel(context.Canceled)
h.paginationInterrupterLock.Lock() h.paginationInterrupterLock.Lock()
if _, alreadyPaginating := h.paginationInterrupter[roomID]; alreadyPaginating { if _, alreadyPaginating := h.paginationInterrupter[roomID]; alreadyPaginating {
h.paginationInterrupterLock.Unlock() h.paginationInterrupterLock.Unlock()
@ -296,12 +306,12 @@ func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit i
if resp.End == "" { if resp.End == "" {
resp.End = database.PrevBatchPaginationComplete resp.End = database.PrevBatchPaginationComplete
} }
if len(resp.Chunk) == 0 { if resp.End == database.PrevBatchPaginationComplete || len(resp.Chunk) == 0 {
err = h.DB.Room.SetPrevBatch(ctx, room.ID, resp.End) err = h.DB.Room.SetPrevBatch(ctx, room.ID, resp.End)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to set prev_batch: %w", err) return nil, fmt.Errorf("failed to set prev_batch: %w", err)
} }
return &PaginationResponse{Events: events, HasMore: resp.End != database.PrevBatchPaginationComplete}, nil return &PaginationResponse{Events: events, HasMore: resp.End != ""}, nil
} }
wakeupSessionRequests := false wakeupSessionRequests := false
err = h.DB.DoTxn(ctx, nil, func(ctx context.Context) error { err = h.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
@ -366,5 +376,5 @@ func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit i
if err == nil && wakeupSessionRequests { if err == nil && wakeupSessionRequests {
h.WakeupRequestQueue() h.WakeupRequestQueue()
} }
return &PaginationResponse{Events: events, HasMore: resp.End != database.PrevBatchPaginationComplete}, err return &PaginationResponse{Events: events, HasMore: true}, err
} }

View file

@ -71,13 +71,6 @@ func (h *HiClient) SendMessage(
relatesTo *event.RelatesTo, relatesTo *event.RelatesTo,
mentions *event.Mentions, mentions *event.Mentions,
) (*database.Event, error) { ) (*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 var unencrypted bool
if strings.HasPrefix(text, "/unencrypted ") { if strings.HasPrefix(text, "/unencrypted ") {
text = strings.TrimPrefix(text, "/unencrypted ") text = strings.TrimPrefix(text, "/unencrypted ")
@ -97,7 +90,7 @@ func (h *HiClient) SendMessage(
if !json.Valid(content) { if !json.Valid(content) {
return nil, fmt.Errorf("invalid JSON in /raw command") return nil, fmt.Errorf("invalid JSON in /raw command")
} }
return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "", unencrypted, false) return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "", unencrypted)
} else if strings.HasPrefix(text, "/rawstate ") { } else if strings.HasPrefix(text, "/rawstate ") {
parts := strings.SplitN(text, " ", 4) parts := strings.SplitN(text, " ", 4)
if len(parts) < 4 || len(parts[1]) == 0 { if len(parts) < 4 || len(parts[1]) == 0 {
@ -161,18 +154,12 @@ func (h *HiClient) SendMessage(
Body: "", Body: "",
MsgType: contentCopy.MsgType, MsgType: contentCopy.MsgType,
URL: contentCopy.URL, URL: contentCopy.URL,
GeoURI: contentCopy.GeoURI,
NewContent: &contentCopy, NewContent: &contentCopy,
RelatesTo: relatesTo, RelatesTo: relatesTo,
} }
if contentCopy.File != nil { if contentCopy.File != nil {
content.URL = contentCopy.File.URL content.URL = contentCopy.File.URL
} }
if extra != nil {
extra = map[string]any{
"m.new_content": extra,
}
}
} else { } else {
content.RelatesTo = relatesTo content.RelatesTo = relatesTo
} }
@ -182,7 +169,7 @@ func (h *HiClient) SendMessage(
content.MsgType = "" content.MsgType = ""
evtType = event.EventSticker evtType = event.EventSticker
} }
return h.send(ctx, roomID, evtType, &event.Content{Parsed: content, Raw: extra}, origText, unencrypted, false) return h.send(ctx, roomID, evtType, &event.Content{Parsed: content, Raw: extra}, origText, unencrypted)
} }
func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error { func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error {
@ -226,7 +213,6 @@ func (h *HiClient) SetState(
evtType event.Type, evtType event.Type,
stateKey string, stateKey string,
content any, content any,
extra ...mautrix.ReqSendEvent,
) (id.EventID, error) { ) (id.EventID, error) {
room, err := h.DB.Room.Get(ctx, roomID) room, err := h.DB.Room.Get(ctx, roomID)
if err != nil { if err != nil {
@ -234,14 +220,10 @@ func (h *HiClient) SetState(
} else if room == nil { } else if room == nil {
return "", fmt.Errorf("unknown room") return "", fmt.Errorf("unknown room")
} }
resp, err := h.Client.SendStateEvent(ctx, room.ID, evtType, stateKey, content, extra...) resp, err := h.Client.SendStateEvent(ctx, room.ID, evtType, stateKey, content)
if err != nil { if err != nil {
return "", err return "", err
} }
if resp.UnstableDelayID != "" {
// Mildly hacky, but it's fine'
return id.EventID(resp.UnstableDelayID), nil
}
return resp.EventID, nil return resp.EventID, nil
} }
@ -250,14 +232,8 @@ func (h *HiClient) Send(
roomID id.RoomID, roomID id.RoomID,
evtType event.Type, evtType event.Type,
content any, content any,
disableEncryption bool,
synchronous bool,
) (*database.Event, error) { ) (*database.Event, error) {
if evtType == event.EventRedaction { return h.send(ctx, roomID, evtType, content, "", false)
// 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) { func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, error) {
@ -276,7 +252,7 @@ func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, e
return nil, fmt.Errorf("unknown room") return nil, fmt.Errorf("unknown room")
} }
dbEvt.SendError = "" dbEvt.SendError = ""
go h.actuallySend(context.WithoutCancel(ctx), room, dbEvt, event.Type{Type: dbEvt.Type, Class: event.MessageEventType}, false) go h.actuallySend(context.WithoutCancel(ctx), room, dbEvt, event.Type{Type: dbEvt.Type, Class: event.MessageEventType})
return dbEvt, nil return dbEvt, nil
} }
@ -287,7 +263,6 @@ func (h *HiClient) send(
content any, content any,
overrideEditSource string, overrideEditSource string,
disableEncryption bool, disableEncryption bool,
synchronous bool,
) (*database.Event, error) { ) (*database.Event, error) {
room, err := h.DB.Room.Get(ctx, roomID) room, err := h.DB.Room.Get(ctx, roomID)
if err != nil { if err != nil {
@ -346,15 +321,11 @@ func (h *HiClient) send(
zerolog.Ctx(ctx).Err(err).Msg("Failed to stop typing while sending message") zerolog.Ctx(ctx).Err(err).Msg("Failed to stop typing while sending message")
} }
}() }()
if synchronous { go h.actuallySend(ctx, room, dbEvt, evtType)
h.actuallySend(ctx, room, dbEvt, evtType, true)
} else {
go h.actuallySend(ctx, room, dbEvt, evtType, false)
}
return dbEvt, nil return dbEvt, nil
} }
func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt *database.Event, evtType event.Type, synchronous bool) { func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt *database.Event, evtType event.Type) {
var err error var err error
defer func() { defer func() {
if dbEvt.SendError != "" { if dbEvt.SendError != "" {
@ -364,12 +335,10 @@ func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt
Msg("Failed to update send error in database after sending failed") Msg("Failed to update send error in database after sending failed")
} }
} }
if !synchronous {
h.EventHandler(&SendComplete{ h.EventHandler(&SendComplete{
Event: dbEvt, Event: dbEvt,
Error: err, Error: err,
}) })
}
}() }()
if dbEvt.Decrypted != nil && len(dbEvt.Content) <= 2 { if dbEvt.Decrypted != nil && len(dbEvt.Content) <= 2 {
var encryptedContent *event.EncryptedEventContent var encryptedContent *event.EncryptedEventContent
@ -442,18 +411,6 @@ func (h *HiClient) EnsureGroupSessionShared(ctx context.Context, roomID id.RoomI
} }
} }
func (h *HiClient) SendToDevice(ctx context.Context, evtType event.Type, content *mautrix.ReqSendToDevice, encrypt bool) (*mautrix.RespSendToDevice, error) {
if encrypt {
var err error
content, err = h.Crypto.EncryptToDevices(ctx, evtType, content)
if err != nil {
return nil, fmt.Errorf("failed to encrypt: %w", err)
}
evtType = event.ToDeviceEncrypted
}
return h.Client.SendToDevice(ctx, evtType, content)
}
func (h *HiClient) loadMembers(ctx context.Context, room *database.Room) error { func (h *HiClient) loadMembers(ctx context.Context, room *database.Room) error {
if room.HasMemberList { if room.HasMemberList {
return nil return nil
@ -516,9 +473,6 @@ func (h *HiClient) shouldShareKeysToInvitedUsers(ctx context.Context, roomID id.
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get history visibility event") zerolog.Ctx(ctx).Err(err).Msg("Failed to get history visibility event")
return false return false
} else if historyVisibility == nil {
zerolog.Ctx(ctx).Warn().Msg("History visibility event not found")
return false
} }
mautrixEvt := historyVisibility.AsRawMautrix() mautrixEvt := historyVisibility.AsRawMautrix()
err = mautrixEvt.Content.ParseRaw(mautrixEvt.Type) err = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)

View file

@ -66,9 +66,6 @@ func (h *HiClient) markSyncOK() {
func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) error { func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
listenToDevice := h.ToDeviceInSync.Load()
var syncTD []*SyncToDevice
postponedToDevices := resp.ToDevice.Events[:0] postponedToDevices := resp.ToDevice.Events[:0]
for _, evt := range resp.ToDevice.Events { for _, evt := range resp.ToDevice.Events {
evt.Type.Class = event.ToDeviceEventType evt.Type.Class = event.ToDeviceEventType
@ -83,80 +80,19 @@ func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.Res
switch content := evt.Content.Parsed.(type) { switch content := evt.Content.Parsed.(type) {
case *event.EncryptedEventContent: case *event.EncryptedEventContent:
unhandledDecrypted := h.Crypto.HandleEncryptedEvent(ctx, evt) h.Crypto.HandleEncryptedEvent(ctx, evt)
if unhandledDecrypted != nil && listenToDevice {
syncTD = append(syncTD, &SyncToDevice{
Sender: evt.Sender,
Type: unhandledDecrypted.Type,
Content: unhandledDecrypted.Content.VeryRaw,
Encrypted: true,
})
}
case *event.RoomKeyWithheldEventContent: case *event.RoomKeyWithheldEventContent:
// TODO move this check to mautrix-go?
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) h.Crypto.HandleRoomKeyWithheld(ctx, content)
}
case *event.SecretRequestEventContent, *event.RoomKeyRequestEventContent:
postponedToDevices = append(postponedToDevices, evt)
default: default:
if listenToDevice { postponedToDevices = append(postponedToDevices, evt)
syncTD = append(syncTD, &SyncToDevice{
Sender: evt.Sender,
Type: evt.Type,
Content: evt.Content.VeryRaw,
})
}
} }
} }
resp.ToDevice.Events = postponedToDevices resp.ToDevice.Events = postponedToDevices
if len(syncTD) > 0 {
ctx.Value(syncContextKey).(*syncContext).evt.ToDevice = syncTD
}
h.Crypto.MarkOlmHashSavePoint(ctx) h.Crypto.MarkOlmHashSavePoint(ctx)
return nil return nil
} }
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) { func (h *HiClient) postProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) {
h.Crypto.HandleOTKCounts(ctx, &resp.DeviceOTKCount) h.Crypto.HandleOTKCounts(ctx, &resp.DeviceOTKCount)
go h.asyncPostProcessSyncResponse(ctx, resp, since) go h.asyncPostProcessSyncResponse(ctx, resp, since)
@ -361,10 +297,6 @@ func (h *HiClient) processSyncLeftRoom(ctx context.Context, roomID id.RoomID, ro
if err != nil { if err != nil {
return fmt.Errorf("failed to delete invited room: %w", err) 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 := ctx.Value(syncContextKey).(*syncContext).evt
payload.LeftRooms = append(payload.LeftRooms, roomID) payload.LeftRooms = append(payload.LeftRooms, roomID)
return nil return nil
@ -598,7 +530,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
return nil, nil return nil, nil
} }
const CurrentHTMLSanitizerVersion = 10 const CurrentHTMLSanitizerVersion = 8
func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) { func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) {
if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) || if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) ||
@ -640,14 +572,6 @@ func (h *HiClient) processEvent(
return dbEvt, nil 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) dbEvt := database.MautrixToEvent(evt)
contentWithoutFallback := removeReplyFallback(evt) contentWithoutFallback := removeReplyFallback(evt)
if contentWithoutFallback != nil { if contentWithoutFallback != nil {
@ -656,7 +580,7 @@ func (h *HiClient) processEvent(
} }
var decryptionErr error var decryptionErr error
var decryptedMautrixEvt *event.Event var decryptedMautrixEvt *event.Event
if evt.Type == event.EventEncrypted && (dbEvt.RedactedBy == "" || len(dbEvt.Content) > 2) { if evt.Type == event.EventEncrypted && dbEvt.RedactedBy == "" {
decryptedMautrixEvt, decryptionErr = h.decryptEventInto(ctx, evt, dbEvt) decryptedMautrixEvt, decryptionErr = h.decryptEventInto(ctx, evt, dbEvt)
} else if evt.Type == event.EventRedaction { } else if evt.Type == event.EventRedaction {
if evt.Redacts != "" && gjson.GetBytes(evt.Content.VeryRaw, "redacts").Str != evt.Redacts.String() { if evt.Redacts != "" && gjson.GetBytes(evt.Content.VeryRaw, "redacts").Str != evt.Redacts.String() {
@ -785,13 +709,12 @@ func (h *HiClient) processStateAndTimeline(
return fmt.Errorf("failed to get relation target of redaction target: %w", err) return fmt.Errorf("failed to get relation target of redaction target: %w", err)
} }
} }
if updatedRoom.PreviewEventRowID == dbEvt.RowID || (updatedRoom.PreviewEventRowID == 0 && room.PreviewEventRowID == dbEvt.RowID) { if updatedRoom.PreviewEventRowID == dbEvt.RowID {
updatedRoom.PreviewEventRowID = 0 updatedRoom.PreviewEventRowID = 0
recalculatePreviewEvent = true recalculatePreviewEvent = true
} }
return nil return nil
} }
megolmSessionDiscarded := false
processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) { processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) {
evt.RoomID = room.ID evt.RoomID = room.ID
dbEvt, err := h.processEvent(ctx, evt, summary, decryptionQueue, evt.Unsigned.TransactionID != "") dbEvt, err := h.processEvent(ctx, evt, summary, decryptionQueue, evt.Unsigned.TransactionID != "")
@ -803,9 +726,6 @@ func (h *HiClient) processStateAndTimeline(
newNotifications = append(newNotifications, SyncNotification{ newNotifications = append(newNotifications, SyncNotification{
RowID: dbEvt.RowID, RowID: dbEvt.RowID,
Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound), Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound),
Highlight: dbEvt.UnreadType.Is(database.UnreadTypeHighlight),
Event: dbEvt,
Room: room,
}) })
} }
newUnreadCounts.AddOne(dbEvt.UnreadType) newUnreadCounts.AddOne(dbEvt.UnreadType)
@ -824,9 +744,6 @@ func (h *HiClient) processStateAndTimeline(
if summary != nil && slices.Contains(summary.Heroes, id.UserID(*evt.StateKey)) { if summary != nil && slices.Contains(summary.Heroes, id.UserID(*evt.StateKey)) {
heroesChanged = true heroesChanged = true
} }
if !megolmSessionDiscarded && room.EncryptionEvent != nil {
megolmSessionDiscarded = h.maybeDiscardOutboundSession(ctx, membership, evt)
}
} else if evt.Type == event.StateElementFunctionalMembers { } else if evt.Type == event.StateElementFunctionalMembers {
heroesChanged = true heroesChanged = true
} }
@ -969,20 +886,18 @@ func (h *HiClient) processStateAndTimeline(
updatedRoom.PreviewEventRowID, err = h.DB.Room.RecalculatePreview(ctx, room.ID) updatedRoom.PreviewEventRowID, err = h.DB.Room.RecalculatePreview(ctx, room.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to recalculate preview event: %w", err) return fmt.Errorf("failed to recalculate preview event: %w", err)
} else if updatedRoom.PreviewEventRowID != 0 { }
_, err = addOldEvent(updatedRoom.PreviewEventRowID, "") _, err = addOldEvent(updatedRoom.PreviewEventRowID, "")
if err != nil { if err != nil {
return fmt.Errorf("failed to get preview event: %w", err) 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 // 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 { if (heroesChanged && updatedRoom.NameQuality <= database.NameQualityParticipants) || updatedRoom.NameQuality == database.NameQualityNil {
name, dmAvatarURL, dmUserID, err := h.calculateRoomParticipantName(ctx, room.ID, summary) name, dmAvatarURL, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
if err != nil { if err != nil {
return fmt.Errorf("failed to calculate room name: %w", err) return fmt.Errorf("failed to calculate room name: %w", err)
} }
updatedRoom.DMUserID = &dmUserID
updatedRoom.Name = &name updatedRoom.Name = &name
updatedRoom.NameQuality = database.NameQualityParticipants updatedRoom.NameQuality = database.NameQualityParticipants
if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar { if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar {
@ -1008,7 +923,6 @@ func (h *HiClient) processStateAndTimeline(
} else { } else {
updatedRoom.UnreadCounts.Add(newUnreadCounts) updatedRoom.UnreadCounts.Add(newUnreadCounts)
} }
dismissNotifications := room.UnreadNotifications > 0 && updatedRoom.UnreadNotifications == 0 && len(newNotifications) == 0
if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) { if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) {
updatedRoom.PrevBatch = timeline.PrevBatch updatedRoom.PrevBatch = timeline.PrevBatch
} }
@ -1035,10 +949,8 @@ func (h *HiClient) processStateAndTimeline(
State: changedState, State: changedState,
Reset: timeline.Limited, Reset: timeline.Limited,
Events: allNewEvents, Events: allNewEvents,
Receipts: receiptMap,
Notifications: newNotifications, Notifications: newNotifications,
DismissNotifications: dismissNotifications, Receipts: receiptMap,
} }
} }
return nil return nil
@ -1054,15 +966,15 @@ func joinMemberNames(names []string, totalCount int) string {
} }
} }
func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, id.UserID, error) { func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, error) {
var primaryAvatarURL id.ContentURI var primaryAvatarURL id.ContentURI
if summary == nil || len(summary.Heroes) == 0 { if summary == nil || len(summary.Heroes) == 0 {
return "Empty room", primaryAvatarURL, "", nil return "Empty room", primaryAvatarURL, nil
} }
var functionalMembers []id.UserID var functionalMembers []id.UserID
functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "") functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "")
if err != nil { 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 { } else if functionalMembersEvt != nil {
mautrixEvt := functionalMembersEvt.AsRawMautrix() mautrixEvt := functionalMembersEvt.AsRawMautrix()
_ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type) _ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)
@ -1078,21 +990,16 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
} else if summary.InvitedMemberCount != nil { } else if summary.InvitedMemberCount != nil {
memberCount = *summary.InvitedMemberCount memberCount = *summary.InvitedMemberCount
} }
var dmUserID id.UserID
for _, hero := range summary.Heroes { for _, hero := range summary.Heroes {
if slices.Contains(functionalMembers, hero) { if slices.Contains(functionalMembers, hero) {
// TODO save member count so push rule evaluation would use the subtracted one?
memberCount-- memberCount--
continue continue
} else if len(members) >= 5 { } else if len(members) >= 5 {
break break
} }
if dmUserID == "" {
dmUserID = hero
}
heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String()) heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String())
if err != nil { 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 { } else if heroEvt == nil {
leftMembers = append(leftMembers, hero.String()) leftMembers = append(leftMembers, hero.String())
continue continue
@ -1108,28 +1015,19 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
} }
if membership == "join" || membership == "invite" { if membership == "join" || membership == "invite" {
members = append(members, name) members = append(members, name)
dmUserID = hero
} else { } else {
leftMembers = append(leftMembers, name) leftMembers = append(leftMembers, name)
} }
} }
if !primaryAvatarURL.IsValid() { if len(members)+len(leftMembers) > 1 || !primaryAvatarURL.IsValid() {
primaryAvatarURL = id.ContentURI{} primaryAvatarURL = id.ContentURI{}
} }
if len(members) > 0 { if len(members) > 0 {
if len(members) > 1 { return joinMemberNames(members, memberCount), primaryAvatarURL, nil
primaryAvatarURL = id.ContentURI{}
dmUserID = ""
}
return joinMemberNames(members, memberCount), primaryAvatarURL, dmUserID, nil
} else if len(leftMembers) > 0 { } else if len(leftMembers) > 0 {
if len(leftMembers) > 1 { return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, nil
primaryAvatarURL = id.ContentURI{}
dmUserID = ""
}
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, "", nil
} else { } else {
return "Empty room", primaryAvatarURL, "", nil return "Empty room", primaryAvatarURL, nil
} }
} }

View file

@ -69,7 +69,7 @@ func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration,
c.syncErrors++ c.syncErrors++
delay := 1 * time.Second delay := 1 * time.Second
if c.syncErrors > 5 { if c.syncErrors > 5 {
delay = min(time.Duration(c.syncErrors)*time.Second, 30*time.Second) delay = max(time.Duration(c.syncErrors)*time.Second, 30*time.Second)
} }
c.markSyncErrored(err, false) c.markSyncErrored(err, false)
c.Log.Err(err).Dur("retry_in", delay).Msg("Sync failed") c.Log.Err(err).Dur("retry_in", delay).Msg("Sync failed")

View file

@ -73,9 +73,8 @@ export default tseslint.config(
"one-var-declaration-per-line": ["error", "initializations"], "one-var-declaration-per-line": ["error", "initializations"],
"quotes": ["error", "double", {allowTemplateLiterals: true}], "quotes": ["error", "double", {allowTemplateLiterals: true}],
"semi": ["error", "never"], "semi": ["error", "never"],
"curly": ["error", "all"],
"comma-dangle": ["error", "always-multiline"], "comma-dangle": ["error", "always-multiline"],
"max-len": ["error", 120], "max-len": ["warn", 120],
"space-before-function-paren": ["error", { "space-before-function-paren": ["error", {
"anonymous": "never", "anonymous": "never",
"named": "never", "named": "never",

View file

@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en" data-gomuks="true"> <html lang="en">
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<link id="favicon" rel="icon" type="image/png" href="gomuks.png"/> <link id="favicon" rel="icon" type="image/png" href="gomuks.png"/>
<link rel="manifest" href="manifest.json"/> <link rel="manifest" href="manifest.json"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, interactive-widget=resizes-content"/> <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"/>
<title>gomuks web</title> <title>gomuks web</title>
<!-- etag placeholder --> <!-- etag placeholder -->
</head> </head>

1629
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,16 +13,15 @@
"dependencies": { "dependencies": {
"@wailsio/runtime": "^3.0.0-alpha.29", "@wailsio/runtime": "^3.0.0-alpha.29",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"fast-deep-equal": "^3.1.3",
"katex": "^0.16.11", "katex": "^0.16.11",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"matrix-widget-api": "^1.13.1",
"monaco-editor": "^0.52.0", "monaco-editor": "^0.52.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-blurhash": "^0.3.0", "react-blurhash": "^0.3.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-spinners": "^0.15.0", "react-spinners": "^0.15.0",
"unhomoglyph": "^1.0.6" "unhomoglyph": "^1.0.6",
"virtua": "^0.39.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
@ -39,7 +38,7 @@
"globals": "^15.9.0", "globals": "^15.9.0",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.7.0", "typescript-eslint": "^8.7.0",
"vite": "^6.0.9", "vite": "^5.4.8",
"vite-plugin-svgr": "^4.2.0" "vite-plugin-svgr": "^4.2.0"
} }
} }

View file

@ -16,17 +16,15 @@
import type { MouseEvent } from "react" import type { MouseEvent } from "react"
import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts" import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
import RPCClient, { SendMessageParams } from "./rpc.ts" import RPCClient, { SendMessageParams } from "./rpc.ts"
import { RoomStateStore, StateStore, WidgetListener } from "./statestore" import { RoomStateStore, StateStore } from "./statestore"
import type { import type {
ClientState, ClientState,
ElementRecentEmoji, ElementRecentEmoji,
EventID, EventID,
EventType, EventType,
GomuksAndroidMessageToWeb,
ImagePackRooms, ImagePackRooms,
RPCEvent, RPCEvent,
RawDBEvent, RawDBEvent,
RelationType,
RoomID, RoomID,
RoomStateGUID, RoomStateGUID,
SyncStatus, SyncStatus,
@ -41,7 +39,6 @@ export default class Client {
#stateRequests: RoomStateGUID[] = [] #stateRequests: RoomStateGUID[] = []
#stateRequestPromise: Promise<void> | null = null #stateRequestPromise: Promise<void> | null = null
#gcInterval: number | undefined #gcInterval: number | undefined
#toDeviceRequested = false
constructor(readonly rpc: RPCClient) { constructor(readonly rpc: RPCClient) {
this.rpc.event.listen(this.#handleEvent) this.rpc.event.listen(this.#handleEvent)
@ -74,74 +71,6 @@ export default class Client {
this.requestNotificationPermission() 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) => { requestNotificationPermission = (evt?: MouseEvent) => {
window.Notification?.requestPermission().then(permission => { window.Notification?.requestPermission().then(permission => {
console.log("Notification permission:", permission) console.log("Notification permission:", permission)
@ -155,29 +84,9 @@ export default class Client {
navigator.registerProtocolHandler("matrix", "#/uri/%s") navigator.registerProtocolHandler("matrix", "#/uri/%s")
} }
addWidgetListener(listener: WidgetListener): () => void {
this.store.widgetListeners.add(listener)
// TODO only request to-device events if there are widgets that need them?
if (!this.#toDeviceRequested) {
this.#toDeviceRequested = true
this.rpc.setListenToDevice(true)
}
return () => {
this.store.widgetListeners.delete(listener)
if (this.store.widgetListeners.size === 0 && this.#toDeviceRequested) {
this.#toDeviceRequested = false
this.rpc.setListenToDevice(false)
}
}
}
start(): () => void { start(): () => void {
const abort = new AbortController() const abort = new AbortController()
if (window.gomuksAndroid) {
this.#reallyStartAndroid(abort.signal)
} else {
this.#reallyStart(abort.signal) this.#reallyStart(abort.signal)
}
this.#gcInterval = setInterval(() => { this.#gcInterval = setInterval(() => {
console.log("Garbage collection completed:", this.store.doGarbageCollection()) console.log("Garbage collection completed:", this.store.doGarbageCollection())
}, window.gcSettings.interval) }, window.gcSettings.interval)
@ -239,42 +148,20 @@ export default class Client {
}) })
} }
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID, unredact?: boolean) { requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) {
if (typeof room === "string") { if (typeof room === "string") {
room = this.store.rooms.get(room) room = this.store.rooms.get(room)
} }
if (!room || (!unredact && room.eventsByID.has(eventID)) ||room.requestedEvents.has(eventID)) { if (!room || room.eventsByID.has(eventID) || room.requestedEvents.has(eventID)) {
return return
} }
room.requestedEvents.add(eventID) room.requestedEvents.add(eventID)
this.rpc.getEvent(room.roomID, eventID, unredact).then( this.rpc.getEvent(room.roomID, eventID).then(
evt => { evt => room.applyEvent(evt),
room.applyEvent(evt, false, unredact) err => console.error(`Failed to fetch event ${eventID}`, err),
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) { async pinMessage(room: RoomStateStore, evtID: EventID, wantPinned: boolean) {
const pinnedEvents = room.getPinnedEvents() const pinnedEvents = room.getPinnedEvents()
const currentlyPinned = pinnedEvents.includes(evtID) const currentlyPinned = pinnedEvents.includes(evtID)
@ -309,14 +196,12 @@ export default class Client {
} }
} }
async sendEvent( async sendEvent(roomID: RoomID, type: EventType, content: unknown): Promise<void> {
roomID: RoomID, type: EventType, content: unknown, disableEncryption: boolean = false,
): Promise<void> {
const room = this.store.rooms.get(roomID) const room = this.store.rooms.get(roomID)
if (!room) { if (!room) {
throw new Error("Room not found") throw new Error("Room not found")
} }
const dbEvent = await this.rpc.sendEvent(roomID, type, content, disableEncryption) const dbEvent = await this.rpc.sendEvent(roomID, type, content)
this.#handleOutgoingEvent(dbEvent, room) this.#handleOutgoingEvent(dbEvent, room)
} }

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { parseMXC } from "@/util/validation.ts" import { parseMXC } from "@/util/validation.ts"
import { ContentURI, RoomID, UserID, UserProfile } from "./types" import { ContentURI, LazyLoadSummary, RoomID, UserID, UserProfile } from "./types"
export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => { export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => {
const [server, mediaID] = parseMXC(mxc) const [server, mediaID] = parseMXC(mxc)
@ -78,56 +78,36 @@ function getFallbackCharacter(from: unknown, idx: number): string {
return Array.from(from.slice(0, (idx + 1) * 2))[idx]?.toUpperCase().toWellFormed() ?? "" return Array.from(from.slice(0, (idx + 1) * 2))[idx]?.toUpperCase().toWellFormed() ?? ""
} }
export const getAvatarURL = ( export const getAvatarURL = (userID: UserID, content?: UserProfile | null): string | undefined => {
userID: UserID,
content?: UserProfile | null,
thumbnail = false,
forceFallback = false,
): string | undefined => {
const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1) const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1)
const backgroundColor = getUserColor(userID) const backgroundColor = getUserColor(userID)
const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url) const [server, mediaID] = parseMXC(content?.avatar_url)
if (!mediaID || forceFallback) { if (!mediaID) {
return makeFallbackAvatar(backgroundColor, fallbackCharacter) return makeFallbackAvatar(backgroundColor, fallbackCharacter)
} }
const encrypted = !!content?.avatar_file
const fallback = `${backgroundColor}:${fallbackCharacter}` const fallback = `${backgroundColor}:${fallbackCharacter}`
const url = `_gomuks/media/${server}/${mediaID}?encrypted=${encrypted}&fallback=${encodeURIComponent(fallback)}` return `_gomuks/media/${server}/${mediaID}?encrypted=false&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 { interface RoomForAvatarURL {
room_id: RoomID room_id: RoomID
name?: string name?: string
dm_user_id?: UserID dm_user_id?: UserID
lazy_load_summary?: LazyLoadSummary
avatar?: ContentURI avatar?: ContentURI
avatar_url?: ContentURI avatar_url?: ContentURI
} }
export const getRoomAvatarURL = ( export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: ContentURI): string | undefined => {
room: RoomForAvatarURL, let dmUserID: UserID | undefined
avatarOverride?: ContentURI, if ("dm_user_id" in room) {
thumbnail = false, dmUserID = room.dm_user_id
forceFallback = false, } else if ("lazy_load_summary" in room) {
): string | undefined => { dmUserID = room.lazy_load_summary?.["m.heroes"]?.length === 1
return getAvatarURL(room.dm_user_id ?? room.room_id, { ? room.lazy_load_summary["m.heroes"][0] : undefined
}
return getAvatarURL(dmUserID ?? room.room_id, {
displayname: room.name, displayname: room.name,
avatar_url: avatarOverride ?? room.avatar ?? room.avatar_url, 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)
} }

View file

@ -17,13 +17,11 @@ import { CachedEventDispatcher, EventDispatcher } from "../util/eventdispatcher.
import { CancellablePromise } from "../util/promise.ts" import { CancellablePromise } from "../util/promise.ts"
import type { import type {
ClientWellKnown, ClientWellKnown,
DBPushRegistration,
EventID, EventID,
EventRowID,
EventType, EventType,
JSONValue,
LoginFlowsResponse, LoginFlowsResponse,
LoginRequest, LoginRequest,
MembershipAction,
Mentions, Mentions,
MessageEventContent, MessageEventContent,
PaginationResponse, PaginationResponse,
@ -33,14 +31,8 @@ import type {
RawDBEvent, RawDBEvent,
ReceiptType, ReceiptType,
RelatesTo, RelatesTo,
RelationType,
ReqCreateRoom,
ResolveAliasResponse, ResolveAliasResponse,
RespCreateRoom,
RespMediaConfig,
RespOpenIDToken,
RespRoomJoin, RespRoomJoin,
RespTurnServer,
RoomAlias, RoomAlias,
RoomID, RoomID,
RoomStateGUID, RoomStateGUID,
@ -150,14 +142,8 @@ export default abstract class RPCClient {
return this.request("send_message", params) return this.request("send_message", params)
} }
sendEvent( sendEvent(room_id: RoomID, type: EventType, content: unknown): Promise<RawDBEvent> {
room_id: RoomID, return this.request("send_event", { room_id, type, content })
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> { resendEvent(transaction_id: string): Promise<RawDBEvent> {
@ -174,17 +160,8 @@ export default abstract class RPCClient {
setState( setState(
room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>, room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>,
extra: { delay_ms?: number } = {},
): Promise<EventID> { ): Promise<EventID> {
return this.request("set_state", { room_id, type, state_key, content, ...extra }) return this.request("set_state", { room_id, type, state_key, content })
}
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> { setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise<boolean> {
@ -203,10 +180,6 @@ export default abstract class RPCClient {
return this.request("get_profile", { user_id }) 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[]> { getMutualRooms(user_id: UserID): Promise<RoomID[]> {
return this.request("get_mutual_rooms", { user_id }) return this.request("get_mutual_rooms", { user_id })
} }
@ -223,14 +196,6 @@ export default abstract class RPCClient {
return this.request("ensure_group_session_shared", { room_id }) return this.request("ensure_group_session_shared", { room_id })
} }
sendToDevice(
event_type: EventType,
messages: { [userId: string]: { [deviceId: string]: object } },
encrypted: boolean = false,
): Promise<void> {
return this.request("send_to_device", { event_type, messages, encrypted })
}
getSpecificRoomState(keys: RoomStateGUID[]): Promise<RawDBEvent[]> { getSpecificRoomState(keys: RoomStateGUID[]): Promise<RawDBEvent[]> {
return this.request("get_specific_room_state", { keys }) return this.request("get_specific_room_state", { keys })
} }
@ -241,12 +206,12 @@ export default abstract class RPCClient {
return this.request("get_room_state", { room_id, include_members, fetch_members, refetch }) return this.request("get_room_state", { room_id, include_members, fetch_members, refetch })
} }
getEvent(room_id: RoomID, event_id: EventID, unredact?: boolean): Promise<RawDBEvent> { getEvent(room_id: RoomID, event_id: EventID): Promise<RawDBEvent> {
return this.request("get_event", { room_id, event_id, unredact }) return this.request("get_event", { room_id, event_id })
} }
getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise<RawDBEvent[]> { getEventsByRowIDs(row_ids: EventRowID[]): Promise<RawDBEvent[]> {
return this.request("get_related_events", { room_id, event_id, relation_type }) return this.request("get_events_by_row_ids", { row_ids })
} }
paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise<PaginationResponse> { paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise<PaginationResponse> {
@ -269,14 +234,6 @@ export default abstract class RPCClient {
return this.request("leave_room", { room_id, reason }) 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> { resolveAlias(alias: RoomAlias): Promise<ResolveAliasResponse> {
return this.request("resolve_alias", { alias }) return this.request("resolve_alias", { alias })
} }
@ -300,24 +257,4 @@ export default abstract class RPCClient {
verify(recovery_key: string): Promise<boolean> { verify(recovery_key: string): Promise<boolean> {
return this.request("verify", { recovery_key }) 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)
}
} }

View file

@ -1,4 +1,3 @@
export * from "./main.ts" export * from "./main.ts"
export * from "./room.ts" export * from "./room.ts"
export * from "./hooks.ts" export * from "./hooks.ts"
export * from "./space.ts"

View file

@ -45,7 +45,6 @@ export class InvitedRoomStore implements RoomListEntry, RoomSummary {
readonly invited_by?: UserID readonly invited_by?: UserID
readonly inviter_profile?: MemberEventContent readonly inviter_profile?: MemberEventContent
readonly is_direct: boolean readonly is_direct: boolean
readonly is_invite = true
constructor(public readonly meta: DBInvitedRoom, parent: StateStore) { constructor(public readonly meta: DBInvitedRoom, parent: StateStore) {
this.room_id = meta.room_id this.room_id = meta.room_id

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { getAvatarThumbnailURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { Preferences, getLocalStoragePreferences, getPreferenceProxy } from "@/api/types/preferences" import { Preferences, getLocalStoragePreferences, getPreferenceProxy } from "@/api/types/preferences"
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji" import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
@ -32,7 +32,6 @@ import {
SendCompleteData, SendCompleteData,
SyncCompleteData, SyncCompleteData,
SyncRoom, SyncRoom,
SyncToDevice,
TypingEventData, TypingEventData,
UnknownEventContent, UnknownEventContent,
UserID, UserID,
@ -40,7 +39,7 @@ import {
} from "../types" } from "../types"
import { InvitedRoomStore } from "./invitedroom.ts" import { InvitedRoomStore } from "./invitedroom.ts"
import { RoomStateStore } from "./room.ts" import { RoomStateStore } from "./room.ts"
import { DirectChatSpace, RoomListFilter, Space, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts" import { DirectChatSpace, RoomListFilter, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts"
export interface RoomListEntry { export interface RoomListEntry {
room_id: RoomID room_id: RoomID
@ -55,7 +54,6 @@ export interface RoomListEntry {
unread_notifications: number unread_notifications: number
unread_highlights: number unread_highlights: number
marked_unread: boolean marked_unread: boolean
is_invite?: boolean
} }
export interface GCSettings { export interface GCSettings {
@ -63,13 +61,6 @@ export interface GCSettings {
lastOpenedCutoff: number, lastOpenedCutoff: number,
} }
export interface WidgetListener {
onTimelineEvent(evt: MemDBEvent): void
onStateEvent(evt: MemDBEvent): void
onToDeviceEvent(evt: SyncToDevice): void
onRoomChange(roomID: RoomID | null): void
}
window.gcSettings ??= { window.gcSettings ??= {
// Run garbage collection every 15 minutes. // Run garbage collection every 15 minutes.
interval: 15 * 60 * 1000, interval: 15 * 60 * 1000,
@ -82,7 +73,6 @@ export class StateStore {
readonly rooms: Map<RoomID, RoomStateStore> = new Map() readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map() readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([]) readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
readonly roomListEntries = new Map<RoomID, RoomListEntry>()
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([]) readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map() readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
readonly spaceOrphans = new SpaceOrphansSpace(this) readonly spaceOrphans = new SpaceOrphansSpace(this)
@ -107,19 +97,9 @@ export class StateStore {
readonly localPreferenceCache: Preferences = getLocalStoragePreferences("global_prefs", this.preferenceSub.notify) readonly localPreferenceCache: Preferences = getLocalStoragePreferences("global_prefs", this.preferenceSub.notify)
serverPreferenceCache: Preferences = {} serverPreferenceCache: Preferences = {}
switchRoom?: (roomID: RoomID | null) => void switchRoom?: (roomID: RoomID | null) => void
#activeRoomID: RoomID | null = null activeRoomID: RoomID | null = null
activeRoomIsPreview: boolean = false activeRoomIsPreview: boolean = false
imageAuthToken?: string imageAuthToken?: string
readonly widgetListeners: Set<WidgetListener> = new Set()
get activeRoomID(): RoomID | null {
return this.#activeRoomID
}
set activeRoomID(roomID: RoomID | null) {
this.#activeRoomID = roomID
this.widgetListeners.forEach(listener => listener.onRoomChange(roomID))
}
#roomListFilterFunc = (entry: RoomListEntry) => { #roomListFilterFunc = (entry: RoomListEntry) => {
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) { if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
@ -130,39 +110,6 @@ export class StateStore {
return true 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 { get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
if (!this.currentRoomListFilter && !this.currentRoomListQuery) { if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
return null return null
@ -222,7 +169,8 @@ export class StateStore {
const name = entry.meta.name ?? "Unnamed room" const name = entry.meta.name ?? "Unnamed room"
return { return {
room_id: entry.meta.room_id, room_id: entry.meta.room_id,
dm_user_id: entry.meta.dm_user_id, dm_user_id: entry.meta.lazy_load_summary?.["m.heroes"]?.length === 1
? entry.meta.lazy_load_summary["m.heroes"][0] : undefined,
sorting_timestamp: entry.meta.sorting_timestamp, sorting_timestamp: entry.meta.sorting_timestamp,
preview_event, preview_event,
preview_sender, preview_sender,
@ -256,26 +204,19 @@ export class StateStore {
} }
applySync(sync: SyncCompleteData) { applySync(sync: SyncCompleteData) {
let prevActiveRoom: RoomID | null = null
if (sync.clear_state && this.rooms.size > 0) { 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") console.info("Clearing state store as sync told to reset and there are rooms in the store")
prevActiveRoom = this.activeRoomID
this.clear() this.clear()
} }
const resyncRoomList = this.roomList.current.length === 0 const resyncRoomList = this.roomList.current.length === 0
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>() const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
if (sync.to_device?.length && this.widgetListeners.size > 0) {
for (const listener of this.widgetListeners) {
sync.to_device.forEach(listener.onToDeviceEvent)
}
}
for (const data of sync.invited_rooms ?? []) { for (const data of sync.invited_rooms ?? []) {
const room = new InvitedRoomStore(data, this) const room = new InvitedRoomStore(data, this)
const oldEntry = this.inviteRooms.get(room.room_id)
this.inviteRooms.set(room.room_id, room) this.inviteRooms.set(room.room_id, room)
if (!resyncRoomList) { if (!resyncRoomList) {
changedRoomListEntries.set(room.room_id, room) changedRoomListEntries.set(room.room_id, room)
this.#applyUnreadModification(room, this.roomListEntries.get(room.room_id)) this.#applyUnreadModification(room, oldEntry)
this.roomListEntries.set(room.room_id, room)
} }
if (this.activeRoomID === room.room_id) { if (this.activeRoomID === room.room_id) {
this.switchRoom?.(room.room_id) this.switchRoom?.(room.room_id)
@ -298,12 +239,8 @@ export class StateStore {
if (roomListEntryChanged) { if (roomListEntryChanged) {
const entry = this.#makeRoomListEntry(data, room) const entry = this.#makeRoomListEntry(data, room)
changedRoomListEntries.set(roomID, entry) changedRoomListEntries.set(roomID, entry)
this.#applyUnreadModification(entry, this.roomListEntries.get(roomID)) this.#applyUnreadModification(entry, room.roomListEntry)
if (entry) { room.roomListEntry = entry
this.roomListEntries.set(roomID, entry)
} else {
this.roomListEntries.delete(roomID)
}
} }
if (!resyncRoomList) { if (!resyncRoomList) {
// When we join a valid replacement room, hide the tombstoned room. // When we join a valid replacement room, hide the tombstoned room.
@ -341,7 +278,6 @@ export class StateStore {
} }
this.rooms.delete(roomID) this.rooms.delete(roomID)
changedRoomListEntries.set(roomID, null) changedRoomListEntries.set(roomID, null)
this.#applyUnreadModification(null, this.roomListEntries.get(roomID))
} }
let updatedRoomList: RoomListEntry[] | undefined let updatedRoomList: RoomListEntry[] | undefined
@ -353,7 +289,7 @@ export class StateStore {
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp) updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
for (const entry of updatedRoomList) { for (const entry of updatedRoomList) {
this.#applyUnreadModification(entry, undefined) this.#applyUnreadModification(entry, undefined)
this.roomListEntries.set(entry.room_id, entry) this.rooms.get(entry.room_id)!.roomListEntry = entry
} }
} else if (changedRoomListEntries.size > 0) { } else if (changedRoomListEntries.size > 0) {
updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id)) updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id))
@ -390,10 +326,6 @@ export class StateStore {
this.topLevelSpaces.emit(sync.top_level_spaces) this.topLevelSpaces.emit(sync.top_level_spaces)
this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id })) 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() { invalidateEmojiPackKeyCache() {
@ -499,7 +431,7 @@ export class StateStore {
body = body.slice(0, 350) + " […]" body = body.slice(0, 350) + " […]"
} }
const memberEvt = room.getStateEvent("m.room.member", evt.sender) const memberEvt = room.getStateEvent("m.room.member", evt.sender)
const icon = `${getAvatarThumbnailURL(evt.sender, memberEvt?.content)}&image_auth=${this.imageAuthToken}` const icon = `${getAvatarURL(evt.sender, memberEvt?.content)}&image_auth=${this.imageAuthToken}`
const roomName = room.meta.current.name ?? "Unnamed room" const roomName = room.meta.current.name ?? "Unnamed room"
const senderName = memberEvt?.content.displayname ?? evt.sender const senderName = memberEvt?.content.displayname ?? evt.sender
const title = senderName === roomName ? senderName : `${senderName} (${roomName})` const title = senderName === roomName ? senderName : `${senderName} (${roomName})`
@ -583,7 +515,6 @@ export class StateStore {
this.rooms.clear() this.rooms.clear()
this.inviteRooms.clear() this.inviteRooms.clear()
this.spaceEdges.clear() this.spaceEdges.clear()
this.pseudoSpaces.forEach(space => space.clearUnreads())
this.roomList.emit([]) this.roomList.emit([])
this.topLevelSpaces.emit([]) this.topLevelSpaces.emit([])
this.accountData.clear() this.accountData.clear()

View file

@ -18,7 +18,7 @@ import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
import toSearchableString from "@/util/searchablestring.ts" import toSearchableString from "@/util/searchablestring.ts"
import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts" import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts"
import { getDisplayname, getServerName } from "@/util/validation.ts" import { getDisplayname } from "@/util/validation.ts"
import { import {
ContentURI, ContentURI,
DBReceipt, DBReceipt,
@ -42,7 +42,7 @@ import {
UserID, UserID,
roomStateGUIDToString, roomStateGUIDToString,
} from "../types" } from "../types"
import type { StateStore } from "./main.ts" import type { RoomListEntry, StateStore } from "./main.ts"
function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean { function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
if (!arr1 || !arr2) { if (!arr1 || !arr2) {
@ -70,7 +70,6 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
meta1.avatar === meta2.avatar && meta1.avatar === meta2.avatar &&
meta1.topic === meta2.topic && meta1.topic === meta2.topic &&
meta1.canonical_alias === meta2.canonical_alias && meta1.canonical_alias === meta2.canonical_alias &&
meta1.dm_user_id === meta2.dm_user_id &&
llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) && llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) &&
meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm && meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm &&
meta1.has_member_list === meta2.has_member_list meta1.has_member_list === meta2.has_member_list
@ -127,6 +126,7 @@ export class RoomStateStore {
readUpToRow = -1 readUpToRow = -1
hasMoreHistory = true hasMoreHistory = true
hidden = false hidden = false
roomListEntry: RoomListEntry | undefined | null
constructor(meta: DBRoom, private parent: StateStore) { constructor(meta: DBRoom, private parent: StateStore) {
this.roomID = meta.room_id this.roomID = meta.room_id
@ -246,35 +246,6 @@ export class RoomStateStore {
return this.#membersCache ?? [] 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[] { getPinnedEvents(): EventID[] {
const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned
if (Array.isArray(pinnedList)) { if (Array.isArray(pinnedList)) {
@ -344,35 +315,13 @@ export class RoomStateStore {
return true return true
} }
getOrApplyEvent(evt: RawDBEvent) { applyEvent(evt: RawDBEvent, pending: boolean = false) {
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 const memEvt = evt as MemDBEvent
memEvt.mem = true memEvt.mem = true
memEvt.pending = pending memEvt.pending = pending
if (pending) { if (pending) {
memEvt.timeline_rowid = UNSENT_TIMELINE_ROWID_BASE + memEvt.timestamp 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) { if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
memEvt.type = evt.decrypted_type memEvt.type = evt.decrypted_type
memEvt.encrypted = evt.content as EncryptedEventContent memEvt.encrypted = evt.content as EncryptedEventContent
@ -383,24 +332,20 @@ export class RoomStateStore {
if (memEvt.last_edit_rowid) { if (memEvt.last_edit_rowid) {
memEvt.last_edit = this.eventsByRowID.get(memEvt.last_edit_rowid) memEvt.last_edit = this.eventsByRowID.get(memEvt.last_edit_rowid)
if (memEvt.last_edit) { if (memEvt.last_edit) {
memEvt.orig_content = memEvt.orig_content ?? memEvt.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.content = memEvt.last_edit.content["m.new_content"] ?? memEvt.last_edit.content
memEvt.local_content = memEvt.last_edit.local_content memEvt.local_content = memEvt.last_edit.local_content
} }
} else if (memEvt.relation_type === "m.replace" && memEvt.relates_to) { } else if (memEvt.relation_type === "m.replace" && memEvt.relates_to) {
const editTarget = this.eventsByID.get(memEvt.relates_to) const editTarget = this.eventsByID.get(memEvt.relates_to)
if (editTarget?.last_edit_rowid === memEvt.rowid) { if (editTarget?.last_edit_rowid === memEvt.rowid && !editTarget.last_edit) {
const modified: MemDBEvent = { this.eventsByRowID.set(editTarget.rowid, {
...editTarget, ...editTarget,
last_edit: memEvt, last_edit: memEvt,
orig_local_content: editTarget.orig_local_content ?? editTarget.local_content, orig_content: editTarget.content,
orig_content: editTarget.orig_content ?? editTarget.content, content: memEvt.content["m.new_content"],
content: memEvt.content["m.new_content"] ?? memEvt.content,
local_content: memEvt.local_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) this.eventSubs.notify(editTarget.event_id)
} }
} }
@ -457,8 +402,6 @@ export class RoomStateStore {
for (const evt of sync.events ?? []) { for (const evt of sync.events ?? []) {
this.applyEvent(evt) this.applyEvent(evt)
} }
const hasWidgets = this.parent.widgetListeners.size > 0
const newState: MemDBEvent[] = []
for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) { for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
let stateMap = this.state.get(evtType) let stateMap = this.state.get(evtType)
if (!stateMap) { if (!stateMap) {
@ -468,12 +411,6 @@ export class RoomStateStore {
for (const [key, rowID] of Object.entries(changedEvts)) { for (const [key, rowID] of Object.entries(changedEvts)) {
stateMap.set(key, rowID) stateMap.set(key, rowID)
this.invalidateStateCaches(evtType, key) this.invalidateStateCaches(evtType, key)
if (hasWidgets) {
const evt = this.eventsByRowID.get(rowID)
if (evt) {
newState.push(evt)
}
}
} }
this.stateSubs.notify(evtType) this.stateSubs.notify(evtType)
} }
@ -493,13 +430,6 @@ export class RoomStateStore {
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) { for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
this.applyReceipts(receipts, evtID, false) this.applyReceipts(receipts, evtID, false)
} }
if (hasWidgets && ((sync.timeline && sync.timeline.length > 0) || newState.length > 0)) {
const evts = sync.timeline?.map(evt => this.eventsByRowID.get(evt.event_rowid)).filter(evt => !!evt)
this.parent.widgetListeners.forEach(listener => {
evts?.forEach(listener.onTimelineEvent)
newState.forEach(listener.onStateEvent)
})
}
} }
applyState(evt: RawDBEvent) { applyState(evt: RawDBEvent) {

View file

@ -40,10 +40,6 @@ export abstract class Space implements RoomListFilter {
abstract id: string abstract id: string
abstract include(room: RoomListEntry): boolean abstract include(room: RoomListEntry): boolean
clearUnreads() {
this.counts.emit(emptyUnreadCounts)
}
applyUnreads(newCounts?: SpaceUnreadCounts | null, oldCounts?: SpaceUnreadCounts | null) { applyUnreads(newCounts?: SpaceUnreadCounts | null, oldCounts?: SpaceUnreadCounts | null) {
const mergedCounts: SpaceUnreadCounts = { const mergedCounts: SpaceUnreadCounts = {
unread_messages: this.counts.current.unread_messages unread_messages: this.counts.current.unread_messages

View file

@ -1,30 +0,0 @@
// 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

View file

@ -86,13 +86,6 @@ export interface SyncNotification {
sound: boolean sound: boolean
} }
export interface SyncToDevice {
sender: UserID
type: EventType
content: Record<string, unknown>
encrypted: boolean
}
export interface SyncCompleteData { export interface SyncCompleteData {
rooms: Record<RoomID, SyncRoom> | null rooms: Record<RoomID, SyncRoom> | null
invited_rooms: DBInvitedRoom[] | null invited_rooms: DBInvitedRoom[] | null
@ -102,7 +95,6 @@ export interface SyncCompleteData {
top_level_spaces: RoomID[] | null top_level_spaces: RoomID[] | null
since?: string since?: string
clear_state?: boolean clear_state?: boolean
to_device?: SyncToDevice[] | null
} }
export interface SyncCompleteEvent extends BaseRPCCommand<SyncCompleteData> { export interface SyncCompleteEvent extends BaseRPCCommand<SyncCompleteData> {

View file

@ -54,7 +54,6 @@ export interface DBRoom {
name_quality: RoomNameQuality name_quality: RoomNameQuality
avatar?: ContentURI avatar?: ContentURI
explicit_avatar: boolean explicit_avatar: boolean
dm_user_id?: UserID
topic?: string topic?: string
canonical_alias?: RoomAlias canonical_alias?: RoomAlias
lazy_load_summary?: LazyLoadSummary lazy_load_summary?: LazyLoadSummary
@ -156,9 +155,7 @@ export interface MemDBEvent extends BaseDBEvent {
pending: boolean pending: boolean
encrypted?: EncryptedEventContent encrypted?: EncryptedEventContent
orig_content?: UnknownEventContent orig_content?: UnknownEventContent
orig_local_content?: LocalContent
last_edit?: MemDBEvent last_edit?: MemDBEvent
viewing_redacted?: boolean
} }
export interface DBAccountData { export interface DBAccountData {
@ -286,13 +283,3 @@ export interface ProfileEncryptionInfo {
user_trusted: boolean user_trusted: boolean
errors: string[] errors: string[]
} }
export interface DBPushRegistration {
device_id: string
type: "fcm"
data: unknown
encryption: { key: string }
expiration?: number
}
export type MembershipAction = "invite" | "kick" | "ban" | "unban"

View file

@ -1,4 +1,3 @@
export * from "./mxtypes.ts" export * from "./mxtypes.ts"
export * from "./hitypes.ts" export * from "./hitypes.ts"
export * from "./hievents.ts" export * from "./hievents.ts"
export * from "./android.ts"

View file

@ -25,14 +25,6 @@ export type RoomVersion = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" |
export type RoomType = "" | "m.space" export type RoomType = "" | "m.space"
export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread" 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 { export interface RoomPredecessor {
room_id: RoomID room_id: RoomID
event_id: EventID event_id: EventID
@ -73,20 +65,9 @@ export interface EncryptedEventContent {
export interface UserProfile { export interface UserProfile {
displayname?: string displayname?: string
avatar_url?: ContentURI avatar_url?: ContentURI
avatar_file?: EncryptedFile
[custom: string]: unknown [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 type Membership = "join" | "leave" | "ban" | "invite" | "knock"
export interface MemberEventContent extends UserProfile { export interface MemberEventContent extends UserProfile {
@ -112,15 +93,6 @@ export interface ACLEventContent {
deny?: string[] deny?: string[]
} }
export interface PolicyRuleContent {
entity: string
reason: string
recommendation: string
"org.matrix.msc4205.hashes"?: {
sha256: string
}
}
export interface PowerLevelEventContent { export interface PowerLevelEventContent {
users?: Record<UserID, number> users?: Record<UserID, number>
users_default?: number users_default?: number
@ -181,10 +153,6 @@ export interface URLPreview {
"og:description"?: string "og:description"?: string
} }
export interface BeeperPerMessageProfile extends UserProfile {
id: string
}
export interface BaseMessageEventContent { export interface BaseMessageEventContent {
msgtype: string msgtype: string
body: string body: string
@ -197,7 +165,6 @@ export interface BaseMessageEventContent {
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string "page.codeberg.everypizza.msc4193.spoiler.reason"?: string
"m.url_previews"?: URLPreview[] "m.url_previews"?: URLPreview[]
"com.beeper.linkpreviews"?: URLPreview[] "com.beeper.linkpreviews"?: URLPreview[]
"com.beeper.per_message_profile"?: BeeperPerMessageProfile
} }
export interface TextMessageEventContent extends BaseMessageEventContent { export interface TextMessageEventContent extends BaseMessageEventContent {
@ -221,10 +188,6 @@ export interface ReactionEventContent {
"com.beeper.reaction.shortcode"?: string "com.beeper.reaction.shortcode"?: string
} }
export interface IgnoredUsersEventContent {
ignored_users: Record<string, unknown>
}
export interface EncryptedFile { export interface EncryptedFile {
url: ContentURI url: ContentURI
k: string k: string
@ -316,48 +279,3 @@ export interface RoomSummary {
export interface RespRoomJoin { export interface RespRoomJoin {
room_id: RoomID 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
}

View file

@ -55,22 +55,10 @@ export const preferences = {
}), }),
show_media_previews: new Preference<boolean>({ show_media_previews: new Preference<boolean>({
displayName: "Show image and video previews", displayName: "Show image and video previews",
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.", description: "If disabled, images and videos will only be visible after clicking and will not be downloaded automatically.",
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: true, 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>({ code_block_line_wrap: new Preference<boolean>({
displayName: "Code block line wrap", displayName: "Code block line wrap",
description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.", description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.",
@ -151,12 +139,6 @@ export const preferences = {
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", defaultValue: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
}), }),
element_call_base_url: new Preference<string>({
displayName: "Element call base URL",
description: "The widget base URL for Element calls.",
allowedContexts: anyContext,
defaultValue: "https://call.element.io",
}),
gif_provider: new Preference<GIFProvider>({ gif_provider: new Preference<GIFProvider>({
displayName: "GIF provider", displayName: "GIF provider",
description: "The service to use to search for GIFs", description: "The service to use to search for GIFs",

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 477 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 341 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 419 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 261 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 300 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 357 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 604 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 571 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 332 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 793 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 393 B

View file

@ -86,7 +86,6 @@
--timeline-message-gap-small-event: 0; --timeline-message-gap-small-event: 0;
--timeline-sender-name-timestamp-gap: .25rem; --timeline-sender-name-timestamp-gap: .25rem;
--timeline-sender-name-content-gap: 0; --timeline-sender-name-content-gap: 0;
--timeline-vertical-padding: 0;
--timeline-horizontal-padding: 1.5rem; --timeline-horizontal-padding: 1.5rem;
--timeline-status-size: 4rem; --timeline-status-size: 4rem;
@ -167,6 +166,7 @@ body {
padding: 0; padding: 0;
background-color: var(--background-color); background-color: var(--background-color);
line-height: 1.5; line-height: 1.5;
font-size: 16px;
touch-action: none; touch-action: none;
color: var(--text-color); color: var(--text-color);
min-height: 100vh; min-height: 100vh;
@ -175,7 +175,6 @@ body {
html { html {
touch-action: none; touch-action: none;
background-color: var(--background-color); background-color: var(--background-color);
font-size: 16px;
} }
#root { #root {
@ -208,8 +207,6 @@ button, a.button, span.button {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: inherit; color: inherit;
/* Buttons sometimes have their own fonts? */
font-family: var(--font-stack);
&:hover, &:focus { &:hover, &:focus {
background-color: var(--button-hover-color); background-color: var(--button-hover-color);

View file

@ -13,11 +13,10 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import equal from "fast-deep-equal"
import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react" import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react"
import { SyncLoader } from "react-spinners" import { SyncLoader } from "react-spinners"
import Client from "@/api/client.ts" import Client from "@/api/client.ts"
import { RoomListFilter, RoomStateStore } from "@/api/statestore" import { RoomStateStore } from "@/api/statestore"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts" import { useEventAsState } from "@/util/eventdispatcher.ts"
import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts" import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts"
@ -25,7 +24,7 @@ import ClientContext from "./ClientContext.ts"
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts" import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
import StylePreferences from "./StylePreferences.tsx" import StylePreferences from "./StylePreferences.tsx"
import Keybindings from "./keybindings.ts" import Keybindings from "./keybindings.ts"
import { ModalContext, ModalWrapper, NestableModalContext } from "./modal" import { ModalWrapper } from "./modal"
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx" import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
import RoomList from "./roomlist/RoomList.tsx" import RoomList from "./roomlist/RoomList.tsx"
import RoomPreview, { RoomPreviewProps } from "./roomview/RoomPreview.tsx" import RoomPreview, { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
@ -33,6 +32,19 @@ import RoomView from "./roomview/RoomView.tsx"
import { useResizeHandle } from "./util/useResizeHandle.tsx" import { useResizeHandle } from "./util/useResizeHandle.tsx"
import "./MainScreen.css" import "./MainScreen.css"
function objectIsEqual(a: RightPanelProps | null, b: RightPanelProps | null): boolean {
if (a === null || b === null) {
return a === null && b === null
}
for (const key of Object.keys(a)) {
// @ts-expect-error 3:<
if (a[key] !== b[key]) {
return false
}
}
return true
}
class ContextFields implements MainScreenContextFields { class ContextFields implements MainScreenContextFields {
public keybindings: Keybindings public keybindings: Keybindings
private rightPanelStack: RightPanelProps[] = [] private rightPanelStack: RightPanelProps[] = []
@ -40,7 +52,6 @@ class ContextFields implements MainScreenContextFields {
constructor( constructor(
private directSetRightPanel: (props: RightPanelProps | null) => void, private directSetRightPanel: (props: RightPanelProps | null) => void,
private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void, private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void,
private directSetSpace: (space: RoomListFilter | null) => void,
private client: Client, private client: Client,
) { ) {
this.keybindings = new Keybindings(client.store, this) this.keybindings = new Keybindings(client.store, this)
@ -52,10 +63,10 @@ class ContextFields implements MainScreenContextFields {
} }
setRightPanel = (props: RightPanelProps | null, pushState = true) => { setRightPanel = (props: RightPanelProps | null, pushState = true) => {
if ((props?.type !== "user") && !this.client.store.activeRoomID) { if ((props?.type === "members" || props?.type === "pinned-messages") && !this.client.store.activeRoomID) {
props = null props = null
} }
const isEqual = equal(this.currentRightPanel, props) const isEqual = objectIsEqual(this.currentRightPanel, props)
if (isEqual && !pushState) { if (isEqual && !pushState) {
return return
} }
@ -69,7 +80,7 @@ class ContextFields implements MainScreenContextFields {
} else { } else {
this.directSetRightPanel(props) this.directSetRightPanel(props)
for (let i = this.rightPanelStack.length - 1; i >= 0; i--) { for (let i = this.rightPanelStack.length - 1; i >= 0; i--) {
if (equal(this.rightPanelStack[i], props)) { if (objectIsEqual(this.rightPanelStack[i], props)) {
this.rightPanelStack = this.rightPanelStack.slice(0, i + 1) this.rightPanelStack = this.rightPanelStack.slice(0, i + 1)
if (pushState) { if (pushState) {
history.go(i - this.rightPanelStack.length) history.go(i - this.rightPanelStack.length)
@ -84,17 +95,12 @@ class ContextFields implements MainScreenContextFields {
} }
} }
setActiveRoom = ( setActiveRoom = (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>, pushState = true) => {
roomID: RoomID | null,
previewMeta?: Partial<RoomPreviewProps>,
toSpace?: RoomListFilter,
pushState = true,
) => {
console.log("Switching to room", roomID) console.log("Switching to room", roomID)
if (roomID) { if (roomID) {
const room = this.client.store.rooms.get(roomID) const room = this.client.store.rooms.get(roomID)
if (room) { if (room) {
this.#setActiveRoom(room, toSpace, pushState) this.#setActiveRoom(room, pushState)
} else { } else {
this.#setPreviewRoom(roomID, pushState, previewMeta) this.#setPreviewRoom(roomID, pushState, previewMeta)
} }
@ -103,24 +109,6 @@ 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>) { #setPreviewRoom(roomID: RoomID, pushState: boolean, meta?: Partial<RoomPreviewProps>) {
const invite = this.client.store.inviteRooms.get(roomID) const invite = this.client.store.inviteRooms.get(roomID)
this.#closeActiveRoom(false) this.#closeActiveRoom(false)
@ -132,7 +120,6 @@ class ContextFields implements MainScreenContextFields {
room_id: roomID, room_id: roomID,
source_via: meta?.via, source_via: meta?.via,
source_alias: meta?.alias, source_alias: meta?.alias,
space_id: history.state?.space_id,
}, "") }, "")
} }
} }
@ -144,21 +131,10 @@ class ContextFields implements MainScreenContextFields {
return room.preferences.room_window_title.replace("$room", name!) return room.preferences.room_window_title.replace("$room", name!)
} }
#setActiveRoom(room: RoomStateStore, space: RoomListFilter | undefined | null, pushState: boolean) { #setActiveRoom(room: RoomStateStore, pushState: boolean) {
window.activeRoom = room window.activeRoom = room
this.directSetActiveRoom(room) this.directSetActiveRoom(room)
this.directSetRightPanel(null) 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.rightPanelStack = []
this.client.store.activeRoomID = room.roomID this.client.store.activeRoomID = room.roomID
this.client.store.activeRoomIsPreview = false this.client.store.activeRoomIsPreview = false
@ -172,7 +148,7 @@ class ContextFields implements MainScreenContextFields {
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`) .querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
?.scrollIntoView({ block: "nearest" }) ?.scrollIntoView({ block: "nearest" })
if (pushState) { if (pushState) {
history.pushState({ room_id: room.roomID, space_id: space?.id ?? history.state?.space_id }, "") history.pushState({ room_id: room.roomID }, "")
} }
let roomNameForTitle = room.meta.current.name let roomNameForTitle = room.meta.current.name
if (roomNameForTitle && roomNameForTitle.length > 48) { if (roomNameForTitle && roomNameForTitle.length > 48) {
@ -190,7 +166,7 @@ class ContextFields implements MainScreenContextFields {
this.client.store.activeRoomIsPreview = false this.client.store.activeRoomIsPreview = false
this.keybindings.activeRoom = null this.keybindings.activeRoom = null
if (pushState) { if (pushState) {
history.pushState({ space_id: history.state?.space_id }, "") history.pushState({}, "")
} }
document.title = this.#getWindowTitle() document.title = this.#getWindowTitle()
} }
@ -205,9 +181,8 @@ class ContextFields implements MainScreenContextFields {
} }
clickRightPanelOpener = (evt: React.MouseEvent) => { clickRightPanelOpener = (evt: React.MouseEvent) => {
evt.preventDefault()
const type = evt.currentTarget.getAttribute("data-target-panel") const type = evt.currentTarget.getAttribute("data-target-panel")
if (type === "pinned-messages" || type === "members" || type === "widgets") { if (type === "pinned-messages" || type === "members") {
this.setRightPanel({ type }) this.setRightPanel({ type })
} else if (type === "user") { } else if (type === "user") {
this.setRightPanel({ type, userID: evt.currentTarget.getAttribute("data-target-user")! }) this.setRightPanel({ type, userID: evt.currentTarget.getAttribute("data-target-user")! })
@ -222,11 +197,8 @@ class ContextFields implements MainScreenContextFields {
const SYNC_ERROR_HIDE_DELAY = 30 * 1000 const SYNC_ERROR_HIDE_DELAY = 30 * 1000
const handleURLHash = (client: Client, context: MainScreenContextFields, hashOnly = false) => { const handleURLHash = (client: Client) => {
if (!location.hash.startsWith("#/uri/")) { if (!location.hash.startsWith("#/uri/")) {
if (hashOnly) {
return null
}
if (location.search) { if (location.search) {
const currentETag = ( const currentETag = (
document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement
@ -252,7 +224,7 @@ const handleURLHash = (client: Client, context: MainScreenContextFields, hashOnl
const uri = parseMatrixURI(decodedURI) const uri = parseMatrixURI(decodedURI)
if (!uri) { if (!uri) {
console.error("Invalid matrix URI", decodedURI) console.error("Invalid matrix URI", decodedURI)
return hashOnly ? null : history.state return history.state
} }
console.log("Handling URI", uri) console.log("Handling URI", uri)
const newURL = new URL(location.href) const newURL = new URL(location.href)
@ -276,7 +248,7 @@ const handleURLHash = (client: Client, context: MainScreenContextFields, hashOnl
// TODO loading indicator or something for this? // TODO loading indicator or something for this?
client.rpc.resolveAlias(uri.identifier).then( client.rpc.resolveAlias(uri.identifier).then(
res => { res => {
context.setActiveRoom(res.room_id, { window.mainScreenContext.setActiveRoom(res.room_id, {
alias: uri.identifier, alias: uri.identifier,
via: res.servers.slice(0, 3), via: res.servers.slice(0, 3),
}) })
@ -286,9 +258,8 @@ const handleURLHash = (client: Client, context: MainScreenContextFields, hashOnl
return null return null
} else { } else {
console.error("Invalid matrix URI", uri) console.error("Invalid matrix URI", uri)
history.replaceState(history.state, "", newURL.toString())
} }
return hashOnly ? null : history.state return history.state
} }
type ActiveRoomType = [RoomStateStore | RoomPreviewProps | null, RoomStateStore | RoomPreviewProps | null] type ActiveRoomType = [RoomStateStore | RoomPreviewProps | null, RoomStateStore | RoomPreviewProps | null]
@ -308,42 +279,30 @@ const activeRoomReducer = (
const MainScreen = () => { const MainScreen = () => {
const [[prevActiveRoom, activeRoom], directSetActiveRoom] = useReducer(activeRoomReducer, [null, null]) const [[prevActiveRoom, activeRoom], directSetActiveRoom] = useReducer(activeRoomReducer, [null, null])
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
const skipNextTransitionRef = useRef(false) const skipNextTransitionRef = useRef(false)
const [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(null) const [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(null)
const client = use(ClientContext)! const client = use(ClientContext)!
const syncStatus = useEventAsState(client.syncStatus) const syncStatus = useEventAsState(client.syncStatus)
const context = useMemo( const context = useMemo(
() => new ContextFields(directSetRightPanel, directSetActiveRoom, directSetSpace, client), () => new ContextFields(directSetRightPanel, directSetActiveRoom, client),
[client], [client],
) )
useEffect(() => { useEffect(() => {
window.mainScreenContext = context window.mainScreenContext = context
const listener = (evt: Pick<PopStateEvent, "state" | "hasUAVisualTransition">) => { const listener = (evt: PopStateEvent) => {
skipNextTransitionRef.current = evt.hasUAVisualTransition skipNextTransitionRef.current = evt.hasUAVisualTransition
const roomID = evt.state?.room_id ?? null 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) { if (roomID !== client.store.activeRoomID) {
context.setActiveRoom(roomID, { context.setActiveRoom(roomID, {
alias: ensureString(evt.state?.source_alias) || undefined, alias: ensureString(evt.state?.source_alias) || undefined,
via: ensureStringArray(evt.state?.source_via), via: ensureStringArray(evt.state?.source_via),
}, undefined, false) }, false)
} }
context.setRightPanel(evt.state?.right_panel ?? null, 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) window.addEventListener("popstate", listener)
const initHandle = () => { const initHandle = () => {
const state = handleURLHash(client, context) const state = handleURLHash(client)
listener({ state } as PopStateEvent) listener({ state } as PopStateEvent)
} }
let cancel = () => {} let cancel = () => {}
@ -354,7 +313,6 @@ const MainScreen = () => {
} }
return () => { return () => {
window.removeEventListener("popstate", listener) window.removeEventListener("popstate", listener)
window.removeEventListener("hashchange", hashListener)
cancel() cancel()
} }
}, [context, client]) }, [context, client])
@ -410,8 +368,11 @@ const MainScreen = () => {
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
} }
}, [activeRoom, prevActiveRoom]) }, [activeRoom, prevActiveRoom])
const mainContent = <main className={classNames.join(" ")} style={extraStyle}> return <MainScreenContext value={context}>
<RoomList activeRoomID={activeRoom?.roomID ?? null} space={space}/> <ModalWrapper>
<StylePreferences client={client} activeRoom={activeRealRoom}/>
<main className={classNames.join(" ")} style={extraStyle}>
<RoomList activeRoomID={activeRoom?.roomID ?? null}/>
{resizeHandle1} {resizeHandle1}
{renderedRoom {renderedRoom
? renderedRoom instanceof RoomStateStore ? renderedRoom instanceof RoomStateStore
@ -428,14 +389,8 @@ const MainScreen = () => {
{rightPanel && <RightPanel {...rightPanel}/>} {rightPanel && <RightPanel {...rightPanel}/>}
</>} </>}
</main> </main>
return <MainScreenContext value={context}>
<ModalWrapper ContextType={ModalContext} historyStateKey="modal">
<ModalWrapper ContextType={NestableModalContext} historyStateKey="nestable_modal">
<StylePreferences client={client} activeRoom={activeRealRoom}/>
{mainContent}
{syncLoader} {syncLoader}
</ModalWrapper> </ModalWrapper>
</ModalWrapper>
</MainScreenContext> </MainScreenContext>
} }

View file

@ -13,15 +13,13 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { createContext } from "react" import { createContext } from "react"
import { RoomListFilter } from "@/api/statestore"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import type { RightPanelProps } from "./rightpanel/RightPanel.tsx" import type { RightPanelProps } from "./rightpanel/RightPanel.tsx"
import type { RoomPreviewProps } from "./roomview/RoomPreview.tsx" import type { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
export interface MainScreenContextFields { export interface MainScreenContextFields {
setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>, toSpace?: RoomListFilter) => void setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>) => void
setSpace: (space: RoomListFilter | null, pushState?: boolean) => void
clickRoom: (evt: React.MouseEvent) => void clickRoom: (evt: React.MouseEvent) => void
clearActiveRoom: () => void clearActiveRoom: () => void
@ -34,9 +32,6 @@ const stubContext = {
get setActiveRoom(): never { get setActiveRoom(): never {
throw new Error("MainScreenContext used outside main screen") throw new Error("MainScreenContext used outside main screen")
}, },
get setSpace(): never {
throw new Error("MainScreenContext used outside main screen")
},
get clickRoom(): never { get clickRoom(): never {
throw new Error("MainScreenContext used outside main screen") throw new Error("MainScreenContext used outside main screen")
}, },

View file

@ -117,15 +117,6 @@ const StylePreferences = ({ client, activeRoom }: StylePreferencesProps) => {
--timeline-status-size: 2rem; --timeline-status-size: 2rem;
} }
`, [preferences.display_read_receipts]) `, [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" ? ` useAsyncStyle(() => preferences.code_block_theme === "auto" ? `
@import url("_gomuks/codeblock/github.css") (prefers-color-scheme: light); @import url("_gomuks/codeblock/github.css") (prefers-color-scheme: light);
@import url("_gomuks/codeblock/github-dark.css") (prefers-color-scheme: dark); @import url("_gomuks/codeblock/github-dark.css") (prefers-color-scheme: dark);

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX, RefObject, use, useEffect } from "react" import { JSX, RefObject, use, useEffect } from "react"
import { getAvatarThumbnailURL, getMediaURL } from "@/api/media.ts" import { getAvatarURL, getMediaURL } from "@/api/media.ts"
import { AutocompleteMemberEntry, RoomStateStore, useCustomEmojis } from "@/api/statestore" import { AutocompleteMemberEntry, RoomStateStore, useCustomEmojis } from "@/api/statestore"
import { Emoji, emojiToMarkdown, useSortedAndFilteredEmojis } from "@/util/emoji" import { Emoji, emojiToMarkdown, useSortedAndFilteredEmojis } from "@/util/emoji"
import { escapeMarkdown } from "@/util/markdown.ts" import { escapeMarkdown } from "@/util/markdown.ts"
@ -138,7 +138,7 @@ const userFuncs = {
<img <img
className="small avatar" className="small avatar"
loading="lazy" loading="lazy"
src={getAvatarThumbnailURL(user.userID, { displayname: user.displayName, avatar_url: user.avatarURL })} src={getAvatarURL(user.userID, { displayname: user.displayName, avatar_url: user.avatarURL })}
alt="" alt=""
/> />
{user.displayName} {user.displayName}

View file

@ -13,7 +13,6 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { RefCallback, useState } from "react"
import Client from "@/api/client.ts" import Client from "@/api/client.ts"
import { RoomStateStore, usePreference } from "@/api/statestore" import { RoomStateStore, usePreference } from "@/api/statestore"
import type { MediaMessageEventContent } from "@/api/types" import type { MediaMessageEventContent } from "@/api/types"
@ -28,19 +27,10 @@ export interface ComposerMediaProps {
} }
export const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => { export const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
const defaultMaxWidth = 360
const paddingAndButtonWidth = 16 + 40
const [maxWidth, setMaxWidth] = useState(defaultMaxWidth)
const [mediaContent, containerClass, containerStyle] = useMediaContent( const [mediaContent, containerClass, containerStyle] = useMediaContent(
content, "m.room.message", { height: 120, width: maxWidth }, content, "m.room.message", { height: 120, width: 360 },
) )
const containerRef: RefCallback<HTMLDivElement> = elem => { return <div className="composer-media">
setMaxWidth(Math.min(
(elem?.getBoundingClientRect().width ?? defaultMaxWidth) - paddingAndButtonWidth,
defaultMaxWidth,
))
}
return <div className="composer-media" ref={containerRef}>
<div className={`media-container ${containerClass}`} style={containerStyle}> <div className={`media-container ${containerClass}`} style={containerStyle}>
{mediaContent} {mediaContent}
</div> </div>

View file

@ -55,7 +55,6 @@ export interface ComposerState {
replyTo: EventID | null replyTo: EventID | null
silentReply: boolean silentReply: boolean
explicitReplyInThread: boolean explicitReplyInThread: boolean
startNewThread: boolean
uninited?: boolean uninited?: boolean
} }
@ -68,7 +67,6 @@ const emptyComposer: ComposerState = {
location: null, location: null,
silentReply: false, silentReply: false,
explicitReplyInThread: false, explicitReplyInThread: false,
startNewThread: false,
} }
const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true } const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true }
const composerReducer = ( const composerReducer = (
@ -118,7 +116,7 @@ const MessageComposer = () => {
document.execCommand("insertText", false, text) document.execCommand("insertText", false, text)
}, []) }, [])
roomCtx.setReplyTo = useCallback((evt: EventID | null) => { roomCtx.setReplyTo = useCallback((evt: EventID | null) => {
setState({ replyTo: evt, silentReply: false, explicitReplyInThread: false, startNewThread: false }) setState({ replyTo: evt, silentReply: false, explicitReplyInThread: false })
textInput.current?.focus() textInput.current?.focus()
}, []) }, [])
const setSilentReply = useCallback((newVal: boolean | React.MouseEvent) => { const setSilentReply = useCallback((newVal: boolean | React.MouseEvent) => {
@ -137,14 +135,6 @@ const MessageComposer = () => {
setState(state => ({ explicitReplyInThread: !state.explicitReplyInThread })) 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) => { roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => {
if (evt === null) { if (evt === null) {
rawSetEditing(null) rawSetEditing(null)
@ -170,14 +160,13 @@ const MessageComposer = () => {
replyTo: null, replyTo: null,
silentReply: false, silentReply: false,
explicitReplyInThread: false, explicitReplyInThread: false,
startNewThread: false,
}) })
textInput.current?.focus() textInput.current?.focus()
}, [room.roomID]) }, [room.roomID])
const canSend = Boolean(state.text || state.media || state.location) const canSend = Boolean(state.text || state.media || state.location)
const onClickSend = (evt: React.FormEvent) => { const onClickSend = (evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
if (!canSend || loadingMedia) { if (!canSend) {
return return
} }
doSendMessage(state) doSendMessage(state)
@ -215,10 +204,6 @@ const MessageComposer = () => {
relates_to.rel_type = "m.thread" relates_to.rel_type = "m.thread"
relates_to.event_id = replyToEvt.content?.["m.relates_to"].event_id relates_to.event_id = replyToEvt.content?.["m.relates_to"].event_id
relates_to.is_falling_back = !state.explicitReplyInThread 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 let base_content: MessageEventContent | undefined
@ -568,7 +553,7 @@ const MessageComposer = () => {
style.left = style.right style.left = style.right
delete style.right delete style.right
openModal({ openModal({
content: <div className="context-menu event-context-menu" style={style}> content: <div className="event-context-menu" style={style}>
{makeAttachmentButtons(true)} {makeAttachmentButtons(true)}
</div>, </div>,
}) })
@ -595,8 +580,6 @@ const MessageComposer = () => {
onSetSilent={setSilentReply} onSetSilent={setSilentReply}
isExplicitInThread={state.explicitReplyInThread} isExplicitInThread={state.explicitReplyInThread}
onSetExplicitInThread={setExplicitReplyInThread} onSetExplicitInThread={setExplicitReplyInThread}
startNewThread={state.startNewThread}
onSetStartNewThread={setStartNewThread}
/>} />}
{editing && <ReplyBody {editing && <ReplyBody
room={room} room={room}

View file

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX, use } from "react" import { JSX, use } from "react"
import { PulseLoader } from "react-spinners" import { PulseLoader } from "react-spinners"
import { getAvatarThumbnailURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { useMultipleRoomMembers, useRoomTyping } from "@/api/statestore" import { useMultipleRoomMembers, useRoomTyping } from "@/api/statestore"
import { humanJoin } from "@/util/join.ts" import { humanJoin } from "@/util/join.ts"
import { getDisplayname } from "@/util/validation.ts" import { getDisplayname } from "@/util/validation.ts"
@ -40,7 +40,7 @@ const TypingNotifications = () => {
key={sender} key={sender}
className="small avatar" className="small avatar"
loading="lazy" loading="lazy"
src={getAvatarThumbnailURL(sender, member)} src={getAvatarURL(sender, member)}
alt="" alt=""
/>) />)
memberNames.push(getDisplayname(sender, member)) memberNames.push(getDisplayname(sender, member))

View file

@ -34,7 +34,6 @@ export function filter(users: AutocompleteMemberEntry[], query: string): Autocom
interface filteredUserCache { interface filteredUserCache {
query: string query: string
result: AutocompleteMemberEntry[] result: AutocompleteMemberEntry[]
slicedResult?: AutocompleteMemberEntry[]
} }
export function useFilteredMembers( export function useFilteredMembers(
@ -45,16 +44,15 @@ export function useFilteredMembers(
if (!query) { if (!query) {
prev.current.query = "" prev.current.query = ""
prev.current.result = allMembers prev.current.result = allMembers
prev.current.slicedResult = slice && allMembers.length > 100 ? allMembers.slice(0, 100) : undefined
} else if (prev.current.query !== query) { } else if (prev.current.query !== query) {
prev.current.result = (sort ? filterAndSort : filter)( prev.current.result = (sort ? filterAndSort : filter)(
query.startsWith(prev.current.query) ? prev.current.result : allMembers, query.startsWith(prev.current.query) ? prev.current.result : allMembers,
query, query,
) )
prev.current.slicedResult = prev.current.result.length > 100 && slice if (prev.current.result.length > 100 && slice) {
? prev.current.result.slice(0, 100) prev.current.result = prev.current.result.slice(0, 100)
: undefined }
prev.current.query = query prev.current.query = query
} }
return prev.current.slicedResult ?? prev.current.result return prev.current.result
} }

View file

@ -226,7 +226,7 @@ div.emoji-picker, div.sticker-picker {
} }
@media screen and (max-width: 37.5rem) { @media screen and (max-width: 37.5rem) {
div.emoji-picker, div.gif-picker, div.sticker-picker { div.emoji-picker, div.gif-picker {
inset: 0 0 3rem 0 !important; inset: 0 0 3rem 0 !important;
width: 100%; width: 100%;
height: calc(100% - 3rem); height: calc(100% - 3rem);

View file

@ -1,3 +0,0 @@
div.room-list-menu {
}

View file

@ -1,121 +0,0 @@
// 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>
}

View file

@ -1,65 +0,0 @@
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

View file

@ -19,18 +19,8 @@ div.overlay {
overflow: hidden; overflow: hidden;
display: flex; 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 { > div.modal-box-inner {
overflow: auto; overflow: scroll;
} }
} }
} }

View file

@ -36,7 +36,7 @@ const LightboxWrapper = ({ children }: { children: React.ReactNode }) => {
return return
} }
params = { params = {
src: target.getAttribute("data-full-src") ?? target.src, src: target.src,
alt: target.alt, alt: target.alt,
} }
setParams(params) setParams(params)
@ -75,11 +75,6 @@ export interface LightboxProps extends LightboxParams {
onClose: () => void onClose: () => void
} }
interface Point {
x: number
y: number
}
export class Lightbox extends Component<LightboxProps> { export class Lightbox extends Component<LightboxProps> {
translate = { x: 0, y: 0 } translate = { x: 0, y: 0 }
zoom = 1 zoom = 1
@ -87,9 +82,6 @@ export class Lightbox extends Component<LightboxProps> {
maybePanning = false maybePanning = false
readonly ref = createRef<HTMLImageElement>() readonly ref = createRef<HTMLImageElement>()
readonly wrapperRef = createRef<HTMLDivElement>() readonly wrapperRef = createRef<HTMLDivElement>()
prevTouch1: Point | null = null
prevTouch2: Point | null = null
prevTouchDist: number | null = null
get style() { get style() {
return { return {
@ -99,14 +91,6 @@ export class Lightbox extends Component<LightboxProps> {
} }
} }
get orientation(): number {
let rot = (this.rotate / 90) % 4
if (rot < 0) {
rot += 4
}
return rot
}
close = () => { close = () => {
this.translate = { x: 0, y: 0 } this.translate = { x: 0, y: 0 }
this.rotate = 0 this.rotate = 0
@ -131,55 +115,18 @@ export class Lightbox extends Component<LightboxProps> {
return return
} }
evt.preventDefault() evt.preventDefault()
this.#doZoom(-evt.deltaY / 1000, evt.nativeEvent.offsetX, evt.nativeEvent.offsetY, false) 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)
const style = this.style const style = this.style
this.ref.current.style.translate = style.translate this.ref.current.style.translate = style.translate
this.ref.current.style.scale = style.scale 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) => { onMouseDown = (evt: React.MouseEvent) => {
if (evt.buttons === 1) { if (evt.buttons === 1) {
evt.preventDefault() evt.preventDefault()
@ -203,57 +150,6 @@ export class Lightbox extends Component<LightboxProps> {
this.ref.current.style.cursor = "grabbing" 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>) => { onKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
const key = keyToString(evt) const key = keyToString(evt)
if (key === "Escape") { if (key === "Escape") {
@ -293,10 +189,6 @@ export class Lightbox extends Component<LightboxProps> {
className="overlay dimmed lightbox" className="overlay dimmed lightbox"
onClick={this.onClick} onClick={this.onClick}
onMouseMove={isTouchDevice ? undefined : this.onMouseMove} 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} tabIndex={-1}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
ref={this.wrapperRef} ref={this.wrapperRef}

View file

@ -13,74 +13,57 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Context, JSX, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react" import React, { JSX, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react"
import ErrorBoundary from "../util/ErrorBoundary.tsx" import { ModalCloseContext, ModalContext, ModalState } from "./contexts.ts"
import { ModalCloseContext, ModalState, openModal } from "./contexts.ts"
interface ModalWrapperProps { const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
children: React.ReactNode
ContextType: Context<openModal>
historyStateKey: string
}
const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperProps) => {
const [state, setState] = useReducer((prevState: ModalState | null, newState: ModalState | null) => { const [state, setState] = useReducer((prevState: ModalState | null, newState: ModalState | null) => {
prevState?.onClose?.() prevState?.onClose?.()
return newState return newState
}, null) }, null)
const onClickWrapper = useCallback((evt?: React.MouseEvent) => { const onClickWrapper = useCallback((evt?: React.MouseEvent) => {
if (evt && (evt.target !== evt.currentTarget || state?.noDismiss)) { if (evt && evt.target !== evt.currentTarget) {
return return
} }
evt?.stopPropagation()
setState(null) setState(null)
if (history.state?.[historyStateKey]) { if (history.state?.modal) {
history.back() history.back()
} }
}, [historyStateKey, state]) }, [])
const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => { const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => {
if (evt.key === "Escape" && !state?.noDismiss) { if (evt.key === "Escape") {
setState(null) setState(null)
if (history.state?.[historyStateKey]) { if (history.state?.modal) {
history.back() history.back()
} }
} }
evt.stopPropagation() evt.stopPropagation()
} }
const openModal = useCallback((newState: ModalState) => { const openModal = useCallback((newState: ModalState) => {
if (!history.state?.[historyStateKey] && newState.captureInput !== false) { if (!history.state?.modal && newState.captureInput !== false) {
history.pushState({ ...(history.state ?? {}), [historyStateKey]: true }, "") history.pushState({ ...(history.state ?? {}), modal: true }, "")
} }
setState(newState) setState(newState)
}, [historyStateKey]) }, [])
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => { 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))) { if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
wrapperRef.current.focus() wrapperRef.current.focus()
} }
}, [state, onClickWrapper, historyStateKey, openModal]) }, [state])
useEffect(() => { useEffect(() => {
window.closeModal = onClickWrapper
const listener = (evt: PopStateEvent) => { const listener = (evt: PopStateEvent) => {
if (!evt.state?.[historyStateKey]) { if (!evt.state?.modal) {
setState(null) setState(null)
} }
} }
window.addEventListener("popstate", listener) window.addEventListener("popstate", listener)
return () => window.removeEventListener("popstate", listener) return () => window.removeEventListener("popstate", listener)
}, [historyStateKey]) }, [])
let modal: JSX.Element | null = null let modal: JSX.Element | null = null
if (state) { if (state) {
let content = <ModalCloseContext value={onClickWrapper}> let content = <ModalCloseContext value={onClickWrapper}>{state.content}</ModalCloseContext>
<ErrorBoundary thing="modal">
{state.content}
</ErrorBoundary>
</ModalCloseContext>
if (state.boxed) { if (state.boxed) {
content = <div className={`modal-box ${state.boxClass ?? ""}`}> content = <div className={`modal-box ${state.boxClass ?? ""}`}>
<div className={`modal-box-inner ${state.innerBoxClass ?? ""}`}> <div className={`modal-box-inner ${state.innerBoxClass ?? ""}`}>
@ -102,10 +85,10 @@ const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperPr
modal = content modal = content
} }
} }
return <ContextType value={openModal}> return <ModalContext value={openModal}>
{children} {children}
{modal} {modal}
</ContextType> </ModalContext>
} }
export default ModalWrapper export default ModalWrapper

View file

@ -33,15 +33,11 @@ export interface ModalState {
innerBoxClass?: string innerBoxClass?: string
onClose?: () => void onClose?: () => void
captureInput?: boolean captureInput?: boolean
noDismiss?: boolean
} }
export type openModal = (state: ModalState) => void type openModal = (state: ModalState) => void
export const ModalContext = createContext<openModal>(() => export const ModalContext = createContext<openModal>(() =>
console.error("Tried to open modal without being inside context")) 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>(() => {}) export const ModalCloseContext = createContext<() => void>(() => {})

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useState } from "react" import React, { use, useState } from "react"
import { getAvatarThumbnailURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { MemDBEvent, MemberEventContent } from "@/api/types" import { MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts" import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.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}> return <div className="member" data-target-panel="user" data-target-user={userID} onClick={onClick}>
<img <img
className="avatar" className="avatar"
src={getAvatarThumbnailURL(userID, content)} src={getAvatarURL(userID, content)}
alt="" alt=""
loading="lazy" loading="lazy"
/> />
@ -50,7 +50,7 @@ const MemberList = () => {
roomCtx.store.membersRequested = true roomCtx.store.membersRequested = true
use(ClientContext)?.loadRoomState(roomCtx.store.roomID, { omitMembers: false, refetch: false }) use(ClientContext)?.loadRoomState(roomCtx.store.roomID, { omitMembers: false, refetch: false })
} }
const memberEvents = useFilteredMembers(roomCtx?.store, filter, false, false) const memberEvents = useFilteredMembers(roomCtx?.store, filter)
if (!roomCtx) { if (!roomCtx) {
return null return null
} }

View file

@ -51,23 +51,9 @@ div.right-panel-content.pinned-messages {
} }
} }
div.right-panel-content.widgets { div.right-panel-content.user {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .5rem;
padding: .5rem;
> button {
padding: .5rem;
width: 100%;
}
> div.separator {
flex: 1;
}
}
div.right-panel-content.user {
padding: 1rem; padding: 1rem;
div.avatar-container { div.avatar-container {
@ -77,6 +63,7 @@ div.right-panel-content.user {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
flex-shrink: 0;
margin: 0 auto; margin: 0 auto;
> img { > img {
@ -102,27 +89,6 @@ div.right-panel-content.user {
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
word-break: break-word; 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 { hr {
@ -212,25 +178,6 @@ 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 { div.errors {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -13,30 +13,20 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import type { IWidget } from "matrix-widget-api"
import { JSX, use } from "react" import { JSX, use } from "react"
import type { UserID } from "@/api/types" import type { UserID } from "@/api/types"
import MainScreenContext, { MainScreenContextFields } from "../MainScreenContext.ts" import MainScreenContext 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 MemberList from "./MemberList.tsx"
import PinnedMessages from "./PinnedMessages.tsx" import PinnedMessages from "./PinnedMessages.tsx"
import UserInfo from "./UserInfo.tsx" import UserInfo from "./UserInfo.tsx"
import WidgetList from "./WidgetList.tsx"
import BackIcon from "@/icons/back.svg?react" import BackIcon from "@/icons/back.svg?react"
import CloseIcon from "@/icons/close.svg?react" import CloseIcon from "@/icons/close.svg?react"
import "./RightPanel.css" import "./RightPanel.css"
export type RightPanelType = "pinned-messages" | "members" | "widgets" | "widget" | "user" | "element-call" export type RightPanelType = "pinned-messages" | "members" | "user"
interface RightPanelSimpleProps { interface RightPanelSimpleProps {
type: "pinned-messages" | "members" | "widgets" | "element-call" type: "pinned-messages" | "members"
}
interface RightPanelWidgetProps {
type: "widget"
info: IWidget
} }
interface RightPanelUserProps { interface RightPanelUserProps {
@ -44,37 +34,25 @@ interface RightPanelUserProps {
userID: UserID userID: UserID
} }
export type RightPanelProps = RightPanelUserProps | RightPanelWidgetProps | RightPanelSimpleProps export type RightPanelProps = RightPanelUserProps | RightPanelSimpleProps
function getTitle(props: RightPanelProps): string { function getTitle(type: RightPanelType): string {
switch (props.type) { switch (type) {
case "pinned-messages": case "pinned-messages":
return "Pinned Messages" return "Pinned Messages"
case "members": case "members":
return "Room Members" return "Room Members"
case "widgets":
return "Widgets in room"
case "widget":
return props.info.name || "Widget"
case "element-call":
return "Element Call"
case "user": case "user":
return "User Info" return "User Info"
} }
} }
function renderRightPanelContent(props: RightPanelProps, mainScreen: MainScreenContextFields): JSX.Element | null { function renderRightPanelContent(props: RightPanelProps): JSX.Element | null {
switch (props.type) { switch (props.type) {
case "pinned-messages": case "pinned-messages":
return <PinnedMessages /> return <PinnedMessages />
case "members": case "members":
return <MemberList /> 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": case "user":
return <UserInfo userID={props.userID} /> return <UserInfo userID={props.userID} />
} }
@ -88,24 +66,17 @@ const RightPanel = (props: RightPanelProps) => {
data-target-panel="members" data-target-panel="members"
onClick={mainScreen.clickRightPanelOpener} onClick={mainScreen.clickRightPanelOpener}
><BackIcon/></button> ><BackIcon/></button>
} else if (props.type === "element-call" || props.type === "widget") {
backButton = <button
data-target-panel="widgets"
onClick={mainScreen.clickRightPanelOpener}
><BackIcon/></button>
} }
return <div className="right-panel"> return <div className="right-panel">
<div className="right-panel-header"> <div className="right-panel-header">
<div className="left-side"> <div className="left-side">
{backButton} {backButton}
<div className="panel-name">{getTitle(props)}</div> <div className="panel-name">{getTitle(props.type)}</div>
</div> </div>
<button onClick={mainScreen.closeRightPanel}><CloseIcon/></button> <button onClick={mainScreen.closeRightPanel}><CloseIcon/></button>
</div> </div>
<div className={`right-panel-content ${props.type}`}> <div className={`right-panel-content ${props.type}`}>
<ErrorBoundary thing="right panel content"> {renderRightPanelContent(props)}
{renderRightPanelContent(props, mainScreen)}
</ErrorBoundary>
</div> </div>
</div> </div>
} }

View file

@ -1,102 +0,0 @@
// 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

View file

@ -1,119 +0,0 @@
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

View file

@ -1,55 +0,0 @@
// 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

View file

@ -13,20 +13,18 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useCallback, useEffect, useState } from "react" import { use, useEffect, useState } from "react"
import { PuffLoader } from "react-spinners" import { PuffLoader } from "react-spinners"
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { useRoomMember } from "@/api/statestore" import { useRoomMember } from "@/api/statestore"
import { MemberEventContent, UserID, UserProfile } from "@/api/types" import { MemberEventContent, UserID, UserProfile } from "@/api/types"
import { ensureString, getLocalpart } from "@/util/validation.ts" import { getLocalpart } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import { LightboxContext } from "../modal" import { LightboxContext } from "../modal"
import { RoomContext } from "../roomview/roomcontext.ts" import { RoomContext } from "../roomview/roomcontext.ts"
import UserExtendedProfile from "./UserExtendedProfile.tsx"
import DeviceList from "./UserInfoDeviceList.tsx" import DeviceList from "./UserInfoDeviceList.tsx"
import UserInfoError from "./UserInfoError.tsx" import UserInfoError from "./UserInfoError.tsx"
import MutualRooms from "./UserInfoMutualRooms.tsx" import MutualRooms from "./UserInfoMutualRooms.tsx"
import UserModeration from "./UserModeration.tsx"
interface UserInfoProps { interface UserInfoProps {
userID: UserID userID: UserID
@ -40,20 +38,16 @@ const UserInfo = ({ userID }: UserInfoProps) => {
const member = (memberEvt?.content ?? null) as MemberEventContent | null const member = (memberEvt?.content ?? null) as MemberEventContent | null
const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null) const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null)
const [errors, setErrors] = useState<string[] | null>(null) const [errors, setErrors] = useState<string[] | null>(null)
const refreshProfile = useCallback((clearState = false) => { useEffect(() => {
if (clearState) {
setErrors(null) setErrors(null)
setGlobalProfile(null) setGlobalProfile(null)
}
client.rpc.getProfile(userID).then( client.rpc.getProfile(userID).then(
setGlobalProfile, setGlobalProfile,
err => setErrors([`${err}`]), err => setErrors([`${err}`]),
) )
}, [userID, client]) }, [roomCtx, userID, client])
useEffect(() => refreshProfile(true), [refreshProfile])
const displayname = ensureString(member?.displayname) const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID)
|| ensureString(globalProfile?.displayname)
|| getLocalpart(userID)
return <> return <>
<div className="avatar-container"> <div className="avatar-container">
{member === null && globalProfile === null && errors == null ? <PuffLoader {member === null && globalProfile === null && errors == null ? <PuffLoader
@ -62,7 +56,6 @@ const UserInfo = ({ userID }: UserInfoProps) => {
className="avatar-loader" className="avatar-loader"
/> : <img /> : <img
className="avatar" className="avatar"
// this is a big avatar (236px by default), use full resolution
src={getAvatarURL(userID, member ?? globalProfile)} src={getAvatarURL(userID, member ?? globalProfile)}
onClick={openLightbox} onClick={openLightbox}
alt="" alt=""
@ -70,13 +63,17 @@ const UserInfo = ({ userID }: UserInfoProps) => {
</div> </div>
<div className="displayname" title={displayname}>{displayname}</div> <div className="displayname" title={displayname}>{displayname}</div>
<div className="userid" title={userID}>{userID}</div> <div className="userid" title={userID}>{userID}</div>
<UserExtendedProfile profile={globalProfile} refreshProfile={refreshProfile} client={client} userID={userID}/> <hr/>
<DeviceList client={client} room={roomCtx?.store} userID={userID}/>
{userID !== client.userID && <> {userID !== client.userID && <>
<MutualRooms client={client} userID={userID}/> <MutualRooms client={client} userID={userID}/>
<UserModeration client={client} room={roomCtx?.store} member={memberEvt} userID={userID}/> <hr/>
</>} </>}
<DeviceList client={client} room={roomCtx?.store} userID={userID}/>
<hr/>
{errors?.length ? <>
<UserInfoError errors={errors}/> <UserInfoError errors={errors}/>
<hr/>
</> : null}
</> </>
} }

View file

@ -40,7 +40,8 @@ const MutualRooms = ({ client, userID }: MutualRoomsProps) => {
} }
return { return {
room_id: roomID, room_id: roomID,
dm_user_id: roomData.meta.current.dm_user_id, dm_user_id: roomData.meta.current.lazy_load_summary?.["m.heroes"]?.length === 1
? roomData.meta.current.lazy_load_summary["m.heroes"][0] : undefined,
name: roomData.meta.current.name ?? "Unnamed room", name: roomData.meta.current.name ?? "Unnamed room",
avatar: roomData.meta.current.avatar, avatar: roomData.meta.current.avatar,
search_name: "", search_name: "",

View file

@ -1,118 +0,0 @@
// 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

View file

@ -1,55 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import type { IWidget } from "matrix-widget-api"
import { use } from "react"
import MainScreenContext from "../MainScreenContext.ts"
import { RoomContext } from "../roomview/roomcontext.ts"
const WidgetList = () => {
const roomCtx = use(RoomContext)
const mainScreen = use(MainScreenContext)
const widgets = roomCtx?.store.state.get("im.vector.modular.widgets") ?? new Map()
const widgetElements = []
for (const [stateKey, rowid] of widgets.entries()) {
const evt = roomCtx?.store.eventsByRowID.get(rowid)
if (!evt || !evt.content.url) {
continue
}
const onClick = () => mainScreen.setRightPanel({
type: "widget",
info: {
id: stateKey,
creatorUserId: evt.sender,
...evt.content,
} as IWidget,
})
widgetElements.push(<button key={rowid} onClick={onClick}>
{evt.content.name || stateKey}
</button>)
}
const openElementCall = () => {
mainScreen.setRightPanel({ type: "element-call" })
}
return <>
{widgetElements}
<div className="separator" />
<button onClick={openElementCall}>Element Call</button>
</>
}
export default WidgetList

View file

@ -13,23 +13,20 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { JSX, memo, use } from "react" import { JSX, memo, use } from "react"
import { getRoomAvatarThumbnailURL } from "@/api/media.ts" import { getRoomAvatarURL } from "@/api/media.ts"
import type { RoomListEntry } from "@/api/statestore" import type { RoomListEntry } from "@/api/statestore"
import type { MemDBEvent, MemberEventContent } from "@/api/types" import type { MemDBEvent, MemberEventContent } from "@/api/types"
import useContentVisibility from "@/util/contentvisibility.ts" import useContentVisibility from "@/util/contentvisibility.ts"
import { getDisplayname } from "@/util/validation.ts" import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts" import MainScreenContext from "../MainScreenContext.ts"
import { RoomMenu, getModalStyleFromMouse } from "../menu"
import { ModalContext } from "../modal"
import UnreadCount from "./UnreadCount.tsx" import UnreadCount from "./UnreadCount.tsx"
export interface RoomListEntryProps { export interface RoomListEntryProps {
room: RoomListEntry room: RoomListEntry
isActive: boolean isActive: boolean
hidden: boolean hidden: boolean
hideAvatar?: boolean
} }
function getPreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null): [string, JSX.Element | null] { function getPreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null): [string, JSX.Element | null] {
@ -58,7 +55,7 @@ function getPreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null):
return ["", null] return ["", null]
} }
function renderEntry(room: RoomListEntry, hideAvatar: boolean | undefined) { function renderEntry(room: RoomListEntry) {
const [previewText, croppedPreviewText] = getPreviewText(room.preview_event, room.preview_sender) const [previewText, croppedPreviewText] = getPreviewText(room.preview_event, room.preview_sender)
return <> return <>
@ -66,7 +63,7 @@ function renderEntry(room: RoomListEntry, hideAvatar: boolean | undefined) {
<img <img
loading="lazy" loading="lazy"
className="avatar room-avatar" className="avatar room-avatar"
src={getRoomAvatarThumbnailURL(room, undefined, hideAvatar)} src={getRoomAvatarURL(room)}
alt="" alt=""
/> />
</div> </div>
@ -78,35 +75,15 @@ function renderEntry(room: RoomListEntry, hideAvatar: boolean | undefined) {
</> </>
} }
const Entry = ({ room, isActive, hidden, hideAvatar }: RoomListEntryProps) => { const Entry = ({ room, isActive, hidden }: RoomListEntryProps) => {
const [isVisible, divRef] = useContentVisibility<HTMLDivElement>() const [isVisible, divRef] = useContentVisibility<HTMLDivElement>()
const openModal = use(ModalContext)
const mainScreen = use(MainScreenContext)
const client = use(ClientContext)!
const onContextMenu = (evt: React.MouseEvent<HTMLDivElement>) => {
const realRoom = client.store.rooms.get(room.room_id)
if (!realRoom) {
// TODO implement separate menu for invite rooms
console.error("Room state store not found for", room.room_id)
return
}
openModal({
content: <RoomMenu
room={realRoom}
entry={room}
style={getModalStyleFromMouse(evt, 6 * 40)}
/>,
})
evt.preventDefault()
}
return <div return <div
ref={divRef} ref={divRef}
className={`room-entry ${isActive ? "active" : ""} ${hidden ? "hidden" : ""}`} className={`room-entry ${isActive ? "active" : ""} ${hidden ? "hidden" : ""}`}
onClick={mainScreen.clickRoom} onClick={use(MainScreenContext).clickRoom}
onContextMenu={onContextMenu}
data-room-id={room.room_id} data-room-id={room.room_id}
> >
{isVisible ? renderEntry(room, hideAvatar) : null} {isVisible ? renderEntry(room) : null}
</div> </div>
} }

View file

@ -27,7 +27,7 @@ export interface FakeSpaceProps {
space: Space | null space: Space | null
setSpace: (space: RoomListFilter | null) => void setSpace: (space: RoomListFilter | null) => void
isActive: boolean isActive: boolean
onClickUnread?: (evt: React.MouseEvent<HTMLDivElement>, space: Space | null) => void onClickUnread?: (evt: React.MouseEvent<HTMLDivElement> | null, space: Space | null) => void
} }
const getFakeSpaceMeta = (space: RoomListFilter | null): [string | undefined, JSX.Element | null] => { const getFakeSpaceMeta = (space: RoomListFilter | null): [string | undefined, JSX.Element | null] => {
@ -47,9 +47,7 @@ const getFakeSpaceMeta = (space: RoomListFilter | null): [string | undefined, JS
const FakeSpace = ({ space, setSpace, isActive, onClickUnread }: FakeSpaceProps) => { const FakeSpace = ({ space, setSpace, isActive, onClickUnread }: FakeSpaceProps) => {
const unreads = useEventAsState(space?.counts) const unreads = useEventAsState(space?.counts)
const onClickUnreadWrapped = onClickUnread const onClickUnreadWrapped = onClickUnread ? () => onClickUnread(null, space) : undefined
? (evt: React.MouseEvent<HTMLDivElement>) => onClickUnread(evt, space)
: undefined
const [title, icon] = getFakeSpaceMeta(space) const [title, icon] = getFakeSpaceMeta(space)
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={() => setSpace(space)} title={title}> return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={() => setSpace(space)} title={title}>
<UnreadCount counts={unreads} space={true} onClick={onClickUnreadWrapped} /> <UnreadCount counts={unreads} space={true} onClick={onClickUnreadWrapped} />

View file

@ -119,7 +119,7 @@ div.room-entry {
width: 3rem; width: 3rem;
> img.room-avatar { > img.room-avatar {
margin: .25rem; padding: 4px;
} }
} }
@ -172,7 +172,6 @@ div.room-entry-unreads {
justify-content: center; justify-content: center;
border-radius: var(--unread-count-size); border-radius: var(--unread-count-size);
color: var(--unread-counter-text-color); color: var(--unread-counter-text-color);
user-select: none;
background-color: var(--unread-counter-message-bg); background-color: var(--unread-counter-message-bg);
height: var(--unread-count-size); height: var(--unread-count-size);

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useCallback, useRef, useState } from "react" import React, { use, useCallback, useRef, useState } from "react"
import { RoomListFilter, Space as SpaceStore, SpaceUnreadCounts, usePreference } from "@/api/statestore" import { RoomListFilter, Space as SpaceStore, SpaceUnreadCounts } from "@/api/statestore/space.ts"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts" import { useEventAsState } from "@/util/eventdispatcher.ts"
import reverseMap from "@/util/reversemap.ts" import reverseMap from "@/util/reversemap.ts"
@ -31,38 +31,48 @@ import "./RoomList.css"
interface RoomListProps { interface RoomListProps {
activeRoomID: RoomID | null activeRoomID: RoomID | null
space: RoomListFilter | null
} }
const RoomList = ({ activeRoomID, space }: RoomListProps) => { const RoomList = ({ activeRoomID }: RoomListProps) => {
const client = use(ClientContext)! const client = use(ClientContext)!
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
const roomList = useEventAsState(client.store.roomList) const roomList = useEventAsState(client.store.roomList)
const spaces = useEventAsState(client.store.topLevelSpaces) const spaces = useEventAsState(client.store.topLevelSpaces)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const [query, directSetQuery] = useState("") const [query, directSetQuery] = useState("")
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
const setQuery = (evt: React.ChangeEvent<HTMLInputElement>) => { const setQuery = (evt: React.ChangeEvent<HTMLInputElement>) => {
client.store.currentRoomListQuery = toSearchableString(evt.target.value) client.store.currentRoomListQuery = toSearchableString(evt.target.value)
directSetQuery(evt.target.value) directSetQuery(evt.target.value)
} }
const setSpace = useCallback((space: RoomListFilter | null) => {
directSetSpace(space)
client.store.currentRoomListFilter = space
if (client.store.activeRoomID && space) {
const entry = client.store.rooms.get(client.store.activeRoomID)?.roomListEntry
if (entry && !space.include(entry)) {
mainScreen.setActiveRoom(null)
}
}
}, [client, mainScreen])
const onClickSpace = useCallback((evt: React.MouseEvent<HTMLDivElement>) => { const onClickSpace = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!) const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!)
mainScreen.setSpace(store) setSpace(store)
}, [mainScreen, client]) }, [setSpace, client])
const onClickSpaceUnread = useCallback(( const onClickSpaceUnread = useCallback((
evt: React.MouseEvent<HTMLDivElement>, space?: SpaceStore | null, evt: React.MouseEvent<HTMLDivElement> | null, space?: SpaceStore | null,
) => { ) => {
if (!space) { if (evt) {
const targetSpace = evt.currentTarget.closest("div.space-entry")?.getAttribute("data-target-space") const targetSpace = evt.currentTarget.closest("div.space-entry")?.getAttribute("data-target-space")
if (!targetSpace) { if (!targetSpace) {
return return
} }
space = client.store.getSpaceStore(targetSpace) space = client.store.getSpaceStore(targetSpace)
}
if (!space) { if (!space) {
return return
} }
}
const counts = space.counts.current const counts = space.counts.current
let wantedField: keyof SpaceUnreadCounts let wantedField: keyof SpaceUnreadCounts
if (counts.unread_highlights > 0) { if (counts.unread_highlights > 0) {
@ -77,8 +87,7 @@ const RoomList = ({ activeRoomID, space }: RoomListProps) => {
for (let i = client.store.roomList.current.length - 1; i >= 0; i--) { for (let i = client.store.roomList.current.length - 1; i >= 0; i--) {
const entry = client.store.roomList.current[i] const entry = client.store.roomList.current[i]
if (entry[wantedField] > 0 && space.include(entry)) { if (entry[wantedField] > 0 && space.include(entry)) {
mainScreen.setActiveRoom(entry.room_id, undefined, space) mainScreen.setActiveRoom(entry.room_id)
evt.stopPropagation()
break break
} }
} }
@ -103,7 +112,6 @@ const RoomList = ({ activeRoomID, space }: RoomListProps) => {
} }
} }
const showInviteAvatars = usePreference(client.store, null, "show_invite_avatars")
const roomListFilter = client.store.roomListFilterFunc const roomListFilter = client.store.roomListFilterFunc
return <div className="room-list-wrapper"> return <div className="room-list-wrapper">
<div className="room-search-wrapper"> <div className="room-search-wrapper">
@ -122,11 +130,11 @@ const RoomList = ({ activeRoomID, space }: RoomListProps) => {
</button> </button>
</div> </div>
<div className="space-bar"> <div className="space-bar">
<FakeSpace space={null} setSpace={mainScreen.setSpace} isActive={space === null} /> <FakeSpace space={null} setSpace={setSpace} isActive={space === null} />
{client.store.pseudoSpaces.map(pseudoSpace => <FakeSpace {client.store.pseudoSpaces.map(pseudoSpace => <FakeSpace
key={pseudoSpace.id} key={pseudoSpace.id}
space={pseudoSpace} space={pseudoSpace}
setSpace={mainScreen.setSpace} setSpace={setSpace}
onClickUnread={onClickSpaceUnread} onClickUnread={onClickSpaceUnread}
isActive={space?.id === pseudoSpace.id} isActive={space?.id === pseudoSpace.id}
/>)} />)}
@ -146,7 +154,6 @@ const RoomList = ({ activeRoomID, space }: RoomListProps) => {
isActive={room.room_id === activeRoomID} isActive={room.room_id === activeRoomID}
hidden={roomListFilter ? !roomListFilter(room) : false} hidden={roomListFilter ? !roomListFilter(room) : false}
room={room} room={room}
hideAvatar={room.is_invite && !showInviteAvatars}
/>, />,
)} )}
</div> </div>

View file

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React from "react" import React from "react"
import Client from "@/api/client.ts" import Client from "@/api/client.ts"
import { getRoomAvatarThumbnailURL } from "@/api/media.ts" import { getRoomAvatarURL } from "@/api/media.ts"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts" import { useEventAsState } from "@/util/eventdispatcher.ts"
import UnreadCount from "./UnreadCount.tsx" import UnreadCount from "./UnreadCount.tsx"
@ -37,7 +37,7 @@ const Space = ({ roomID, client, onClick, isActive, onClickUnread }: SpaceProps)
} }
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={onClick} data-target-space={roomID}> return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={onClick} data-target-space={roomID}>
<UnreadCount counts={unreads} space={true} onClick={onClickUnread} /> <UnreadCount counts={unreads} space={true} onClick={onClickUnread} />
<img src={getRoomAvatarThumbnailURL(room)} alt={room.name} title={room.name} className="avatar" /> <img src={getRoomAvatarURL(room)} alt={room.name} title={room.name} className="avatar" />
</div> </div>
} }

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { SpaceUnreadCounts } from "@/api/statestore" import { SpaceUnreadCounts } from "@/api/statestore/space.ts"
interface UnreadCounts extends SpaceUnreadCounts { interface UnreadCounts extends SpaceUnreadCounts {
marked_unread?: boolean marked_unread?: boolean
@ -38,9 +38,9 @@ const UnreadCount = ({ counts, space, onClick }: UnreadCountProps) => {
const countIsBig = !space const countIsBig = !space
&& Boolean(counts.unread_notifications || counts.unread_highlights || counts.marked_unread) && Boolean(counts.unread_notifications || counts.unread_highlights || counts.marked_unread)
let unreadCountDisplay = unreadCount.toString() let unreadCountDisplay = unreadCount.toString()
if (unreadCount > 999 && (countIsBig || space)) { if (unreadCount > 999 && countIsBig) {
unreadCountDisplay = "99+" unreadCountDisplay = "99+"
} else if (unreadCount > 9999) { } else if (unreadCount > 9999 && countIsBig) {
unreadCountDisplay = "999+" unreadCountDisplay = "999+"
} }
const classNames = ["unread-count"] const classNames = ["unread-count"]

Some files were not shown because too many files have changed in this diff Show more