Merge remote-tracking branch 'origin/main' into nexy7574/presence

# Conflicts:
#	pkg/hicli/json-commands.go
#	web/src/api/rpc.ts
#	web/src/api/types/mxtypes.ts
#	web/src/ui/rightpanel/UserInfo.tsx
This commit is contained in:
nexy7574 2025-01-12 21:30:19 +00:00
commit 13a45b7722
No known key found for this signature in database
GPG key ID: 0FA334385D0B689F
105 changed files with 3393 additions and 1201 deletions

View file

@ -4,37 +4,38 @@ go 1.23.0
toolchain go1.23.3
require github.com/wailsapp/wails/v3 v3.0.0-alpha.7
require github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3
require (
go.mau.fi/gomuks v0.3.1
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a
)
require (
dario.cat/mergo v1.0.0 // indirect
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/adrg/xdg v0.5.0 // indirect
github.com/alecthomas/chroma/v2 v2.15.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/buckket/go-blurhash v1.1.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cloudflare/circl v1.3.8 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.5 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/ebitengine/purego v0.4.0-alpha.4 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-git/go-git/v5 v5.11.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-git/go-billy/v5 v5.6.0 // indirect
github.com/go-git/go-git/v5 v5.12.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
@ -52,31 +53,31 @@ require (
github.com/rs/xid v1.6.0 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/samber/lo v1.38.1 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/skeema/knownhosts v1.2.1 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wailsapp/go-webview2 v1.0.15 // indirect
github.com/wailsapp/go-webview2 v1.0.18 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.28.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce // indirect
mvdan.cc/xurls/v2 v2.5.0 // indirect
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f // indirect
mvdan.cc/xurls/v2 v2.6.0 // indirect
)
replace go.mau.fi/gomuks => ../

View file

@ -1,5 +1,5 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@ -7,12 +7,14 @@ github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@ -31,39 +33,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/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
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/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@ -71,8 +73,8 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@ -106,8 +108,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
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=
@ -121,8 +123,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@ -130,11 +132,11 @@ github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ=
github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -149,19 +151,19 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wailsapp/go-webview2 v1.0.15 h1:IeQFoWmsHp32y64I41J+Zod3SopjHs918KSO4jUqEnY=
github.com/wailsapp/go-webview2 v1.0.15/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
github.com/wailsapp/go-webview2 v1.0.18 h1:SSSCoLA+MYikSp1U0WmvELF/4c3x5kH8Vi31TKyZ4yk=
github.com/wailsapp/go-webview2 v1.0.18/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v3 v3.0.0-alpha.7 h1:LNX2EnbxTEYJYICJT8UkuzoGVNalRizTNGBY47endmk=
github.com/wailsapp/wails/v3 v3.0.0-alpha.7/go.mod h1:lBz4zedFxreJBoVpMe9u89oo4IE3IlyHJg5rOWnGNR0=
github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3 h1:9aCL0IXD60A5iscQ/ps6f3ti3IlaoG6LQe0RZ9JkueU=
github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3/go.mod h1:9Ca1goy5oqxmy8Oetb8Tchkezcx4tK03DK+SqYByu5Y=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a h1:D9RCHBFjxah9F/YB7amvRJjT2IEOFWcz8jpcEY8dBV0=
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -169,10 +171,10 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -195,7 +197,6 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -208,20 +209,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -247,10 +249,10 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce h1:wkVN87Hlq63YCuUhVYhvSy0qeAUCzbD5quEtd95rxyE=
maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f h1:+nAznNCSCm+P4am9CmguSfNfcbpA7qtTzJQrwCP8aHM=
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f/go.mod h1:FmwzK7RSzrd1OfGDgJzFWXl7nYmYm8/P0Y77sy/A1Uw=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=

18
go.mod
View file

@ -5,11 +5,11 @@ go 1.23.0
toolchain go1.23.4
require (
github.com/alecthomas/chroma/v2 v2.14.0
github.com/alecthomas/chroma/v2 v2.15.0
github.com/buckket/go-blurhash v1.1.0
github.com/chzyer/readline v1.5.1
github.com/coder/websocket v1.8.12
github.com/gabriel-vasile/mimetype v1.4.7
github.com/gabriel-vasile/mimetype v1.4.8
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/rivo/uniseg v0.4.7
@ -17,29 +17,29 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/yuin/goldmark v1.7.8
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a
go.mau.fi/zeroconfig v0.1.3
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.32.0
golang.org/x/image v0.23.0
golang.org/x/net v0.33.0
golang.org/x/text v0.21.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce
mvdan.cc/xurls/v2 v2.5.0
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f
mvdan.cc/xurls/v2 v2.6.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect
golang.org/x/sys v0.29.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)

40
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=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
@ -22,10 +22,10 @@ github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
@ -63,14 +63,14 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a h1:D9RCHBFjxah9F/YB7amvRJjT2IEOFWcz8jpcEY8dBV0=
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
@ -79,8 +79,8 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
@ -91,7 +91,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce h1:wkVN87Hlq63YCuUhVYhvSy0qeAUCzbD5quEtd95rxyE=
maunium.net/go/mautrix v0.22.2-0.20241219213402-918ed4bf23ce/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f h1:+nAznNCSCm+P4am9CmguSfNfcbpA7qtTzJQrwCP8aHM=
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f/go.mod h1:FmwzK7RSzrd1OfGDgJzFWXl7nYmYm8/P0Y77sy/A1Uw=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=

View file

@ -125,7 +125,7 @@ func (new *noErrorWriter) Write(p []byte) (n int, err error) {
// note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts
const fallbackAvatarTemplate = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<circle cx="500" cy="500" r="500" fill="%s"/>
<rect x="0" y="0" width="1000" height="1000" fill="%s"/>
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
>%s</text>

View file

@ -17,16 +17,17 @@ import (
type Database struct {
*dbutil.Database
Account AccountQuery
AccountData AccountDataQuery
Room RoomQuery
InvitedRoom InvitedRoomQuery
Event EventQuery
CurrentState CurrentStateQuery
Timeline TimelineQuery
SessionRequest SessionRequestQuery
Receipt ReceiptQuery
Media MediaQuery
Account *AccountQuery
AccountData *AccountDataQuery
Room *RoomQuery
InvitedRoom *InvitedRoomQuery
Event *EventQuery
CurrentState *CurrentStateQuery
Timeline *TimelineQuery
SessionRequest *SessionRequestQuery
Receipt *ReceiptQuery
Media *MediaQuery
SpaceEdge *SpaceEdgeQuery
}
func New(rawDB *dbutil.Database) *Database {
@ -35,16 +36,17 @@ func New(rawDB *dbutil.Database) *Database {
return &Database{
Database: rawDB,
Account: AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
AccountData: AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
Room: RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
InvitedRoom: InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
Event: EventQuery{QueryHelper: eventQH},
CurrentState: CurrentStateQuery{QueryHelper: eventQH},
Timeline: TimelineQuery{QueryHelper: eventQH},
SessionRequest: SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
Receipt: ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
Media: MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
Event: &EventQuery{QueryHelper: eventQH},
CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
Timeline: &TimelineQuery{QueryHelper: eventQH},
SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)},
}
}
@ -79,3 +81,7 @@ func newAccountData(_ *dbutil.QueryHelper[*AccountData]) *AccountData {
func newAccount(_ *dbutil.QueryHelper[*Account]) *Account {
return &Account{}
}
func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
return &SpaceEdge{}
}

View file

@ -21,12 +21,14 @@ import (
const (
getRoomBaseQuery = `
SELECT room_id, creation_content, tombstone_content, name, name_quality, avatar, explicit_avatar, topic, canonical_alias,
SELECT room_id, creation_content, tombstone_content, name, name_quality,
avatar, explicit_avatar, dm_user_id, topic, canonical_alias,
lazy_load_summary, encryption_event, has_member_list, preview_event_rowid, sorting_timestamp,
unread_highlights, unread_notifications, unread_messages, marked_unread, prev_batch
FROM room
`
getRoomsBySortingTimestampQuery = getRoomBaseQuery + `WHERE sorting_timestamp < $1 AND sorting_timestamp > 0 ORDER BY sorting_timestamp DESC LIMIT $2`
getRoomsByTypeQuery = getRoomBaseQuery + `WHERE room_type = $1`
getRoomByIDQuery = getRoomBaseQuery + `WHERE room_id = $1`
ensureRoomExistsQuery = `
INSERT INTO room (room_id) VALUES ($1)
@ -34,24 +36,26 @@ const (
`
upsertRoomFromSyncQuery = `
UPDATE room
SET creation_content = COALESCE(room.creation_content, $2),
SET room_type = COALESCE(room.room_type, json($2)->>'$.type'),
creation_content = COALESCE(room.creation_content, $2),
tombstone_content = COALESCE(room.tombstone_content, $3),
name = COALESCE($4, room.name),
name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END,
avatar = COALESCE($6, room.avatar),
explicit_avatar = CASE WHEN $6 IS NOT NULL THEN $7 ELSE room.explicit_avatar END,
topic = COALESCE($8, room.topic),
canonical_alias = COALESCE($9, room.canonical_alias),
lazy_load_summary = COALESCE($10, room.lazy_load_summary),
encryption_event = COALESCE($11, room.encryption_event),
has_member_list = room.has_member_list OR $12,
preview_event_rowid = COALESCE($13, room.preview_event_rowid),
sorting_timestamp = COALESCE($14, room.sorting_timestamp),
unread_highlights = COALESCE($15, room.unread_highlights),
unread_notifications = COALESCE($16, room.unread_notifications),
unread_messages = COALESCE($17, room.unread_messages),
marked_unread = COALESCE($18, room.marked_unread),
prev_batch = COALESCE($19, room.prev_batch)
dm_user_id = COALESCE($8, room.dm_user_id),
topic = COALESCE($9, room.topic),
canonical_alias = COALESCE($10, room.canonical_alias),
lazy_load_summary = COALESCE($11, room.lazy_load_summary),
encryption_event = COALESCE($12, room.encryption_event),
has_member_list = room.has_member_list OR $13,
preview_event_rowid = COALESCE($14, room.preview_event_rowid),
sorting_timestamp = COALESCE($15, room.sorting_timestamp),
unread_highlights = COALESCE($16, room.unread_highlights),
unread_notifications = COALESCE($17, room.unread_notifications),
unread_messages = COALESCE($18, room.unread_messages),
marked_unread = COALESCE($19, room.marked_unread),
prev_batch = COALESCE($20, room.prev_batch)
WHERE room_id = $1
`
setRoomPrevBatchQuery = `
@ -95,6 +99,10 @@ func (rq *RoomQuery) GetBySortTS(ctx context.Context, maxTS time.Time, limit int
return rq.QueryMany(ctx, getRoomsBySortingTimestampQuery, maxTS.UnixMilli(), limit)
}
func (rq *RoomQuery) GetAllSpaces(ctx context.Context) ([]*Room, error) {
return rq.QueryMany(ctx, getRoomsByTypeQuery, event.RoomTypeSpace)
}
func (rq *RoomQuery) Upsert(ctx context.Context, room *Room) error {
return rq.Exec(ctx, upsertRoomFromSyncQuery, room.sqlVariables()...)
}
@ -147,6 +155,7 @@ type Room struct {
NameQuality NameQuality `json:"name_quality"`
Avatar *id.ContentURI `json:"avatar,omitempty"`
ExplicitAvatar bool `json:"explicit_avatar"`
DMUserID *id.UserID `json:"dm_user_id,omitempty"`
Topic *string `json:"topic,omitempty"`
CanonicalAlias *id.RoomAlias `json:"canonical_alias,omitempty"`
@ -182,6 +191,10 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) {
other.ExplicitAvatar = r.ExplicitAvatar
hasChanges = true
}
if r.DMUserID != nil {
other.DMUserID = r.DMUserID
hasChanges = true
}
if r.Topic != nil {
other.Topic = r.Topic
hasChanges = true
@ -244,6 +257,7 @@ func (r *Room) Scan(row dbutil.Scannable) (*Room, error) {
&r.NameQuality,
&r.Avatar,
&r.ExplicitAvatar,
&r.DMUserID,
&r.Topic,
&r.CanonicalAlias,
dbutil.JSON{Data: &r.LazyLoadSummary},
@ -275,6 +289,7 @@ func (r *Room) sqlVariables() []any {
r.NameQuality,
r.Avatar,
r.ExplicitAvatar,
r.DMUserID,
r.Topic,
r.CanonicalAlias,
dbutil.JSONPtr(r.LazyLoadSummary),

250
pkg/hicli/database/space.go Normal file
View file

@ -0,0 +1,250 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"database/sql"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getAllSpaceChildren = `
SELECT space_id, child_id, child_event_rowid, "order", suggested, parent_event_rowid, canonical, parent_validated
FROM space_edge
-- This check should be redundant thanks to parent_validated and validation before insert for children
--INNER JOIN room ON space_id = room.room_id AND room.room_type = 'm.space'
WHERE (space_id = $1 OR $1 = '') AND (child_event_rowid IS NOT NULL OR parent_validated)
ORDER BY space_id, "order", child_id
`
getTopLevelSpaces = `
SELECT space_id
FROM (SELECT DISTINCT(space_id) FROM space_edge) outeredge
LEFT JOIN room_account_data ON
room_account_data.user_id = $1
AND room_account_data.room_id = outeredge.space_id
AND room_account_data.type = 'org.matrix.msc3230.space_order'
WHERE NOT EXISTS(
SELECT 1
FROM space_edge inneredge
INNER JOIN room ON inneredge.space_id = room.room_id
WHERE inneredge.child_id = outeredge.space_id
AND (inneredge.child_event_rowid IS NOT NULL OR inneredge.parent_validated)
) AND EXISTS(SELECT 1 FROM room WHERE room_id = space_id AND room_type = 'm.space')
ORDER BY room_account_data.content->>'$.order' NULLS LAST, space_id
`
revalidateAllParents = `
UPDATE space_edge
SET parent_validated=(SELECT EXISTS(
SELECT 1
FROM room
INNER JOIN current_state cs ON cs.room_id = room.room_id AND cs.event_type = 'm.room.power_levels' AND cs.state_key = ''
INNER JOIN event pls ON cs.event_rowid = pls.rowid
INNER JOIN event edgeevt ON space_edge.parent_event_rowid = edgeevt.rowid
WHERE room.room_id = space_edge.space_id
AND room.room_type = 'm.space'
AND COALESCE(
(
SELECT value
FROM json_each(pls.content, '$.users')
WHERE key=edgeevt.sender AND type='integer'
),
pls.content->>'$.users_default',
0
) >= COALESCE(
pls.content->>'$.events."m.space.child"',
pls.content->>'$.state_default',
50
)
))
WHERE parent_event_rowid IS NOT NULL
`
revalidateAllParentsPointingAtSpaceQuery = revalidateAllParents + ` AND space_id=$1`
revalidateAllParentsOfRoomQuery = revalidateAllParents + ` AND child_id=$1`
revalidateSpecificParentQuery = revalidateAllParents + ` AND space_id=$1 AND child_id=$2`
clearSpaceChildrenQuery = `
UPDATE space_edge SET child_event_rowid=NULL, "order"='', suggested=false
WHERE space_id=$1
`
clearSpaceParentsQuery = `
UPDATE space_edge SET parent_event_rowid=NULL, canonical=false, parent_validated=false
WHERE child_id=$1
`
removeSpaceChildQuery = clearSpaceChildrenQuery + ` AND child_id=$2`
removeSpaceParentQuery = clearSpaceParentsQuery + ` AND space_id=$2`
deleteEmptySpaceEdgeRowsQuery = `
DELETE FROM space_edge WHERE child_event_rowid IS NULL AND parent_event_rowid IS NULL
`
addSpaceChildQuery = `
INSERT INTO space_edge (space_id, child_id, child_event_rowid, "order", suggested)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (space_id, child_id) DO UPDATE
SET child_event_rowid=EXCLUDED.child_event_rowid,
"order"=EXCLUDED."order",
suggested=EXCLUDED.suggested
`
addSpaceParentQuery = `
INSERT INTO space_edge (space_id, child_id, parent_event_rowid, canonical)
VALUES ($1, $2, $3, $4)
ON CONFLICT (space_id, child_id) DO UPDATE
SET parent_event_rowid=EXCLUDED.parent_event_rowid,
canonical=EXCLUDED.canonical,
parent_validated=false
`
)
var massInsertSpaceParentBuilder = dbutil.NewMassInsertBuilder[SpaceParentEntry, [1]any](addSpaceParentQuery, "($%d, $1, $%d, $%d)")
var massInsertSpaceChildBuilder = dbutil.NewMassInsertBuilder[SpaceChildEntry, [1]any](addSpaceChildQuery, "($1, $%d, $%d, $%d, $%d)")
type SpaceEdgeQuery struct {
*dbutil.QueryHelper[*SpaceEdge]
}
func (seq *SpaceEdgeQuery) AddChild(ctx context.Context, spaceID, childID id.RoomID, childEventRowID EventRowID, order string, suggested bool) error {
return seq.Exec(ctx, addSpaceChildQuery, spaceID, childID, childEventRowID, order, suggested)
}
func (seq *SpaceEdgeQuery) AddParent(ctx context.Context, spaceID, childID id.RoomID, parentEventRowID EventRowID, canonical bool) error {
return seq.Exec(ctx, addSpaceParentQuery, spaceID, childID, parentEventRowID, canonical)
}
type SpaceParentEntry struct {
ParentID id.RoomID
EventRowID EventRowID
Canonical bool
}
func (spe SpaceParentEntry) GetMassInsertValues() [3]any {
return [...]any{spe.ParentID, spe.EventRowID, spe.Canonical}
}
type SpaceChildEntry struct {
ChildID id.RoomID
EventRowID EventRowID
Order string
Suggested bool
}
func (sce SpaceChildEntry) GetMassInsertValues() [4]any {
return [...]any{sce.ChildID, sce.EventRowID, sce.Order, sce.Suggested}
}
func (seq *SpaceEdgeQuery) SetChildren(ctx context.Context, spaceID id.RoomID, children []SpaceChildEntry, removedChildren []id.RoomID, clear bool) error {
if clear {
err := seq.Exec(ctx, clearSpaceChildrenQuery, spaceID)
if err != nil {
return err
}
} else if len(removedChildren) > 0 {
for _, child := range removedChildren {
err := seq.Exec(ctx, removeSpaceChildQuery, spaceID, child)
if err != nil {
return err
}
}
}
if len(removedChildren) > 0 {
err := seq.Exec(ctx, deleteEmptySpaceEdgeRowsQuery, spaceID)
if err != nil {
return err
}
}
if len(children) == 0 {
return nil
}
query, params := massInsertSpaceChildBuilder.Build([1]any{spaceID}, children)
return seq.Exec(ctx, query, params...)
}
func (seq *SpaceEdgeQuery) SetParents(ctx context.Context, childID id.RoomID, parents []SpaceParentEntry, removedParents []id.RoomID, clear bool) error {
if clear {
err := seq.Exec(ctx, clearSpaceParentsQuery, childID)
if err != nil {
return err
}
} else if len(removedParents) > 0 {
for _, parent := range removedParents {
err := seq.Exec(ctx, removeSpaceParentQuery, childID, parent)
if err != nil {
return err
}
}
}
if len(removedParents) > 0 {
err := seq.Exec(ctx, deleteEmptySpaceEdgeRowsQuery)
if err != nil {
return err
}
}
if len(parents) == 0 {
return nil
}
query, params := massInsertSpaceParentBuilder.Build([1]any{childID}, parents)
return seq.Exec(ctx, query, params...)
}
func (seq *SpaceEdgeQuery) RevalidateAllChildrenOfParentValidity(ctx context.Context, spaceID id.RoomID) error {
return seq.Exec(ctx, revalidateAllParentsPointingAtSpaceQuery, spaceID)
}
func (seq *SpaceEdgeQuery) RevalidateAllParentsOfRoomValidity(ctx context.Context, childID id.RoomID) error {
return seq.Exec(ctx, revalidateAllParentsOfRoomQuery, childID)
}
func (seq *SpaceEdgeQuery) RevalidateSpecificParentValidity(ctx context.Context, spaceID, childID id.RoomID) error {
return seq.Exec(ctx, revalidateSpecificParentQuery, spaceID, childID)
}
func (seq *SpaceEdgeQuery) GetAll(ctx context.Context, spaceID id.RoomID) (map[id.RoomID][]*SpaceEdge, error) {
edges := make(map[id.RoomID][]*SpaceEdge)
err := seq.QueryManyIter(ctx, getAllSpaceChildren, spaceID).Iter(func(edge *SpaceEdge) (bool, error) {
edges[edge.SpaceID] = append(edges[edge.SpaceID], edge)
edge.SpaceID = ""
if !edge.ParentValidated {
edge.ParentEventRowID = 0
edge.Canonical = false
}
return true, nil
})
return edges, err
}
var roomIDScanner = dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID])
func (seq *SpaceEdgeQuery) GetTopLevelIDs(ctx context.Context, userID id.UserID) ([]id.RoomID, error) {
return roomIDScanner.NewRowIter(seq.GetDB().Query(ctx, getTopLevelSpaces, userID)).AsList()
}
type SpaceEdge struct {
SpaceID id.RoomID `json:"space_id,omitempty"`
ChildID id.RoomID `json:"child_id"`
ChildEventRowID EventRowID `json:"child_event_rowid,omitempty"`
Order string `json:"order,omitempty"`
Suggested bool `json:"suggested,omitempty"`
ParentEventRowID EventRowID `json:"parent_event_rowid,omitempty"`
Canonical bool `json:"canonical,omitempty"`
ParentValidated bool `json:"-"`
}
func (se *SpaceEdge) Scan(row dbutil.Scannable) (*SpaceEdge, error) {
var childRowID, parentRowID sql.NullInt64
err := row.Scan(
&se.SpaceID, &se.ChildID,
&childRowID, &se.Order, &se.Suggested,
&parentRowID, &se.Canonical, &se.ParentValidated,
)
if err != nil {
return nil, err
}
se.ChildEventRowID = EventRowID(childRowID.Int64)
se.ParentEventRowID = EventRowID(parentRowID.Int64)
return se, nil
}

View file

@ -1,4 +1,4 @@
-- v0 -> v9 (compatible with v5+): Latest revision
-- v0 -> v11 (compatible with v10+): Latest revision
CREATE TABLE account (
user_id TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
@ -10,6 +10,7 @@ CREATE TABLE account (
CREATE TABLE room (
room_id TEXT NOT NULL PRIMARY KEY,
room_type TEXT,
creation_content TEXT,
tombstone_content TEXT,
@ -17,6 +18,7 @@ CREATE TABLE room (
name_quality INTEGER NOT NULL DEFAULT 0,
avatar TEXT,
explicit_avatar INTEGER NOT NULL DEFAULT 0,
dm_user_id TEXT,
topic TEXT,
canonical_alias TEXT,
lazy_load_summary TEXT,
@ -35,7 +37,7 @@ CREATE TABLE room (
CONSTRAINT room_preview_event_fkey FOREIGN KEY (preview_event_rowid) REFERENCES event (rowid) ON DELETE SET NULL
) STRICT;
CREATE INDEX room_type_idx ON room (creation_content ->> 'type');
CREATE INDEX room_type_idx ON room (room_type);
CREATE INDEX room_sorting_timestamp_idx ON room (sorting_timestamp DESC);
CREATE INDEX room_preview_idx ON room (preview_event_rowid);
-- CREATE INDEX room_sorting_timestamp_idx ON room (unread_notifications > 0);
@ -278,3 +280,24 @@ CREATE TABLE receipt (
CONSTRAINT receipt_room_fkey FOREIGN KEY (room_id) REFERENCES room (room_id) ON DELETE CASCADE
-- note: there's no foreign key on event ID because receipts could point at events that are too far in history.
) STRICT;
CREATE TABLE space_edge (
space_id TEXT NOT NULL,
child_id TEXT NOT NULL,
-- m.space.child fields
child_event_rowid INTEGER,
"order" TEXT NOT NULL DEFAULT '',
suggested INTEGER NOT NULL DEFAULT false CHECK ( suggested IN (false, true) ),
-- m.space.parent fields
parent_event_rowid INTEGER,
canonical INTEGER NOT NULL DEFAULT false CHECK ( canonical IN (false, true) ),
parent_validated INTEGER NOT NULL DEFAULT false CHECK ( parent_validated IN (false, true) ),
PRIMARY KEY (space_id, child_id),
CONSTRAINT space_edge_child_event_fkey FOREIGN KEY (child_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
CONSTRAINT space_edge_parent_event_fkey FOREIGN KEY (parent_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
CONSTRAINT space_edge_child_event_unique UNIQUE (child_event_rowid),
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
) STRICT;
CREATE INDEX space_edge_child_idx ON space_edge (child_id);

View file

@ -0,0 +1,83 @@
-- v10 (compatible with v10+): Add support for spaces
ALTER TABLE room ADD COLUMN room_type TEXT;
UPDATE room SET room_type=COALESCE(creation_content->>'$.type', '');
DROP INDEX room_type_idx;
CREATE INDEX room_type_idx ON room (room_type);
CREATE TABLE space_edge (
space_id TEXT NOT NULL,
child_id TEXT NOT NULL,
-- m.space.child fields
child_event_rowid INTEGER,
"order" TEXT NOT NULL DEFAULT '',
suggested INTEGER NOT NULL DEFAULT false CHECK ( suggested IN (false, true) ),
-- m.space.parent fields
parent_event_rowid INTEGER,
canonical INTEGER NOT NULL DEFAULT false CHECK ( canonical IN (false, true) ),
parent_validated INTEGER NOT NULL DEFAULT false CHECK ( parent_validated IN (false, true) ),
PRIMARY KEY (space_id, child_id),
CONSTRAINT space_edge_child_event_fkey FOREIGN KEY (child_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
CONSTRAINT space_edge_parent_event_fkey FOREIGN KEY (parent_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
CONSTRAINT space_edge_child_event_unique UNIQUE (child_event_rowid),
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
) STRICT;
CREATE INDEX space_edge_child_idx ON space_edge (child_id);
INSERT INTO space_edge (space_id, child_id, child_event_rowid, "order", suggested)
SELECT
event.room_id,
event.state_key,
event.rowid,
CASE WHEN typeof(content->>'$.order')='TEXT' THEN content->>'$.order' ELSE '' END,
CASE WHEN json_type(content, '$.suggested') IN ('true', 'false') THEN content->>'$.suggested' ELSE false END
FROM current_state
INNER JOIN event ON current_state.event_rowid = event.rowid
LEFT JOIN room ON current_state.room_id = room.room_id
WHERE type = 'm.space.child'
AND json_array_length(event.content, '$.via') > 0
AND event.state_key LIKE '!%'
AND (room.room_id IS NULL OR room.room_type = 'm.space');
INSERT INTO space_edge (space_id, child_id, parent_event_rowid, canonical)
SELECT
event.state_key,
event.room_id,
event.rowid,
CASE WHEN json_type(content, '$.canonical') IN ('true', 'false') THEN content->>'$.canonical' ELSE false END
FROM current_state
INNER JOIN event ON current_state.event_rowid = event.rowid
LEFT JOIN room ON event.state_key = room.room_id
WHERE type = 'm.space.parent'
AND json_array_length(event.content, '$.via') > 0
AND event.state_key LIKE '!%'
AND (room.room_id IS NULL OR room.room_type = 'm.space')
ON CONFLICT (space_id, child_id) DO UPDATE
SET parent_event_rowid = excluded.parent_event_rowid,
canonical = excluded.canonical;
UPDATE space_edge
SET parent_validated=(SELECT EXISTS(
SELECT 1
FROM room
INNER JOIN current_state cs ON cs.room_id = room.room_id AND cs.event_type = 'm.room.power_levels' AND cs.state_key = ''
INNER JOIN event pls ON cs.event_rowid = pls.rowid
INNER JOIN event edgeevt ON space_edge.parent_event_rowid = edgeevt.rowid
WHERE room.room_id = space_edge.space_id
AND room.room_type = 'm.space'
AND COALESCE(
(
SELECT value
FROM json_each(pls.content, '$.users')
WHERE key=edgeevt.sender AND type='integer'
),
pls.content->>'$.users_default',
0
) >= COALESCE(
pls.content->>'$.events."m.space.child"',
pls.content->>'$.state_default',
50
)
))
WHERE parent_event_rowid IS NOT NULL;

View file

@ -0,0 +1,19 @@
-- v11 (compatible with v10+): Store direct chat user ID in database
ALTER TABLE room ADD COLUMN dm_user_id TEXT;
WITH dm_user_ids AS (
SELECT room_id, value
FROM room
INNER JOIN json_each(lazy_load_summary, '$."m.heroes"')
WHERE value NOT IN (SELECT value FROM json_each((
SELECT event.content
FROM current_state cs
INNER JOIN event ON cs.event_rowid = event.rowid
WHERE cs.room_id=room.room_id AND cs.event_type='io.element.functional_members' AND cs.state_key=''
), '$.service_members'))
GROUP BY room_id
HAVING COUNT(*) = 1
)
UPDATE room
SET dm_user_id=value
FROM dm_user_ids du
WHERE room.room_id=du.room_id;

View file

@ -31,12 +31,14 @@ type SyncNotification struct {
}
type SyncComplete struct {
Since *string `json:"since,omitempty"`
ClearState bool `json:"clear_state,omitempty"`
AccountData map[event.Type]*database.AccountData `json:"account_data"`
Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
LeftRooms []id.RoomID `json:"left_rooms"`
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
Since *string `json:"since,omitempty"`
ClearState bool `json:"clear_state,omitempty"`
AccountData map[event.Type]*database.AccountData `json:"account_data"`
Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
LeftRooms []id.RoomID `json:"left_rooms"`
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
}
func (c *SyncComplete) IsEmpty() bool {

View file

@ -14,12 +14,9 @@ import (
func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) *SyncRoom {
syncRoom := &SyncRoom{
Meta: room,
Events: make([]*database.Event, 0, 2),
Timeline: make([]database.TimelineRowTuple, 0),
State: map[event.Type]map[string]database.EventRowID{},
Notifications: make([]SyncNotification, 0),
Receipts: make(map[id.EventID][]*database.Receipt),
Meta: room,
Events: make([]*database.Event, 0, 2),
State: map[event.Type]map[string]database.EventRowID{},
}
ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID)
if err != nil {
@ -27,7 +24,6 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
if ctx.Err() != nil {
return nil
}
syncRoom.AccountData = make(map[event.Type]*database.AccountData)
} else {
syncRoom.AccountData = make(map[event.Type]*database.AccountData, len(ad))
for _, data := range ad {
@ -70,6 +66,49 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*SyncComplete] {
return func(yield func(*SyncComplete) bool) {
maxTS := time.Now().Add(1 * time.Hour)
{
spaces, err := h.DB.Room.GetAllSpaces(ctx)
if err != nil {
if ctx.Err() == nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get initial spaces to send to client")
}
return
}
payload := SyncComplete{
Rooms: make(map[id.RoomID]*SyncRoom, len(spaces)),
}
for _, room := range spaces {
payload.Rooms[room.ID] = h.getInitialSyncRoom(ctx, room)
if ctx.Err() != nil {
return
}
}
payload.TopLevelSpaces, err = h.DB.SpaceEdge.GetTopLevelIDs(ctx, h.Account.UserID)
if err != nil {
if ctx.Err() == nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get top-level space IDs to send to client")
}
return
}
payload.SpaceEdges, err = h.DB.SpaceEdge.GetAll(ctx, "")
if err != nil {
if ctx.Err() == nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get space edges to send to client")
}
return
}
payload.InvitedRooms, err = h.DB.InvitedRoom.GetAll(ctx)
if err != nil {
if ctx.Err() == nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get invited rooms to send to client")
}
return
}
payload.ClearState = true
if !yield(&payload) {
return
}
}
for i := 0; ; i++ {
rooms, err := h.DB.Room.GetBySortTS(ctx, maxTS, batchSize)
if err != nil {
@ -79,22 +118,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
return
}
payload := SyncComplete{
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)-1),
LeftRooms: make([]id.RoomID, 0),
AccountData: make(map[event.Type]*database.AccountData),
}
if i == 0 {
payload.InvitedRooms, err = h.DB.InvitedRoom.GetAll(ctx)
if err != nil {
if ctx.Err() == nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get invited rooms to send to client")
}
return
}
payload.ClearState = true
}
if payload.InvitedRooms == nil {
payload.InvitedRooms = make([]*database.InvitedRoom, 0)
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)),
}
for _, room := range rooms {
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
@ -106,7 +130,9 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
return
}
}
if !yield(&payload) || len(rooms) < batchSize {
if !yield(&payload) {
return
} else if len(rooms) < batchSize {
break
}
}
@ -117,10 +143,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
return
}
payload := SyncComplete{
Rooms: make(map[id.RoomID]*SyncRoom),
InvitedRooms: make([]*database.InvitedRoom, 0),
LeftRooms: make([]id.RoomID, 0),
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
}
for _, data := range ad {
payload.AccountData[event.Type{Type: data.Type, Class: event.AccountDataEventType}] = data

View file

@ -91,6 +91,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) {
return h.Client.GetProfile(ctx, params.UserID)
})
case "set_profile_field":
return unmarshalAndCall(req.Data, func(params *setProfileFieldParams) (bool, error) {
return true, h.Client.UnstableSetProfileField(ctx, params.Field, params.Value)
})
case "get_presence":
return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespPresence, error) {
return h.Client.GetPresence(ctx, params.UserID)
@ -166,6 +170,8 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
return h.Client.ResolveAlias(ctx, params.Alias)
})
case "request_openid_token":
return h.Client.RequestOpenIDToken(ctx)
case "logout":
if h.LogoutFunc == nil {
return nil, errors.New("logout not supported")
@ -286,6 +292,11 @@ type getProfileParams struct {
UserID id.UserID `json:"user_id"`
}
type setProfileFieldParams struct {
Field string `json:"field"`
Value any `json:"value"`
}
type getEventParams struct {
RoomID id.RoomID `json:"room_id"`
EventID id.EventID `json:"event_id"`

View file

@ -121,13 +121,14 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
if err != nil {
return fmt.Errorf("failed to save events: %w", err)
}
sdc := &spaceDataCollector{}
for i := range currentStateEntries {
currentStateEntries[i].EventRowID = dbEvts[i].RowID
if mediaReferenceEntries[i] != nil {
mediaReferenceEntries[i].EventRowID = dbEvts[i].RowID
}
if evts[i].Type != event.StateMember {
processImportantEvent(ctx, evts[i], room, updatedRoom)
processImportantEvent(ctx, evts[i], room, updatedRoom, dbEvts[i].RowID, sdc)
}
}
err = h.DB.Media.AddMany(ctx, mediaCacheEntries)
@ -146,6 +147,11 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
return fmt.Errorf("failed to save current state entries: %w", err)
}
roomChanged := updatedRoom.CheckChangesAndCopyInto(room)
// TODO dispatch space edge changes if something changed? (fairly unlikely though)
err = sdc.Apply(ctx, room, h.DB.SpaceEdge)
if err != nil {
return err
}
if roomChanged {
err = h.DB.Room.Upsert(ctx, updatedRoom)
if err != nil {
@ -155,19 +161,9 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
h.EventHandler(&SyncComplete{
Rooms: map[id.RoomID]*SyncRoom{
roomID: {
Meta: room,
Timeline: make([]database.TimelineRowTuple, 0),
State: make(map[event.Type]map[string]database.EventRowID),
AccountData: make(map[event.Type]*database.AccountData),
Events: make([]*database.Event, 0),
Reset: false,
Notifications: make([]SyncNotification, 0),
Receipts: make(map[id.EventID][]*database.Receipt),
Meta: room,
},
},
InvitedRooms: make([]*database.InvitedRoom, 0),
AccountData: make(map[event.Type]*database.AccountData),
LeftRooms: make([]id.RoomID, 0),
})
}
}

View file

@ -91,6 +91,17 @@ func (h *HiClient) SendMessage(
return nil, fmt.Errorf("invalid JSON in /raw command")
}
return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "", unencrypted)
} else if strings.HasPrefix(text, "/rawstate ") {
parts := strings.SplitN(text, " ", 4)
if len(parts) < 4 || len(parts[1]) == 0 {
return nil, fmt.Errorf("invalid /rawstate command")
}
content := json.RawMessage(parts[3])
if !json.Valid(content) {
return nil, fmt.Errorf("invalid JSON in /rawstate command")
}
_, err := h.SetState(ctx, roomID, event.Type{Type: parts[1], Class: event.StateEventType}, parts[2], content)
return nil, err
}
var content event.MessageEventContent
msgType := event.MsgText

View file

@ -88,6 +88,7 @@ func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.Res
}
}
resp.ToDevice.Events = postponedToDevices
h.Crypto.MarkOlmHashSavePoint(ctx)
return nil
}
@ -409,12 +410,7 @@ func (h *HiClient) addMediaCache(
}
func (h *HiClient) cacheMedia(ctx context.Context, evt *event.Event, rowID database.EventRowID) {
switch evt.Type {
case event.EventMessage, event.EventSticker:
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return
}
cacheMessageEventContent := func(content *event.MessageEventContent) {
if content.File != nil {
h.addMediaCache(ctx, rowID, content.File.URL, content.File, content.Info, content.GetFileName())
} else if content.URL != "" {
@ -425,6 +421,35 @@ func (h *HiClient) cacheMedia(ctx context.Context, evt *event.Event, rowID datab
} else if content.GetInfo().ThumbnailURL != "" {
h.addMediaCache(ctx, rowID, content.Info.ThumbnailURL, nil, content.Info.ThumbnailInfo, "")
}
for _, image := range content.BeeperGalleryImages {
h.cacheMedia(ctx, &event.Event{
Type: event.EventMessage,
Content: event.Content{Parsed: image},
}, rowID)
}
for _, preview := range content.BeeperLinkPreviews {
info := &event.FileInfo{MimeType: preview.ImageType}
if preview.ImageEncryption != nil {
h.addMediaCache(ctx, rowID, preview.ImageEncryption.URL, preview.ImageEncryption, info, "")
} else if preview.ImageURL != "" {
h.addMediaCache(ctx, rowID, preview.ImageURL, nil, info, "")
}
}
}
switch evt.Type {
case event.EventMessage, event.EventSticker:
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return
}
cacheMessageEventContent(content)
if content.NewContent != nil {
cacheMessageEventContent(content.NewContent)
}
case event.StateRoomAvatar:
_ = evt.Content.ParseRaw(evt.Type)
content, ok := evt.Content.Parsed.(*event.RoomAvatarEventContent)
@ -656,6 +681,7 @@ func (h *HiClient) processStateAndTimeline(
updatedRoom.LazyLoadSummary = summary
heroesChanged = true
}
sdc := &spaceDataCollector{}
decryptionQueue := make(map[id.SessionID]*database.SessionRequest)
allNewEvents := make([]*database.Event, 0, len(state.Events)+len(timeline.Events))
addedEvents := make(map[database.EventRowID]struct{})
@ -739,7 +765,7 @@ func (h *HiClient) processStateAndTimeline(
if err != nil {
return -1, fmt.Errorf("failed to save current state event ID %s for %s/%s: %w", evt.ID, evt.Type.Type, *evt.StateKey, err)
}
processImportantEvent(ctx, evt, room, updatedRoom)
processImportantEvent(ctx, evt, room, updatedRoom, dbEvt.RowID, sdc)
}
allNewEvents = append(allNewEvents, dbEvt)
addedEvents[dbEvt.RowID] = struct{}{}
@ -882,10 +908,11 @@ func (h *HiClient) processStateAndTimeline(
}
// Calculate name from participants if participants changed and current name was generated from participants, or if the room name was unset
if (heroesChanged && updatedRoom.NameQuality <= database.NameQualityParticipants) || updatedRoom.NameQuality == database.NameQualityNil {
name, dmAvatarURL, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
name, dmAvatarURL, dmUserID, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
if err != nil {
return fmt.Errorf("failed to calculate room name: %w", err)
}
updatedRoom.DMUserID = &dmUserID
updatedRoom.Name = &name
updatedRoom.NameQuality = database.NameQualityParticipants
if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar {
@ -921,6 +948,10 @@ func (h *HiClient) processStateAndTimeline(
return fmt.Errorf("failed to save room data: %w", err)
}
}
err = sdc.Apply(ctx, room, h.DB.SpaceEdge)
if err != nil {
return err
}
// TODO why is *old* unread count sometimes zero when processing the read receipt that is making it zero?
if roomChanged || len(accountData) > 0 || len(newOwnReceipts) > 0 || len(receipts) > 0 || len(timelineRowTuples) > 0 || len(allNewEvents) > 0 {
for _, receipt := range receipts {
@ -950,15 +981,15 @@ func joinMemberNames(names []string, totalCount int) string {
}
}
func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, error) {
func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, id.UserID, error) {
var primaryAvatarURL id.ContentURI
if summary == nil || len(summary.Heroes) == 0 {
return "Empty room", primaryAvatarURL, nil
return "Empty room", primaryAvatarURL, "", nil
}
var functionalMembers []id.UserID
functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "")
if err != nil {
return "", primaryAvatarURL, fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err)
return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err)
} else if functionalMembersEvt != nil {
mautrixEvt := functionalMembersEvt.AsRawMautrix()
_ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)
@ -974,16 +1005,21 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
} else if summary.InvitedMemberCount != nil {
memberCount = *summary.InvitedMemberCount
}
var dmUserID id.UserID
for _, hero := range summary.Heroes {
if slices.Contains(functionalMembers, hero) {
// TODO save member count so push rule evaluation would use the subtracted one?
memberCount--
continue
} else if len(members) >= 5 {
break
}
if dmUserID == "" {
dmUserID = hero
}
heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String())
if err != nil {
return "", primaryAvatarURL, fmt.Errorf("failed to get %s's member event: %w", hero, err)
return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s's member event: %w", hero, err)
} else if heroEvt == nil {
leftMembers = append(leftMembers, hero.String())
continue
@ -999,19 +1035,28 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
}
if membership == "join" || membership == "invite" {
members = append(members, name)
dmUserID = hero
} else {
leftMembers = append(leftMembers, name)
}
}
if len(members)+len(leftMembers) > 1 || !primaryAvatarURL.IsValid() {
if !primaryAvatarURL.IsValid() {
primaryAvatarURL = id.ContentURI{}
}
if len(members) > 0 {
return joinMemberNames(members, memberCount), primaryAvatarURL, nil
if len(members) > 1 {
primaryAvatarURL = id.ContentURI{}
dmUserID = ""
}
return joinMemberNames(members, memberCount), primaryAvatarURL, dmUserID, nil
} else if len(leftMembers) > 0 {
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, nil
if len(leftMembers) > 1 {
primaryAvatarURL = id.ContentURI{}
dmUserID = ""
}
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, "", nil
} else {
return "Empty room", primaryAvatarURL, nil
return "Empty room", primaryAvatarURL, "", nil
}
}
@ -1022,20 +1067,112 @@ func intPtrEqual(a, b *int) bool {
return *a == *b
}
func processImportantEvent(ctx context.Context, evt *event.Event, existingRoomData, updatedRoom *database.Room) (roomDataChanged bool) {
type spaceDataCollector struct {
Children []database.SpaceChildEntry
Parents []database.SpaceParentEntry
RemovedChildren []id.RoomID
RemovedParents []id.RoomID
PowerLevelChanged bool
IsFullState bool
}
func (sdc *spaceDataCollector) Collect(evt *event.Event, rowID database.EventRowID) {
switch evt.Type {
case event.StatePowerLevels:
sdc.PowerLevelChanged = true
case event.StateCreate:
sdc.IsFullState = true
case event.StateSpaceChild:
content := evt.Content.AsSpaceChild()
if len(content.Via) == 0 {
sdc.RemovedChildren = append(sdc.RemovedChildren, id.RoomID(*evt.StateKey))
} else {
sdc.Children = append(sdc.Children, database.SpaceChildEntry{
ChildID: id.RoomID(*evt.StateKey),
EventRowID: rowID,
Order: content.Order,
Suggested: content.Suggested,
})
}
case event.StateSpaceParent:
content := evt.Content.AsSpaceParent()
if len(content.Via) == 0 {
sdc.RemovedParents = append(sdc.RemovedParents, id.RoomID(*evt.StateKey))
} else {
sdc.Parents = append(sdc.Parents, database.SpaceParentEntry{
ParentID: id.RoomID(*evt.StateKey),
EventRowID: rowID,
Canonical: content.Canonical,
})
}
}
}
func (sdc *spaceDataCollector) Apply(ctx context.Context, room *database.Room, seq *database.SpaceEdgeQuery) error {
if room.CreationContent == nil || room.CreationContent.Type != event.RoomTypeSpace {
sdc.Children = nil
sdc.RemovedChildren = nil
sdc.PowerLevelChanged = false
}
if len(sdc.Children) == 0 && len(sdc.RemovedChildren) == 0 &&
len(sdc.Parents) == 0 && len(sdc.RemovedParents) == 0 &&
!sdc.PowerLevelChanged {
return nil
}
return seq.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
if len(sdc.Children) > 0 || len(sdc.RemovedChildren) > 0 {
err := seq.SetChildren(ctx, room.ID, sdc.Children, sdc.RemovedChildren, sdc.IsFullState)
if err != nil {
return fmt.Errorf("failed to set space children: %w", err)
}
}
if len(sdc.Parents) > 0 || len(sdc.RemovedParents) > 0 {
err := seq.SetParents(ctx, room.ID, sdc.Parents, sdc.RemovedParents, sdc.IsFullState)
if err != nil {
return fmt.Errorf("failed to set space parents: %w", err)
}
if len(sdc.Parents) > 0 {
err = seq.RevalidateAllParentsOfRoomValidity(ctx, room.ID)
if err != nil {
return fmt.Errorf("failed to revalidate own parent references: %w", err)
}
}
}
if sdc.PowerLevelChanged {
err := seq.RevalidateAllChildrenOfParentValidity(ctx, room.ID)
if err != nil {
return fmt.Errorf("failed to revalidate child parent references to self: %w", err)
}
}
return nil
})
}
func processImportantEvent(
ctx context.Context,
evt *event.Event,
existingRoomData, updatedRoom *database.Room,
rowID database.EventRowID,
sdc *spaceDataCollector,
) (roomDataChanged bool) {
if evt.StateKey == nil {
return
}
switch evt.Type {
case event.StateCreate, event.StateTombstone, event.StateRoomName, event.StateCanonicalAlias,
event.StateRoomAvatar, event.StateTopic, event.StateEncryption:
event.StateRoomAvatar, event.StateTopic, event.StateEncryption, event.StatePowerLevels:
if *evt.StateKey != "" {
return
}
case event.StateSpaceChild, event.StateSpaceParent:
if !strings.HasPrefix(*evt.StateKey, "!") {
return
}
default:
return
}
err := evt.Content.ParseRaw(evt.Type)
sdc.Collect(evt, rowID)
if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) {
zerolog.Ctx(ctx).Warn().Err(err).
Stringer("event_type", &evt.Type).

View file

@ -13,6 +13,7 @@ import (
"time"
"github.com/mattn/go-sqlite3"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
@ -46,7 +47,9 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync,
err = c.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
return c.processSyncResponse(ctx, resp, since)
})
if errors.Is(err, sqlite3.ErrLocked) && i < 24 {
var sqliteErr sqlite3.Error
if errors.As(err, &sqliteErr) && sqliteErr.Code == sqlite3.ErrBusy && i < 24 {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Database is busy, retrying")
c.markSyncErrored(err, false)
continue
} else if err != nil {

View file

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

View file

@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link 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"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, interactive-widget=resizes-content"/>
<title>gomuks web</title>
<!-- etag placeholder -->
</head>
@ -12,5 +12,16 @@
<div id="root"></div>
<script type="module" src="src/main.tsx"></script>
<audio id="default-notification-sound" preload="auto" src="sounds/bright.flac"></audio>
<svg style="position: absolute; width: 0; height: 0;" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="squircle" clipPathUnits="objectBoundingBox">
<path d="M 0,0.5
C 0,0 0,0 0.5,0
1,0 1,0 1,0.5
1,1 1,1 0.5,1
0,1 0,1 0,0.5"></path>
</clipPath>
</defs>
</svg>
</body>
</html>

805
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useEffect, useLayoutEffect, useMemo } from "react"
import { useEffect, useMemo } from "react"
import { ScaleLoader } from "react-spinners"
import Client from "./api/client.ts"
import RPCClient from "./api/rpc.ts"
@ -22,7 +22,7 @@ import WSClient from "./api/wsclient.ts"
import ClientContext from "./ui/ClientContext.ts"
import MainScreen from "./ui/MainScreen.tsx"
import { LoginScreen, VerificationScreen } from "./ui/login"
import { LightboxWrapper } from "./ui/modal/Lightbox.tsx"
import { LightboxWrapper } from "./ui/modal"
import { useEventAsState } from "./util/eventdispatcher.ts"
function makeRPCClient(): RPCClient {
@ -36,10 +36,10 @@ function App() {
const client = useMemo(() => new Client(makeRPCClient()), [])
const connState = useEventAsState(client.rpc.connect)
const clientState = useEventAsState(client.state)
useLayoutEffect(() => {
useEffect(() => {
window.client = client
return client.start()
}, [client])
useEffect(() => client.start(), [client])
const afterConnectError = Boolean(connState?.error && connState.reconnecting && clientState?.is_verified)
useEffect(() => {
@ -70,18 +70,18 @@ function App() {
</div> : null
if (connState?.error && !afterConnectError) {
return errorOverlay
return <div className="pre-main">{errorOverlay}</div>
} else if ((!connState?.connected && !afterConnectError) || !clientState) {
const msg = connState?.connected ?
"Waiting for client state..." : "Connecting to backend..."
return <div className="pre-connect">
return <div className="pre-main waiting-to-connect">
<ScaleLoader width="2rem" height="2rem" color="var(--primary-color)"/>
{msg}
</div>
} else if (!clientState.is_logged_in) {
return <LoginScreen client={client} clientState={clientState}/>
return <div className="pre-main"><LoginScreen client={client} clientState={clientState}/></div>
} else if (!clientState.is_verified) {
return <VerificationScreen client={client} clientState={clientState}/>
return <div className="pre-main"><VerificationScreen client={client} clientState={clientState}/></div>
} else {
return <ClientContext value={client}>
<LightboxWrapper>

View file

@ -211,7 +211,9 @@ export default class Client {
throw new Error("Room not found")
}
const dbEvent = await this.rpc.sendMessage(params)
this.#handleOutgoingEvent(dbEvent, room)
if (dbEvent) {
this.#handleOutgoingEvent(dbEvent, room)
}
}
async subscribeToEmojiPack(pack: RoomStateGUID, subscribe: boolean = true) {

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { parseMXC } from "@/util/validation.ts"
import { ContentURI, LazyLoadSummary, RoomID, UserID, UserProfile } from "./types"
import { ContentURI, RoomID, UserID, UserProfile } from "./types"
export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => {
const [server, mediaID] = parseMXC(mxc)
@ -54,7 +54,7 @@ export const getUserColor = (userID: UserID) => {
// note: this should stay in sync with fallbackAvatarTemplate in cmd/gomuks.media.go
function makeFallbackAvatar(backgroundColor: string, fallbackCharacter: string): string {
return "data:image/svg+xml," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<circle cx="500" cy="500" r="500" fill="${backgroundColor}"/>
<rect x="0" y="0" width="1000" height="1000" fill="${backgroundColor}"/>
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
>${escapeHTMLChar(fallbackCharacter)}</text>
@ -81,32 +81,25 @@ function getFallbackCharacter(from: unknown, idx: number): string {
export const getAvatarURL = (userID: UserID, content?: UserProfile | null): string | undefined => {
const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1)
const backgroundColor = getUserColor(userID)
const [server, mediaID] = parseMXC(content?.avatar_url)
const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url)
if (!mediaID) {
return makeFallbackAvatar(backgroundColor, fallbackCharacter)
}
const encrypted = !!content?.avatar_file
const fallback = `${backgroundColor}:${fallbackCharacter}`
return `_gomuks/media/${server}/${mediaID}?encrypted=false&fallback=${encodeURIComponent(fallback)}`
return `_gomuks/media/${server}/${mediaID}?encrypted=${encrypted}&fallback=${encodeURIComponent(fallback)}`
}
interface RoomForAvatarURL {
room_id: RoomID
name?: string
dm_user_id?: UserID
lazy_load_summary?: LazyLoadSummary
avatar?: ContentURI
avatar_url?: ContentURI
}
export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: ContentURI): string | undefined => {
let dmUserID: UserID | undefined
if ("dm_user_id" in room) {
dmUserID = room.dm_user_id
} else if ("lazy_load_summary" in room) {
dmUserID = room.lazy_load_summary?.heroes?.length === 1
? room.lazy_load_summary.heroes[0] : undefined
}
return getAvatarURL(dmUserID ?? room.room_id, {
return getAvatarURL(room.dm_user_id ?? room.room_id, {
displayname: room.name,
avatar_url: avatarOverride ?? room.avatar ?? room.avatar_url,
})

View file

@ -20,6 +20,7 @@ import type {
EventID,
EventRowID,
EventType,
JSONValue,
LoginFlowsResponse,
LoginRequest,
Mentions,
@ -33,6 +34,7 @@ import type {
ReceiptType,
RelatesTo,
ResolveAliasResponse,
RespOpenIDToken,
RespRoomJoin,
RoomAlias,
RoomID,
@ -139,7 +141,7 @@ export default abstract class RPCClient {
return this.request("logout", {})
}
sendMessage(params: SendMessageParams): Promise<RawDBEvent> {
sendMessage(params: SendMessageParams): Promise<RawDBEvent | null> {
return this.request("send_message", params)
}
@ -181,6 +183,10 @@ export default abstract class RPCClient {
return this.request("get_profile", { user_id })
}
setProfileField(field: string, value: JSONValue): Promise<boolean> {
return this.request("set_profile_field", { field, value })
}
getPresence(user_id: UserID): Promise<Presence> {
return this.request("get_presence", { user_id })
}
@ -266,4 +272,8 @@ export default abstract class RPCClient {
verify(recovery_key: string): Promise<boolean> {
return this.request("verify", { recovery_key })
}
requestOpenIDToken(): Promise<RespOpenIDToken> {
return this.request("request_openid_token", {})
}
}

View file

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

View file

@ -39,6 +39,7 @@ import {
} from "../types"
import { InvitedRoomStore } from "./invitedroom.ts"
import { RoomStateStore } from "./room.ts"
import { DirectChatSpace, RoomListFilter, Space, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts"
export interface RoomListEntry {
room_id: RoomID
@ -72,11 +73,23 @@ export class StateStore {
readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
currentRoomListFilter: string = ""
readonly roomListEntries = new Map<RoomID, RoomListEntry>()
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
readonly spaceOrphans = new SpaceOrphansSpace(this)
readonly directChatsSpace = new DirectChatSpace()
readonly unreadsSpace = new UnreadsSpace(this)
readonly pseudoSpaces = [
this.spaceOrphans,
this.directChatsSpace,
this.unreadsSpace,
] as const
currentRoomListQuery: string = ""
currentRoomListFilter: RoomListFilter | null = null
readonly accountData: Map<string, UnknownEventContent> = new Map()
readonly accountDataSubs = new MultiSubscribable()
readonly emojiRoomsSub = new Subscribable()
readonly preferences: Preferences = getPreferenceProxy(this)
readonly preferences = getPreferenceProxy(this)
#frequentlyUsedEmoji: Map<string, number> | null = null
#emojiPackKeys: RoomStateGUID[] | null = null
#watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | null = null
@ -89,11 +102,58 @@ export class StateStore {
activeRoomIsPreview: boolean = false
imageAuthToken?: string
getFilteredRoomList(): RoomListEntry[] {
if (!this.currentRoomListFilter) {
return this.roomList.current
#roomListFilterFunc = (entry: RoomListEntry) => {
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
return false
} else if (this.currentRoomListFilter && !this.currentRoomListFilter.include(entry)) {
return false
}
return this.roomList.current.filter(entry => entry.search_name.includes(this.currentRoomListFilter))
return true
}
getSpaceByID(spaceID: string | undefined): RoomListFilter | null {
if (!spaceID) {
return null
}
const realSpace = this.spaceEdges.get(spaceID)
if (realSpace) {
return realSpace
}
for (const pseudoSpace of this.pseudoSpaces) {
if (pseudoSpace.id === spaceID) {
return pseudoSpace
}
}
console.warn("Failed to find space", spaceID)
return null
}
findMatchingSpace(room: RoomListEntry): Space | null {
if (this.spaceOrphans.include(room)) {
return this.spaceOrphans
}
for (const spaceID of this.topLevelSpaces.current) {
const space = this.spaceEdges.get(spaceID)
if (space?.include(room)) {
return space
}
}
if (this.directChatsSpace.include(room)) {
return this.directChatsSpace
}
return null
}
get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
return null
}
return this.#roomListFilterFunc
}
getFilteredRoomList(): RoomListEntry[] {
const fn = this.roomListFilterFunc
return fn ? this.roomList.current.filter(fn) : this.roomList.current
}
#shouldHideRoom(entry: SyncRoom): boolean {
@ -122,7 +182,7 @@ export class StateStore {
entry.meta.unread_highlights !== oldEntry.meta.current.unread_highlights ||
entry.meta.marked_unread !== oldEntry.meta.current.marked_unread ||
entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid ||
entry.events.findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
(entry.events ?? []).findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
}
#makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null {
@ -143,8 +203,7 @@ export class StateStore {
const name = entry.meta.name ?? "Unnamed room"
return {
room_id: entry.meta.room_id,
dm_user_id: entry.meta.lazy_load_summary?.heroes?.length === 1
? entry.meta.lazy_load_summary.heroes[0] : undefined,
dm_user_id: entry.meta.dm_user_id,
sorting_timestamp: entry.meta.sorting_timestamp,
preview_event,
preview_sender,
@ -158,6 +217,25 @@ export class StateStore {
}
}
#applyUnreadModification(meta: RoomListEntry | null, oldMeta: RoomListEntry | undefined | null) {
const someMeta = meta ?? oldMeta
if (!someMeta) {
return
}
if (this.spaceOrphans.include(someMeta)) {
this.spaceOrphans.applyUnreads(meta, oldMeta)
return
}
if (this.directChatsSpace.include(someMeta)) {
this.directChatsSpace.applyUnreads(meta, oldMeta)
}
for (const space of this.spaceEdges.values()) {
if (space.include(someMeta)) {
space.applyUnreads(meta, oldMeta)
}
}
}
applySync(sync: SyncCompleteData) {
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")
@ -165,18 +243,20 @@ export class StateStore {
}
const resyncRoomList = this.roomList.current.length === 0
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
for (const data of sync.invited_rooms) {
for (const data of sync.invited_rooms ?? []) {
const room = new InvitedRoomStore(data, this)
this.inviteRooms.set(room.room_id, room)
if (!resyncRoomList) {
changedRoomListEntries.set(room.room_id, room)
this.#applyUnreadModification(room, this.roomListEntries.get(room.room_id))
this.roomListEntries.set(room.room_id, room)
}
if (this.activeRoomID === room.room_id) {
this.switchRoom?.(room.room_id)
}
}
const hasInvites = this.inviteRooms.size > 0
for (const [roomID, data] of Object.entries(sync.rooms)) {
for (const [roomID, data] of Object.entries(sync.rooms ?? {})) {
let isNewRoom = false
let room = this.rooms.get(roomID)
if (!room) {
@ -190,7 +270,14 @@ export class StateStore {
const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room))
room.applySync(data)
if (roomListEntryChanged) {
changedRoomListEntries.set(roomID, this.#makeRoomListEntry(data, room))
const entry = this.#makeRoomListEntry(data, room)
changedRoomListEntries.set(roomID, entry)
this.#applyUnreadModification(entry, this.roomListEntries.get(roomID))
if (entry) {
this.roomListEntries.set(roomID, entry)
} else {
this.roomListEntries.delete(roomID)
}
}
if (!resyncRoomList) {
// When we join a valid replacement room, hide the tombstoned room.
@ -203,7 +290,7 @@ export class StateStore {
}
}
if (window.Notification?.permission === "granted" && !focused.current) {
if (window.Notification?.permission === "granted" && !focused.current && data.notifications) {
for (const notification of data.notifications) {
this.showNotification(room, notification.event_rowid, notification.sound)
}
@ -212,7 +299,7 @@ export class StateStore {
this.switchRoom?.(roomID)
}
}
for (const ad of Object.values(sync.account_data)) {
for (const ad of Object.values(sync.account_data ?? {})) {
if (ad.type === "io.element.recent_emoji") {
this.#frequentlyUsedEmoji = null
} else if (ad.type === "fi.mau.gomuks.preferences") {
@ -222,21 +309,26 @@ export class StateStore {
this.accountData.set(ad.type, ad.content)
this.accountDataSubs.notify(ad.type)
}
for (const roomID of sync.left_rooms) {
for (const roomID of sync.left_rooms ?? []) {
if (this.activeRoomID === roomID) {
this.switchRoom?.(null)
}
this.rooms.delete(roomID)
changedRoomListEntries.set(roomID, null)
this.#applyUnreadModification(null, this.roomListEntries.get(roomID))
}
let updatedRoomList: RoomListEntry[] | undefined
if (resyncRoomList) {
updatedRoomList = this.inviteRooms.values().toArray()
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms)
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms ?? {})
.map(entry => this.#makeRoomListEntry(entry))
.filter(entry => entry !== null))
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
for (const entry of updatedRoomList) {
this.#applyUnreadModification(entry, undefined)
this.roomListEntries.set(entry.room_id, entry)
}
} else if (changedRoomListEntries.size > 0) {
updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id))
for (const entry of changedRoomListEntries.values()) {
@ -259,6 +351,19 @@ export class StateStore {
if (updatedRoomList) {
this.roomList.emit(updatedRoomList)
}
if (sync.space_edges) {
// Ensure all space stores exist first
for (const spaceID of Object.keys(sync.space_edges)) {
this.getSpaceStore(spaceID, true)
}
for (const [spaceID, children] of Object.entries(sync.space_edges ?? {})) {
this.getSpaceStore(spaceID, true).children = children
}
}
if (sync.top_level_spaces) {
this.topLevelSpaces.emit(sync.top_level_spaces)
this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id }))
}
}
invalidateEmojiPackKeyCache() {
@ -324,6 +429,20 @@ export class StateStore {
return this.#watchedRoomEmojiPacks ?? {}
}
getSpaceStore(spaceID: RoomID, force: true): SpaceEdgeStore
getSpaceStore(spaceID: RoomID): SpaceEdgeStore | null
getSpaceStore(spaceID: RoomID, force?: true): SpaceEdgeStore | null {
let store = this.spaceEdges.get(spaceID)
if (!store) {
if (!force && this.rooms.get(spaceID)?.meta.current.creation_content?.type !== "m.space") {
return null
}
store = new SpaceEdgeStore(spaceID, this)
this.spaceEdges.set(spaceID, store)
}
return store
}
get frequentlyUsedEmoji(): Map<string, number> {
if (this.#frequentlyUsedEmoji === null) {
const emojiData = this.accountData.get("io.element.recent_emoji")
@ -433,9 +552,13 @@ export class StateStore {
clear() {
this.rooms.clear()
this.inviteRooms.clear()
this.spaceEdges.clear()
this.pseudoSpaces.forEach(space => space.clearUnreads())
this.roomList.emit([])
this.topLevelSpaces.emit([])
this.accountData.clear()
this.currentRoomListFilter = ""
this.currentRoomListQuery = ""
this.currentRoomListFilter = null
this.#frequentlyUsedEmoji = null
this.#emojiPackKeys = null
this.#watchedRoomEmojiPacks = null

View file

@ -62,7 +62,7 @@ function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean {
return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] &&
ll1?.["m.invited_member_count"] === ll2?.["m.invited_member_count"] &&
arraysAreEqual(ll1?.heroes, ll2?.heroes)
arraysAreEqual(ll1?.["m.heroes"], ll2?.["m.heroes"])
}
function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
@ -70,6 +70,7 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
meta1.avatar === meta2.avatar &&
meta1.topic === meta2.topic &&
meta1.canonical_alias === meta2.canonical_alias &&
meta1.dm_user_id === meta2.dm_user_id &&
llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) &&
meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm &&
meta1.has_member_list === meta2.has_member_list
@ -92,6 +93,7 @@ export class RoomStateStore {
readonly meta: NonNullCachedEventDispatcher<DBRoom>
timeline: TimelineRowTuple[] = []
timelineCache: (MemDBEvent | null)[] = []
editTargets: EventRowID[] = []
state: Map<EventType, Map<string, EventRowID>> = new Map()
stateLoaded = false
typing: UserID[] = []
@ -111,7 +113,7 @@ export class RoomStateStore {
readonly accountDataSubs = new MultiSubscribable()
readonly openNotifications: Map<EventRowID, Notification> = new Map()
readonly #emojiPacksCache: Map<string, CustomEmojiPack | null> = new Map()
readonly preferences: Preferences
readonly preferences: Required<Preferences>
readonly localPreferenceCache: Preferences
readonly preferenceSub = new NoDataSubscribable()
serverPreferenceCache: Preferences = {}
@ -134,16 +136,25 @@ export class RoomStateStore {
}
#updateTimelineCache() {
const ownMessages: EventRowID[] = []
this.timelineCache = this.timeline.map(rt => {
const evt = this.eventsByRowID.get(rt.event_rowid)
if (!evt) {
return null
}
evt.timeline_rowid = rt.timeline_rowid
if (
evt.sender === this.parent.userID
&& evt.type === "m.room.message"
&& evt.relation_type !== "m.replace"
) {
ownMessages.push(evt.rowid)
}
return evt
}).concat(this.pendingEvents
.map(rowID => this.eventsByRowID.get(rowID))
.filter(evt => !!evt))
this.editTargets = ownMessages
}
notifyTimelineSubscribers() {
@ -380,7 +391,7 @@ export class RoomStateStore {
} else {
this.meta.emit(sync.meta)
}
for (const ad of Object.values(sync.account_data)) {
for (const ad of Object.values(sync.account_data ?? {})) {
if (ad.type === "fi.mau.gomuks.preferences") {
this.serverPreferenceCache = ad.content
this.preferenceSub.notify()
@ -388,10 +399,10 @@ export class RoomStateStore {
this.accountData.set(ad.type, ad.content)
this.accountDataSubs.notify(ad.type)
}
for (const evt of sync.events) {
for (const evt of sync.events ?? []) {
this.applyEvent(evt)
}
for (const [evtType, changedEvts] of Object.entries(sync.state)) {
for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
let stateMap = this.state.get(evtType)
if (!stateMap) {
stateMap = new Map()
@ -404,9 +415,9 @@ export class RoomStateStore {
this.stateSubs.notify(evtType)
}
if (sync.reset) {
this.timeline = sync.timeline
this.timeline = sync.timeline ?? []
this.pendingEvents.splice(0, this.pendingEvents.length)
} else {
} else if (sync.timeline) {
this.timeline.push(...sync.timeline)
}
if (sync.meta.unread_notifications === 0 && sync.meta.unread_highlights === 0) {
@ -416,7 +427,7 @@ export class RoomStateStore {
this.openNotifications.clear()
}
this.notifyTimelineSubscribers()
for (const [evtID, receipts] of Object.entries(sync.receipts)) {
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
this.applyReceipts(receipts, evtID, false)
}
}

View file

@ -0,0 +1,199 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { RoomListEntry, StateStore } from "@/api/statestore/main.ts"
import { DBSpaceEdge, RoomID } from "@/api/types"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
export interface RoomListFilter {
id: string
include(room: RoomListEntry): boolean
}
export interface SpaceUnreadCounts {
unread_messages: number
unread_notifications: number
unread_highlights: number
}
const emptyUnreadCounts: SpaceUnreadCounts = {
unread_messages: 0,
unread_notifications: 0,
unread_highlights: 0,
}
export abstract class Space implements RoomListFilter {
counts = new NonNullCachedEventDispatcher(emptyUnreadCounts)
abstract id: string
abstract include(room: RoomListEntry): boolean
clearUnreads() {
this.counts.emit(emptyUnreadCounts)
}
applyUnreads(newCounts?: SpaceUnreadCounts | null, oldCounts?: SpaceUnreadCounts | null) {
const mergedCounts: SpaceUnreadCounts = {
unread_messages: this.counts.current.unread_messages
+ (newCounts?.unread_messages ?? 0) - (oldCounts?.unread_messages ?? 0),
unread_notifications: this.counts.current.unread_notifications
+ (newCounts?.unread_notifications ?? 0) - (oldCounts?.unread_notifications ?? 0),
unread_highlights: this.counts.current.unread_highlights
+ (newCounts?.unread_highlights ?? 0) - (oldCounts?.unread_highlights ?? 0),
}
if (mergedCounts.unread_messages < 0) {
mergedCounts.unread_messages = 0
}
if (mergedCounts.unread_notifications < 0) {
mergedCounts.unread_notifications = 0
}
if (mergedCounts.unread_highlights < 0) {
mergedCounts.unread_highlights = 0
}
if (
mergedCounts.unread_messages !== this.counts.current.unread_messages
|| mergedCounts.unread_notifications !== this.counts.current.unread_notifications
|| mergedCounts.unread_highlights !== this.counts.current.unread_highlights
) {
this.counts.emit(mergedCounts)
}
}
}
export class DirectChatSpace extends Space {
id = "fi.mau.gomuks.direct_chats"
include(room: RoomListEntry): boolean {
return Boolean(room.dm_user_id)
}
}
export class UnreadsSpace extends Space {
id = "fi.mau.gomuks.unreads"
constructor(private parent: StateStore) {
super()
}
include(room: RoomListEntry): boolean {
return Boolean(room.room_id === this.parent.activeRoomID
|| room.unread_messages
|| room.unread_notifications
|| room.unread_highlights
|| room.marked_unread)
}
}
export class SpaceEdgeStore extends Space {
#children: DBSpaceEdge[] = []
#childRooms: Set<RoomID> = new Set()
#flattenedRooms: Set<RoomID> = new Set()
#childSpaces: Set<SpaceEdgeStore> = new Set()
readonly #parentSpaces: Set<SpaceEdgeStore> = new Set()
constructor(public id: RoomID, private parent: StateStore) {
super()
}
#addParent(parent: SpaceEdgeStore) {
this.#parentSpaces.add(parent)
}
#removeParent(parent: SpaceEdgeStore) {
this.#parentSpaces.delete(parent)
}
include(room: RoomListEntry) {
return this.#flattenedRooms.has(room.room_id)
}
get children() {
return this.#children
}
#updateFlattened(recalculate: boolean, added: Set<RoomID>) {
if (recalculate) {
let flattened = new Set(this.#childRooms)
for (const space of this.#childSpaces) {
flattened = flattened.union(space.#flattenedRooms)
}
this.#flattenedRooms = flattened
} else if (added.size > 50) {
this.#flattenedRooms = this.#flattenedRooms.union(added)
} else if (added.size > 0) {
for (const room of added) {
this.#flattenedRooms.add(room)
}
}
}
#notifyParentsOfChange(recalculate: boolean, added: Set<RoomID>, stack: WeakSet<SpaceEdgeStore>) {
if (stack.has(this)) {
return
}
stack.add(this)
for (const parent of this.#parentSpaces) {
parent.#updateFlattened(recalculate, added)
parent.#notifyParentsOfChange(recalculate, added, stack)
}
stack.delete(this)
}
set children(newChildren: DBSpaceEdge[]) {
const newChildRooms = new Set<RoomID>()
const newChildSpaces = new Set<SpaceEdgeStore>()
for (const child of newChildren) {
const spaceStore = this.parent.getSpaceStore(child.child_id)
if (spaceStore) {
newChildSpaces.add(spaceStore)
spaceStore.#addParent(this)
} else {
newChildRooms.add(child.child_id)
}
}
for (const space of this.#childSpaces) {
if (!newChildSpaces.has(space)) {
space.#removeParent(this)
}
}
const addedRooms = newChildRooms.difference(this.#childRooms)
const removedRooms = this.#childRooms.difference(newChildRooms)
const didAddChildren = newChildSpaces.difference(this.#childSpaces).size > 0
const recalculateFlattened = removedRooms.size > 0 || didAddChildren
this.#children = newChildren
this.#childRooms = newChildRooms
this.#childSpaces = newChildSpaces
if (this.#childSpaces.size > 0) {
this.#updateFlattened(recalculateFlattened, addedRooms)
} else {
this.#flattenedRooms = newChildRooms
}
if (this.#parentSpaces.size > 0) {
this.#notifyParentsOfChange(recalculateFlattened, addedRooms, new WeakSet())
}
}
}
export class SpaceOrphansSpace extends SpaceEdgeStore {
static id = "fi.mau.gomuks.space_orphans"
constructor(parent: StateStore) {
super(SpaceOrphansSpace.id, parent)
}
include(room: RoomListEntry): boolean {
return !super.include(room) && !room.dm_user_id
}
}

View file

@ -19,6 +19,7 @@ import {
DBReceipt,
DBRoom,
DBRoomAccountData,
DBSpaceEdge,
EventRowID,
RawDBEvent,
TimelineRowTuple,
@ -81,13 +82,13 @@ export interface ImageAuthTokenEvent extends BaseRPCCommand<string> {
export interface SyncRoom {
meta: DBRoom
timeline: TimelineRowTuple[]
events: RawDBEvent[]
state: Record<EventType, Record<string, EventRowID>>
timeline: TimelineRowTuple[] | null
events: RawDBEvent[] | null
state: Record<EventType, Record<string, EventRowID>> | null
reset: boolean
notifications: SyncNotification[]
account_data: Record<EventType, DBRoomAccountData>
receipts: Record<EventID, DBReceipt[]>
notifications: SyncNotification[] | null
account_data: Record<EventType, DBRoomAccountData> | null
receipts: Record<EventID, DBReceipt[]> | null
}
export interface SyncNotification {
@ -96,10 +97,12 @@ export interface SyncNotification {
}
export interface SyncCompleteData {
rooms: Record<RoomID, SyncRoom>
invited_rooms: DBInvitedRoom[]
left_rooms: RoomID[]
account_data: Record<EventType, DBAccountData>
rooms: Record<RoomID, SyncRoom> | null
invited_rooms: DBInvitedRoom[] | null
left_rooms: RoomID[] | null
account_data: Record<EventType, DBAccountData> | null
space_edges: Record<RoomID, DBSpaceEdge[]> | null
top_level_spaces: RoomID[] | null
since?: string
clear_state?: boolean
}

View file

@ -54,6 +54,7 @@ export interface DBRoom {
name_quality: RoomNameQuality
avatar?: ContentURI
explicit_avatar: boolean
dm_user_id?: UserID
topic?: string
canonical_alias?: RoomAlias
lazy_load_summary?: LazyLoadSummary
@ -71,6 +72,18 @@ export interface DBRoom {
prev_batch: string
}
export interface DBSpaceEdge {
// space_id: RoomID
child_id: RoomID
child_event_rowid?: EventRowID
order?: string
suggested?: true
parent_event_rowid?: EventRowID
canonical?: true
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
export type UnknownEventContent = Record<string, any>

View file

@ -25,6 +25,14 @@ export type RoomVersion = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" |
export type RoomType = "" | "m.space"
export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread"
export type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| {[key: string]: JSONValue}
export interface RoomPredecessor {
room_id: RoomID
event_id: EventID
@ -43,7 +51,7 @@ export interface TombstoneEventContent {
}
export interface LazyLoadSummary {
heroes?: UserID[]
"m.heroes"?: UserID[]
"m.joined_member_count"?: number
"m.invited_member_count"?: number
}
@ -65,9 +73,20 @@ export interface EncryptedEventContent {
export interface UserProfile {
displayname?: string
avatar_url?: ContentURI
avatar_file?: EncryptedFile
[custom: string]: unknown
}
export interface PronounSet {
subject?: string
object?: string
possessive_determiner?: string
possessive_pronoun?: string
reflexive?: string
summary: string
language: string
}
export type PresenceState = "online" | "offline" | "unavailable"
export interface Presence {
@ -102,6 +121,12 @@ export interface ACLEventContent {
deny?: string[]
}
export interface PolicyRuleContent {
entity: string
reason: string
recommendation: string
}
export interface PowerLevelEventContent {
users?: Record<UserID, number>
users_default?: number
@ -149,6 +174,23 @@ export interface ContentWarning {
description?: string
}
export interface URLPreview {
matched_url: string
"beeper:image:encryption"?: EncryptedFile
"matrix:image:size": number
"og:image"?: ContentURI
"og:url": string
"og:image:width"?: number
"og:image:height"?: number
"og:image:type"?: string
"og:title"?: string
"og:description"?: string
}
export interface BeeperPerMessageProfile extends UserProfile {
id: string
}
export interface BaseMessageEventContent {
msgtype: string
body: string
@ -159,6 +201,9 @@ export interface BaseMessageEventContent {
"town.robin.msc3725.content_warning"?: ContentWarning
"page.codeberg.everypizza.msc4193.spoiler"?: boolean
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string
"m.url_previews"?: URLPreview[]
"com.beeper.linkpreviews"?: URLPreview[]
"com.beeper.per_message_profile"?: BeeperPerMessageProfile
}
export interface TextMessageEventContent extends BaseMessageEventContent {
@ -273,3 +318,10 @@ export interface RoomSummary {
export interface RespRoomJoin {
room_id: RoomID
}
export interface RespOpenIDToken {
access_token: string
expires_in: number
matrix_server_name: string
token_type: "Bearer"
}

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import type { ContentURI } from "../../types"
import { Preference, anyContext } from "./types.ts"
import { Preference, anyContext, anyGlobalContext } from "./types.ts"
export const codeBlockStyles = [
"auto", "abap", "algol_nu", "algol", "arduino", "autumn", "average", "base16-snazzy", "borland", "bw",
@ -102,6 +102,18 @@ export const preferences = {
allowedContexts: anyContext,
defaultValue: true,
}),
render_url_previews: new Preference<boolean>({
displayName: "Render URL previews",
description: "Whether to render MSC4095 URL previews in the room timeline.",
allowedContexts: anyContext,
defaultValue: true,
}),
small_replies: new Preference<boolean>({
displayName: "Compact reply style",
description: "Whether to use a Discord-like compact style for replies instead of the traditional style.",
allowedContexts: anyContext,
defaultValue: false,
}),
show_date_separators: new Preference<boolean>({
displayName: "Show date separators",
description: "Whether messages in different days should have a date separator between them in the room timeline.",
@ -159,6 +171,24 @@ export const preferences = {
allowedContexts: anyContext,
defaultValue: "",
}),
room_window_title: new Preference<string>({
displayName: "In-room window title",
description: "The title to use for the window when viewing a room. $room will be replaced with the room name",
allowedContexts: anyContext,
defaultValue: "$room - gomuks web",
}),
window_title: new Preference<string>({
displayName: "Default window title",
description: "The title to use for the window when not in a room.",
allowedContexts: anyGlobalContext,
defaultValue: "gomuks web",
}),
favicon: new Preference<string>({
displayName: "Favicon",
description: "The URL to use for the favicon.",
allowedContexts: anyContext,
defaultValue: "gomuks.png",
}),
} as const
export const existingPreferenceKeys = new Set(Object.keys(preferences))

View file

@ -19,7 +19,7 @@ import { PreferenceContext, PreferenceValueType } from "./types.ts"
const prefKeys = Object.keys(preferences)
export function getPreferenceProxy(store: StateStore, room?: RoomStateStore): Preferences {
export function getPreferenceProxy(store: StateStore, room?: RoomStateStore): Required<Preferences> {
return new Proxy({}, {
set(): boolean {
throw new Error("The preference proxy is read-only")
@ -61,5 +61,5 @@ export function getPreferenceProxy(store: StateStore, room?: RoomStateStore): Pr
writable: false,
} : undefined
},
})
}) as Required<Preferences>
}

1
web/src/icons/home.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M240-200h120v-240h240v240h120v-360L480-740 240-560v360Zm-80 80v-480l320-240 320 240v480H520v-240h-80v240H160Zm320-350Z"/></svg>

After

Width:  |  Height:  |  Size: 244 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-80q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80Zm0-420ZM160-200v-80h80v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v13q-11 22-16 45t-4 47q-10-2-19.5-3.5T480-720q-66 0-113 47t-47 113v280h320v-257q18 8 38.5 12.5T720-520v240h80v80H160Zm560-400q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Z"/></svg>

After

Width:  |  Height:  |  Size: 480 B

1
web/src/icons/person.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/></svg>

After

Width:  |  Height:  |  Size: 549 B

1
web/src/icons/tag.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m240-160 40-160H120l20-80h160l40-160H180l20-80h160l40-160h80l-40 160h160l40-160h80l-40 160h160l-20 80H660l-40 160h160l-20 80H600l-40 160h-80l40-160H360l-40 160h-80Zm140-240h160l40-160H420l-40 160Z"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View file

@ -11,6 +11,7 @@
--semisecondary-text-color: #555;
--link-text-color: #0467dd;
--visited-link-text-color: var(--link-text-color);
--small-font-size: .875rem;
--code-background-color: rgba(0, 0, 0, 0.15);
--media-placeholder-default-background: rgba(0, 0, 0, .1);
@ -22,6 +23,7 @@
--border-color: #ccc;
--pill-background-color: #ccc;
--url-preview-background-color: rgba(0, 0, 0, .05);
--highlight-pill-background-color: #c00;
--highlight-pill-text-color: #fff;
--button-hover-color: rgba(0, 0, 0, .2);
@ -53,6 +55,9 @@
--unread-counter-notification-bg: rgba(50, 150, 0, 0.7);
--unread-counter-marked-unread-bg: var(--unread-counter-notification-bg);
--unread-counter-highlight-bg: rgba(200, 0, 0, 0.7);
--space-unread-counter-message-bg: rgb(100, 100, 100, 0.9);
--space-unread-counter-notification-bg: rgb(50, 150, 0);
--space-unread-counter-highlight-bg: rgb(200, 0, 0);
--sender-color-0: #a4041d;
--sender-color-1: #9b2200;
@ -113,6 +118,7 @@
--border-color: #222;
--pill-background-color: #222;
--url-preview-background-color: #222;
--button-hover-color: rgba(255, 255, 255, .2);
--light-hover-color: rgba(255, 255, 255, .1);
@ -137,6 +143,9 @@
--unread-counter-message-bg: rgba(255, 255, 255, 0.5);
--unread-counter-notification-bg: rgba(150, 255, 0, 0.7);
--unread-counter-highlight-bg: rgba(255, 50, 50, 0.7);
--space-unread-counter-message-bg: rgb(200, 200, 200, 0.8);
--space-unread-counter-notification-bg: rgb(150, 255, 0);
--space-unread-counter-highlight-bg: rgb(255, 50, 50);
--sender-color-0: #ff877c;
--sender-color-1: #f6913d;
@ -159,11 +168,12 @@ body {
font-family: var(--font-stack);
margin: 0;
padding: 0;
background-color: var(--login-background-color);
background-color: var(--background-color);
line-height: 1.5;
font-size: 16px;
touch-action: none;
color: var(--text-color);
min-height: 100vh;
}
html {
@ -248,9 +258,15 @@ div.connection-error-wrapper {
}
}
div.pre-connect {
margin-top: 2rem;
text-align: center;
div.pre-main {
position: fixed;
inset: 0;
background-color: var(--login-background-color);
&.waiting-to-connect {
padding-top: 2rem;
text-align: center;
}
}
a {

View file

@ -1,5 +1,5 @@
main.matrix-main {
--room-list-width: 300px;
--room-list-width: 350px;
--right-panel-width: 300px;
position: fixed;
@ -40,10 +40,12 @@ main.matrix-main {
> div.room-list-resizer {
grid-area: rh1;
z-index: 1;
}
> div.right-panel-resizer {
grid-area: rh2;
z-index: 1;
}
}

View file

@ -13,10 +13,10 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX, use, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from "react"
import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react"
import { SyncLoader } from "react-spinners"
import Client from "@/api/client.ts"
import { RoomStateStore } from "@/api/statestore"
import { RoomListFilter, RoomStateStore } from "@/api/statestore"
import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts"
@ -24,7 +24,7 @@ import ClientContext from "./ClientContext.ts"
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
import StylePreferences from "./StylePreferences.tsx"
import Keybindings from "./keybindings.ts"
import { ModalWrapper } from "./modal/Modal.tsx"
import { ModalWrapper } from "./modal"
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
import RoomList from "./roomlist/RoomList.tsx"
import RoomPreview, { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
@ -52,6 +52,7 @@ class ContextFields implements MainScreenContextFields {
constructor(
private directSetRightPanel: (props: RightPanelProps | null) => void,
private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void,
private directSetSpace: (space: RoomListFilter | null) => void,
private client: Client,
) {
this.keybindings = new Keybindings(client.store, this)
@ -95,12 +96,17 @@ class ContextFields implements MainScreenContextFields {
}
}
setActiveRoom = (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>, pushState = true) => {
setActiveRoom = (
roomID: RoomID | null,
previewMeta?: Partial<RoomPreviewProps>,
toSpace?: RoomListFilter,
pushState = true,
) => {
console.log("Switching to room", roomID)
if (roomID) {
const room = this.client.store.rooms.get(roomID)
if (room) {
this.#setActiveRoom(room, pushState)
this.#setActiveRoom(room, toSpace, pushState)
} else {
this.#setPreviewRoom(roomID, pushState, previewMeta)
}
@ -109,6 +115,24 @@ class ContextFields implements MainScreenContextFields {
}
}
setSpace = (space: RoomListFilter | null, pushState = true) => {
if (space === this.client.store.currentRoomListFilter) {
return
}
console.log("Switching to space", space?.id)
this.directSetSpace(space)
this.client.store.currentRoomListFilter = space
if (pushState) {
if (this.client.store.activeRoomID && space) {
const entry = this.client.store.roomListEntries.get(this.client.store.activeRoomID)
if (entry && !space.include(entry)) {
this.setActiveRoom(null)
}
}
history.replaceState({ ...(history.state || {}), space_id: space?.id }, "")
}
}
#setPreviewRoom(roomID: RoomID, pushState: boolean, meta?: Partial<RoomPreviewProps>) {
const invite = this.client.store.inviteRooms.get(roomID)
this.#closeActiveRoom(false)
@ -120,14 +144,33 @@ class ContextFields implements MainScreenContextFields {
room_id: roomID,
source_via: meta?.via,
source_alias: meta?.alias,
space_id: history.state?.space_id,
}, "")
}
}
#setActiveRoom(room: RoomStateStore, pushState: boolean) {
#getWindowTitle(room?: RoomStateStore, name?: string) {
if (!room) {
return this.client.store.preferences.window_title
}
return room.preferences.room_window_title.replace("$room", name!)
}
#setActiveRoom(room: RoomStateStore, space: RoomListFilter | undefined | null, pushState: boolean) {
window.activeRoom = room
this.directSetActiveRoom(room)
this.directSetRightPanel(null)
if (!space && this.client.store.currentRoomListFilter) {
const roomListEntry = this.client.store.roomListEntries.get(room.roomID)
if (roomListEntry && !this.client.store.currentRoomListFilter.include(roomListEntry)) {
space = this.client.store.findMatchingSpace(roomListEntry)
}
}
if (space && space !== this.client.store.currentRoomListFilter) {
console.log("Switching to space", space?.id)
this.directSetSpace(space)
this.client.store.currentRoomListFilter = space
}
this.rightPanelStack = []
this.client.store.activeRoomID = room.roomID
this.client.store.activeRoomIsPreview = false
@ -141,13 +184,13 @@ class ContextFields implements MainScreenContextFields {
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
?.scrollIntoView({ block: "nearest" })
if (pushState) {
history.pushState({ room_id: room.roomID }, "")
history.pushState({ room_id: room.roomID, space_id: space?.id ?? history.state?.space_id }, "")
}
let roomNameForTitle = room.meta.current.name
if (roomNameForTitle && roomNameForTitle.length > 48) {
roomNameForTitle = roomNameForTitle.slice(0, 45) + "…"
}
document.title = `${roomNameForTitle} - gomuks web`
document.title = this.#getWindowTitle(room, roomNameForTitle)
}
#closeActiveRoom(pushState: boolean) {
@ -159,9 +202,9 @@ class ContextFields implements MainScreenContextFields {
this.client.store.activeRoomIsPreview = false
this.keybindings.activeRoom = null
if (pushState) {
history.pushState({}, "")
history.pushState({ space_id: history.state?.space_id }, "")
}
document.title = "gomuks web"
document.title = this.#getWindowTitle()
}
clickRoom = (evt: React.MouseEvent) => {
@ -174,6 +217,7 @@ class ContextFields implements MainScreenContextFields {
}
clickRightPanelOpener = (evt: React.MouseEvent) => {
evt.preventDefault()
const type = evt.currentTarget.getAttribute("data-target-panel")
if (type === "pinned-messages" || type === "members") {
this.setRightPanel({ type })
@ -190,8 +234,11 @@ class ContextFields implements MainScreenContextFields {
const SYNC_ERROR_HIDE_DELAY = 30 * 1000
const handleURLHash = (client: Client) => {
const handleURLHash = (client: Client, context: MainScreenContextFields, hashOnly = false) => {
if (!location.hash.startsWith("#/uri/")) {
if (hashOnly) {
return null
}
if (location.search) {
const currentETag = (
document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement
@ -217,7 +264,7 @@ const handleURLHash = (client: Client) => {
const uri = parseMatrixURI(decodedURI)
if (!uri) {
console.error("Invalid matrix URI", decodedURI)
return history.state
return hashOnly ? null : history.state
}
console.log("Handling URI", uri)
const newURL = new URL(location.href)
@ -241,7 +288,7 @@ const handleURLHash = (client: Client) => {
// TODO loading indicator or something for this?
client.rpc.resolveAlias(uri.identifier).then(
res => {
window.mainScreenContext.setActiveRoom(res.room_id, {
context.setActiveRoom(res.room_id, {
alias: uri.identifier,
via: res.servers.slice(0, 3),
})
@ -251,8 +298,9 @@ const handleURLHash = (client: Client) => {
return null
} else {
console.error("Invalid matrix URI", uri)
history.replaceState(history.state, "", newURL.toString())
}
return history.state
return hashOnly ? null : history.state
}
type ActiveRoomType = [RoomStateStore | RoomPreviewProps | null, RoomStateStore | RoomPreviewProps | null]
@ -272,32 +320,42 @@ const activeRoomReducer = (
const MainScreen = () => {
const [[prevActiveRoom, activeRoom], directSetActiveRoom] = useReducer(activeRoomReducer, [null, null])
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
const skipNextTransitionRef = useRef(false)
const [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(null)
const client = use(ClientContext)!
const syncStatus = useEventAsState(client.syncStatus)
const context = useMemo(
() => new ContextFields(directSetRightPanel, directSetActiveRoom, client),
() => new ContextFields(directSetRightPanel, directSetActiveRoom, directSetSpace, client),
[client],
)
useLayoutEffect(() => {
window.mainScreenContext = context
}, [context])
useEffect(() => {
const listener = (evt: PopStateEvent) => {
window.mainScreenContext = context
const listener = (evt: Pick<PopStateEvent, "state" | "hasUAVisualTransition">) => {
skipNextTransitionRef.current = evt.hasUAVisualTransition
const roomID = evt.state?.room_id ?? null
const spaceID = evt.state?.space_id ?? undefined
if (spaceID !== client.store.currentRoomListFilter?.id) {
context.setSpace(client.store.getSpaceByID(spaceID), false)
}
if (roomID !== client.store.activeRoomID) {
context.setActiveRoom(roomID, {
alias: ensureString(evt?.state.source_alias) || undefined,
via: ensureStringArray(evt?.state.source_via),
}, false)
alias: ensureString(evt.state?.source_alias) || undefined,
via: ensureStringArray(evt.state?.source_via),
}, undefined, false)
}
context.setRightPanel(evt.state?.right_panel ?? null, false)
}
const hashListener = () => {
const state = handleURLHash(client, context, true)
if (state !== null) {
listener({ state, hasUAVisualTransition: false })
}
}
window.addEventListener("hashchange", hashListener)
window.addEventListener("popstate", listener)
const initHandle = () => {
const state = handleURLHash(client)
const state = handleURLHash(client, context)
listener({ state } as PopStateEvent)
}
let cancel = () => {}
@ -308,12 +366,13 @@ const MainScreen = () => {
}
return () => {
window.removeEventListener("popstate", listener)
window.removeEventListener("hashchange", hashListener)
cancel()
}
}, [context, client])
useEffect(() => context.keybindings.listen(), [context])
const [roomListWidth, resizeHandle1] = useResizeHandle(
300, 48, Math.min(900, window.innerWidth * 0.4),
350, 96, Math.min(900, window.innerWidth * 0.4),
"roomListWidth", { className: "room-list-resizer" },
)
const [rightPanelWidth, resizeHandle2] = useResizeHandle(
@ -367,7 +426,7 @@ const MainScreen = () => {
<ModalWrapper>
<StylePreferences client={client} activeRoom={activeRealRoom}/>
<main className={classNames.join(" ")} style={extraStyle}>
<RoomList activeRoomID={activeRoom?.roomID ?? null}/>
<RoomList activeRoomID={activeRoom?.roomID ?? null} space={space}/>
{resizeHandle1}
{renderedRoom
? renderedRoom instanceof RoomStateStore

View file

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

View file

@ -13,7 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { useInsertionEffect } from "react"
import React, { useEffect, useInsertionEffect } from "react"
import type Client from "@/api/client.ts"
import { RoomStateStore, usePreferences } from "@/api/statestore"
@ -128,7 +128,12 @@ const StylePreferences = ({ client, activeRoom }: StylePreferencesProps) => {
@import url("_gomuks/codeblock/${preferences.code_block_theme}.css");
`, [preferences.code_block_theme], "gomuks-pref-code-block-theme")
useAsyncStyle(() => preferences.custom_css, [preferences.custom_css], "gomuks-pref-custom-css")
useEffect(() => {
favicon.href = preferences.favicon
}, [preferences.favicon])
return null
}
const favicon = document.getElementById("favicon") as HTMLLinkElement
export default React.memo(StylePreferences)

View file

@ -35,6 +35,7 @@ div.autocompletions {
> img {
width: 1.5rem;
height: 1.5rem;
object-fit: contain;
}
}
}

View file

@ -80,13 +80,13 @@ function useAutocompleter<T>({
})
document.querySelector(`div.autocompletion-item[data-index='${index}']`)?.scrollIntoView({ block: "nearest" })
})
const onClick = useEvent((evt: React.MouseEvent<HTMLDivElement>) => {
const onClick = (evt: React.MouseEvent<HTMLDivElement>) => {
const idx = evt.currentTarget.getAttribute("data-index")
if (idx) {
onSelect(+idx)
setAutocomplete(null)
}
})
}
useEffect(() => {
if (params.selected !== undefined) {
onSelect(params.selected)

View file

@ -0,0 +1,63 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Client from "@/api/client.ts"
import { RoomStateStore, usePreference } from "@/api/statestore"
import type { MediaMessageEventContent } from "@/api/types"
import { LeafletPicker } from "../maps/async.tsx"
import { useMediaContent } from "../timeline/content/useMediaContent.tsx"
import CloseIcon from "@/icons/close.svg?react"
import "./MessageComposer.css"
export interface ComposerMediaProps {
content: MediaMessageEventContent
clearMedia: false | (() => void)
}
export const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
const [mediaContent, containerClass, containerStyle] = useMediaContent(
content, "m.room.message", { height: 120, width: 360 },
)
return <div className="composer-media">
<div className={`media-container ${containerClass}`} style={containerStyle}>
{mediaContent}
</div>
{clearMedia && <button onClick={clearMedia}><CloseIcon/></button>}
</div>
}
export interface ComposerLocationValue {
lat: number
long: number
prec?: number
}
export interface ComposerLocationProps {
room: RoomStateStore
client: Client
location: ComposerLocationValue
onChange: (location: ComposerLocationValue) => void
clearLocation: () => void
}
export const ComposerLocation = ({ client, room, location, onChange, clearLocation }: ComposerLocationProps) => {
const tileTemplate = usePreference(client.store, room, "leaflet_tile_template")
return <div className="composer-location">
<div className="location-container">
<LeafletPicker tileTemplate={tileTemplate} onChange={onChange} initialLocation={location}/>
</div>
<button onClick={clearLocation}><CloseIcon/></button>
</div>
}

View file

@ -34,6 +34,11 @@ div.message-composer {
height: 2rem;
width: 2rem;
padding: .25rem;
> svg {
width: 1.5rem;
height: 1.5rem;
}
}
> input[type="file"] {

View file

@ -15,8 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { CSSProperties, use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners"
import Client from "@/api/client.ts"
import { RoomStateStore, usePreference, useRoomEvent } from "@/api/statestore"
import { useRoomEvent } from "@/api/statestore"
import type {
EventID,
MediaMessageEventContent,
@ -29,21 +28,18 @@ import type {
import { PartialEmoji, emojiToMarkdown } from "@/util/emoji"
import { isMobileDevice } from "@/util/ismobile.ts"
import { escapeMarkdown } from "@/util/markdown.ts"
import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
import GIFPicker from "../emojipicker/GIFPicker.tsx"
import StickerPicker from "../emojipicker/StickerPicker.tsx"
import { keyToString } from "../keybindings.ts"
import { LeafletPicker } from "../maps/async.tsx"
import { ModalContext } from "../modal/Modal.tsx"
import { ModalContext } from "../modal"
import { useRoomContext } from "../roomview/roomcontext.ts"
import { ReplyBody } from "../timeline/ReplyBody.tsx"
import { useMediaContent } from "../timeline/content/useMediaContent.tsx"
import type { AutocompleteQuery } from "./Autocompleter.tsx"
import { ComposerLocation, ComposerLocationValue, ComposerMedia } from "./ComposerMedia.tsx"
import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./getAutocompleter.ts"
import AttachIcon from "@/icons/attach.svg?react"
import CloseIcon from "@/icons/close.svg?react"
import EmojiIcon from "@/icons/emoji-categories/smileys-emotion.svg?react"
import GIFIcon from "@/icons/gif.svg?react"
import LocationIcon from "@/icons/location.svg?react"
@ -52,12 +48,6 @@ import SendIcon from "@/icons/send.svg?react"
import StickerIcon from "@/icons/sticker.svg?react"
import "./MessageComposer.css"
export interface ComposerLocationValue {
lat: number
long: number
prec?: number
}
export interface ComposerState {
text: string
media: MediaMessageEventContent | null
@ -174,13 +164,13 @@ const MessageComposer = () => {
textInput.current?.focus()
}, [room.roomID])
const canSend = Boolean(state.text || state.media || state.location)
const sendMessage = useEvent((evt: React.FormEvent) => {
const onClickSend = (evt: React.FormEvent) => {
evt.preventDefault()
if (!canSend) {
return
}
doSendMessage(state)
})
}
const doSendMessage = (state: ComposerState) => {
if (editing) {
setState(draftStore.get(room.roomID) ?? emptyComposer)
@ -245,7 +235,7 @@ const MessageComposer = () => {
mentions,
}).catch(err => window.alert("Failed to send message: " + err))
}
const onComposerCaretChange = useEvent((evt: CaretEvent<HTMLTextAreaElement>, newText?: string) => {
const onComposerCaretChange = (evt: CaretEvent<HTMLTextAreaElement>, newText?: string) => {
const area = evt.currentTarget
if (area.selectionStart <= (autocomplete?.startPos ?? 0)) {
if (autocomplete) {
@ -281,8 +271,8 @@ const MessageComposer = () => {
})
}
}
})
const onComposerKeyDown = useEvent((evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
}
const onComposerKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
const inp = evt.currentTarget
const fullKey = keyToString(evt)
const sendKey = fullKey === "Enter" || fullKey === "Ctrl+Enter"
@ -295,7 +285,7 @@ const MessageComposer = () => {
|| autocomplete.selected !== undefined
|| !document.getElementById("composer-autocompletions")?.classList.contains("has-items")
)) {
sendMessage(evt)
onClickSend(evt)
} else if (autocomplete) {
let autocompleteUpdate: Partial<AutocompleteQuery> | null | undefined
if (fullKey === "Tab" || fullKey === "ArrowDown") {
@ -320,18 +310,18 @@ const MessageComposer = () => {
}
} else if (fullKey === "ArrowUp" && inp.selectionStart === 0 && inp.selectionEnd === 0) {
const currentlyEditing = editing
? roomCtx.ownMessages.indexOf(editing.rowid)
: roomCtx.ownMessages.length
const prevEventToEditID = roomCtx.ownMessages[currentlyEditing - 1]
? room.editTargets.indexOf(editing.rowid)
: room.editTargets.length
const prevEventToEditID = room.editTargets[currentlyEditing - 1]
const prevEventToEdit = prevEventToEditID ? room.eventsByRowID.get(prevEventToEditID) : undefined
if (prevEventToEdit) {
roomCtx.setEditing(prevEventToEdit)
evt.preventDefault()
}
} else if (editing && fullKey === "ArrowDown" && inp.selectionStart === state.text.length) {
const currentlyEditingIdx = roomCtx.ownMessages.indexOf(editing.rowid)
const currentlyEditingIdx = room.editTargets.indexOf(editing.rowid)
const nextEventToEdit = currentlyEditingIdx
? room.eventsByRowID.get(roomCtx.ownMessages[currentlyEditingIdx + 1]) : undefined
? room.eventsByRowID.get(room.editTargets[currentlyEditingIdx + 1]) : undefined
roomCtx.setEditing(nextEventToEdit ?? null)
// This timeout is very hacky and probably doesn't work in every case
setTimeout(() => inp.setSelectionRange(0, 0), 0)
@ -340,8 +330,8 @@ const MessageComposer = () => {
evt.stopPropagation()
roomCtx.setEditing(null)
}
})
const onChange = useEvent((evt: React.ChangeEvent<HTMLTextAreaElement>) => {
}
const onChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
setState({ text: evt.target.value })
const now = Date.now()
if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) {
@ -358,7 +348,7 @@ const MessageComposer = () => {
}
}
onComposerCaretChange(evt, evt.target.value)
})
}
const doUploadFile = useCallback((file: File | null | undefined) => {
if (!file) {
return
@ -380,10 +370,7 @@ const MessageComposer = () => {
.catch(err => window.alert("Failed to upload file: " + err))
.finally(() => setLoadingMedia(false))
}, [room])
const onAttachFile = useEvent(
(evt: React.ChangeEvent<HTMLInputElement>) => doUploadFile(evt.target.files?.[0]),
)
const onPaste = useEvent((evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
const onPaste = (evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
const file = evt.clipboardData?.files?.[0]
const text = evt.clipboardData.getData("text/plain")
const input = evt.currentTarget
@ -400,7 +387,7 @@ const MessageComposer = () => {
return
}
evt.preventDefault()
})
}
// To ensure the cursor jumps to the end, do this in an effect rather than as the initial value of useState
// To try to avoid the input bar flashing, use useLayoutEffect instead of useEffect
useLayoutEffect(() => {
@ -450,7 +437,6 @@ const MessageComposer = () => {
draftStore.set(room.roomID, state)
}
}, [roomCtx, room, state, editing])
const openFilePicker = useCallback(() => fileInput.current!.click(), [])
const clearMedia = useCallback(() => setState({ media: null, location: null }), [])
const onChangeLocation = useCallback((location: ComposerLocationValue) => setState({ location }), [])
const closeReply = useCallback((evt: React.MouseEvent) => {
@ -461,56 +447,6 @@ const MessageComposer = () => {
evt.stopPropagation()
roomCtx.setEditing(null)
}, [roomCtx])
const getEmojiPickerStyle = () => ({
bottom: (composerRef.current?.clientHeight ?? 32) + 4 + 24,
right: "var(--timeline-horizontal-padding)",
})
const openEmojiPicker = useEvent(() => {
openModal({
content: <EmojiPicker
style={getEmojiPickerStyle()}
room={roomCtx.store}
onSelect={(emoji: PartialEmoji) => {
const mdEmoji = emojiToMarkdown(emoji)
setState({
text: state.text.slice(0, textInput.current?.selectionStart ?? 0)
+ mdEmoji
+ state.text.slice(textInput.current?.selectionEnd ?? 0),
})
if (textInput.current) {
textInput.current.setSelectionRange(textInput.current.selectionStart + mdEmoji.length, 0)
}
}}
// TODO allow keeping open on select on non-mobile devices
// (requires onSelect to be able to keep track of the state after updating it)
closeOnSelect={true}
/>,
onClose: () => !isMobileDevice && textInput.current?.focus(),
})
})
const openGIFPicker = useEvent(() => {
openModal({
content: <GIFPicker
style={getEmojiPickerStyle()}
room={roomCtx.store}
onSelect={media => setState({ media })}
/>,
onClose: () => !isMobileDevice && textInput.current?.focus(),
})
})
const openStickerPicker = useEvent(() => {
openModal({
content: <StickerPicker
style={getEmojiPickerStyle()}
room={roomCtx.store}
onSelect={media => doSendMessage({ ...state, media, text: "" })}
/>,
onClose: () => !isMobileDevice && textInput.current?.focus(),
})
})
const openLocationPicker = useEvent(() => {
setState({ location: { lat: 0, long: 0, prec: 1 }, media: null })
})
const Autocompleter = getAutocompleter(autocomplete, client, room)
let mediaDisabledTitle: string | undefined
let stickerDisabledTitle: string | undefined
@ -533,7 +469,57 @@ const MessageComposer = () => {
} else if (state.text && !editing) {
stickerDisabledTitle = "You can't attach a sticker to a message with text"
}
const getEmojiPickerStyle = () => ({
bottom: (composerRef.current?.clientHeight ?? 32) + 4 + 24,
right: "var(--timeline-horizontal-padding)",
})
const makeAttachmentButtons = (includeText = false) => {
const openEmojiPicker = () => {
openModal({
content: <EmojiPicker
style={getEmojiPickerStyle()}
room={roomCtx.store}
onSelect={(emoji: PartialEmoji) => {
const mdEmoji = emojiToMarkdown(emoji)
setState({
text: state.text.slice(0, textInput.current?.selectionStart ?? 0)
+ mdEmoji
+ state.text.slice(textInput.current?.selectionEnd ?? 0),
})
if (textInput.current) {
textInput.current.setSelectionRange(textInput.current.selectionStart + mdEmoji.length, 0)
}
}}
// TODO allow keeping open on select on non-mobile devices
// (requires onSelect to be able to keep track of the state after updating it)
closeOnSelect={true}
/>,
onClose: () => !isMobileDevice && textInput.current?.focus(),
})
}
const openGIFPicker = () => {
openModal({
content: <GIFPicker
style={getEmojiPickerStyle()}
room={roomCtx.store}
onSelect={media => setState({ media })}
/>,
onClose: () => !isMobileDevice && textInput.current?.focus(),
})
}
const openStickerPicker = () => {
openModal({
content: <StickerPicker
style={getEmojiPickerStyle()}
room={roomCtx.store}
onSelect={media => doSendMessage({ ...state, media, text: "" })}
/>,
onClose: () => !isMobileDevice && textInput.current?.focus(),
})
}
const openLocationPicker = () => {
setState({ location: { lat: 0, long: 0, prec: 1 }, media: null })
}
return <>
<button onClick={openEmojiPicker} title="Add emoji"><EmojiIcon/>{includeText && "Emoji"}</button>
<button
@ -556,13 +542,13 @@ const MessageComposer = () => {
title={locationDisabledTitle ?? "Add location"}
><LocationIcon/>{includeText && "Location"}</button>
<button
onClick={openFilePicker}
onClick={() => fileInput.current!.click()}
disabled={!!mediaDisabledTitle}
title={mediaDisabledTitle ?? "Add file attachment"}
><AttachIcon/>{includeText && "File"}</button>
</>
}
const openButtonsModal = useEvent(() => {
const openButtonsModal = () => {
const style: CSSProperties = getEmojiPickerStyle()
style.left = style.right
delete style.right
@ -571,7 +557,7 @@ const MessageComposer = () => {
{makeAttachmentButtons(true)}
</div>,
})
})
}
const inlineButtons = state.text === "" || window.innerWidth > 720
const showSendButton = canSend || window.innerWidth > 720
const disableClearMedia = editing && state.media?.msgtype === "m.sticker"
@ -602,7 +588,7 @@ const MessageComposer = () => {
isThread={false}
onClose={stopEditing}
/>}
{loadingMedia && <div className="composer-media"><ScaleLoader/></div>}
{loadingMedia && <div className="composer-media"><ScaleLoader color="var(--primary-color)"/></div>}
{state.media && <ComposerMedia content={state.media} clearMedia={!disableClearMedia && clearMedia}/>}
{state.location && <ComposerLocation
room={room} client={client}
@ -625,49 +611,19 @@ const MessageComposer = () => {
/>
{inlineButtons && makeAttachmentButtons()}
{showSendButton && <button
onClick={sendMessage}
onClick={onClickSend}
disabled={!canSend || loadingMedia}
title="Send message"
><SendIcon/></button>}
<input ref={fileInput} onChange={onAttachFile} type="file" value=""/>
<input
ref={fileInput}
onChange={evt => doUploadFile(evt.target.files?.[0])}
type="file"
value=""
/>
</div>
</div>
</>
}
interface ComposerMediaProps {
content: MediaMessageEventContent
clearMedia: false | (() => void)
}
const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
const [mediaContent, containerClass, containerStyle] = useMediaContent(
content, "m.room.message", { height: 120, width: 360 },
)
return <div className="composer-media">
<div className={`media-container ${containerClass}`} style={containerStyle}>
{mediaContent}
</div>
{clearMedia && <button onClick={clearMedia}><CloseIcon/></button>}
</div>
}
interface ComposerLocationProps {
room: RoomStateStore
client: Client
location: ComposerLocationValue
onChange: (location: ComposerLocationValue) => void
clearLocation: () => void
}
const ComposerLocation = ({ client, room, location, onChange, clearLocation }: ComposerLocationProps) => {
const tileTemplate = usePreference(client.store, room, "leaflet_tile_template")
return <div className="composer-location">
<div className="location-container">
<LeafletPicker tileTemplate={tileTemplate} onChange={onChange} initialLocation={location}/>
</div>
<button onClick={clearLocation}><CloseIcon/></button>
</div>
}
export default MessageComposer

View file

@ -13,11 +13,10 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useCallback } from "react"
import React, { use } from "react"
import { stringToRoomStateGUID } from "@/api/types"
import useContentVisibility from "@/util/contentvisibility.ts"
import { CATEGORY_FREQUENTLY_USED, CustomEmojiPack, Emoji, categories } from "@/util/emoji"
import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts"
import renderEmoji from "./renderEmoji.tsx"
@ -56,27 +55,25 @@ export const EmojiGroup = ({
}
return emoji
}
const onClickEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
onSelect(getEmojiFromAttrs(evt.currentTarget)))
const onMouseOverEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
setPreviewEmoji?.(getEmojiFromAttrs(evt.currentTarget)))
const onMouseOutEmoji = useCallback(() => setPreviewEmoji?.(undefined), [setPreviewEmoji])
const onClickSubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
const onMouseOverEmoji = setPreviewEmoji && ((evt: React.MouseEvent<HTMLButtonElement>) =>
setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
const onMouseOutEmoji = setPreviewEmoji && (() => setPreviewEmoji(undefined))
const onClickSubscribePack = (evt: React.MouseEvent<HTMLButtonElement>) => {
const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
if (!guid) {
return
}
client.subscribeToEmojiPack(guid, true)
.catch(err => window.alert(`Failed to subscribe to emoji pack: ${err}`))
})
const onClickUnsubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
}
const onClickUnsubscribePack = (evt: React.MouseEvent<HTMLButtonElement>) => {
const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
if (!guid) {
return
}
client.subscribeToEmojiPack(guid, false)
.catch(err => window.alert(`Failed to unsubscribe from emoji pack: ${err}`))
})
}
let categoryName: string
if (typeof categoryID === "number") {
@ -112,7 +109,7 @@ export const EmojiGroup = ({
data-emoji-index={idx}
onMouseOver={onMouseOverEmoji}
onMouseOut={onMouseOutEmoji}
onClick={onClickEmoji}
onClick={evt => onSelect(getEmojiFromAttrs(evt.currentTarget))}
title={emoji.t}
>{renderEmoji(emoji)}</button>) : null}
</div>

View file

@ -19,9 +19,8 @@ import { RoomStateStore, useCustomEmojis } from "@/api/statestore"
import { roomStateGUIDToString } from "@/api/types"
import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji"
import { isMobileDevice } from "@/util/ismobile.ts"
import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts"
import { ModalCloseContext } from "../modal/Modal.tsx"
import { ModalCloseContext } from "../modal"
import { EmojiGroup } from "./EmojiGroup.tsx"
import renderEmoji from "./renderEmoji.tsx"
import useCategoryUnderline from "./useCategoryUnderline.ts"
@ -72,7 +71,6 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
frequentlyUsed: client.store.frequentlyUsedEmoji,
customEmojiPacks,
})
const clearQuery = useCallback(() => setQuery(""), [])
const close = closeOnSelect ? use(ModalCloseContext) : null
const onSelectWrapped = useCallback((emoji?: PartialEmoji) => {
if (!emoji) {
@ -85,12 +83,10 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
}
close?.()
}, [onSelect, selected, client, close])
const onClickFreeformReact = useEvent(() => onSelectWrapped({ u: query }))
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
const onClickCategoryButton = (evt: React.MouseEvent) => {
const categoryID = evt.currentTarget.getAttribute("data-category-id")!
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView()
}, [])
}
return <div className="emoji-picker" style={style}>
<div className="emoji-category-bar" ref={emojiCategoryBarRef}>
<button
@ -123,12 +119,12 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
<div className="emoji-search">
<input
autoFocus={!isMobileDevice}
onChange={onChangeQuery}
onChange={evt => setQuery(evt.target.value)}
value={query}
type="search"
placeholder="Search emojis"
/>
<button onClick={clearQuery} disabled={query === ""}>
<button onClick={() => setQuery("")} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button>
</div>
@ -155,7 +151,7 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
})}
{allowFreeform && query && <button
className="freeform-react"
onClick={onClickFreeformReact}
onClick={() => onSelectWrapped({ u: query })}
>React with "{query}"</button>}
</div>
</div>

View file

@ -13,12 +13,12 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { CSSProperties, use, useCallback, useEffect, useState } from "react"
import React, { CSSProperties, use, useEffect, useState } from "react"
import { RoomStateStore, usePreference } from "@/api/statestore"
import { MediaMessageEventContent } from "@/api/types"
import { isMobileDevice } from "@/util/ismobile.ts"
import ClientContext from "../ClientContext.ts"
import { ModalCloseContext } from "../modal/Modal.tsx"
import { ModalCloseContext } from "../modal"
import { GIF, getTrendingGIFs, searchGIF } from "./gifsource.ts"
import CloseIcon from "@/icons/close.svg?react"
import SearchIcon from "@/icons/search.svg?react"
@ -36,13 +36,11 @@ const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
const [results, setResults] = useState<GIF[]>([])
const [error, setError] = useState<unknown>()
const close = use(ModalCloseContext)
const clearQuery = useCallback(() => setQuery(""), [])
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
const client = use(ClientContext)!
const provider = usePreference(client.store, room, "gif_provider")
const providerName = provider.slice(0, 1).toUpperCase() + provider.slice(1)
// const reuploadGIFs = room.preferences.reupload_gifs
const onSelectGIF = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
const onSelectGIF = (evt: React.MouseEvent<HTMLDivElement>) => {
const idx = evt.currentTarget.getAttribute("data-gif-index")
if (!idx) {
return
@ -64,7 +62,7 @@ const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
url: gif.proxied_mxc,
})
close()
}, [onSelect, close, results])
}
useEffect(() => {
if (!query) {
if (trendingCache.has(provider)) {
@ -106,12 +104,12 @@ const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
<div className="gif-search">
<input
autoFocus={!isMobileDevice}
onChange={onChangeQuery}
onChange={evt => setQuery(evt.target.value)}
value={query}
type="search"
placeholder={`Search ${providerName}`}
/>
<button onClick={clearQuery} disabled={query === ""}>
<button onClick={() => setQuery("")} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button>
</div>

View file

@ -20,7 +20,7 @@ import { roomStateGUIDToString } from "@/api/types"
import { Emoji, useFilteredEmojis } from "@/util/emoji"
import { isMobileDevice } from "@/util/ismobile.ts"
import ClientContext from "../ClientContext.ts"
import { ModalCloseContext } from "../modal/Modal.tsx"
import { ModalCloseContext } from "../modal"
import { EmojiGroup } from "./EmojiGroup.tsx"
import { MediaPickerProps } from "./GIFPicker.tsx"
import useCategoryUnderline from "./useCategoryUnderline.ts"
@ -39,7 +39,6 @@ const StickerPicker = ({ style, onSelect, room }: MediaPickerProps) => {
customEmojiPacks,
stickers: true,
})
const clearQuery = useCallback(() => setQuery(""), [])
const close = use(ModalCloseContext)
const onSelectWrapped = useCallback((emoji?: Emoji) => {
if (!emoji) {
@ -48,16 +47,15 @@ const StickerPicker = ({ style, onSelect, room }: MediaPickerProps) => {
onSelect({
msgtype: "m.sticker",
body: emoji.t,
info: emoji.i,
info: emoji.i ?? {},
url: emoji.u,
})
close()
}, [onSelect, close])
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
const onClickCategoryButton = (evt: React.MouseEvent) => {
const categoryID = evt.currentTarget.getAttribute("data-category-id")!
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView()
}, [])
}
return <div className="sticker-picker" style={style}>
<div className="emoji-category-bar" ref={emojiCategoryBarRef}>
@ -76,12 +74,12 @@ const StickerPicker = ({ style, onSelect, room }: MediaPickerProps) => {
<div className="emoji-search">
<input
autoFocus={!isMobileDevice}
onChange={onChangeQuery}
onChange={evt => setQuery(evt.target.value)}
value={query}
type="search"
placeholder="Search stickers"
/>
<button onClick={clearQuery} disabled={query === ""}>
<button onClick={() => setQuery("")} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button>
</div>

View file

@ -13,10 +13,9 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { useCallback, useState } from "react"
import React, { useState } from "react"
import * as beeper from "@/api/beeper.ts"
import type Client from "@/api/client.ts"
import useEvent from "@/util/useEvent.ts"
interface BeeperLoginProps {
domain: string
@ -29,18 +28,18 @@ const BeeperLogin = ({ domain, client }: BeeperLoginProps) => {
const [code, setCode] = useState("")
const [error, setError] = useState("")
const onChangeEmail = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
const onChangeEmail = (evt: React.ChangeEvent<HTMLInputElement>) => {
setEmail(evt.target.value)
}, [])
const onChangeCode = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
}
const onChangeCode = (evt: React.ChangeEvent<HTMLInputElement>) => {
let codeDigits = evt.target.value.replace(/\D/g, "").slice(0, 6)
if (codeDigits.length > 3) {
codeDigits = codeDigits.slice(0, 3) + " " + codeDigits.slice(3)
}
setCode(codeDigits)
}, [])
}
const requestCode = useEvent((evt: React.FormEvent) => {
const requestCode = (evt: React.FormEvent) => {
evt.preventDefault()
beeper.doStartLogin(domain).then(
request => beeper.doRequestCode(domain, request, email).then(
@ -49,8 +48,8 @@ const BeeperLogin = ({ domain, client }: BeeperLoginProps) => {
),
err => setError(`Failed to start login: ${err}`),
)
})
const submitCode = useEvent((evt: React.FormEvent) => {
}
const submitCode = (evt: React.FormEvent) => {
evt.preventDefault()
beeper.doSubmitCode(domain, requestID, code).then(
token => {
@ -61,7 +60,7 @@ const BeeperLogin = ({ domain, client }: BeeperLoginProps) => {
},
err => setError(`Failed to submit code: ${err}`),
)
})
}
return <form onSubmit={requestID ? submitCode : requestCode} className="beeper-login">
<h2>Beeper email login</h2>

View file

@ -1,19 +1,23 @@
main.matrix-login {
max-width: 30rem;
max-width: 42rem;
width: 100%;
padding: 3rem 6rem;
box-sizing: border-box;
box-shadow: 0 0 1rem var(--modal-box-shadow-color);
margin: 2rem;
margin: 2rem auto;
@media (width < 800px) {
max-width: 38rem;
padding: 2rem 4rem;
width: calc(100% - 4rem);
}
@media (width < 500px) {
padding: 1rem;
box-shadow: none;
margin: 0 !important;
width: 100%;
}
h1 {

View file

@ -16,7 +16,6 @@
import React, { useCallback, useEffect, useState } from "react"
import type Client from "@/api/client.ts"
import type { ClientState } from "@/api/types"
import useEvent from "@/util/useEvent.ts"
import BeeperLogin from "./BeeperLogin.tsx"
import "./LoginScreen.css"
@ -34,7 +33,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
const [loginFlows, setLoginFlows] = useState<string[] | null>(null)
const [error, setError] = useState("")
const loginSSO = useEvent(() => {
const loginSSO = () => {
fetch("_gomuks/sso", {
method: "POST",
body: JSON.stringify({ homeserver_url: homeserverURL }),
@ -53,9 +52,9 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
},
err => setError(`Failed to start SSO login: ${err}`),
)
})
}
const login = useEvent((evt: React.FormEvent) => {
const login = (evt: React.FormEvent) => {
evt.preventDefault()
if (!loginFlows) {
// do nothing
@ -67,7 +66,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
err => setError(err.toString()),
)
}
})
}
const resolveLoginFlows = useCallback((serverURL: string) => {
client.rpc.getLoginFlows(serverURL).then(
@ -108,16 +107,10 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
clearTimeout(timeout)
}
}, [homeserverURL, loginFlows, resolveLoginFlows])
const onChangeUsername = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
setUsername(evt.target.value)
}, [])
const onChangePassword = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
setPassword(evt.target.value)
}, [])
const onChangeHomeserverURL = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
const onChangeHomeserverURL = (evt: React.ChangeEvent<HTMLInputElement>) => {
setLoginFlows(null)
setHomeserverURL(evt.target.value)
}, [])
}
const supportsSSO = loginFlows?.includes("m.login.sso") ?? false
const supportsPassword = loginFlows?.includes("m.login.password")
@ -130,7 +123,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
id="mxlogin-username"
placeholder="User ID"
value={username}
onChange={onChangeUsername}
onChange={evt => setUsername(evt.target.value)}
/>
<input
type="text"
@ -144,7 +137,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
id="mxlogin-password"
placeholder="Password"
value={password}
onChange={onChangePassword}
onChange={evt => setPassword(evt.target.value)}
/>}
<div className="buttons">
{supportsSSO && <button

View file

@ -13,7 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { useCallback, useState } from "react"
import React, { useState } from "react"
import { LoginScreenProps } from "./LoginScreen.tsx"
import "./LoginScreen.css"
@ -24,13 +24,13 @@ export const VerificationScreen = ({ client, clientState }: LoginScreenProps) =>
const [recoveryKey, setRecoveryKey] = useState("")
const [error, setError] = useState("")
const verify = useCallback((evt: React.FormEvent) => {
const verify = (evt: React.FormEvent) => {
evt.preventDefault()
client.rpc.verify(recoveryKey).then(
() => {},
err => setError(err.toString()),
)
}, [recoveryKey, client])
}
return <main className="matrix-login">
<h1>gomuks web</h1>

View file

@ -13,8 +13,9 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component, createContext, createRef, useCallback, useLayoutEffect, useState } from "react"
import React, { Component, createRef, useCallback, useLayoutEffect, useState } from "react"
import { keyToString } from "../keybindings.ts"
import { LightboxContext, LightboxParams } from "./contexts.ts"
import CloseIcon from "@/icons/close.svg?react"
import DownloadIcon from "@/icons/download.svg?react"
import RotateLeftIcon from "@/icons/rotate-left.svg?react"
@ -25,17 +26,7 @@ import "./Lightbox.css"
const isTouchDevice = window.ontouchstart !== undefined
export interface LightboxParams {
src: string
alt: string
}
export type OpenLightboxType = (params: LightboxParams | React.MouseEvent<HTMLImageElement>) => void
export const LightboxContext = createContext<OpenLightboxType>(() =>
console.error("Tried to open lightbox without being inside context"))
export const LightboxWrapper = ({ children }: { children: React.ReactNode }) => {
const LightboxWrapper = ({ children }: { children: React.ReactNode }) => {
const [params, setParams] = useState<LightboxParams | null>(null)
const onOpen = useCallback((params: LightboxParams | React.MouseEvent<HTMLImageElement>) => {
if ((params as React.MouseEvent).target) {
@ -224,3 +215,5 @@ export class Lightbox extends Component<LightboxProps> {
</div>
}
}
export default LightboxWrapper

View file

@ -13,25 +13,10 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { JSX, createContext, useCallback, useLayoutEffect, useReducer, useRef } from "react"
import React, { JSX, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react"
import { ModalCloseContext, ModalContext, ModalState } from "./contexts.ts"
export interface ModalState {
content: JSX.Element
dimmed?: boolean
boxed?: boolean
boxClass?: string
innerBoxClass?: string
onClose?: () => void
}
type openModal = (state: ModalState) => void
export const ModalContext = createContext<openModal>(() =>
console.error("Tried to open modal without being inside context"))
export const ModalCloseContext = createContext<() => void>(() => {})
export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
const [state, setState] = useReducer((prevState: ModalState | null, newState: ModalState | null) => {
prevState?.onClose?.()
return newState
@ -45,7 +30,7 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
history.back()
}
}, [])
const onKeyWrapper = useCallback((evt: React.KeyboardEvent<HTMLDivElement>) => {
const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => {
if (evt.key === "Escape") {
setState(null)
if (history.state?.modal) {
@ -53,9 +38,9 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
}
}
evt.stopPropagation()
}, [])
}
const openModal = useCallback((newState: ModalState) => {
if (!history.state?.modal) {
if (!history.state?.modal && newState.captureInput !== false) {
history.pushState({ ...(history.state ?? {}), modal: true }, "")
}
setState(newState)
@ -65,6 +50,9 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
wrapperRef.current.focus()
}
}, [state])
useEffect(() => {
window.closeModal = onClickWrapper
const listener = (evt: PopStateEvent) => {
if (!evt.state?.modal) {
setState(null)
@ -72,7 +60,7 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
}
window.addEventListener("popstate", listener)
return () => window.removeEventListener("popstate", listener)
}, [state])
}, [onClickWrapper])
let modal: JSX.Element | null = null
if (state) {
let content = <ModalCloseContext value={onClickWrapper}>{state.content}</ModalCloseContext>
@ -83,18 +71,24 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
</div>
</div>
}
modal = <div
className={`overlay modal ${state.dimmed ? "dimmed" : ""}`}
onClick={onClickWrapper}
onKeyDown={onKeyWrapper}
tabIndex={-1}
ref={wrapperRef}
>
{content}
</div>
if (state.captureInput !== false) {
modal = <div
className={`overlay modal ${state.dimmed ? "dimmed" : ""}`}
onClick={onClickWrapper}
onKeyDown={onKeyWrapper}
tabIndex={-1}
ref={wrapperRef}
>
{content}
</div>
} else {
modal = content
}
}
return <ModalContext value={openModal}>
{children}
{modal}
</ModalContext>
}
export default ModalWrapper

View file

@ -0,0 +1,43 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { JSX, createContext } from "react"
export interface LightboxParams {
src: string
alt: string
}
export type OpenLightboxType = (params: LightboxParams | React.MouseEvent<HTMLImageElement>) => void
export const LightboxContext = createContext<OpenLightboxType>(() =>
console.error("Tried to open lightbox without being inside context"))
export interface ModalState {
content: JSX.Element
dimmed?: boolean
boxed?: boolean
boxClass?: string
innerBoxClass?: string
onClose?: () => void
captureInput?: boolean
}
type openModal = (state: ModalState) => void
export const ModalContext = createContext<openModal>(() =>
console.error("Tried to open modal without being inside context"))
export const ModalCloseContext = createContext<() => void>(() => {})

View file

@ -0,0 +1,3 @@
export * from "./contexts.ts"
export { default as ModalWrapper } from "./Modal.tsx"
export { default as LightboxWrapper } from "./Lightbox.tsx"

View file

@ -13,7 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useCallback, useState } from "react"
import React, { use, useState } from "react"
import { getAvatarURL } from "@/api/media.ts"
import { MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts"
@ -45,8 +45,6 @@ const MemberRow = ({ evt, onClick }: MemberRowProps) => {
const MemberList = () => {
const [filter, setFilter] = useState("")
const [limit, setLimit] = useState(30)
const increaseLimit = useCallback(() => setLimit(limit => limit + 50), [])
const onChangeFilter = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setFilter(e.target.value), [])
const roomCtx = use(RoomContext)
if (roomCtx?.store && !roomCtx?.store.membersRequested && !roomCtx?.store.fullMembersLoaded) {
roomCtx.store.membersRequested = true
@ -69,10 +67,15 @@ const MemberList = () => {
}
}
return <>
<input className="member-filter" value={filter} onChange={onChangeFilter} placeholder="Filter members" />
<input
className="member-filter"
value={filter}
onChange={evt => setFilter(evt.target.value)}
placeholder="Filter members"
/>
<div className="member-list">
{members}
{memberEvents.length > limit ? <button onClick={increaseLimit}>
{memberEvents.length > limit ? <button onClick={() => setLimit(limit => limit + 50)}>
and {memberEvents.length - limit} others
</button> : null}
</div>

View file

@ -167,6 +167,20 @@ div.right-panel-content.user {
word-break: break-word;
}
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 {
width: 100%;
opacity: .2;

View file

@ -0,0 +1,114 @@
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
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 (e) {
return `${e}`
}
}
const ClockElement = ({ tz }: { tz: string }) => {
const [time, setTime] = useState(currentTimeAdjusted(tz))
useEffect(() => {
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])
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 <>
<hr/>
<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

@ -13,15 +13,16 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useEffect, useState } from "react"
import { use, useCallback, useEffect, useState } from "react"
import { PuffLoader } from "react-spinners"
import { getAvatarURL } from "@/api/media.ts"
import { useRoomMember } from "@/api/statestore"
import { MemberEventContent, UserID, UserProfile, Presence } from "@/api/types"
import { getLocalpart } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import { LightboxContext } from "../modal/Lightbox.tsx"
import { LightboxContext } from "../modal"
import { RoomContext } from "../roomview/roomcontext.ts"
import UserExtendedProfile from "./UserExtendedProfile.tsx"
import DeviceList from "./UserInfoDeviceList.tsx"
import UserInfoError from "./UserInfoError.tsx"
import MutualRooms from "./UserInfoMutualRooms.tsx"
@ -40,14 +41,17 @@ const UserInfo = ({ userID }: UserInfoProps) => {
const member = (memberEvt?.content ?? null) as MemberEventContent | null
const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null)
const [errors, setErrors] = useState<string[] | null>(null)
useEffect(() => {
setErrors(null)
setGlobalProfile(null)
const refreshProfile = useCallback((clearState = false) => {
if (clearState) {
setErrors(null)
setGlobalProfile(null)
}
client.rpc.getProfile(userID).then(
setGlobalProfile,
err => setErrors([`${err}`]),
)
}, [roomCtx, userID, client])
}, [userID, client])
useEffect(() => refreshProfile(true), [refreshProfile])
const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID)
return <>
@ -66,6 +70,9 @@ const UserInfo = ({ userID }: UserInfoProps) => {
<div className="displayname" title={displayname}>{displayname}</div>
<div className="userid" title={userID}>{userID}</div>
<UserPresence client={client} userID={userID}/>
{globalProfile && <UserExtendedProfile
profile={globalProfile} refreshProfile={refreshProfile} client={client} userID={userID}
/>}
<hr/>
{userID !== client.userID && <>
<MutualRooms client={client} userID={userID}/>

View file

@ -13,7 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useCallback, useEffect, useState, useTransition } from "react"
import { useEffect, useState, useTransition } from "react"
import { ScaleLoader } from "react-spinners"
import Client from "@/api/client.ts"
import { RoomStateStore } from "@/api/statestore"
@ -34,17 +34,19 @@ const DeviceList = ({ client, room, userID }: DeviceListProps) => {
const [view, setEncryptionInfo] = useState<ProfileEncryptionInfo | null>(null)
const [errors, setErrors] = useState<string[] | null>(null)
const [trackChangePending, startTransition] = useTransition()
const doTrackDeviceList = useCallback(() => {
const doTrackDeviceList = () => {
startTransition(async () => {
try {
const resp = await client.rpc.trackUserDevices(userID)
setEncryptionInfo(resp)
setErrors(resp.errors)
startTransition(() => {
setEncryptionInfo(resp)
setErrors(resp.errors)
})
} catch (err) {
setErrors([`${err}`])
startTransition(() => setErrors([`${err}`]))
}
})
}, [client, userID])
}
useEffect(() => {
setEncryptionInfo(null)
setErrors(null)

View file

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

View file

@ -13,7 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { memo, use } from "react"
import { JSX, memo, use } from "react"
import { getRoomAvatarURL } from "@/api/media.ts"
import type { RoomListEntry } from "@/api/statestore"
import type { MemDBEvent, MemberEventContent } from "@/api/types"
@ -21,6 +21,7 @@ import useContentVisibility from "@/util/contentvisibility.ts"
import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts"
import UnreadCount from "./UnreadCount.tsx"
export interface RoomListEntryProps {
room: RoomListEntry
@ -28,11 +29,12 @@ export interface RoomListEntryProps {
hidden: boolean
}
function usePreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null): [string, string] {
function getPreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null): [string, JSX.Element | null] {
if (!evt) {
return ["", ""]
return ["", null]
}
if ((evt.type === "m.room.message" || evt.type === "m.sticker") && typeof evt.content.body === "string") {
// eslint-disable-next-line react-hooks/rules-of-hooks
const client = use(ClientContext)!
const displayname = evt.sender === client.userID
? "You"
@ -43,26 +45,18 @@ function usePreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null):
}
return [
`${displayname}: ${evt.content.body}`,
`${displayname.length > 16 ? displayname.slice(0, 12) + "…" : displayname}: ${previewText}`,
<>
<span style={{ unicodeBidi: "isolate" }}>
{displayname.length > 16 ? displayname.slice(0, 12) + "…" : displayname}
</span>: {previewText}
</>,
]
}
return ["", ""]
return ["", null]
}
interface InnerProps {
room: RoomListEntry
}
const EntryInner = ({ room }: InnerProps) => {
const [previewText, croppedPreviewText] = usePreviewText(room.preview_event, room.preview_sender)
const unreadCount = room.unread_messages || room.unread_notifications || room.unread_highlights
const countIsBig = Boolean(room.unread_notifications || room.unread_highlights)
let unreadCountDisplay = unreadCount.toString()
if (unreadCount > 999 && countIsBig) {
unreadCountDisplay = "99+"
} else if (unreadCount > 9999 && countIsBig) {
unreadCountDisplay = "999+"
}
function renderEntry(room: RoomListEntry) {
const [previewText, croppedPreviewText] = getPreviewText(room.preview_event, room.preview_sender)
return <>
<div className="room-entry-left">
@ -77,15 +71,7 @@ const EntryInner = ({ room }: InnerProps) => {
<div className="room-name">{room.name}</div>
{previewText && <div className="message-preview" title={previewText}>{croppedPreviewText}</div>}
</div>
{(room.unread_messages || room.marked_unread) ? <div className="room-entry-unreads">
<div title={unreadCount.toString()} className={`unread-count ${
room.marked_unread ? "marked-unread" : ""} ${
room.unread_notifications ? "notified" : ""} ${
room.unread_highlights ? "highlighted" : ""}`}
>
{unreadCountDisplay}
</div>
</div> : null}
<UnreadCount counts={room} />
</>
}
@ -97,7 +83,7 @@ const Entry = ({ room, isActive, hidden }: RoomListEntryProps) => {
onClick={use(MainScreenContext).clickRoom}
data-room-id={room.room_id}
>
{isVisible ? <EntryInner room={room}/> : null}
{isVisible ? renderEntry(room) : null}
</div>
}

View file

@ -0,0 +1,60 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX } from "react"
import { RoomListFilter, Space } from "@/api/statestore/space.ts"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import UnreadCount from "./UnreadCount.tsx"
import HomeIcon from "@/icons/home.svg?react"
import NotificationsIcon from "@/icons/notifications.svg?react"
import PersonIcon from "@/icons/person.svg?react"
import TagIcon from "@/icons/tag.svg?react"
import "./RoomList.css"
export interface FakeSpaceProps {
space: Space | null
setSpace: (space: RoomListFilter | null) => void
isActive: boolean
onClickUnread?: (evt: React.MouseEvent<HTMLDivElement>, space: Space | null) => void
}
const getFakeSpaceMeta = (space: RoomListFilter | null): [string | undefined, JSX.Element | null] => {
switch (space?.id) {
case undefined:
return ["Home", <HomeIcon />]
case "fi.mau.gomuks.direct_chats":
return ["Direct chats", <PersonIcon />]
case "fi.mau.gomuks.unreads":
return ["Unread chats", <NotificationsIcon />]
case "fi.mau.gomuks.space_orphans":
return ["Rooms outside spaces", <TagIcon />]
default:
return [undefined, null]
}
}
const FakeSpace = ({ space, setSpace, isActive, onClickUnread }: FakeSpaceProps) => {
const unreads = useEventAsState(space?.counts)
const onClickUnreadWrapped = onClickUnread
? (evt: React.MouseEvent<HTMLDivElement>) => onClickUnread(evt, space)
: undefined
const [title, icon] = getFakeSpaceMeta(space)
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={() => setSpace(space)} title={title}>
<UnreadCount counts={unreads} space={true} onClick={onClickUnreadWrapped} />
{icon}
</div>
}
export default FakeSpace

View file

@ -5,14 +5,68 @@ div.room-list-wrapper {
box-sizing: border-box;
overflow: hidden;
scrollbar-color: var(--room-list-scrollbar-color);
display: flex;
flex-direction: column;
display: grid;
grid-template:
"spacebar search" 3.5rem
"spacebar roomlist" 1fr
/ 3rem 1fr;
}
div.room-list {
background-color: var(--room-list-background-overlay);
overflow-y: auto;
flex: 1;
grid-area: roomlist;
}
div.space-bar {
background-color: var(--space-list-background-overlay);
grid-area: spacebar;
overflow: auto;
scrollbar-width: none;
> div.space-entry {
width: 2rem;
height: 2rem;
padding: .25rem;
margin: .25rem;
border-radius: .25rem;
cursor: var(--clickable-cursor);
&:hover, &:focus {
background-color: var(--room-list-entry-hover-color);
}
&.active {
background-color: var(--room-list-entry-selected-color);
}
> svg {
width: 100%;
height: 100%;
}
> img.avatar {
border-radius: 0;
clip-path: url(#squircle);
width: 100%;
height: 100%;
}
> div.room-entry-unreads {
z-index: 2;
height: 0;
width: 0;
margin-left: auto;
position: relative;
> div.unread-count {
position: absolute;
/* This positioning doesn't feel very precise, but it looks correct enough */
margin-top: .75rem;
margin-right: .25rem;
}
}
}
}
div.room-search-wrapper {
@ -21,6 +75,7 @@ div.room-search-wrapper {
align-items: center;
height: 3.5rem;
background-color: var(--room-list-search-background-overlay);
grid-area: search;
> input {
padding: 0 0 0 1rem;
@ -64,7 +119,7 @@ div.room-entry {
width: 3rem;
> img.room-avatar {
padding: 4px;
margin: .25rem;
}
}
@ -98,51 +153,65 @@ div.room-entry {
}
}
}
}
div.room-entry-unreads {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
margin-right: .25rem;
> div.unread-count {
--unread-count-size: 1rem;
--unread-count-padding-inline: calc(var(--unread-count-size)/4);
--unread-count-padding-block: calc(var(--unread-count-size)/8);
> div.room-entry-unreads {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
margin-right: .25rem;
border-radius: var(--unread-count-size);
color: var(--unread-counter-text-color);
user-select: none;
> div.unread-count {
--unread-count-size: 1rem;
--unread-count-padding-inline: calc(var(--unread-count-size)/4);
--unread-count-padding-block: calc(var(--unread-count-size)/8);
background-color: var(--unread-counter-message-bg);
height: var(--unread-count-size);
min-width: calc(var(--unread-count-size) - 2*(var(--unread-count-padding-inline) - var(--unread-count-padding-block)));
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--unread-count-size);
color: var(--unread-counter-text-color);
line-height: 1;
font-size: .75em;
background-color: var(--unread-counter-message-bg);
height: var(--unread-count-size);
min-width: calc(var(--unread-count-size) - 2*(var(--unread-count-padding-inline) - var(--unread-count-padding-block)));
padding-inline: var(--unread-count-padding-inline);
padding-block: var(--unread-count-padding-block);
line-height: 1;
font-size: .75em;
&.big {
--unread-count-size: 1.5rem;
font-size: 1em;
font-weight: bold;
}
padding-inline: var(--unread-count-padding-inline);
padding-block: var(--unread-count-padding-block);
&.marked-unread {
background-color: var(--unread-counter-marked-unread-bg);
}
&.notified, &.marked-unread, &.highlighted {
--unread-count-size: 1.5rem;
font-size: 1em;
font-weight: bold;
}
&.notified {
background-color: var(--unread-counter-notification-bg);
}
&.marked-unread {
background-color: var(--unread-counter-marked-unread-bg);
}
&.highlighted {
background-color: var(--unread-counter-highlight-bg);
}
&.space {
--unread-count-size: .75rem;
background-color: var(--space-unread-counter-message-bg);
&.notified {
background-color: var(--unread-counter-notification-bg);
background-color: var(--space-unread-counter-notification-bg);
}
&.highlighted {
background-color: var(--unread-counter-highlight-bg);
background-color: var(--space-unread-counter-highlight-bg);
}
}
}

View file

@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useCallback, useRef, useState } from "react"
import { RoomListFilter, Space as SpaceStore, SpaceUnreadCounts } from "@/api/statestore"
import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import reverseMap from "@/util/reversemap.ts"
@ -22,34 +23,72 @@ import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts"
import { keyToString } from "../keybindings.ts"
import Entry from "./Entry.tsx"
import FakeSpace from "./FakeSpace.tsx"
import Space from "./Space.tsx"
import CloseIcon from "@/icons/close.svg?react"
import SearchIcon from "@/icons/search.svg?react"
import "./RoomList.css"
interface RoomListProps {
activeRoomID: RoomID | null
space: RoomListFilter | null
}
const RoomList = ({ activeRoomID }: RoomListProps) => {
const RoomList = ({ activeRoomID, space }: RoomListProps) => {
const client = use(ClientContext)!
const mainScreen = use(MainScreenContext)
const roomList = useEventAsState(client.store.roomList)
const roomFilterRef = useRef<HTMLInputElement>(null)
const [roomFilter, setRoomFilter] = useState("")
const [realRoomFilter, setRealRoomFilter] = useState("")
const spaces = useEventAsState(client.store.topLevelSpaces)
const searchInputRef = useRef<HTMLInputElement>(null)
const [query, directSetQuery] = useState("")
const updateRoomFilter = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
setRoomFilter(evt.target.value)
client.store.currentRoomListFilter = toSearchableString(evt.target.value)
setRealRoomFilter(client.store.currentRoomListFilter)
}, [client])
const clearQuery = useCallback(() => {
setRoomFilter("")
client.store.currentRoomListFilter = ""
setRealRoomFilter("")
roomFilterRef.current?.focus()
}, [client])
const onKeyDown = useCallback((evt: React.KeyboardEvent<HTMLInputElement>) => {
const setQuery = (evt: React.ChangeEvent<HTMLInputElement>) => {
client.store.currentRoomListQuery = toSearchableString(evt.target.value)
directSetQuery(evt.target.value)
}
const onClickSpace = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!)
mainScreen.setSpace(store)
}, [mainScreen, client])
const onClickSpaceUnread = useCallback((
evt: React.MouseEvent<HTMLDivElement>, space?: SpaceStore | null,
) => {
if (!space) {
const targetSpace = evt.currentTarget.closest("div.space-entry")?.getAttribute("data-target-space")
if (!targetSpace) {
return
}
space = client.store.getSpaceStore(targetSpace)
if (!space) {
return
}
}
const counts = space.counts.current
let wantedField: keyof SpaceUnreadCounts
if (counts.unread_highlights > 0) {
wantedField = "unread_highlights"
} else if (counts.unread_notifications > 0) {
wantedField = "unread_notifications"
} else if (counts.unread_messages > 0) {
wantedField = "unread_messages"
} else {
return
}
for (let i = client.store.roomList.current.length - 1; i >= 0; i--) {
const entry = client.store.roomList.current[i]
if (entry[wantedField] > 0 && space.include(entry)) {
mainScreen.setActiveRoom(entry.room_id, undefined, space)
evt.stopPropagation()
break
}
}
}, [mainScreen, client])
const clearQuery = () => {
client.store.currentRoomListQuery = ""
directSetQuery("")
searchInputRef.current?.focus()
}
const onKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
const key = keyToString(evt)
if (key === "Escape") {
clearQuery()
@ -62,30 +101,49 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
evt.stopPropagation()
evt.preventDefault()
}
}, [mainScreen, client.store, clearQuery])
}
const roomListFilter = client.store.roomListFilterFunc
return <div className="room-list-wrapper">
<div className="room-search-wrapper">
<input
value={roomFilter}
onChange={updateRoomFilter}
value={query}
onChange={setQuery}
onKeyDown={onKeyDown}
className="room-search"
type="text"
placeholder="Search rooms"
ref={roomFilterRef}
ref={searchInputRef}
id="room-search"
/>
<button onClick={clearQuery} disabled={roomFilter === ""}>
{roomFilter !== "" ? <CloseIcon/> : <SearchIcon/>}
<button onClick={clearQuery} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button>
</div>
<div className="space-bar">
<FakeSpace space={null} setSpace={mainScreen.setSpace} isActive={space === null} />
{client.store.pseudoSpaces.map(pseudoSpace => <FakeSpace
key={pseudoSpace.id}
space={pseudoSpace}
setSpace={mainScreen.setSpace}
onClickUnread={onClickSpaceUnread}
isActive={space?.id === pseudoSpace.id}
/>)}
{spaces.map(roomID => <Space
key={roomID}
roomID={roomID}
client={client}
onClick={onClickSpace}
isActive={space?.id === roomID}
onClickUnread={onClickSpaceUnread}
/>)}
</div>
<div className="room-list">
{reverseMap(roomList, room =>
<Entry
key={room.room_id}
isActive={room.room_id === activeRoomID}
hidden={roomFilter ? !room.search_name.includes(realRoomFilter) : false}
hidden={roomListFilter ? !roomListFilter(room) : false}
room={room}
/>,
)}

View file

@ -0,0 +1,44 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React from "react"
import Client from "@/api/client.ts"
import { getRoomAvatarURL } from "@/api/media.ts"
import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import UnreadCount from "./UnreadCount.tsx"
import "./RoomList.css"
export interface SpaceProps {
roomID: RoomID
client: Client
onClick: (evt: React.MouseEvent<HTMLDivElement>) => void
onClickUnread: (evt: React.MouseEvent<HTMLDivElement>) => void
isActive: boolean
}
const Space = ({ roomID, client, onClick, isActive, onClickUnread }: SpaceProps) => {
const unreads = useEventAsState(client.store.spaceEdges.get(roomID)?.counts)
const room = useEventAsState(client.store.rooms.get(roomID)?.meta)
if (!room) {
return
}
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={onClick} data-target-space={roomID}>
<UnreadCount counts={unreads} space={true} onClick={onClickUnread} />
<img src={getRoomAvatarURL(room)} alt={room.name} title={room.name} className="avatar" />
</div>
}
export default Space

View file

@ -0,0 +1,75 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { SpaceUnreadCounts } from "@/api/statestore"
interface UnreadCounts extends SpaceUnreadCounts {
marked_unread?: boolean
}
interface UnreadCountProps {
counts: UnreadCounts | null
space?: true
onClick?: (evt: React.MouseEvent<HTMLDivElement>) => void
}
const UnreadCount = ({ counts, space, onClick }: UnreadCountProps) => {
if (!counts) {
return null
}
const unreadCount = space
? counts.unread_highlights || counts.unread_notifications || counts.unread_messages
: counts.unread_messages || counts.unread_notifications || counts.unread_highlights
if (!unreadCount && !counts.marked_unread) {
return null
}
const countIsBig = !space
&& Boolean(counts.unread_notifications || counts.unread_highlights || counts.marked_unread)
let unreadCountDisplay = unreadCount.toString()
if (unreadCount > 999 && (countIsBig || space)) {
unreadCountDisplay = "99+"
} else if (unreadCount > 9999) {
unreadCountDisplay = "999+"
}
const classNames = ["unread-count"]
if (countIsBig) {
classNames.push("big")
}
if (space) {
classNames.push("space")
}
const unreadCountTitle = [
counts.unread_highlights && `${counts.unread_highlights} highlights`,
counts.unread_notifications && `${counts.unread_notifications} notifications`,
counts.unread_messages && `${counts.unread_messages} messages`,
counts.marked_unread && "Marked unread",
].filter(x => !!x).join("\n")
if (counts.marked_unread) {
classNames.push("marked-unread")
}
if (counts.unread_notifications) {
classNames.push("notified")
}
if (counts.unread_highlights) {
classNames.push("highlighted")
}
return <div className="room-entry-unreads">
<div title={unreadCountTitle} className={classNames.join(" ")} onClick={onClick}>
{unreadCountDisplay}
</div>
</div>
}
export default UnreadCount

View file

@ -13,7 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useCallback, useEffect, useState } from "react"
import { use, useEffect, useState } from "react"
import { ScaleLoader } from "react-spinners"
import { getAvatarURL, getRoomAvatarURL } from "@/api/media.ts"
import { InvitedRoomStore } from "@/api/statestore/invitedroom.ts"
@ -21,7 +21,7 @@ import { RoomID, RoomSummary } from "@/api/types"
import { getDisplayname, getServerName } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts"
import { LightboxContext } from "../modal/Lightbox.tsx"
import { LightboxContext } from "../modal"
import MutualRooms from "../rightpanel/UserInfoMutualRooms.tsx"
import ErrorIcon from "@/icons/error.svg?react"
import GroupIcon from "@/icons/group.svg?react"
@ -41,7 +41,7 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
const [loading, setLoading] = useState(false)
const [buttonClicked, setButtonClicked] = useState(false)
const [error, setError] = useState<string | null>(null)
const doJoinRoom = useCallback(() => {
const doJoinRoom = () => {
let realVia = via
if (!via?.length && invite?.invited_by) {
realVia = [getServerName(invite.invited_by)]
@ -54,8 +54,8 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
setButtonClicked(false)
},
)
}, [client, roomID, via, alias, invite])
const doRejectInvite = useCallback(() => {
}
const doRejectInvite = () => {
setButtonClicked(true)
client.rpc.leaveRoom(roomID).then(
() => {
@ -67,7 +67,7 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
setButtonClicked(false)
},
)
}, [client, mainScreen, roomID])
}
useEffect(() => {
setSummary(null)
setError(null)

View file

@ -19,3 +19,17 @@ div.room-view {
align-items: center;
}
}
div#mobile-event-menu-container {
grid-area: header;
overflow: hidden;
border-bottom: 1px solid var(--border-color);
&:empty {
display: none;
}
&:not(:empty) + div.room-header {
display: none;
}
}

View file

@ -41,8 +41,15 @@ const RoomView = ({ room, rightPanelResizeHandle, rightPanel }: RoomViewProps) =
}
}
}, [roomContextData])
const onClick = (evt: React.MouseEvent<HTMLDivElement>) => {
if (roomContextData.focusedEventRowID) {
roomContextData.setFocusedEventRowID(null)
evt.stopPropagation()
}
}
return <RoomContext value={roomContextData}>
<div className="room-view">
<div className="room-view" onClick={onClick}>
<div id="mobile-event-menu-container"/>
<RoomViewHeader room={room}/>
<TimelineView/>
<MessageComposer/>

View file

@ -5,6 +5,7 @@ div.room-header {
padding-left: .5rem;
border-bottom: 1px solid var(--border-color);
overflow: hidden;
grid-area: header;
> div.room-name-and-topic {
flex: 1;

View file

@ -13,13 +13,13 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useCallback } from "react"
import { use } from "react"
import { getRoomAvatarURL } from "@/api/media.ts"
import { RoomStateStore } from "@/api/statestore"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import MainScreenContext from "../MainScreenContext.ts"
import { LightboxContext } from "../modal/Lightbox.tsx"
import { ModalContext } from "../modal/Modal.tsx"
import { LightboxContext } from "../modal"
import { ModalContext } from "../modal"
import SettingsView from "../settings/SettingsView.tsx"
import BackIcon from "@/icons/back.svg?react"
import PeopleIcon from "@/icons/group.svg?react"
@ -35,14 +35,14 @@ const RoomViewHeader = ({ room }: RoomViewHeaderProps) => {
const roomMeta = useEventAsState(room.meta)
const mainScreen = use(MainScreenContext)
const openModal = use(ModalContext)
const openSettings = useCallback(() => {
const openSettings = () => {
openModal({
dimmed: true,
boxed: true,
innerBoxClass: "settings-view",
content: <SettingsView room={room} />,
})
}, [room, openModal])
}
return <div className="room-header">
<button className="back" onClick={mainScreen.clearActiveRoom}><BackIcon/></button>
<img

View file

@ -28,8 +28,9 @@ export class RoomContextData {
public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo")
public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing")
public insertText: (text: string) => void = noop("insertText")
public directSetFocusedEventRowID: (eventRowID: EventRowID | null) => void = noop("setFocusedEventRowID")
public focusedEventRowID: EventRowID | null = null
public readonly isEditing = new NonNullCachedEventDispatcher<boolean>(false)
public ownMessages: EventRowID[] = []
public scrolledToBottom = true
constructor(public store: RoomStateStore) {}
@ -40,6 +41,11 @@ export class RoomContextData {
}
}
setFocusedEventRowID = (eventRowID: number | null) => {
this.directSetFocusedEventRowID(eventRowID)
this.focusedEventRowID = eventRowID
}
appendMentionToComposer = (evt: React.MouseEvent<HTMLSpanElement>) => {
const targetUser = evt.currentTarget.getAttribute("data-target-user")
if (!targetUser) {

View file

@ -29,8 +29,8 @@ import {
import { useEventAsState } from "@/util/eventdispatcher.ts"
import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts"
import { LightboxContext } from "../modal/Lightbox.tsx"
import { ModalCloseContext } from "../modal/Modal.tsx"
import { LightboxContext } from "../modal"
import { ModalCloseContext } from "../modal"
import JSONView from "../util/JSONView.tsx"
import Toggle from "../util/Toggle.tsx"
import CloseIcon from "@/icons/close.svg?react"
@ -45,52 +45,39 @@ interface PreferenceCellProps<T extends PreferenceValueType> {
inheritedValue: T
}
const useRemover = (
const makeRemover = (
context: PreferenceContext, setPref: SetPrefFunc, name: keyof Preferences, value: PreferenceValueType | undefined,
) => {
const onClear = useCallback(() => {
setPref(context, name, undefined)
}, [setPref, context, name])
if (value === undefined) {
return null
}
return <button onClick={onClear}><CloseIcon /></button>
return <button onClick={() => setPref(context, name, undefined)}><CloseIcon /></button>
}
const BooleanPreferenceCell = ({ context, name, setPref, value, inheritedValue }: PreferenceCellProps<boolean>) => {
const onChange = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
setPref(context, name, evt.target.checked)
}, [setPref, context, name])
return <div className="preference boolean-preference">
<Toggle checked={value ?? inheritedValue} onChange={onChange}/>
{useRemover(context, setPref, name, value)}
<Toggle checked={value ?? inheritedValue} onChange={evt => setPref(context, name, evt.target.checked)}/>
{makeRemover(context, setPref, name, value)}
</div>
}
const TextPreferenceCell = ({ context, name, setPref, value, inheritedValue }: PreferenceCellProps<string>) => {
const onChange = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
setPref(context, name, evt.target.value)
}, [setPref, context, name])
return <div className="preference string-preference">
<input value={value ?? inheritedValue} onChange={onChange}/>
{useRemover(context, setPref, name, value)}
<input value={value ?? inheritedValue} onChange={evt => setPref(context, name, evt.target.value)}/>
{makeRemover(context, setPref, name, value)}
</div>
}
const SelectPreferenceCell = ({ context, name, pref, setPref, value, inheritedValue }: PreferenceCellProps<string>) => {
const onChange = useCallback((evt: React.ChangeEvent<HTMLSelectElement>) => {
setPref(context, name, evt.target.value)
}, [setPref, context, name])
const remover = useRemover(context, setPref, name, value)
if (!pref.allowedValues) {
return null
}
return <div className="preference select-preference">
<select value={value ?? inheritedValue} onChange={onChange}>
<select value={value ?? inheritedValue} onChange={evt => setPref(context, name, evt.target.value)}>
{pref.allowedValues.map(value =>
<option key={value} value={value}>{value}</option>)}
</select>
{remover}
{makeRemover(context, setPref, name, value)}
</div>
}
@ -123,6 +110,9 @@ const PreferenceRow = ({
val: PreferenceValueType | undefined,
inheritedVal: PreferenceValueType,
) => {
if (!pref.allowedContexts.includes(context)) {
return null
}
if (prefType === "boolean") {
return <BooleanPreferenceCell
name={name}
@ -186,7 +176,7 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
const client = use(ClientContext)!
const appliedContext = getActiveCSSContext(client, room)
const [context, setContext] = useState(appliedContext)
const getContextText = useCallback((context: PreferenceContext) => {
const getContextText = (context: PreferenceContext) => {
if (context === PreferenceContext.Account) {
return client.store.serverPreferenceCache.custom_css
} else if (context === PreferenceContext.Device) {
@ -196,17 +186,17 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
} else if (context === PreferenceContext.RoomDevice) {
return room.localPreferenceCache.custom_css
}
}, [client, room])
}
const origText = getContextText(context)
const [text, setText] = useState(origText ?? "")
const onChangeContext = useCallback((evt: React.ChangeEvent<HTMLSelectElement>) => {
const onChangeContext = (evt: React.ChangeEvent<HTMLSelectElement>) => {
const newContext = evt.target.value as PreferenceContext
setContext(newContext)
setText(getContextText(newContext) ?? "")
}, [getContextText])
const onChangeText = useCallback((evt: React.ChangeEvent<HTMLTextAreaElement>) => {
}
const onChangeText = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(evt.target.value)
}, [])
}
const onSave = useEvent(() => {
if (vscodeOpen) {
setText(vscodeContentRef.current)
@ -215,18 +205,18 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
setPref(context, "custom_css", text)
}
})
const onDelete = useEvent(() => {
const onDelete = () => {
setPref(context, "custom_css", undefined)
setText("")
})
}
const [vscodeOpen, setVSCodeOpen] = useState(false)
const vscodeContentRef = useRef("")
const vscodeInitialContentRef = useRef("")
const onClickVSCode = useEvent(() => {
const onClickVSCode = () => {
vscodeContentRef.current = text
vscodeInitialContentRef.current = text
setVSCodeOpen(true)
})
}
const closeVSCode = useCallback(() => {
setVSCodeOpen(false)
setText(vscodeContentRef.current)
@ -247,7 +237,9 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
</span>}
</div>
{vscodeOpen ? <div className="vscode-wrapper">
<Suspense fallback={<div className="loader"><ScaleLoader width={40} height={80} color="var(--primary-color)"/></div>}>
<Suspense fallback={
<div className="loader"><ScaleLoader width={40} height={80} color="var(--primary-color)"/></div>
}>
<Monaco
initData={vscodeInitialContentRef.current}
onClose={closeVSCode}
@ -296,7 +288,9 @@ const SettingsView = ({ room }: SettingsViewProps) => {
const roomMeta = useEventAsState(room.meta)
const client = use(ClientContext)!
const closeModal = use(ModalCloseContext)
const setPref = useCallback((context: PreferenceContext, key: keyof Preferences, value: PreferenceValueType | undefined) => {
const setPref = useCallback((
context: PreferenceContext, key: keyof Preferences, value: PreferenceValueType | undefined,
) => {
if (context === PreferenceContext.Account) {
client.rpc.setAccountData("fi.mau.gomuks.preferences", {
...client.store.serverPreferenceCache,
@ -321,15 +315,15 @@ const SettingsView = ({ room }: SettingsViewProps) => {
}
}
}, [client, room])
const onClickLogout = useCallback(() => {
const onClickLogout = () => {
if (window.confirm("Really log out and delete all local data?")) {
client.logout().then(
() => console.info("Successfully logged out"),
err => window.alert(`Failed to log out: ${err}`),
)
}
}, [client])
const onClickLeave = useCallback(() => {
}
const onClickLeave = () => {
if (window.confirm(`Really leave ${room.meta.current.name}?`)) {
client.rpc.leaveRoom(room.roomID).then(
() => {
@ -339,7 +333,17 @@ const SettingsView = ({ room }: SettingsViewProps) => {
err => window.alert(`Failed to leave room: ${err}`),
)
}
}, [client, room, closeModal])
}
const onClickOpenCSSApp = () => {
client.rpc.requestOpenIDToken().then(
resp => window.open(
`https://css.gomuks.app/login?token=${resp.access_token}&server_name=${resp.matrix_server_name}`,
"_blank",
"noreferrer noopener",
),
err => window.alert(`Failed to request OpenID token: ${err}`),
)
}
usePreferences(client.store, room)
const globalServer = client.store.serverPreferenceCache
const globalLocal = client.store.localPreferenceCache
@ -389,6 +393,7 @@ const SettingsView = ({ room }: SettingsViewProps) => {
<CustomCSSInput setPref={setPref} room={room} />
<AppliedSettingsView room={room} />
<div className="misc-buttons">
<button onClick={onClickOpenCSSApp}>Sign into css.gomuks.app</button>
{window.Notification && <button onClick={client.requestNotificationPermission}>
Request notification permission
</button>}

View file

@ -20,6 +20,7 @@ div.timeline-event > div.read-receipts {
> img {
margin-left: -.35rem;
border: 1px solid var(--background-color);
background-color: var(--background-color);
}
}

View file

@ -1,18 +1,44 @@
blockquote.reply-body {
margin: 0 0 .25rem;
border-left: 2px solid var(--blockquote-border-color);
border-left: 2px solid var(--reply-border-color);
padding: .25rem .5rem;
&.sender-color-0 { border-color: var(--sender-color-0); }
&.sender-color-1 { border-color: var(--sender-color-1); }
&.sender-color-2 { border-color: var(--sender-color-2); }
&.sender-color-3 { border-color: var(--sender-color-3); }
&.sender-color-4 { border-color: var(--sender-color-4); }
&.sender-color-5 { border-color: var(--sender-color-5); }
&.sender-color-6 { border-color: var(--sender-color-6); }
&.sender-color-7 { border-color: var(--sender-color-7); }
&.sender-color-8 { border-color: var(--sender-color-8); }
&.sender-color-9 { border-color: var(--sender-color-9); }
&.sender-color-null { --reply-border-color: var(--blockquote-border-color); }
&.sender-color-0 { --reply-border-color: var(--sender-color-0); }
&.sender-color-1 { --reply-border-color: var(--sender-color-1); }
&.sender-color-2 { --reply-border-color: var(--sender-color-2); }
&.sender-color-3 { --reply-border-color: var(--sender-color-3); }
&.sender-color-4 { --reply-border-color: var(--sender-color-4); }
&.sender-color-5 { --reply-border-color: var(--sender-color-5); }
&.sender-color-6 { --reply-border-color: var(--sender-color-6); }
&.sender-color-7 { --reply-border-color: var(--sender-color-7); }
&.sender-color-8 { --reply-border-color: var(--sender-color-8); }
&.sender-color-9 { --reply-border-color: var(--sender-color-9); }
&.small {
grid-area: reply;
display: flex;
gap: .25rem;
font-size: var(--small-font-size);
height: calc(var(--small-font-size) * 1.5);
border-left: none;
padding: 0;
> div.reply-spine {
margin-top: calc(var(--small-font-size) * 0.75 - 1px);
margin-left: calc(var(--timeline-avatar-size) / 2 - 1px);
width: calc(var(--timeline-avatar-size)/2 + var(--timeline-avatar-gap));
border-left: 2px solid var(--reply-border-color);
border-top: 2px solid var(--reply-border-color);
border-top-left-radius: .5rem;
flex-shrink: 0;
}
> div.message-text {
-webkit-line-clamp: 1;
font-size: var(--small-font-size);
}
}
pre {
display: inline;
@ -38,6 +64,16 @@ blockquote.reply-body {
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--semisecondary-text-color);
user-select: none;
h1, h2, h3, h4, h5, h6 {
font-size: 1em;
}
img {
vertical-align: baseline;
height: 1em;
}
}
&.thread > div.reply-sender > span.event-sender::after {
@ -60,6 +96,21 @@ blockquote.reply-body {
width: 1rem;
height: 1rem;
margin-right: .25rem;
> img {
width: 100%;
height: 100%;
}
}
> div.per-message-event-sender {
color: var(--secondary-text-color);
font-size: .75rem;
margin: 0 .25rem;
> span.via {
margin-right: .25rem;
}
}
> div.buttons {

View file

@ -20,7 +20,7 @@ import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import TooltipButton from "../util/TooltipButton.tsx"
import { ContentErrorBoundary, getBodyType } from "./content"
import { ContentErrorBoundary, getBodyType, getPerMessageProfile } from "./content"
import CloseIcon from "@/icons/close.svg?react"
import NotificationsOffIcon from "@/icons/notifications-off.svg?react"
import NotificationsIcon from "@/icons/notifications.svg?react"
@ -32,6 +32,7 @@ interface ReplyBodyProps {
room: RoomStateStore
event: MemDBEvent
isThread: boolean
small?: boolean
isEditing?: boolean
onClose?: (evt: React.MouseEvent) => void
isSilent?: boolean
@ -44,18 +45,22 @@ interface ReplyIDBodyProps {
room: RoomStateStore
eventID: EventID
isThread: boolean
small: boolean
}
export const ReplyIDBody = ({ room, eventID, isThread }: ReplyIDBodyProps) => {
export const ReplyIDBody = ({ room, eventID, isThread, small }: ReplyIDBodyProps) => {
const event = useRoomEvent(room, eventID)
if (!event) {
// This caches whether the event is requested or not, so it doesn't need to be wrapped in an effect.
use(ClientContext)!.requestEvent(room, eventID)
return <blockquote className="reply-body">
Reply to unknown event<br/><code>{eventID}</code>
return <blockquote className={`reply-body sender-color-null ${small ? "small" : ""}`}>
{small && <div className="reply-spine"/>}
Reply to unknown event
{!small && <br/>}
<code>{eventID}</code>
</blockquote>
}
return <ReplyBody room={room} event={event} isThread={isThread}/>
return <ReplyBody room={room} event={event} isThread={isThread} small={small}/>
}
const onClickReply = (evt: React.MouseEvent) => {
@ -78,7 +83,7 @@ const onClickReply = (evt: React.MouseEvent) => {
}
export const ReplyBody = ({
room, event, onClose, isThread, isEditing, isSilent, onSetSilent, isExplicitInThread, onSetExplicitInThread,
room, event, onClose, isThread, isEditing, isSilent, onSetSilent, isExplicitInThread, onSetExplicitInThread, small,
}: ReplyBodyProps) => {
const client = use(ClientContext)
const memberEvt = useRoomMember(client, room, event.sender)
@ -94,21 +99,50 @@ export const ReplyBody = ({
if (isEditing) {
classNames.push("editing")
}
const userColorIndex = getUserColorIndex(event.sender)
if (small) {
classNames.push("small")
}
const perMessageSender = getPerMessageProfile(event)
let renderMemberEvtContent = memberEvtContent
if (perMessageSender) {
renderMemberEvtContent = {
membership: "join",
displayname: perMessageSender.displayname ?? memberEvtContent?.displayname,
avatar_url: perMessageSender.avatar_url ?? memberEvtContent?.avatar_url,
avatar_file: perMessageSender.avatar_file ?? memberEvtContent?.avatar_file,
}
}
const userColorIndex = getUserColorIndex(perMessageSender?.id ?? event.sender)
classNames.push(`sender-color-${userColorIndex}`)
return <blockquote data-reply-to={event.event_id} className={classNames.join(" ")} onClick={onClickReply}>
{small && <div className="reply-spine"/>}
<div className="reply-sender">
<div className="sender-avatar" title={event.sender}>
<div
className="sender-avatar"
title={perMessageSender ? `${perMessageSender.id} via ${event.sender}` : event.sender}
>
<img
className="small avatar"
loading="lazy"
src={getAvatarURL(event.sender, memberEvtContent)}
src={getAvatarURL(perMessageSender?.id ?? event.sender, renderMemberEvtContent)}
alt=""
/>
</div>
<span className={`event-sender sender-color-${userColorIndex}`}>
{getDisplayname(event.sender, memberEvtContent)}
<span
className={`event-sender sender-color-${userColorIndex}`}
title={perMessageSender ? perMessageSender.id : event.sender}
>
{getDisplayname(event.sender, renderMemberEvtContent)}
</span>
{perMessageSender && <div className="per-message-event-sender">
<span className="via">via</span>
<span
className={`event-sender sender-color-${getUserColorIndex(event.sender)}`}
title={event.sender}
>
{getDisplayname(event.sender, memberEvtContent)}
</span>
</div>}
{onClose && <div className="buttons">
{onSetSilent && (isExplicitInThread || !isThread) && <TooltipButton
tooltipText={isSilent

View file

@ -5,7 +5,7 @@ div.timeline-event {
padding: 0 var(--timeline-horizontal-padding);
display: grid;
grid-template:
"cmc cmc cmc empty" 0
"cmc cmc cmc empty" 0
"avatar gap sender sender" auto
"avatar gap content status" auto
/ var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr var(--timeline-status-size);
@ -24,7 +24,7 @@ div.timeline-event {
transition: background-color 1s;
}
&:hover {
&:hover:not(.no-hover), &.focused-event {
background-color: var(--timeline-hover-bg-color);
&.highlight {
@ -64,6 +64,21 @@ div.timeline-event {
cursor: var(--clickable-cursor);
}
> div.per-message-event-sender {
color: var(--secondary-text-color);
font-size: .75rem;
> span.via {
margin-right: .25rem;
}
> span.event-sender {
font-weight: bold;
user-select: none;
cursor: var(--clickable-cursor);
}
}
> span.event-time, > span.event-edited {
font-size: .7rem;
color: var(--secondary-text-color);
@ -157,6 +172,15 @@ div.timeline-event {
margin-top: var(--timeline-message-gap-small-event);
}
}
&.reply-above {
grid-template:
"cmc cmc cmc empty" 0
"reply reply reply empty" auto
"avatar gap sender sender" auto
"avatar gap content status" auto
/ var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr var(--timeline-status-size);
}
}
div.pinned-event > div.timeline-event {

View file

@ -13,7 +13,8 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useCallback, useState } from "react"
import React, { JSX, use, useState } from "react"
import { createPortal } from "react-dom"
import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
import { useRoomMember } from "@/api/statestore"
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
@ -21,12 +22,13 @@ import { isMobileDevice } from "@/util/ismobile.ts"
import { getDisplayname, isEventID } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts"
import { ModalContext } from "../modal/Modal.tsx"
import { ModalContext } from "../modal"
import { useRoomContext } from "../roomview/roomcontext.ts"
import ReadReceipts from "./ReadReceipts.tsx"
import { ReplyIDBody } from "./ReplyBody.tsx"
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
import URLPreviews from "./URLPreviews.tsx"
import { ContentErrorBoundary, HiddenEvent, getBodyType, getPerMessageProfile, isSmallEvent } from "./content"
import { EventFixedMenu, EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
import ErrorIcon from "@/icons/error.svg?react"
import PendingIcon from "@/icons/pending.svg?react"
import SentIcon from "@/icons/sent.svg?react"
@ -36,6 +38,8 @@ export interface TimelineEventProps {
evt: MemDBEvent
prevEvt: MemDBEvent | null
disableMenu?: boolean
smallReplies?: boolean
isFocused?: boolean
}
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
@ -71,13 +75,13 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
}
}
const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: TimelineEventProps) => {
const roomCtx = useRoomContext()
const client = use(ClientContext)!
const mainScreen = use(MainScreenContext)
const openModal = use(ModalContext)
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
const onContextMenu = useCallback((mouseEvt: React.MouseEvent) => {
const onContextMenu = (mouseEvt: React.MouseEvent) => {
const targetElem = mouseEvt.target as HTMLElement
if (
!roomCtx.store.preferences.message_context_menu
@ -95,7 +99,19 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
style={getModalStyleFromMouse(mouseEvt, 9 * 40)}
/>,
})
}, [openModal, evt, roomCtx])
}
const onClick = (mouseEvt: React.MouseEvent) => {
const targetElem = mouseEvt.target as HTMLElement
if (
targetElem.tagName === "A"
|| targetElem.tagName === "IMG"
) {
return
}
mouseEvt.preventDefault()
mouseEvt.stopPropagation()
roomCtx.setFocusedEventRowID(roomCtx.focusedEventRowID === evt.rowid ? null : evt.rowid)
}
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
const BodyType = getBodyType(evt)
@ -117,6 +133,12 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
if (evt.sender === client.userID) {
wrapperClassNames.push("own-event")
}
if (isMobileDevice || disableMenu) {
wrapperClassNames.push("no-hover")
}
if (isFocused) {
wrapperClassNames.push("focused-event")
}
let dateSeparator = null
const prevEvtDate = prevEvt ? new Date(prevEvt.timestamp) : null
if (prevEvtDate && (
@ -129,17 +151,52 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
<hr role="none"/>
</div>
}
const isSmallBodyType = isSmallEvent(BodyType)
const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"]
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
let replyAboveMessage: JSX.Element | null = null
let replyInMessage: JSX.Element | null = null
if (isEventID(replyTo) && BodyType !== HiddenEvent && !evt.redacted_by) {
const replyElem = <ReplyIDBody
room={roomCtx.store}
eventID={replyTo}
isThread={relatesTo?.rel_type === "m.thread"}
small={!!smallReplies}
/>
if (smallReplies && !isSmallBodyType) {
replyAboveMessage = replyElem
wrapperClassNames.push("reply-above")
} else {
replyInMessage = replyElem
}
}
const perMessageSender = getPerMessageProfile(evt)
const prevPerMessageSender = getPerMessageProfile(prevEvt)
let renderMemberEvtContent = memberEvtContent
if (perMessageSender) {
renderMemberEvtContent = {
membership: "join",
displayname: perMessageSender.displayname ?? memberEvtContent?.displayname,
avatar_url: perMessageSender.avatar_url ?? memberEvtContent?.avatar_url,
avatar_file: perMessageSender.avatar_file ?? memberEvtContent?.avatar_file,
}
}
let smallAvatar = false
let renderAvatar = true
let eventTimeOnly = false
if (isSmallEvent(BodyType)) {
if (isSmallBodyType) {
wrapperClassNames.push("small-event")
smallAvatar = true
eventTimeOnly = true
} else if (prevEvt?.sender === evt.sender &&
prevEvt.timestamp + 15 * 60 * 1000 > evt.timestamp &&
!isSmallEvent(getBodyType(prevEvt)) &&
dateSeparator === null) {
} else if (
prevEvt?.sender === evt.sender
&& prevEvt.timestamp + 15 * 60 * 1000 > evt.timestamp
&& dateSeparator === null
&& !replyAboveMessage
&& !isSmallEvent(getBodyType(prevEvt))
&& prevPerMessageSender?.id === perMessageSender?.id
) {
wrapperClassNames.push("same-sender")
eventTimeOnly = true
renderAvatar = false
@ -147,21 +204,25 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
const fullTime = fullTimeFormatter.format(eventTS)
const shortTime = formatShortTime(eventTS)
const editTime = editEventTS ? `Edited at ${fullTimeFormatter.format(editEventTS)}` : null
const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"]
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
const mainEvent = <div
data-event-id={evt.event_id}
className={wrapperClassNames.join(" ")}
onContextMenu={onContextMenu}
onClick={!disableMenu && isMobileDevice ? onClick : undefined}
>
{!disableMenu && !isMobileDevice && <div
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
>
<EventHoverMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
<EventHoverMenu evt={evt} roomCtx={roomCtx} setForceOpen={setForceContextMenuOpen}/>
</div>}
{isMobileDevice && isFocused && createPortal(
<EventFixedMenu evt={evt} roomCtx={roomCtx} />,
document.getElementById("mobile-event-menu-container")!,
)}
{replyAboveMessage}
{renderAvatar && <div
className="sender-avatar"
title={evt.sender}
title={perMessageSender ? `${perMessageSender.id} via ${evt.sender}` : evt.sender}
data-target-panel="user"
data-target-user={evt.sender}
onClick={mainScreen.clickRightPanelOpener}
@ -169,18 +230,30 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
<img
className={`${smallAvatar ? "small" : ""} avatar`}
loading="lazy"
src={getAvatarURL(evt.sender, memberEvtContent)}
src={getAvatarURL(perMessageSender?.id ?? evt.sender, renderMemberEvtContent)}
alt=""
/>
</div>}
{!eventTimeOnly ? <div className="event-sender-and-time">
<span
className={`event-sender sender-color-${getUserColorIndex(evt.sender)}`}
className={`event-sender sender-color-${getUserColorIndex(perMessageSender?.id ?? evt.sender)}`}
data-target-user={evt.sender}
onClick={roomCtx.appendMentionToComposer}
onClick={perMessageSender ? undefined : roomCtx.appendMentionToComposer}
title={perMessageSender ? perMessageSender.id : evt.sender}
>
{getDisplayname(evt.sender, memberEvtContent)}
{getDisplayname(evt.sender, renderMemberEvtContent)}
</span>
{perMessageSender && <div className="per-message-event-sender">
<span className="via">via</span>
<span
className={`event-sender sender-color-${getUserColorIndex(evt.sender)}`}
data-target-user={evt.sender}
onClick={roomCtx.appendMentionToComposer}
title={evt.sender}
>
{getDisplayname(evt.sender, memberEvtContent)}
</span>
</div>}
<span className="event-time" title={fullTime}>{shortTime}</span>
{(editEventTS && editTime) ? <span className="event-edited" title={editTime}>
(edited at {formatShortTime(editEventTS)})
@ -189,13 +262,10 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
<span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span>
</div>}
<div className="event-content">
{isEventID(replyTo) && BodyType !== HiddenEvent && !evt.redacted_by ? <ReplyIDBody
room={roomCtx.store}
eventID={replyTo}
isThread={relatesTo?.rel_type === "m.thread"}
/> : null}
{replyInMessage}
<ContentErrorBoundary>
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
{!isSmallBodyType && <URLPreviews room={roomCtx.store} event={evt}/>}
</ContentErrorBoundary>
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
</div>

View file

@ -15,8 +15,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners"
import { useRoomTimeline } from "@/api/statestore"
import { MemDBEvent } from "@/api/types"
import { usePreference, useRoomTimeline } from "@/api/statestore"
import { EventRowID, MemDBEvent } from "@/api/types"
import useFocus from "@/util/focus.ts"
import ClientContext from "../ClientContext.ts"
import { useRoomContext } from "../roomview/roomcontext.ts"
@ -29,6 +29,7 @@ const TimelineView = () => {
const timeline = useRoomTimeline(room)
const client = use(ClientContext)!
const [isLoadingHistory, setLoadingHistory] = useState(false)
const [focusedEventRowID, directSetFocusedEventRowID] = useState<EventRowID | null>(null)
const loadHistory = useCallback(() => {
setLoadingHistory(true)
client.loadMoreHistory(room.roomID)
@ -42,16 +43,17 @@ const TimelineView = () => {
const oldestTimelineRow = timeline[0]?.timeline_rowid
const oldScrollHeight = useRef(0)
const focused = useFocus()
const smallReplies = usePreference(client.store, room, "small_replies")
// When the user scrolls the timeline manually, remember if they were at the bottom,
// so that we can keep them at the bottom when new events are added.
const handleScroll = useCallback(() => {
const handleScroll = () => {
if (!timelineViewRef.current) {
return
}
const timelineView = timelineViewRef.current
roomCtx.scrolledToBottom = timelineView.scrollTop + timelineView.clientHeight + 1 >= timelineView.scrollHeight
}, [roomCtx])
}
// Save the scroll height prior to updating the timeline, so that we can adjust the scroll position if needed.
if (timelineViewRef.current) {
oldScrollHeight.current = timelineViewRef.current.scrollHeight
@ -66,13 +68,10 @@ const TimelineView = () => {
timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current
}
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
roomCtx.ownMessages = timeline
.filter(evt => evt !== null
&& evt.sender === client.userID
&& evt.type === "m.room.message"
&& evt.relation_type !== "m.replace")
.map(evt => evt!.rowid)
}, [client.userID, roomCtx, timeline])
useEffect(() => {
roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID
}, [roomCtx])
useEffect(() => {
const newestEvent = timeline[timeline.length - 1]
if (
@ -119,7 +118,9 @@ const TimelineView = () => {
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
<div className="timeline-beginning">
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
{isLoadingHistory ? <><ScaleLoader /> Loading history...</> : "Load more history"}
{isLoadingHistory
? <><ScaleLoader color="var(--primary-color)"/> Loading history...</>
: "Load more history"}
</button> : "No more history available in this room"}
</div>
<div className="timeline-list">
@ -129,7 +130,11 @@ const TimelineView = () => {
return null
}
const thisEvt = <TimelineEvent
key={entry.rowid} evt={entry} prevEvt={prevEvt}
key={entry.rowid}
evt={entry}
prevEvt={prevEvt}
smallReplies={smallReplies}
isFocused={focusedEventRowID === entry.rowid}
/>
prevEvt = entry
return thisEvt

View file

@ -0,0 +1,65 @@
div.url-previews {
display: flex;
flex-direction: row;
gap: 1rem;
overflow-x: scroll;
> div.url-preview {
margin: 0.5rem 0;
border-radius: 0.5rem;
background-color: var(--url-preview-background-color);
border: 1px solid var(--url-preview-background-color);
display: grid;
flex-shrink: 0;
grid-template:
"title" auto
"description" auto
"media" auto
/ 1fr;
div.title {
grid-area: title;
margin: 0.5rem 0.5rem 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: bold;
}
div.description {
grid-area: description;
margin: 0 0.5rem 0.5rem;
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
color: var(--semisecondary-text-color);
}
> div.media-container {
grid-area: media;
border-radius: 0 0 .5rem .5rem;
background-color: var(--background-color);
}
&.inline {
grid-template:
"media title" auto
"media description" auto
/ auto auto;
width: 100%;
max-width: 20rem;
> div.inline-media-wrapper {
grid-area: media;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--background-color);
border-radius: .5rem 0 0 .5rem;
padding: .5rem;
}
}
}
}

View file

@ -0,0 +1,83 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Sumner Evans
//
// 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 React, { use } from "react"
import { getEncryptedMediaURL, getMediaURL } from "@/api/media"
import { RoomStateStore, usePreference } from "@/api/statestore"
import { MemDBEvent, URLPreview } from "@/api/types"
import { ImageContainerSize, calculateMediaSize } from "@/util/mediasize"
import ClientContext from "../ClientContext"
import { LightboxContext } from "../modal"
import "./URLPreviews.css"
const URLPreviews = ({ event, room }: {
room: RoomStateStore
event: MemDBEvent
}) => {
const client = use(ClientContext)!
const renderPreviews = usePreference(client.store, room, "render_url_previews")
if (event.redacted_by || !renderPreviews) {
return null
}
const previews = (event.content["com.beeper.linkpreviews"] ?? event.content["m.url_previews"]) as URLPreview[]
if (!previews) {
return null
}
return <div className="url-previews">
{previews
.filter(p => p["og:title"] || p["og:image"] || p["beeper:image:encryption"])
.map(p => {
const mediaURL = p["beeper:image:encryption"]
? getEncryptedMediaURL(p["beeper:image:encryption"].url)
: getMediaURL(p["og:image"])
const aspectRatio = (p["og:image:width"] ?? 1) / (p["og:image:height"] ?? 1)
let containerSize: ImageContainerSize | undefined
let inline = false
if (aspectRatio < 1.2) {
containerSize = { width: 80, height: 80 }
inline = true
}
const style = calculateMediaSize(p["og:image:width"], p["og:image:height"], containerSize)
const url = p["og:url"] ?? p.matched_url
const title = p["og:title"] ?? p["og:url"] ?? url
const mediaContainer = <div className="media-container" style={style.container}>
<img
loading="lazy"
style={style.media}
src={mediaURL}
onClick={use(LightboxContext)!}
alt=""
/>
</div>
return <div
key={url}
className={inline ? "url-preview inline" : "url-preview"}
style={inline ? {} : { width: style.container.width }}
>
<div className="title">
<a href={url} title={title} target="_blank" rel="noreferrer noopener">{title}</a>
</div>
<div className="description" title={p["og:description"]}>{p["og:description"]}</div>
{mediaURL && (inline
? <div className="inline-media-wrapper">{mediaContainer}</div>
: mediaContainer)}
</div>
})}
</div>
}
export default React.memo(URLPreviews)

View file

@ -13,7 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CSSProperties, JSX, use, useReducer } from "react"
import React, { CSSProperties, JSX, use, useReducer, useState } from "react"
import { Blurhash } from "react-blurhash"
import { GridLoader } from "react-spinners"
import { usePreference } from "@/api/statestore"
@ -49,7 +49,7 @@ const MediaMessageBody = ({ event, room, sender }: EventContentProps) => {
const supportsClickToShow = supportsLoadingPlaceholder || content.msgtype === "m.video"
const showPreviewsByDefault = usePreference(client.store, room, "show_media_previews")
const [loaded, onLoad] = useReducer(switchToTrue, !supportsLoadingPlaceholder)
const [clickedShow, onClickShow] = useReducer(switchToTrue, false)
const [clickedShow, setClickedShow] = useState(false)
let contentWarning = content["town.robin.msc3725.content_warning"]
if (content["page.codeberg.everypizza.msc4193.spoiler"]) {
@ -59,7 +59,10 @@ const MediaMessageBody = ({ event, room, sender }: EventContentProps) => {
}
}
const renderMediaElem = !supportsClickToShow || showPreviewsByDefault || clickedShow
const renderPlaceholderElem = supportsClickToShow && (!renderMediaElem || !!contentWarning || !loaded)
const renderPlaceholderElem = supportsClickToShow
&& (!renderMediaElem
|| (contentWarning && !clickedShow)
|| !loaded)
const isLoadingOnlyCover = !loaded && !contentWarning && renderMediaElem
const [mediaContent, containerClass, containerStyle] = useMediaContent(content, event.type, undefined, onLoad)
@ -69,8 +72,12 @@ const MediaMessageBody = ({ event, room, sender }: EventContentProps) => {
const blurhash = ensureString(
content.info?.["xyz.amorgan.blurhash"] ?? content.info?.thumbnail_info?.["xyz.amorgan.blurhash"],
)
const onClick = !clickedShow ? (evt: React.MouseEvent<HTMLDivElement>) => {
setClickedShow(true)
evt.stopPropagation()
} : undefined
placeholderElem = <div
onClick={onClickShow}
onClick={onClick}
className="placeholder"
>
{(blurhash && containerStyle.width) ? <Blurhash

View file

@ -16,7 +16,7 @@
import React, { use } from "react"
import { getAvatarURL } from "@/api/media.ts"
import { MemberEventContent, UserID } from "@/api/types"
import { LightboxContext } from "../../modal/Lightbox.tsx"
import { LightboxContext } from "../../modal"
import EventContentProps from "./props.ts"
function useChangeDescription(

View file

@ -0,0 +1,61 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX, use } from "react"
import { PolicyRuleContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts"
import MainScreenContext from "../../MainScreenContext.ts"
import EventContentProps from "./props.ts"
const PolicyRuleBody = ({ event, sender }: EventContentProps) => {
const content = event.content as PolicyRuleContent
const prevContent = event.unsigned.prev_content as PolicyRuleContent | undefined
const mainScreen = use(MainScreenContext)
const entity = content.entity ?? prevContent?.entity
const recommendation = content.recommendation ?? prevContent?.recommendation
if (!entity || !recommendation) {
return <div className="policy-body">
{getDisplayname(event.sender, sender?.content)} sent an invalid policy rule
</div>
}
let entityElement = <>{entity}</>
if(event.type === "m.policy.rule.user" && !entity?.includes("*") && !entity?.includes("?")) {
entityElement = (
<a
className="hicli-matrix-uri hicli-matrix-uri-user"
href={`matrix:u/${entity.slice(1)}`}
onClick={mainScreen.clickRightPanelOpener}
data-target-panel="user"
data-target-user={entity}
>
{entity}
</a>
)
}
let recommendationElement: JSX.Element | string = <code>{recommendation}</code>
if (recommendation === "m.ban") {
recommendationElement = "ban"
}
const action = prevContent ? ((content.entity && content.recommendation) ? "updated" : "removed") : "added"
const target = event.type.replace(/^m\.policy\.rule\./, "")
return <div className="policy-body">
{getDisplayname(event.sender, sender?.content)} {action} a {recommendationElement} rule
for {target}s matching <code>{entityElement}</code>
{content.reason ? <> for <code>{content.reason}</code></> : null}
</div>
}
export default PolicyRuleBody

View file

@ -34,7 +34,7 @@ function renderPowerLevels(content: PowerLevelEventContent, prevContent?: PowerL
intDiff`the ban power level from ${prevContent?.ban ?? 50} to ${content.ban ?? 50}`,
intDiff`the kick power level from ${prevContent?.kick ?? 50} to ${content.kick ?? 50}`,
intDiff`the redact power level from ${prevContent?.redact ?? 50} to ${content.redact ?? 50}`,
intDiff`the invite power level from ${prevContent?.redact ?? 0} to ${content.redact ?? 0}`,
intDiff`the invite power level from ${prevContent?.invite ?? 0} to ${content.invite ?? 0}`,
intDiff`the @room notification power level from ${prevContent?.notifications?.room ?? 50} to ${content.notifications?.room ?? 50}`,
]
/* eslint-enable max-len */

View file

@ -17,7 +17,7 @@ import { JSX, use } from "react"
import { getRoomAvatarURL } from "@/api/media.ts"
import { ContentURI, RoomAvatarEventContent } from "@/api/types"
import { ensureString } from "@/util/validation.ts"
import { LightboxContext } from "../../modal/Lightbox.tsx"
import { LightboxContext } from "../../modal"
import EventContentProps from "./props.ts"
const RoomAvatarBody = ({ event, sender, room }: EventContentProps) => {

View file

@ -215,6 +215,10 @@ div.media-container {
border-radius: .25rem;
}
&:has(> div.empty-placeholder) + img {
filter: blur(16px);
}
& + img {
/* In order loading=lazy to work, the image has to be visible,
so put it behind the placeholder instead of below */

View file

@ -1,5 +1,5 @@
import React from "react"
import { MemDBEvent } from "@/api/types"
import { BeeperPerMessageProfile, MemDBEvent, MessageEventContent } from "@/api/types"
import ACLBody from "./ACLBody.tsx"
import EncryptedBody from "./EncryptedBody.tsx"
import HiddenEvent from "./HiddenEvent.tsx"
@ -7,6 +7,7 @@ import LocationMessageBody from "./LocationMessageBody.tsx"
import MediaMessageBody from "./MediaMessageBody.tsx"
import MemberBody from "./MemberBody.tsx"
import PinnedEventsBody from "./PinnedEventsBody.tsx"
import PolicyRuleBody from "./PolicyRuleBody.tsx"
import PowerLevelBody from "./PowerLevelBody.tsx"
import RedactedBody from "./RedactedBody.tsx"
import RoomAvatarBody from "./RoomAvatarBody.tsx"
@ -24,6 +25,7 @@ export { default as MediaMessageBody } from "./MediaMessageBody.tsx"
export { default as LocationMessageBody } from "./LocationMessageBody.tsx"
export { default as MemberBody } from "./MemberBody.tsx"
export { default as PinnedEventsBody } from "./PinnedEventsBody.tsx"
export { default as PolicyRuleBody } from "./PolicyRuleBody.tsx"
export { default as PowerLevelBody } from "./PowerLevelBody.tsx"
export { default as RedactedBody } from "./RedactedBody.tsx"
export { default as RoomAvatarBody } from "./RoomAvatarBody.tsx"
@ -55,6 +57,9 @@ export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionCo
}
return MediaMessageBody
case "m.location":
if (forReply) {
return TextMessageBody
}
return LocationMessageBody
default:
return UnknownMessageBody
@ -79,6 +84,12 @@ export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionCo
return RoomAvatarBody
case "m.room.server_acl":
return ACLBody
case "m.policy.rule.user":
return PolicyRuleBody
case "m.policy.rule.room":
return PolicyRuleBody
case "m.policy.rule.server":
return PolicyRuleBody
case "m.room.pinned_events":
return PinnedEventsBody
case "m.room.power_levels":
@ -94,6 +105,7 @@ export function isSmallEvent(bodyType: React.FunctionComponent<EventContentProps
case RoomNameBody:
case RoomAvatarBody:
case ACLBody:
case PolicyRuleBody:
case PinnedEventsBody:
case PowerLevelBody:
return true
@ -101,3 +113,10 @@ export function isSmallEvent(bodyType: React.FunctionComponent<EventContentProps
return false
}
}
export function getPerMessageProfile(evt: MemDBEvent | null): BeeperPerMessageProfile | undefined {
if (evt === null || evt.type !== "m.room.message" && evt.type !== "m.sticker") {
return undefined
}
return (evt.content as MessageEventContent)["com.beeper.per_message_profile"]
}

View file

@ -17,7 +17,7 @@ import React, { CSSProperties, JSX, use } from "react"
import { getEncryptedMediaURL, getMediaURL } from "@/api/media.ts"
import type { EventType, MediaMessageEventContent } from "@/api/types"
import { ImageContainerSize, calculateMediaSize, defaultVideoContainerSize } from "@/util/mediasize.ts"
import { LightboxContext } from "../../modal/Lightbox.tsx"
import { LightboxContext } from "../../modal"
import DownloadIcon from "@/icons/download.svg?react"
export const useMediaContent = (

View file

@ -13,10 +13,10 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useCallback, useState } from "react"
import React, { use, useState } from "react"
import { MemDBEvent } from "@/api/types"
import useEvent from "@/util/useEvent.ts"
import { ModalCloseContext } from "../../modal/Modal.tsx"
import { isMobileDevice } from "@/util/ismobile.ts"
import { ModalCloseContext } from "../../modal"
import TimelineEvent from "../TimelineEvent.tsx"
interface ConfirmWithMessageProps {
@ -33,14 +33,11 @@ const ConfirmWithMessageModal = ({
}: ConfirmWithMessageProps) => {
const [reason, setReason] = useState("")
const closeModal = use(ModalCloseContext)
const onConfirmWrapped = useEvent((evt: React.FormEvent) => {
const onConfirmWrapped = (evt: React.FormEvent) => {
evt.preventDefault()
closeModal()
onConfirm(reason)
})
const onChangeReason = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
setReason(evt.target.value)
}, [])
}
return <form onSubmit={onConfirmWrapped}>
<h3>{title}</h3>
<div className="timeline-event-container">
@ -49,7 +46,13 @@ const ConfirmWithMessageModal = ({
<div className="confirm-description">
{description}
</div>
<input autoFocus value={reason} type="text" placeholder={placeholder} onChange={onChangeReason} />
<input
autoFocus={!isMobileDevice}
value={reason}
type="text"
placeholder={placeholder}
onChange={evt => setReason(evt.target.value)}
/>
<div className="confirm-buttons">
<button type="button" onClick={closeModal}>Cancel</button>
<button type="submit">{confirmButton}</button>

View file

@ -16,23 +16,26 @@
import { CSSProperties, use } from "react"
import { MemDBEvent } from "@/api/types"
import ClientContext from "../../ClientContext.ts"
import { RoomContextData, useRoomContext } from "../../roomview/roomcontext.ts"
import { RoomContextData } from "../../roomview/roomcontext.ts"
import { usePrimaryItems } from "./usePrimaryItems.tsx"
import { useSecondaryItems } from "./useSecondaryItems.tsx"
import CloseIcon from "@/icons/close.svg?react"
interface EventHoverMenuProps {
interface BaseEventMenuProps {
evt: MemDBEvent
roomCtx: RoomContextData
}
interface EventHoverMenuProps extends BaseEventMenuProps {
setForceOpen: (forceOpen: boolean) => void
}
export const EventHoverMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
const elements = usePrimaryItems(use(ClientContext)!, useRoomContext(), evt, true, undefined, setForceOpen)
export const EventHoverMenu = ({ evt, roomCtx, setForceOpen }: EventHoverMenuProps) => {
const elements = usePrimaryItems(use(ClientContext)!, roomCtx, evt, true, false, undefined, setForceOpen)
return <div className="event-hover-menu">{elements}</div>
}
interface EventContextMenuProps {
evt: MemDBEvent
roomCtx: RoomContextData
interface EventContextMenuProps extends BaseEventMenuProps {
style: CSSProperties
}
@ -43,7 +46,7 @@ export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) =
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
const client = use(ClientContext)!
const primary = usePrimaryItems(client, roomCtx, evt, false, style, undefined)
const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined)
const secondary = useSecondaryItems(client, roomCtx, evt)
return <div style={style} className="event-context-menu full">
{primary}
@ -51,3 +54,16 @@ export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) =>
{secondary}
</div>
}
export const EventFixedMenu = ({ evt, roomCtx }: BaseEventMenuProps) => {
const client = use(ClientContext)!
const primary = usePrimaryItems(client, roomCtx, evt, false, true, undefined, undefined)
const secondary = useSecondaryItems(client, roomCtx, evt, false)
return <div className="event-fixed-menu">
{primary}
<div className="vertical-line"/>
{secondary}
<div className="vertical-line"/>
<button className="close"><CloseIcon/></button>
</div>
}

View file

@ -2,13 +2,9 @@ div.event-hover-menu {
position: absolute;
right: .5rem;
top: -1.5rem;
background-color: var(--background-color);
border: 1px solid var(--border-color);
border-radius: .5rem;
display: flex;
gap: .25rem;
padding: .125rem;
z-index: 1;
> button {
width: 2rem;
@ -16,6 +12,37 @@ div.event-hover-menu {
}
}
div.event-hover-menu, div.event-fixed-menu {
display: flex;
gap: .25rem;
background-color: var(--background-color);
z-index: 1;
}
div.event-fixed-menu {
padding: .25rem;
justify-content: right;
flex-direction: row-reverse;
overflow-x: auto;
overflow-y: hidden;
> div.vertical-line {
width: 1px;
flex-shrink: 0;
background-color: var(--border-color);
}
> button {
width: 3rem;
height: 3rem;
flex-shrink: 0;
&.redact-button {
color: var(--error-color);
}
}
}
div.event-context-menu {
position: fixed;
background-color: var(--background-color);
@ -37,6 +64,11 @@ div.event-context-menu {
justify-content: left;
gap: .5rem;
> svg {
width: 1.5rem;
height: 1.5rem;
}
&:first-of-type {
border-radius: .5rem .5rem 0 0;
}

View file

@ -13,5 +13,5 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export { EventExtraMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
export { getModalStyleFromMouse } from "./util.ts"

View file

@ -13,13 +13,13 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { CSSProperties, use, useCallback } from "react"
import React, { CSSProperties, use } from "react"
import Client from "@/api/client.ts"
import { MemDBEvent } from "@/api/types"
import { emojiToReactionContent } from "@/util/emoji"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import EmojiPicker from "../../emojipicker/EmojiPicker.tsx"
import { ModalCloseContext, ModalContext } from "../../modal/Modal.tsx"
import { ModalCloseContext, ModalContext } from "../../modal"
import { RoomContextData } from "../../roomview/roomcontext.ts"
import { EventExtraMenu } from "./EventMenu.tsx"
import { getEncryption, getModalStyleFromButton, getPending, getPowerLevels } from "./util.ts"
@ -37,17 +37,19 @@ export const usePrimaryItems = (
roomCtx: RoomContextData,
evt: MemDBEvent,
isHover: boolean,
isFixed: boolean,
style?: CSSProperties,
setForceOpen?: (forceOpen: boolean) => void,
) => {
const names = !isHover && !isFixed
const closeModal = !isHover ? use(ModalCloseContext) : noop
const openModal = use(ModalContext)
const onClickReply = useCallback(() => {
const onClickReply = () => {
roomCtx.setReplyTo(evt.event_id)
closeModal()
}, [roomCtx, evt.event_id, closeModal])
const onClickReact = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
}
const onClickReact = (mevt: React.MouseEvent<HTMLButtonElement>) => {
const emojiPickerHeight = 34 * 16
setForceOpen?.(true)
openModal({
@ -63,20 +65,20 @@ export const usePrimaryItems = (
/>,
onClose: () => setForceOpen?.(false),
})
}, [client, roomCtx, evt, style, setForceOpen, openModal])
const onClickEdit = useCallback(() => {
}
const onClickEdit = () => {
closeModal()
roomCtx.setEditing(evt)
}, [roomCtx, evt, closeModal])
const onClickResend = useCallback(() => {
}
const onClickResend = () => {
if (!evt.transaction_id) {
return
}
closeModal()
client.resendEvent(evt.transaction_id)
.catch(err => window.alert(`Failed to resend message: ${err}`))
}, [client, evt.transaction_id, closeModal])
const onClickMore = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
}
const onClickMore = (mevt: React.MouseEvent<HTMLButtonElement>) => {
const moreMenuHeight = 4 * 40
setForceOpen!(true)
openModal({
@ -87,7 +89,7 @@ export const usePrimaryItems = (
/>,
onClose: () => setForceOpen!(false),
})
}, [evt, roomCtx, setForceOpen, openModal])
}
const isEditing = useEventAsState(roomCtx.isEditing)
const [isPending, pendingTitle] = getPending(evt)
const isEncrypted = getEncryption(roomCtx.store)
@ -108,11 +110,11 @@ export const usePrimaryItems = (
return <>
{didFail && <button onClick={onClickResend} title="Resend message">
<RefreshIcon/>
{!isHover && "Resend"}
{names && "Resend"}
</button>}
{canReact && <button disabled={isPending} title={pendingTitle} onClick={onClickReact}>
<ReactIcon/>
{!isHover && "React"}
{names && "React"}
</button>}
{canSend && <button
disabled={isEditing || isPending}
@ -120,11 +122,11 @@ export const usePrimaryItems = (
onClick={onClickReply}
>
<ReplyIcon/>
{!isHover && "Reply"}
{names && "Reply"}
</button>}
{canEdit && <button onClick={onClickEdit} disabled={isPending} title={pendingTitle}>
<EditIcon/>
{!isHover && "Edit"}
{names && "Edit"}
</button>}
{isHover && <button onClick={onClickMore}><MoreIcon/></button>}
</>

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