mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 18:43:41 -05:00
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:
commit
13a45b7722
105 changed files with 3393 additions and 1201 deletions
|
@ -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 => ../
|
||||
|
|
110
desktop/go.sum
110
desktop/go.sum
|
@ -1,5 +1,5 @@
|
|||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
|
@ -7,12 +7,14 @@ github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ
|
|||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
|
||||
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
|
||||
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
|
@ -31,39 +33,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
18
go.mod
|
@ -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
40
go.sum
|
@ -2,10 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
|
||||
|
@ -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=
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
@ -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
250
pkg/hicli/database/space.go
Normal 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
|
||||
}
|
|
@ -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);
|
||||
|
|
83
pkg/hicli/database/upgrades/10-spaces.sql
Normal file
83
pkg/hicli/database/upgrades/10-spaces.sql
Normal 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;
|
19
pkg/hicli/database/upgrades/11-dm-user-id.sql
Normal file
19
pkg/hicli/database/upgrades/11-dm-user-id.sql
Normal file
|
@ -0,0 +1,19 @@
|
|||
-- v11 (compatible with v10+): Store direct chat user ID in database
|
||||
ALTER TABLE room ADD COLUMN dm_user_id TEXT;
|
||||
WITH dm_user_ids AS (
|
||||
SELECT room_id, value
|
||||
FROM room
|
||||
INNER JOIN json_each(lazy_load_summary, '$."m.heroes"')
|
||||
WHERE value NOT IN (SELECT value FROM json_each((
|
||||
SELECT event.content
|
||||
FROM current_state cs
|
||||
INNER JOIN event ON cs.event_rowid = event.rowid
|
||||
WHERE cs.room_id=room.room_id AND cs.event_type='io.element.functional_members' AND cs.state_key=''
|
||||
), '$.service_members'))
|
||||
GROUP BY room_id
|
||||
HAVING COUNT(*) = 1
|
||||
)
|
||||
UPDATE room
|
||||
SET dm_user_id=value
|
||||
FROM dm_user_ids du
|
||||
WHERE room.room_id=du.room_id;
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
805
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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", {})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./main.ts"
|
||||
export * from "./room.ts"
|
||||
export * from "./hooks.ts"
|
||||
export * from "./space.ts"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
199
web/src/api/statestore/space.ts
Normal file
199
web/src/api/statestore/space.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
1
web/src/icons/home.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M240-200h120v-240h240v240h120v-360L480-740 240-560v360Zm-80 80v-480l320-240 320 240v480H520v-240h-80v240H160Zm320-350Z"/></svg>
|
After Width: | Height: | Size: 244 B |
1
web/src/icons/notifications-unread.svg
Normal file
1
web/src/icons/notifications-unread.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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
1
web/src/icons/person.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-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
1
web/src/icons/tag.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m240-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 |
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -35,6 +35,7 @@ div.autocompletions {
|
|||
> img {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
63
web/src/ui/composer/ComposerMedia.tsx
Normal file
63
web/src/ui/composer/ComposerMedia.tsx
Normal 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>
|
||||
}
|
|
@ -34,6 +34,11 @@ div.message-composer {
|
|||
height: 2rem;
|
||||
width: 2rem;
|
||||
padding: .25rem;
|
||||
|
||||
> svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
> input[type="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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
43
web/src/ui/modal/contexts.ts
Normal file
43
web/src/ui/modal/contexts.ts
Normal 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>(() => {})
|
3
web/src/ui/modal/index.ts
Normal file
3
web/src/ui/modal/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from "./contexts.ts"
|
||||
export { default as ModalWrapper } from "./Modal.tsx"
|
||||
export { default as LightboxWrapper } from "./Lightbox.tsx"
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
114
web/src/ui/rightpanel/UserExtendedProfile.tsx
Normal file
114
web/src/ui/rightpanel/UserExtendedProfile.tsx
Normal 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
|
|
@ -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}/>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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: "",
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
60
web/src/ui/roomlist/FakeSpace.tsx
Normal file
60
web/src/ui/roomlist/FakeSpace.tsx
Normal 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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>,
|
||||
)}
|
||||
|
|
44
web/src/ui/roomlist/Space.tsx
Normal file
44
web/src/ui/roomlist/Space.tsx
Normal 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
|
75
web/src/ui/roomlist/UnreadCount.tsx
Normal file
75
web/src/ui/roomlist/UnreadCount.tsx
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
65
web/src/ui/timeline/URLPreviews.css
Normal file
65
web/src/ui/timeline/URLPreviews.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
83
web/src/ui/timeline/URLPreviews.tsx
Normal file
83
web/src/ui/timeline/URLPreviews.tsx
Normal 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)
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
61
web/src/ui/timeline/content/PolicyRuleBody.tsx
Normal file
61
web/src/ui/timeline/content/PolicyRuleBody.tsx
Normal 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
|
|
@ -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 */
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
Loading…
Add table
Reference in a new issue