Merge branch 'main' into terminal
7
.github/workflows/go.yml
vendored
|
@ -2,13 +2,16 @@ name: Go
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
env:
|
||||||
|
GOTOOLCHAIN: local
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
go-version: ["1.23"]
|
go-version: ["1.23", "1.24"]
|
||||||
name: Lint Go ${{ matrix.go-version == '1.23' && '(latest)' || '(old)' }}
|
name: Lint Go ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
|
@ -13,6 +13,9 @@ cache:
|
||||||
paths:
|
paths:
|
||||||
- .cache
|
- .cache
|
||||||
|
|
||||||
|
variables:
|
||||||
|
GOTOOLCHAIN: local
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: node:22-alpine
|
image: node:22-alpine
|
||||||
stage: frontend
|
stage: frontend
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates jq curl
|
RUN apk add --no-cache ca-certificates jq curl ffmpeg
|
||||||
|
|
||||||
ARG EXECUTABLE=./gomuks
|
ARG EXECUTABLE=./gomuks
|
||||||
COPY $EXECUTABLE /usr/bin/gomuks
|
COPY $EXECUTABLE /usr/bin/gomuks
|
||||||
|
|
|
@ -2,13 +2,13 @@ module go.mau.fi/gomuks/desktop
|
||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.23.3
|
toolchain go1.23.5
|
||||||
|
|
||||||
require github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3
|
require github.com/wailsapp/wails/v3 v3.0.0-alpha.9
|
||||||
|
|
||||||
require (
|
require (
|
||||||
go.mau.fi/gomuks v0.3.1
|
go.mau.fi/gomuks v0.4.0
|
||||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
@ -25,6 +25,7 @@ require (
|
||||||
github.com/coder/websocket v1.8.12 // indirect
|
github.com/coder/websocket v1.8.12 // indirect
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.2.5 // indirect
|
github.com/cyphar/filepath-securejoin v0.2.5 // indirect
|
||||||
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
github.com/ebitengine/purego v0.4.0-alpha.4 // indirect
|
github.com/ebitengine/purego v0.4.0-alpha.4 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
|
@ -43,10 +44,10 @@ require (
|
||||||
github.com/leaanthony/u v1.1.0 // indirect
|
github.com/leaanthony/u v1.1.0 // indirect
|
||||||
github.com/lmittmann/tint v1.0.4 // indirect
|
github.com/lmittmann/tint v1.0.4 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
|
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a // indirect
|
||||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
@ -57,26 +58,28 @@ require (
|
||||||
github.com/skeema/knownhosts v1.2.2 // indirect
|
github.com/skeema/knownhosts v1.2.2 // indirect
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tidwall/sjson v1.2.5 // indirect
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.18 // indirect
|
github.com/wailsapp/go-webview2 v1.0.19 // indirect
|
||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
github.com/yuin/goldmark v1.7.8 // indirect
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
go.mau.fi/webp v0.2.0 // indirect
|
||||||
go.mau.fi/zeroconfig v0.1.3 // indirect
|
go.mau.fi/zeroconfig v0.1.3 // indirect
|
||||||
golang.org/x/crypto v0.32.0 // indirect
|
golang.org/x/crypto v0.34.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect
|
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f // indirect
|
||||||
golang.org/x/image v0.23.0 // indirect
|
golang.org/x/image v0.24.0 // indirect
|
||||||
golang.org/x/mod v0.22.0 // indirect
|
golang.org/x/mod v0.23.0 // indirect
|
||||||
golang.org/x/net v0.33.0 // indirect
|
golang.org/x/net v0.35.0 // indirect
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
golang.org/x/sync v0.11.0 // indirect
|
||||||
golang.org/x/sys v0.29.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
golang.org/x/tools v0.28.0 // indirect
|
golang.org/x/tools v0.30.0 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f // indirect
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7 // indirect
|
||||||
mvdan.cc/xurls/v2 v2.6.0 // indirect
|
mvdan.cc/xurls/v2 v2.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,8 @@ github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxG
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
|
github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
|
||||||
|
@ -100,8 +102,9 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
@ -110,8 +113,8 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
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-20250211185408-f2b9d978cd7a h1:ckxP/kGzsxvxXo8jO6E/0QJ8MMmwI7IRj4Fys9QbAZA=
|
||||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
|
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
|
||||||
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
|
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
|
||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||||
|
@ -147,23 +150,26 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/wailsapp/go-webview2 v1.0.18 h1:SSSCoLA+MYikSp1U0WmvELF/4c3x5kH8Vi31TKyZ4yk=
|
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
|
||||||
github.com/wailsapp/go-webview2 v1.0.18/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3 h1:9aCL0IXD60A5iscQ/ps6f3ti3IlaoG6LQe0RZ9JkueU=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.9 h1:b8CfRrhPno8Fra0xFp4Ifyj+ogmXBc35rsQWvcrHtsI=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3/go.mod h1:9Ca1goy5oqxmy8Oetb8Tchkezcx4tK03DK+SqYByu5Y=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.9/go.mod h1:dSv6s722nSWaUyUiapAM1DHc5HKggNGY1a79shO85/g=
|
||||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a h1:D9RCHBFjxah9F/YB7amvRJjT2IEOFWcz8jpcEY8dBV0=
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95 h1:5EfVWWjU2Hte9uE6B/hBgvjnVfBx/7SYDZBnsuo+EBs=
|
||||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
|
||||||
|
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
||||||
|
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
|
||||||
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
||||||
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
@ -171,16 +177,17 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
golang.org/x/crypto v0.34.0 h1:+/C6tk6rf/+t5DhUketUbD1aNGqiSX3j15Z6xuIDlBA=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.34.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
|
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg=
|
||||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
|
||||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
@ -189,13 +196,13 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
@ -215,15 +222,15 @@ golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
@ -231,19 +238,21 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
||||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||||
|
@ -252,7 +261,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f h1:+nAznNCSCm+P4am9CmguSfNfcbpA7qtTzJQrwCP8aHM=
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7 h1:AeNHqITptzOpmfMxnqmQRw6xN7DUDCgsN00BaPyRd4k=
|
||||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f/go.mod h1:FmwzK7RSzrd1OfGDgJzFWXl7nYmYm8/P0Y77sy/A1Uw=
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7/go.mod h1:IHMaSJh7YIxMrZSDVefS+nLdr3RbeLowsCSa6ibONZ0=
|
||||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||||
|
|
30
go.mod
|
@ -2,13 +2,14 @@ module go.mau.fi/gomuks
|
||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.23.4
|
toolchain go1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma/v2 v2.15.0
|
github.com/alecthomas/chroma/v2 v2.15.0
|
||||||
github.com/buckket/go-blurhash v1.1.0
|
github.com/buckket/go-blurhash v1.1.0
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
github.com/coder/websocket v1.8.12
|
github.com/coder/websocket v1.8.12
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8
|
github.com/gabriel-vasile/mimetype v1.4.8
|
||||||
github.com/gdamore/tcell/v2 v2.7.4
|
github.com/gdamore/tcell/v2 v2.7.4
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||||
|
@ -19,15 +20,16 @@ require (
|
||||||
github.com/tidwall/sjson v1.2.5
|
github.com/tidwall/sjson v1.2.5
|
||||||
github.com/yuin/goldmark v1.7.8
|
github.com/yuin/goldmark v1.7.8
|
||||||
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5
|
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5
|
||||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95
|
||||||
|
go.mau.fi/webp v0.2.0
|
||||||
go.mau.fi/zeroconfig v0.1.3
|
go.mau.fi/zeroconfig v0.1.3
|
||||||
golang.org/x/crypto v0.32.0
|
golang.org/x/crypto v0.34.0
|
||||||
golang.org/x/image v0.23.0
|
golang.org/x/image v0.24.0
|
||||||
golang.org/x/net v0.33.0
|
golang.org/x/net v0.35.0
|
||||||
golang.org/x/text v0.21.0
|
golang.org/x/text v0.22.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mauflag v1.0.0
|
maunium.net/go/mauflag v1.0.0
|
||||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7
|
||||||
mvdan.cc/xurls/v2 v2.6.0
|
mvdan.cc/xurls/v2 v2.6.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -36,16 +38,16 @@ require (
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
github.com/gdamore/encoding v1.0.0 // indirect
|
github.com/gdamore/encoding v1.0.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
|
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a // indirect
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/zyedidia/clipboard v1.0.4 // indirect
|
github.com/zyedidia/clipboard v1.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect
|
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f // indirect
|
||||||
golang.org/x/sys v0.29.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/term v0.28.0 // indirect
|
golang.org/x/term v0.29.0 // indirect
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
54
go.sum
|
@ -22,6 +22,8 @@ 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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
@ -35,18 +37,20 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
|
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a h1:ckxP/kGzsxvxXo8jO6E/0QJ8MMmwI7IRj4Fys9QbAZA=
|
||||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
@ -66,8 +70,9 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
@ -77,26 +82,29 @@ github.com/zyedidia/clipboard v1.0.4 h1:r6GUQOyPtIaApRLeD56/U+2uJbXis6ANGbKWCljU
|
||||||
github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA=
|
github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA=
|
||||||
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5 h1:apKftqeRRyj/Vpd5s81fNhS8UErwgfs07KG3NSHB/4Q=
|
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5 h1:apKftqeRRyj/Vpd5s81fNhS8UErwgfs07KG3NSHB/4Q=
|
||||||
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5/go.mod h1:G0Qkfwt84f+5tagHsaRdiTuUFeTlIZu61MN/JL9D8Qo=
|
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5/go.mod h1:G0Qkfwt84f+5tagHsaRdiTuUFeTlIZu61MN/JL9D8Qo=
|
||||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a h1:D9RCHBFjxah9F/YB7amvRJjT2IEOFWcz8jpcEY8dBV0=
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95 h1:5EfVWWjU2Hte9uE6B/hBgvjnVfBx/7SYDZBnsuo+EBs=
|
||||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
|
||||||
|
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
||||||
|
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
|
||||||
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
||||||
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
golang.org/x/crypto v0.34.0 h1:+/C6tk6rf/+t5DhUketUbD1aNGqiSX3j15Z6xuIDlBA=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.34.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
|
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg=
|
||||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
|
||||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
@ -111,21 +119,21 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
@ -139,7 +147,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
||||||
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
||||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f h1:+nAznNCSCm+P4am9CmguSfNfcbpA7qtTzJQrwCP8aHM=
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7 h1:AeNHqITptzOpmfMxnqmQRw6xN7DUDCgsN00BaPyRd4k=
|
||||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f/go.mod h1:FmwzK7RSzrd1OfGDgJzFWXl7nYmYm8/P0Y77sy/A1Uw=
|
maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7/go.mod h1:IHMaSJh7YIxMrZSDVefS+nLdr3RbeLowsCSa6ibONZ0=
|
||||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||||
|
|
|
@ -35,6 +35,7 @@ type Config struct {
|
||||||
Web WebConfig `yaml:"web"`
|
Web WebConfig `yaml:"web"`
|
||||||
Matrix MatrixConfig `yaml:"matrix"`
|
Matrix MatrixConfig `yaml:"matrix"`
|
||||||
Push PushConfig `yaml:"push"`
|
Push PushConfig `yaml:"push"`
|
||||||
|
Media MediaConfig `yaml:"media"`
|
||||||
Logging zeroconfig.Config `yaml:"logging"`
|
Logging zeroconfig.Config `yaml:"logging"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,6 +47,10 @@ type PushConfig struct {
|
||||||
FCMGateway string `yaml:"fcm_gateway"`
|
FCMGateway string `yaml:"fcm_gateway"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaConfig struct {
|
||||||
|
ThumbnailSize int `yaml:"thumbnail_size"`
|
||||||
|
}
|
||||||
|
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
ListenAddress string `yaml:"listen_address"`
|
ListenAddress string `yaml:"listen_address"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
|
@ -74,6 +79,9 @@ func makeDefaultConfig() Config {
|
||||||
Matrix: MatrixConfig{
|
Matrix: MatrixConfig{
|
||||||
DisableHTTP2: false,
|
DisableHTTP2: false,
|
||||||
},
|
},
|
||||||
|
Media: MediaConfig{
|
||||||
|
ThumbnailSize: 120,
|
||||||
|
},
|
||||||
Logging: zeroconfig.Config{
|
Logging: zeroconfig.Config{
|
||||||
MinLevel: ptr.Ptr(zerolog.DebugLevel),
|
MinLevel: ptr.Ptr(zerolog.DebugLevel),
|
||||||
Writers: []zeroconfig.WriterConfig{{
|
Writers: []zeroconfig.WriterConfig{{
|
||||||
|
@ -130,6 +138,10 @@ func (gmx *Gomuks) LoadConfig() error {
|
||||||
gmx.Config.Push.FCMGateway = "https://push.gomuks.app"
|
gmx.Config.Push.FCMGateway = "https://push.gomuks.app"
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if gmx.Config.Media.ThumbnailSize == 0 {
|
||||||
|
gmx.Config.Media.ThumbnailSize = 120
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if len(gmx.Config.Web.OriginPatterns) == 0 {
|
if len(gmx.Config.Web.OriginPatterns) == 0 {
|
||||||
gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"}
|
gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"}
|
||||||
changed = true
|
changed = true
|
||||||
|
|
110
pkg/gomuks/keys.go
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package gomuks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/hlog"
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
|
"go.mau.fi/util/exhttp"
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/crypto"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (gmx *Gomuks) ExportKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
|
found, correct := gmx.doBasicAuth(r)
|
||||||
|
if !found || !correct {
|
||||||
|
hlog.FromRequest(r).Debug().Msg("Requesting credentials for key export request")
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
hlog.FromRequest(r).Err(err).Msg("Failed to parse form")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte("Failed to parse form data\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roomID := id.RoomID(r.PathValue("room_id"))
|
||||||
|
var sessions dbutil.RowIter[*crypto.InboundGroupSession]
|
||||||
|
filename := "gomuks-keys.txt"
|
||||||
|
if roomID == "" {
|
||||||
|
sessions = gmx.Client.CryptoStore.GetAllGroupSessions(r.Context())
|
||||||
|
} else {
|
||||||
|
filename = fmt.Sprintf("gomuks-keys-%s.txt", roomID)
|
||||||
|
sessions = gmx.Client.CryptoStore.GetGroupSessionsForRoom(r.Context(), roomID)
|
||||||
|
}
|
||||||
|
export, err := crypto.ExportKeysIter(r.FormValue("passphrase"), sessions)
|
||||||
|
if errors.Is(err, crypto.ErrNoSessionsForExport) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte("No keys found\n"))
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
hlog.FromRequest(r).Err(err).Msg("Failed to export keys")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("Failed to export keys (see logs for more details)\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(export)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(export)
|
||||||
|
}
|
||||||
|
|
||||||
|
var badMultipartForm = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.BAD_FORM_DATA", Err: "Failed to parse form data", StatusCode: http.StatusBadRequest}
|
||||||
|
|
||||||
|
func (gmx *Gomuks) ImportKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseMultipartForm(5 * 1024 * 1024)
|
||||||
|
if err != nil {
|
||||||
|
badMultipartForm.Write(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
export, _, err := r.FormFile("export")
|
||||||
|
if err != nil {
|
||||||
|
badMultipartForm.WithMessage("Failed to get export file from form: %w", err).Write(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exportData, err := io.ReadAll(export)
|
||||||
|
if err != nil {
|
||||||
|
badMultipartForm.WithMessage("Failed to read export file: %w", err).Write(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
importedCount, totalCount, err := gmx.Client.Crypto.ImportKeys(r.Context(), r.FormValue("passphrase"), exportData)
|
||||||
|
if err != nil {
|
||||||
|
hlog.FromRequest(r).Err(err).Msg("Failed to import keys")
|
||||||
|
mautrix.MUnknown.WithMessage("Failed to import keys: %w", err).Write(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hlog.FromRequest(r).Info().
|
||||||
|
Int("imported_count", importedCount).
|
||||||
|
Int("total_count", totalCount).
|
||||||
|
Msg("Successfully imported keys")
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusOK, map[string]int{
|
||||||
|
"imported": importedCount,
|
||||||
|
"total": totalCount,
|
||||||
|
})
|
||||||
|
}
|
|
@ -36,16 +36,17 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/buckket/go-blurhash"
|
"github.com/buckket/go-blurhash"
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/hlog"
|
"github.com/rs/zerolog/hlog"
|
||||||
_ "golang.org/x/image/webp"
|
|
||||||
|
|
||||||
"go.mau.fi/util/exhttp"
|
"go.mau.fi/util/exhttp"
|
||||||
"go.mau.fi/util/ffmpeg"
|
"go.mau.fi/util/ffmpeg"
|
||||||
"go.mau.fi/util/jsontime"
|
"go.mau.fi/util/jsontime"
|
||||||
"go.mau.fi/util/ptr"
|
"go.mau.fi/util/ptr"
|
||||||
"go.mau.fi/util/random"
|
"go.mau.fi/util/random"
|
||||||
|
cwebp "go.mau.fi/webp"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/crypto/attachment"
|
"maunium.net/go/mautrix/crypto/attachment"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
|
@ -59,7 +60,7 @@ var ErrBadGateway = mautrix.RespError{
|
||||||
StatusCode: http.StatusBadGateway,
|
StatusCode: http.StatusBadGateway,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWriter, r *http.Request, entry *database.Media, force bool) bool {
|
func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWriter, r *http.Request, entry *database.Media, force, useThumbnail bool) bool {
|
||||||
if !entry.UseCache() {
|
if !entry.UseCache() {
|
||||||
if force {
|
if force {
|
||||||
mautrix.MNotFound.WithMessage("Media not found in cache").Write(w)
|
mautrix.MNotFound.WithMessage("Media not found in cache").Write(w)
|
||||||
|
@ -67,11 +68,12 @@ func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWr
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
etag := entry.ETag(useThumbnail)
|
||||||
if entry.Error != nil {
|
if entry.Error != nil {
|
||||||
w.Header().Set("Mau-Cached-Error", "true")
|
w.Header().Set("Mau-Cached-Error", "true")
|
||||||
entry.Error.Write(w)
|
entry.Error.Write(w)
|
||||||
return true
|
return true
|
||||||
} else if r.Header.Get("If-None-Match") == entry.ETag() {
|
} else if etag != "" && r.Header.Get("If-None-Match") == etag {
|
||||||
w.WriteHeader(http.StatusNotModified)
|
w.WriteHeader(http.StatusNotModified)
|
||||||
return true
|
return true
|
||||||
} else if entry.MimeType != "" && r.URL.Query().Has("fallback") && !isAllowedAvatarMime(entry.MimeType) {
|
} else if entry.MimeType != "" && r.URL.Query().Has("fallback") && !isAllowedAvatarMime(entry.MimeType) {
|
||||||
|
@ -79,7 +81,43 @@ func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWr
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
log := zerolog.Ctx(ctx)
|
log := zerolog.Ctx(ctx)
|
||||||
cacheFile, err := os.Open(gmx.cacheEntryToPath(entry.Hash[:]))
|
hash := entry.Hash
|
||||||
|
if useThumbnail {
|
||||||
|
if entry.ThumbnailError != "" {
|
||||||
|
log.Debug().Str(zerolog.ErrorFieldName, entry.ThumbnailError).Msg("Returning cached thumbnail error")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if entry.ThumbnailHash == nil {
|
||||||
|
err := gmx.generateAvatarThumbnail(entry, gmx.Config.Media.ThumbnailSize)
|
||||||
|
if errors.Is(err, os.ErrNotExist) && !force {
|
||||||
|
return false
|
||||||
|
} else if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to generate avatar thumbnail")
|
||||||
|
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hash = entry.ThumbnailHash
|
||||||
|
}
|
||||||
|
cacheFile, err := os.Open(gmx.cacheEntryToPath(hash[:]))
|
||||||
|
if useThumbnail && errors.Is(err, os.ErrNotExist) {
|
||||||
|
err = gmx.generateAvatarThumbnail(entry, gmx.Config.Media.ThumbnailSize)
|
||||||
|
if errors.Is(err, os.ErrNotExist) && !force {
|
||||||
|
return false
|
||||||
|
} else if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to generate avatar thumbnail")
|
||||||
|
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, nil)
|
||||||
|
cacheFile, err = os.Open(gmx.cacheEntryToPath(hash[:]))
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) && !force {
|
if errors.Is(err, os.ErrNotExist) && !force {
|
||||||
return false
|
return false
|
||||||
|
@ -91,7 +129,7 @@ func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWr
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = cacheFile.Close()
|
_ = cacheFile.Close()
|
||||||
}()
|
}()
|
||||||
cacheEntryToHeaders(w, entry)
|
cacheEntryToHeaders(w, entry, useThumbnail)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, err = io.Copy(w, cacheFile)
|
_, err = io.Copy(w, cacheFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -105,13 +143,76 @@ func (gmx *Gomuks) cacheEntryToPath(hash []byte) string {
|
||||||
return filepath.Join(gmx.CacheDir, "media", hashPath[0:2], hashPath[2:4], hashPath[4:])
|
return filepath.Join(gmx.CacheDir, "media", hashPath[0:2], hashPath[2:4], hashPath[4:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media) {
|
func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media, thumbnail bool) {
|
||||||
|
if thumbnail {
|
||||||
|
w.Header().Set("Content-Type", "image/webp")
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(entry.ThumbnailSize, 10))
|
||||||
|
w.Header().Set("Content-Disposition", "inline; filename=thumbnail.webp")
|
||||||
|
} else {
|
||||||
w.Header().Set("Content-Type", entry.MimeType)
|
w.Header().Set("Content-Type", entry.MimeType)
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10))
|
w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10))
|
||||||
w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName}))
|
w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName}))
|
||||||
|
}
|
||||||
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; media-src 'self';")
|
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; media-src 'self';")
|
||||||
w.Header().Set("Cache-Control", "max-age=2592000, immutable")
|
w.Header().Set("Cache-Control", "max-age=2592000, immutable")
|
||||||
w.Header().Set("ETag", entry.ETag())
|
w.Header().Set("ETag", entry.ETag(thumbnail))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gmx *Gomuks) saveMediaCacheEntryWithThumbnail(ctx context.Context, entry *database.Media, err error) {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
entry.ThumbnailError = err.Error()
|
||||||
|
}
|
||||||
|
err = gmx.Client.DB.Media.Put(ctx, entry)
|
||||||
|
if err != nil {
|
||||||
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to save cache entry after generating thumbnail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gmx *Gomuks) generateAvatarThumbnail(entry *database.Media, size int) error {
|
||||||
|
cacheFile, err := os.Open(gmx.cacheEntryToPath(entry.Hash[:]))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open full file: %w", err)
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(cacheFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tempFile, err := os.CreateTemp(gmx.TempDir, "thumbnail-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temporary file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tempFile.Close()
|
||||||
|
_ = os.Remove(tempFile.Name())
|
||||||
|
}()
|
||||||
|
thumbnailImage := imaging.Thumbnail(img, size, size, imaging.Lanczos)
|
||||||
|
fileHasher := sha256.New()
|
||||||
|
wrappedWriter := io.MultiWriter(fileHasher, tempFile)
|
||||||
|
err = cwebp.Encode(wrappedWriter, thumbnailImage, &cwebp.Options{Quality: 80})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encode thumbnail: %w", err)
|
||||||
|
}
|
||||||
|
fileInfo, err := tempFile.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat temporary file: %w", err)
|
||||||
|
}
|
||||||
|
entry.ThumbnailHash = (*[32]byte)(fileHasher.Sum(nil))
|
||||||
|
entry.ThumbnailError = ""
|
||||||
|
entry.ThumbnailSize = fileInfo.Size()
|
||||||
|
cachePath := gmx.cacheEntryToPath(entry.ThumbnailHash[:])
|
||||||
|
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
|
}
|
||||||
|
err = os.Rename(tempFile.Name(), cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to rename temporary file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type noErrorWriter struct {
|
type noErrorWriter struct {
|
||||||
|
@ -191,6 +292,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
encrypted, _ := strconv.ParseBool(query.Get("encrypted"))
|
encrypted, _ := strconv.ParseBool(query.Get("encrypted"))
|
||||||
|
useThumbnail := query.Get("thumbnail") == "avatar"
|
||||||
|
|
||||||
logVal := zerolog.Ctx(r.Context()).With().
|
logVal := zerolog.Ctx(r.Context()).With().
|
||||||
Stringer("mxc_uri", mxc).
|
Stringer("mxc_uri", mxc).
|
||||||
|
@ -211,7 +313,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false) {
|
if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false, useThumbnail) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -301,8 +403,8 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
|
||||||
cacheEntry.Size = resp.ContentLength
|
cacheEntry.Size = resp.ContentLength
|
||||||
fileHasher := sha256.New()
|
fileHasher := sha256.New()
|
||||||
wrappedReader := io.TeeReader(reader, fileHasher)
|
wrappedReader := io.TeeReader(reader, fileHasher)
|
||||||
if cacheEntry.Size > 0 && cacheEntry.EncFile == nil {
|
if cacheEntry.Size > 0 && cacheEntry.EncFile == nil && !useThumbnail {
|
||||||
cacheEntryToHeaders(w, cacheEntry)
|
cacheEntryToHeaders(w, cacheEntry, useThumbnail)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
wrappedReader = io.TeeReader(wrappedReader, &noErrorWriter{w})
|
wrappedReader = io.TeeReader(wrappedReader, &noErrorWriter{w})
|
||||||
w = nil
|
w = nil
|
||||||
|
@ -342,7 +444,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if w != nil {
|
if w != nil {
|
||||||
gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, true)
|
gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, true, useThumbnail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -77,6 +77,8 @@ func (gmx *Gomuks) getNotificationUser(ctx context.Context, roomID id.RoomID, us
|
||||||
if err != nil {
|
if err != nil {
|
||||||
zerolog.Ctx(ctx).Err(err).Stringer("of_user_id", userID).Msg("Failed to get member event")
|
zerolog.Ctx(ctx).Err(err).Stringer("of_user_id", userID).Msg("Failed to get member event")
|
||||||
return
|
return
|
||||||
|
} else if memberEvt == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
var memberContent event.MemberEventContent
|
var memberContent event.MemberEventContent
|
||||||
_ = json.Unmarshal(memberEvt.Content, &memberContent)
|
_ = json.Unmarshal(memberEvt.Content, &memberContent)
|
||||||
|
|
|
@ -53,6 +53,9 @@ func (gmx *Gomuks) CreateAPIRouter() http.Handler {
|
||||||
api.HandleFunc("GET /sso", gmx.HandleSSOComplete)
|
api.HandleFunc("GET /sso", gmx.HandleSSOComplete)
|
||||||
api.HandleFunc("POST /sso", gmx.PrepareSSO)
|
api.HandleFunc("POST /sso", gmx.PrepareSSO)
|
||||||
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
|
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
|
||||||
|
api.HandleFunc("POST /keys/export", gmx.ExportKeys)
|
||||||
|
api.HandleFunc("POST /keys/export/{room_id}", gmx.ExportKeys)
|
||||||
|
api.HandleFunc("POST /keys/import", gmx.ImportKeys)
|
||||||
api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
|
api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
|
||||||
return exhttp.ApplyMiddleware(
|
return exhttp.ApplyMiddleware(
|
||||||
api,
|
api,
|
||||||
|
@ -242,28 +245,34 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
|
||||||
if err == nil && gmx.validateAuth(authCookie.Value, false) {
|
if err == nil && gmx.validateAuth(authCookie.Value, false) {
|
||||||
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
|
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
|
||||||
gmx.writeTokenCookie(w, false, jsonOutput)
|
gmx.writeTokenCookie(w, false, jsonOutput)
|
||||||
} else if username, password, ok := r.BasicAuth(); !ok {
|
} else if found, correct := gmx.doBasicAuth(r); found && correct {
|
||||||
|
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
|
||||||
|
gmx.writeTokenCookie(w, true, jsonOutput)
|
||||||
|
} else {
|
||||||
|
if !found {
|
||||||
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
|
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
|
||||||
|
} else {
|
||||||
|
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials")
|
||||||
|
}
|
||||||
if allowPrompt {
|
if allowPrompt {
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
|
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
} else {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gmx *Gomuks) doBasicAuth(r *http.Request) (found, correct bool) {
|
||||||
|
var username, password string
|
||||||
|
username, password, found = r.BasicAuth()
|
||||||
|
if !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
usernameHash := sha256.Sum256([]byte(username))
|
usernameHash := sha256.Sum256([]byte(username))
|
||||||
expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username))
|
expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username))
|
||||||
usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:])
|
usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:])
|
||||||
passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil
|
passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil
|
||||||
if usernameCorrect && passwordCorrect {
|
correct = passwordCorrect && usernameCorrect
|
||||||
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
|
return
|
||||||
gmx.writeTokenCookie(w, true, jsonOutput)
|
|
||||||
} else {
|
|
||||||
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials")
|
|
||||||
if allowPrompt {
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isImageFetch(header http.Header) bool {
|
func isImageFetch(header http.Header) bool {
|
||||||
|
|
|
@ -36,6 +36,10 @@ const (
|
||||||
getEventByID = getEventBaseQuery + `WHERE event_id = $1`
|
getEventByID = getEventBaseQuery + `WHERE event_id = $1`
|
||||||
getEventByTransactionID = getEventBaseQuery + `WHERE transaction_id = $1`
|
getEventByTransactionID = getEventBaseQuery + `WHERE transaction_id = $1`
|
||||||
getFailedEventsByMegolmSessionID = getEventBaseQuery + `WHERE room_id = $1 AND megolm_session_id = $2 AND decryption_error IS NOT NULL`
|
getFailedEventsByMegolmSessionID = getEventBaseQuery + `WHERE room_id = $1 AND megolm_session_id = $2 AND decryption_error IS NOT NULL`
|
||||||
|
getRelatedEventsQuery = getEventBaseQuery + `
|
||||||
|
WHERE room_id = $1 AND relates_to = $2 AND ($3 = '' OR relation_type = $3)
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
`
|
||||||
insertEventBaseQuery = `
|
insertEventBaseQuery = `
|
||||||
INSERT INTO event (
|
INSERT INTO event (
|
||||||
room_id, event_id, sender, type, state_key, timestamp, content, decrypted, decrypted_type,
|
room_id, event_id, sender, type, state_key, timestamp, content, decrypted, decrypted_type,
|
||||||
|
@ -112,6 +116,10 @@ func (eq *EventQuery) GetByRowID(ctx context.Context, rowID EventRowID) (*Event,
|
||||||
return eq.QueryOne(ctx, getEventByRowID, rowID)
|
return eq.QueryOne(ctx, getEventByRowID, rowID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (eq *EventQuery) GetRelatedEvents(ctx context.Context, roomID id.RoomID, eventID id.EventID, relationType event.RelationType) ([]*Event, error) {
|
||||||
|
return eq.QueryMany(ctx, getRelatedEventsQuery, roomID, eventID, relationType)
|
||||||
|
}
|
||||||
|
|
||||||
func (eq *EventQuery) GetByRowIDs(ctx context.Context, rowIDs ...EventRowID) ([]*Event, error) {
|
func (eq *EventQuery) GetByRowIDs(ctx context.Context, rowIDs ...EventRowID) ([]*Event, error) {
|
||||||
query, params := buildMultiEventGetFunction(nil, rowIDs, getManyEventsByRowID)
|
query, params := buildMultiEventGetFunction(nil, rowIDs, getManyEventsByRowID)
|
||||||
return eq.QueryMany(ctx, query, params...)
|
return eq.QueryMany(ctx, query, params...)
|
||||||
|
|
|
@ -23,24 +23,27 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
insertMediaQuery = `
|
insertMediaQuery = `
|
||||||
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error)
|
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (mxc) DO NOTHING
|
ON CONFLICT (mxc) DO NOTHING
|
||||||
`
|
`
|
||||||
upsertMediaQuery = `
|
upsertMediaQuery = `
|
||||||
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error)
|
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (mxc) DO UPDATE
|
ON CONFLICT (mxc) DO UPDATE
|
||||||
SET enc_file = COALESCE(excluded.enc_file, media.enc_file),
|
SET enc_file = COALESCE(excluded.enc_file, media.enc_file),
|
||||||
file_name = COALESCE(excluded.file_name, media.file_name),
|
file_name = COALESCE(excluded.file_name, media.file_name),
|
||||||
mime_type = COALESCE(excluded.mime_type, media.mime_type),
|
mime_type = COALESCE(excluded.mime_type, media.mime_type),
|
||||||
size = COALESCE(excluded.size, media.size),
|
size = COALESCE(excluded.size, media.size),
|
||||||
hash = COALESCE(excluded.hash, media.hash),
|
hash = COALESCE(excluded.hash, media.hash),
|
||||||
error = excluded.error
|
error = excluded.error,
|
||||||
|
thumbnail_size = COALESCE(excluded.thumbnail_size, media.thumbnail_size),
|
||||||
|
thumbnail_hash = COALESCE(excluded.thumbnail_hash, media.thumbnail_hash),
|
||||||
|
thumbnail_error = excluded.thumbnail_error
|
||||||
WHERE excluded.error IS NULL OR media.hash IS NULL
|
WHERE excluded.error IS NULL OR media.hash IS NULL
|
||||||
`
|
`
|
||||||
getMediaQuery = `
|
getMediaQuery = `
|
||||||
SELECT mxc, enc_file, file_name, mime_type, size, hash, error
|
SELECT mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error
|
||||||
FROM media
|
FROM media
|
||||||
WHERE mxc = $1
|
WHERE mxc = $1
|
||||||
`
|
`
|
||||||
|
@ -137,9 +140,22 @@ type Media struct {
|
||||||
Size int64
|
Size int64
|
||||||
Hash *[32]byte
|
Hash *[32]byte
|
||||||
Error *MediaError
|
Error *MediaError
|
||||||
|
|
||||||
|
ThumbnailError string
|
||||||
|
ThumbnailSize int64
|
||||||
|
ThumbnailHash *[32]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Media) ETag() string {
|
func (m *Media) ETag(thumbnail bool) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if thumbnail {
|
||||||
|
if m.ThumbnailHash == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`"%x"`, m.ThumbnailHash)
|
||||||
|
}
|
||||||
if m.Hash == nil {
|
if m.Hash == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
@ -151,14 +167,18 @@ func (m *Media) UseCache() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Media) sqlVariables() []any {
|
func (m *Media) sqlVariables() []any {
|
||||||
var hash []byte
|
var hash, thumbnailHash []byte
|
||||||
if m.Hash != nil {
|
if m.Hash != nil {
|
||||||
hash = m.Hash[:]
|
hash = m.Hash[:]
|
||||||
}
|
}
|
||||||
|
if m.ThumbnailHash != nil {
|
||||||
|
thumbnailHash = m.ThumbnailHash[:]
|
||||||
|
}
|
||||||
return []any{
|
return []any{
|
||||||
&m.MXC, dbutil.JSONPtr(m.EncFile),
|
&m.MXC, dbutil.JSONPtr(m.EncFile),
|
||||||
dbutil.StrPtr(m.FileName), dbutil.StrPtr(m.MimeType), dbutil.NumPtr(m.Size),
|
dbutil.StrPtr(m.FileName), dbutil.StrPtr(m.MimeType), dbutil.NumPtr(m.Size),
|
||||||
hash, dbutil.JSONPtr(m.Error),
|
hash, dbutil.JSONPtr(m.Error),
|
||||||
|
dbutil.NumPtr(m.ThumbnailSize), thumbnailHash, dbutil.StrPtr(m.ThumbnailError),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -172,19 +192,27 @@ var safeMimes = []string{
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Media) Scan(row dbutil.Scannable) (*Media, error) {
|
func (m *Media) Scan(row dbutil.Scannable) (*Media, error) {
|
||||||
var mimeType, fileName sql.NullString
|
var mimeType, fileName, thumbnailError sql.NullString
|
||||||
var size sql.NullInt64
|
var size, thumbnailSize sql.NullInt64
|
||||||
var hash []byte
|
var hash, thumbnailHash []byte
|
||||||
err := row.Scan(&m.MXC, dbutil.JSON{Data: &m.EncFile}, &fileName, &mimeType, &size, &hash, dbutil.JSON{Data: &m.Error})
|
err := row.Scan(
|
||||||
|
&m.MXC, dbutil.JSON{Data: &m.EncFile}, &fileName, &mimeType, &size,
|
||||||
|
&hash, dbutil.JSON{Data: &m.Error}, &thumbnailSize, &thumbnailHash, &thumbnailError,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.MimeType = mimeType.String
|
m.MimeType = mimeType.String
|
||||||
m.FileName = fileName.String
|
m.FileName = fileName.String
|
||||||
m.Size = size.Int64
|
m.Size = size.Int64
|
||||||
|
m.ThumbnailSize = thumbnailSize.Int64
|
||||||
|
m.ThumbnailError = thumbnailError.String
|
||||||
if len(hash) == 32 {
|
if len(hash) == 32 {
|
||||||
m.Hash = (*[32]byte)(hash)
|
m.Hash = (*[32]byte)(hash)
|
||||||
}
|
}
|
||||||
|
if len(thumbnailHash) == 32 {
|
||||||
|
m.ThumbnailHash = (*[32]byte)(thumbnailHash)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,7 @@ const (
|
||||||
`
|
`
|
||||||
getCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1`
|
getCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1`
|
||||||
getCurrentRoomStateWithoutMembersQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND type<>'m.room.member'`
|
getCurrentRoomStateWithoutMembersQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND type<>'m.room.member'`
|
||||||
|
getCurrentRoomStateMembersQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND type='m.room.member'`
|
||||||
getManyCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE (cs.room_id, cs.event_type, cs.state_key) IN (%s)`
|
getManyCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE (cs.room_id, cs.event_type, cs.state_key) IN (%s)`
|
||||||
getCurrentStateEventQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND cs.event_type = $2 AND cs.state_key = $3`
|
getCurrentStateEventQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND cs.event_type = $2 AND cs.state_key = $3`
|
||||||
)
|
)
|
||||||
|
@ -118,3 +119,7 @@ func (csq *CurrentStateQuery) GetAll(ctx context.Context, roomID id.RoomID) ([]*
|
||||||
func (csq *CurrentStateQuery) GetAllExceptMembers(ctx context.Context, roomID id.RoomID) ([]*Event, error) {
|
func (csq *CurrentStateQuery) GetAllExceptMembers(ctx context.Context, roomID id.RoomID) ([]*Event, error) {
|
||||||
return csq.QueryMany(ctx, getCurrentRoomStateWithoutMembersQuery, roomID)
|
return csq.QueryMany(ctx, getCurrentRoomStateWithoutMembersQuery, roomID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (csq *CurrentStateQuery) GetMembers(ctx context.Context, roomID id.RoomID) ([]*Event, error) {
|
||||||
|
return csq.QueryMany(ctx, getCurrentRoomStateMembersQuery, roomID)
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
-- v0 -> v12 (compatible with v10+): Latest revision
|
-- v0 -> v13 (compatible with v10+): Latest revision
|
||||||
CREATE TABLE account (
|
CREATE TABLE account (
|
||||||
user_id TEXT NOT NULL PRIMARY KEY,
|
user_id TEXT NOT NULL PRIMARY KEY,
|
||||||
device_id TEXT NOT NULL,
|
device_id TEXT NOT NULL,
|
||||||
|
@ -218,7 +218,11 @@ CREATE TABLE media (
|
||||||
mime_type TEXT,
|
mime_type TEXT,
|
||||||
size INTEGER,
|
size INTEGER,
|
||||||
hash BLOB,
|
hash BLOB,
|
||||||
error TEXT
|
error TEXT,
|
||||||
|
|
||||||
|
thumbnail_size INTEGER,
|
||||||
|
thumbnail_hash BLOB,
|
||||||
|
thumbnail_error TEXT
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
CREATE TABLE media_reference (
|
CREATE TABLE media_reference (
|
||||||
|
|
4
pkg/hicli/database/upgrades/13-media-thumbnails.sql
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
-- v13 (compatible with v10+): Add columns for media thumbnails
|
||||||
|
ALTER TABLE media ADD COLUMN thumbnail_size INTEGER;
|
||||||
|
ALTER TABLE media ADD COLUMN thumbnail_hash BLOB;
|
||||||
|
ALTER TABLE media ADD COLUMN thumbnail_error TEXT;
|
|
@ -7,6 +7,8 @@
|
||||||
package hicli
|
package hicli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"go.mau.fi/util/jsontime"
|
"go.mau.fi/util/jsontime"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
@ -35,6 +37,13 @@ type SyncNotification struct {
|
||||||
Room *database.Room `json:"-"`
|
Room *database.Room `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SyncToDevice struct {
|
||||||
|
Sender id.UserID `json:"sender"`
|
||||||
|
Type event.Type `json:"type"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
Encrypted bool `json:"encrypted"`
|
||||||
|
}
|
||||||
|
|
||||||
type SyncComplete struct {
|
type SyncComplete struct {
|
||||||
Since *string `json:"since,omitempty"`
|
Since *string `json:"since,omitempty"`
|
||||||
ClearState bool `json:"clear_state,omitempty"`
|
ClearState bool `json:"clear_state,omitempty"`
|
||||||
|
@ -44,6 +53,8 @@ type SyncComplete struct {
|
||||||
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
|
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
|
||||||
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
|
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
|
||||||
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
|
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
|
||||||
|
|
||||||
|
ToDevice []*SyncToDevice `json:"to_device,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) {
|
func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) {
|
||||||
|
|
|
@ -50,6 +50,8 @@ type HiClient struct {
|
||||||
syncErrors int
|
syncErrors int
|
||||||
lastSync time.Time
|
lastSync time.Time
|
||||||
|
|
||||||
|
ToDeviceInSync atomic.Bool
|
||||||
|
|
||||||
EventHandler func(evt any)
|
EventHandler func(evt any)
|
||||||
LogoutFunc func(context.Context) error
|
LogoutFunc func(context.Context) error
|
||||||
|
|
||||||
|
|
|
@ -101,7 +101,7 @@ func main() {
|
||||||
resp, err := cli.Send(ctx, id.RoomID(fields[1]), event.EventMessage, &event.MessageEventContent{
|
resp, err := cli.Send(ctx, id.RoomID(fields[1]), event.EventMessage, &event.MessageEventContent{
|
||||||
Body: strings.Join(fields[2:], " "),
|
Body: strings.Join(fields[2:], " "),
|
||||||
MsgType: event.MsgText,
|
MsgType: event.MsgText,
|
||||||
})
|
}, false, false)
|
||||||
_, _ = fmt.Fprintln(rl, err)
|
_, _ = fmt.Fprintln(rl, err)
|
||||||
_, _ = fmt.Fprintf(rl, "%+v\n", resp)
|
_, _ = fmt.Fprintf(rl, "%+v\n", resp)
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,8 +120,11 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
||||||
payload := SyncComplete{
|
payload := SyncComplete{
|
||||||
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)),
|
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)),
|
||||||
}
|
}
|
||||||
for _, room := range rooms {
|
for roomIdx, room := range rooms {
|
||||||
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
|
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
|
||||||
|
if roomIdx == 0 {
|
||||||
|
batchSize *= 2
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
maxTS = room.SortingTimestamp.Time
|
maxTS = room.SortingTimestamp.Time
|
||||||
|
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
"maunium.net/go/mautrix/pushrules"
|
||||||
|
|
||||||
"go.mau.fi/gomuks/pkg/hicli/database"
|
"go.mau.fi/gomuks/pkg/hicli/database"
|
||||||
)
|
)
|
||||||
|
@ -46,7 +47,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
})
|
})
|
||||||
case "send_event":
|
case "send_event":
|
||||||
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
|
||||||
return h.Send(ctx, params.RoomID, params.EventType, params.Content)
|
return h.Send(ctx, params.RoomID, params.EventType, params.Content, params.DisableEncryption, params.Synchronous)
|
||||||
})
|
})
|
||||||
case "resend_event":
|
case "resend_event":
|
||||||
return unmarshalAndCall(req.Data, func(params *resendEventParams) (*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *resendEventParams) (*database.Event, error) {
|
||||||
|
@ -66,6 +67,21 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) {
|
return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) {
|
||||||
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content)
|
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content)
|
||||||
})
|
})
|
||||||
|
case "set_membership":
|
||||||
|
return unmarshalAndCall(req.Data, func(params *setMembershipParams) (any, error) {
|
||||||
|
switch params.Action {
|
||||||
|
case "invite":
|
||||||
|
return h.Client.InviteUser(ctx, params.RoomID, &mautrix.ReqInviteUser{UserID: params.UserID, Reason: params.Reason})
|
||||||
|
case "kick":
|
||||||
|
return h.Client.KickUser(ctx, params.RoomID, &mautrix.ReqKickUser{UserID: params.UserID, Reason: params.Reason})
|
||||||
|
case "ban":
|
||||||
|
return h.Client.BanUser(ctx, params.RoomID, &mautrix.ReqBanUser{UserID: params.UserID, Reason: params.Reason})
|
||||||
|
case "unban":
|
||||||
|
return h.Client.UnbanUser(ctx, params.RoomID, &mautrix.ReqUnbanUser{UserID: params.UserID, Reason: params.Reason})
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown action %q", params.Action)
|
||||||
|
}
|
||||||
|
})
|
||||||
case "set_account_data":
|
case "set_account_data":
|
||||||
return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) {
|
return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) {
|
||||||
if params.RoomID != "" {
|
if params.RoomID != "" {
|
||||||
|
@ -108,12 +124,15 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
})
|
})
|
||||||
case "get_event":
|
case "get_event":
|
||||||
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
|
||||||
|
if params.Unredact {
|
||||||
|
return h.GetUnredactedEvent(ctx, params.RoomID, params.EventID)
|
||||||
|
}
|
||||||
return h.GetEvent(ctx, params.RoomID, params.EventID)
|
return h.GetEvent(ctx, params.RoomID, params.EventID)
|
||||||
})
|
})
|
||||||
//case "get_events_by_rowids":
|
case "get_related_events":
|
||||||
// return unmarshalAndCall(req.Data, func(params *getEventsByRowIDsParams) ([]*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *getRelatedEventsParams) ([]*database.Event, error) {
|
||||||
// return h.GetEventsByRowIDs(ctx, params.RowIDs)
|
return h.DB.Event.GetRelatedEvents(ctx, params.RoomID, params.EventID, params.RelationType)
|
||||||
// })
|
})
|
||||||
case "get_room_state":
|
case "get_room_state":
|
||||||
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
|
||||||
return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch)
|
return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch)
|
||||||
|
@ -149,10 +168,29 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
return unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) {
|
return unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) {
|
||||||
return h.Client.LeaveRoom(ctx, params.RoomID, &mautrix.ReqLeave{Reason: params.Reason})
|
return h.Client.LeaveRoom(ctx, params.RoomID, &mautrix.ReqLeave{Reason: params.Reason})
|
||||||
})
|
})
|
||||||
|
case "create_room":
|
||||||
|
return unmarshalAndCall(req.Data, func(params *mautrix.ReqCreateRoom) (*mautrix.RespCreateRoom, error) {
|
||||||
|
return h.Client.CreateRoom(ctx, params)
|
||||||
|
})
|
||||||
|
case "mute_room":
|
||||||
|
return unmarshalAndCall(req.Data, func(params *muteRoomParams) (bool, error) {
|
||||||
|
if params.Muted {
|
||||||
|
return true, h.Client.PutPushRule(ctx, "global", pushrules.RoomRule, string(params.RoomID), &mautrix.ReqPutPushRule{
|
||||||
|
Actions: []pushrules.PushActionType{},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return false, h.Client.DeletePushRule(ctx, "global", pushrules.RoomRule, string(params.RoomID))
|
||||||
|
}
|
||||||
|
})
|
||||||
case "ensure_group_session_shared":
|
case "ensure_group_session_shared":
|
||||||
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
|
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
|
||||||
return true, h.EnsureGroupSessionShared(ctx, params.RoomID)
|
return true, h.EnsureGroupSessionShared(ctx, params.RoomID)
|
||||||
})
|
})
|
||||||
|
case "send_to_device":
|
||||||
|
return unmarshalAndCall(req.Data, func(params *sendToDeviceParams) (*mautrix.RespSendToDevice, error) {
|
||||||
|
params.EventType.Class = event.ToDeviceEventType
|
||||||
|
return h.SendToDevice(ctx, params.EventType, params.ReqSendToDevice, params.Encrypted)
|
||||||
|
})
|
||||||
case "resolve_alias":
|
case "resolve_alias":
|
||||||
return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
|
return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
|
||||||
return h.Client.ResolveAlias(ctx, params.Alias)
|
return h.Client.ResolveAlias(ctx, params.Alias)
|
||||||
|
@ -205,6 +243,14 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
|
return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
|
||||||
return true, h.DB.PushRegistration.Put(ctx, params)
|
return true, h.DB.PushRegistration.Put(ctx, params)
|
||||||
})
|
})
|
||||||
|
case "listen_to_device":
|
||||||
|
return unmarshalAndCall(req.Data, func(listen *bool) (bool, error) {
|
||||||
|
return h.ToDeviceInSync.Swap(*listen), nil
|
||||||
|
})
|
||||||
|
case "get_turn_servers":
|
||||||
|
return h.Client.TurnServer(ctx)
|
||||||
|
case "get_media_config":
|
||||||
|
return h.Client.GetMediaConfig(ctx)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown command %q", req.Command)
|
return nil, fmt.Errorf("unknown command %q", req.Command)
|
||||||
}
|
}
|
||||||
|
@ -237,6 +283,8 @@ type sendEventParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
EventType event.Type `json:"type"`
|
EventType event.Type `json:"type"`
|
||||||
Content json.RawMessage `json:"content"`
|
Content json.RawMessage `json:"content"`
|
||||||
|
DisableEncryption bool `json:"disable_encryption"`
|
||||||
|
Synchronous bool `json:"synchronous"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type resendEventParams struct {
|
type resendEventParams struct {
|
||||||
|
@ -262,6 +310,13 @@ type sendStateEventParams struct {
|
||||||
Content json.RawMessage `json:"content"`
|
Content json.RawMessage `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type setMembershipParams struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
RoomID id.RoomID `json:"room_id"`
|
||||||
|
UserID id.UserID `json:"user_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
type setAccountDataParams struct {
|
type setAccountDataParams struct {
|
||||||
RoomID id.RoomID `json:"room_id,omitempty"`
|
RoomID id.RoomID `json:"room_id,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
@ -291,11 +346,15 @@ type setProfileFieldParams struct {
|
||||||
type getEventParams struct {
|
type getEventParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
EventID id.EventID `json:"event_id"`
|
EventID id.EventID `json:"event_id"`
|
||||||
|
Unredact bool `json:"unredact"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//type getEventsByRowIDsParams struct {
|
type getRelatedEventsParams struct {
|
||||||
// RowIDs []database.EventRowID `json:"row_ids"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
//}
|
EventID id.EventID `json:"event_id"`
|
||||||
|
|
||||||
|
RelationType event.RelationType `json:"relation_type"`
|
||||||
|
}
|
||||||
|
|
||||||
type getRoomStateParams struct {
|
type getRoomStateParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
|
@ -312,6 +371,12 @@ type ensureGroupSessionSharedParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sendToDeviceParams struct {
|
||||||
|
*mautrix.ReqSendToDevice
|
||||||
|
EventType event.Type `json:"event_type"`
|
||||||
|
Encrypted bool `json:"encrypted"`
|
||||||
|
}
|
||||||
|
|
||||||
type resolveAliasParams struct {
|
type resolveAliasParams struct {
|
||||||
Alias id.RoomAlias `json:"alias"`
|
Alias id.RoomAlias `json:"alias"`
|
||||||
}
|
}
|
||||||
|
@ -360,3 +425,8 @@ type getReceiptsParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
EventIDs []id.EventID `json:"event_ids"`
|
EventIDs []id.EventID `json:"event_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type muteRoomParams struct {
|
||||||
|
RoomID id.RoomID `json:"room_id"`
|
||||||
|
Muted bool `json:"muted"`
|
||||||
|
}
|
||||||
|
|
|
@ -22,37 +22,6 @@ import (
|
||||||
|
|
||||||
var ErrPaginationAlreadyInProgress = errors.New("pagination is already in progress")
|
var ErrPaginationAlreadyInProgress = errors.New("pagination is already in progress")
|
||||||
|
|
||||||
/*func (h *HiClient) GetEventsByRowIDs(ctx context.Context, rowIDs []database.EventRowID) ([]*database.Event, error) {
|
|
||||||
events, err := h.DB.Event.GetByRowIDs(ctx, rowIDs...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else if len(events) == 0 {
|
|
||||||
return events, nil
|
|
||||||
}
|
|
||||||
firstRoomID := events[0].RoomID
|
|
||||||
allInSameRoom := true
|
|
||||||
for _, evt := range events {
|
|
||||||
h.ReprocessExistingEvent(ctx, evt)
|
|
||||||
if evt.RoomID != firstRoomID {
|
|
||||||
allInSameRoom = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if allInSameRoom {
|
|
||||||
err = h.DB.Event.FillLastEditRowIDs(ctx, firstRoomID, events)
|
|
||||||
if err != nil {
|
|
||||||
return events, fmt.Errorf("failed to fill last edit row IDs: %w", err)
|
|
||||||
}
|
|
||||||
err = h.DB.Event.FillReactionCounts(ctx, firstRoomID, events)
|
|
||||||
if err != nil {
|
|
||||||
return events, fmt.Errorf("failed to fill reaction counts: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// TODO slow path where events are collected and filling is done one room at a time?
|
|
||||||
}
|
|
||||||
return events, nil
|
|
||||||
}*/
|
|
||||||
|
|
||||||
func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*database.Event, error) {
|
func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*database.Event, error) {
|
||||||
if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
|
if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
|
||||||
return nil, fmt.Errorf("failed to get event from database: %w", err)
|
return nil, fmt.Errorf("failed to get event from database: %w", err)
|
||||||
|
@ -66,6 +35,26 @@ func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.Ev
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HiClient) GetUnredactedEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*database.Event, error) {
|
||||||
|
if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get event from database: %w", err)
|
||||||
|
// TODO this check doesn't handle events which keep some fields on redaction
|
||||||
|
} else if evt != nil && len(evt.Content) > 2 {
|
||||||
|
h.ReprocessExistingEvent(ctx, evt)
|
||||||
|
return evt, nil
|
||||||
|
} else if serverEvt, err := h.Client.GetUnredactedEventContent(ctx, roomID, eventID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get event from server: %w", err)
|
||||||
|
} else if redactedServerEvt, err := h.Client.GetEvent(ctx, roomID, eventID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get redacted event from server: %w", err)
|
||||||
|
// TODO this check will have false positives on actually empty events
|
||||||
|
} else if len(serverEvt.Content.VeryRaw) == 2 {
|
||||||
|
return nil, fmt.Errorf("server didn't return content")
|
||||||
|
} else {
|
||||||
|
serverEvt.Unsigned.RedactedBecause = redactedServerEvt.Unsigned.RedactedBecause
|
||||||
|
return h.processEvent(ctx, serverEvt, nil, nil, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fetchMembers, refetch, dispatchEvt bool) error {
|
func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fetchMembers, refetch, dispatchEvt bool) error {
|
||||||
var evts []*event.Event
|
var evts []*event.Event
|
||||||
if refetch {
|
if refetch {
|
||||||
|
@ -279,6 +268,7 @@ func (h *HiClient) GetReceipts(ctx context.Context, roomID id.RoomID, eventIDs [
|
||||||
|
|
||||||
func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit int) (*PaginationResponse, error) {
|
func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit int) (*PaginationResponse, error) {
|
||||||
ctx, cancel := context.WithCancelCause(ctx)
|
ctx, cancel := context.WithCancelCause(ctx)
|
||||||
|
defer cancel(context.Canceled)
|
||||||
h.paginationInterrupterLock.Lock()
|
h.paginationInterrupterLock.Lock()
|
||||||
if _, alreadyPaginating := h.paginationInterrupter[roomID]; alreadyPaginating {
|
if _, alreadyPaginating := h.paginationInterrupter[roomID]; alreadyPaginating {
|
||||||
h.paginationInterrupterLock.Unlock()
|
h.paginationInterrupterLock.Unlock()
|
||||||
|
|
|
@ -71,6 +71,13 @@ func (h *HiClient) SendMessage(
|
||||||
relatesTo *event.RelatesTo,
|
relatesTo *event.RelatesTo,
|
||||||
mentions *event.Mentions,
|
mentions *event.Mentions,
|
||||||
) (*database.Event, error) {
|
) (*database.Event, error) {
|
||||||
|
if text == "/discardsession" {
|
||||||
|
err := h.CryptoStore.RemoveOutboundGroupSession(ctx, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("outbound megolm session successfully discarded")
|
||||||
|
}
|
||||||
var unencrypted bool
|
var unencrypted bool
|
||||||
if strings.HasPrefix(text, "/unencrypted ") {
|
if strings.HasPrefix(text, "/unencrypted ") {
|
||||||
text = strings.TrimPrefix(text, "/unencrypted ")
|
text = strings.TrimPrefix(text, "/unencrypted ")
|
||||||
|
@ -90,7 +97,7 @@ func (h *HiClient) SendMessage(
|
||||||
if !json.Valid(content) {
|
if !json.Valid(content) {
|
||||||
return nil, fmt.Errorf("invalid JSON in /raw command")
|
return nil, fmt.Errorf("invalid JSON in /raw command")
|
||||||
}
|
}
|
||||||
return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "", unencrypted)
|
return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "", unencrypted, false)
|
||||||
} else if strings.HasPrefix(text, "/rawstate ") {
|
} else if strings.HasPrefix(text, "/rawstate ") {
|
||||||
parts := strings.SplitN(text, " ", 4)
|
parts := strings.SplitN(text, " ", 4)
|
||||||
if len(parts) < 4 || len(parts[1]) == 0 {
|
if len(parts) < 4 || len(parts[1]) == 0 {
|
||||||
|
@ -154,12 +161,18 @@ func (h *HiClient) SendMessage(
|
||||||
Body: "",
|
Body: "",
|
||||||
MsgType: contentCopy.MsgType,
|
MsgType: contentCopy.MsgType,
|
||||||
URL: contentCopy.URL,
|
URL: contentCopy.URL,
|
||||||
|
GeoURI: contentCopy.GeoURI,
|
||||||
NewContent: &contentCopy,
|
NewContent: &contentCopy,
|
||||||
RelatesTo: relatesTo,
|
RelatesTo: relatesTo,
|
||||||
}
|
}
|
||||||
if contentCopy.File != nil {
|
if contentCopy.File != nil {
|
||||||
content.URL = contentCopy.File.URL
|
content.URL = contentCopy.File.URL
|
||||||
}
|
}
|
||||||
|
if extra != nil {
|
||||||
|
extra = map[string]any{
|
||||||
|
"m.new_content": extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
content.RelatesTo = relatesTo
|
content.RelatesTo = relatesTo
|
||||||
}
|
}
|
||||||
|
@ -169,7 +182,7 @@ func (h *HiClient) SendMessage(
|
||||||
content.MsgType = ""
|
content.MsgType = ""
|
||||||
evtType = event.EventSticker
|
evtType = event.EventSticker
|
||||||
}
|
}
|
||||||
return h.send(ctx, roomID, evtType, &event.Content{Parsed: content, Raw: extra}, origText, unencrypted)
|
return h.send(ctx, roomID, evtType, &event.Content{Parsed: content, Raw: extra}, origText, unencrypted, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error {
|
func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error {
|
||||||
|
@ -232,8 +245,14 @@ func (h *HiClient) Send(
|
||||||
roomID id.RoomID,
|
roomID id.RoomID,
|
||||||
evtType event.Type,
|
evtType event.Type,
|
||||||
content any,
|
content any,
|
||||||
|
disableEncryption bool,
|
||||||
|
synchronous bool,
|
||||||
) (*database.Event, error) {
|
) (*database.Event, error) {
|
||||||
return h.send(ctx, roomID, evtType, content, "", false)
|
if evtType == event.EventRedaction {
|
||||||
|
// TODO implement
|
||||||
|
return nil, fmt.Errorf("redaction is not supported")
|
||||||
|
}
|
||||||
|
return h.send(ctx, roomID, evtType, content, "", disableEncryption, synchronous)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, error) {
|
func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, error) {
|
||||||
|
@ -252,7 +271,7 @@ func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, e
|
||||||
return nil, fmt.Errorf("unknown room")
|
return nil, fmt.Errorf("unknown room")
|
||||||
}
|
}
|
||||||
dbEvt.SendError = ""
|
dbEvt.SendError = ""
|
||||||
go h.actuallySend(context.WithoutCancel(ctx), room, dbEvt, event.Type{Type: dbEvt.Type, Class: event.MessageEventType})
|
go h.actuallySend(context.WithoutCancel(ctx), room, dbEvt, event.Type{Type: dbEvt.Type, Class: event.MessageEventType}, false)
|
||||||
return dbEvt, nil
|
return dbEvt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -263,6 +282,7 @@ func (h *HiClient) send(
|
||||||
content any,
|
content any,
|
||||||
overrideEditSource string,
|
overrideEditSource string,
|
||||||
disableEncryption bool,
|
disableEncryption bool,
|
||||||
|
synchronous bool,
|
||||||
) (*database.Event, error) {
|
) (*database.Event, error) {
|
||||||
room, err := h.DB.Room.Get(ctx, roomID)
|
room, err := h.DB.Room.Get(ctx, roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -321,11 +341,15 @@ func (h *HiClient) send(
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to stop typing while sending message")
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to stop typing while sending message")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
go h.actuallySend(ctx, room, dbEvt, evtType)
|
if synchronous {
|
||||||
|
h.actuallySend(ctx, room, dbEvt, evtType, true)
|
||||||
|
} else {
|
||||||
|
go h.actuallySend(ctx, room, dbEvt, evtType, false)
|
||||||
|
}
|
||||||
return dbEvt, nil
|
return dbEvt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt *database.Event, evtType event.Type) {
|
func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt *database.Event, evtType event.Type, synchronous bool) {
|
||||||
var err error
|
var err error
|
||||||
defer func() {
|
defer func() {
|
||||||
if dbEvt.SendError != "" {
|
if dbEvt.SendError != "" {
|
||||||
|
@ -335,10 +359,12 @@ func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt
|
||||||
Msg("Failed to update send error in database after sending failed")
|
Msg("Failed to update send error in database after sending failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !synchronous {
|
||||||
h.EventHandler(&SendComplete{
|
h.EventHandler(&SendComplete{
|
||||||
Event: dbEvt,
|
Event: dbEvt,
|
||||||
Error: err,
|
Error: err,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
if dbEvt.Decrypted != nil && len(dbEvt.Content) <= 2 {
|
if dbEvt.Decrypted != nil && len(dbEvt.Content) <= 2 {
|
||||||
var encryptedContent *event.EncryptedEventContent
|
var encryptedContent *event.EncryptedEventContent
|
||||||
|
@ -411,6 +437,18 @@ func (h *HiClient) EnsureGroupSessionShared(ctx context.Context, roomID id.RoomI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HiClient) SendToDevice(ctx context.Context, evtType event.Type, content *mautrix.ReqSendToDevice, encrypt bool) (*mautrix.RespSendToDevice, error) {
|
||||||
|
if encrypt {
|
||||||
|
var err error
|
||||||
|
content, err = h.Crypto.EncryptToDevices(ctx, evtType, content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt: %w", err)
|
||||||
|
}
|
||||||
|
evtType = event.ToDeviceEncrypted
|
||||||
|
}
|
||||||
|
return h.Client.SendToDevice(ctx, evtType, content)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HiClient) loadMembers(ctx context.Context, room *database.Room) error {
|
func (h *HiClient) loadMembers(ctx context.Context, room *database.Room) error {
|
||||||
if room.HasMemberList {
|
if room.HasMemberList {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -66,6 +66,9 @@ func (h *HiClient) markSyncOK() {
|
||||||
|
|
||||||
func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
|
func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
|
||||||
log := zerolog.Ctx(ctx)
|
log := zerolog.Ctx(ctx)
|
||||||
|
listenToDevice := h.ToDeviceInSync.Load()
|
||||||
|
var syncTD []*SyncToDevice
|
||||||
|
|
||||||
postponedToDevices := resp.ToDevice.Events[:0]
|
postponedToDevices := resp.ToDevice.Events[:0]
|
||||||
for _, evt := range resp.ToDevice.Events {
|
for _, evt := range resp.ToDevice.Events {
|
||||||
evt.Type.Class = event.ToDeviceEventType
|
evt.Type.Class = event.ToDeviceEventType
|
||||||
|
@ -80,19 +83,80 @@ func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.Res
|
||||||
|
|
||||||
switch content := evt.Content.Parsed.(type) {
|
switch content := evt.Content.Parsed.(type) {
|
||||||
case *event.EncryptedEventContent:
|
case *event.EncryptedEventContent:
|
||||||
h.Crypto.HandleEncryptedEvent(ctx, evt)
|
unhandledDecrypted := h.Crypto.HandleEncryptedEvent(ctx, evt)
|
||||||
|
if unhandledDecrypted != nil && listenToDevice {
|
||||||
|
syncTD = append(syncTD, &SyncToDevice{
|
||||||
|
Sender: evt.Sender,
|
||||||
|
Type: unhandledDecrypted.Type,
|
||||||
|
Content: unhandledDecrypted.Content.VeryRaw,
|
||||||
|
Encrypted: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
case *event.RoomKeyWithheldEventContent:
|
case *event.RoomKeyWithheldEventContent:
|
||||||
|
// TODO move this check to mautrix-go?
|
||||||
|
if evt.Sender == h.Account.UserID && content.Code == event.RoomKeyWithheldUnavailable {
|
||||||
|
log.Debug().Any("withheld_content", content).Msg("Ignoring m.unavailable megolm session withheld event")
|
||||||
|
} else {
|
||||||
h.Crypto.HandleRoomKeyWithheld(ctx, content)
|
h.Crypto.HandleRoomKeyWithheld(ctx, content)
|
||||||
default:
|
}
|
||||||
|
case *event.SecretRequestEventContent, *event.RoomKeyRequestEventContent:
|
||||||
postponedToDevices = append(postponedToDevices, evt)
|
postponedToDevices = append(postponedToDevices, evt)
|
||||||
|
default:
|
||||||
|
if listenToDevice {
|
||||||
|
syncTD = append(syncTD, &SyncToDevice{
|
||||||
|
Sender: evt.Sender,
|
||||||
|
Type: evt.Type,
|
||||||
|
Content: evt.Content.VeryRaw,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp.ToDevice.Events = postponedToDevices
|
resp.ToDevice.Events = postponedToDevices
|
||||||
|
if len(syncTD) > 0 {
|
||||||
|
ctx.Value(syncContextKey).(*syncContext).evt.ToDevice = syncTD
|
||||||
|
}
|
||||||
h.Crypto.MarkOlmHashSavePoint(ctx)
|
h.Crypto.MarkOlmHashSavePoint(ctx)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HiClient) maybeDiscardOutboundSession(ctx context.Context, newMembership event.Membership, evt *event.Event) bool {
|
||||||
|
var prevMembership event.Membership = "unknown"
|
||||||
|
if evt.Unsigned.PrevContent != nil {
|
||||||
|
prevMembership = event.Membership(gjson.GetBytes(evt.Unsigned.PrevContent.VeryRaw, "membership").Str)
|
||||||
|
}
|
||||||
|
if prevMembership == "unknown" || prevMembership == "" {
|
||||||
|
cs, err := h.DB.CurrentState.Get(ctx, evt.RoomID, event.StateMember, h.Account.UserID.String())
|
||||||
|
if err != nil {
|
||||||
|
zerolog.Ctx(ctx).Warn().Err(err).
|
||||||
|
Stringer("room_id", evt.RoomID).
|
||||||
|
Str("user_id", evt.GetStateKey()).
|
||||||
|
Msg("Failed to get previous membership")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
prevMembership = event.Membership(gjson.GetBytes(cs.Content, "membership").Str)
|
||||||
|
}
|
||||||
|
if prevMembership == newMembership ||
|
||||||
|
(prevMembership == event.MembershipInvite && newMembership == event.MembershipJoin) ||
|
||||||
|
(prevMembership == event.MembershipJoin && newMembership == event.MembershipInvite) ||
|
||||||
|
(prevMembership == event.MembershipBan && newMembership == event.MembershipLeave) ||
|
||||||
|
(prevMembership == event.MembershipLeave && newMembership == event.MembershipBan) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
zerolog.Ctx(ctx).Debug().
|
||||||
|
Stringer("room_id", evt.RoomID).
|
||||||
|
Str("user_id", evt.GetStateKey()).
|
||||||
|
Str("prev_membership", string(prevMembership)).
|
||||||
|
Str("new_membership", string(newMembership)).
|
||||||
|
Msg("Got membership state change, invalidating group session in room")
|
||||||
|
err := h.CryptoStore.RemoveOutboundGroupSession(ctx, evt.RoomID)
|
||||||
|
if err != nil {
|
||||||
|
zerolog.Ctx(ctx).Warn().Stringer("room_id", evt.RoomID).Msg("Failed to invalidate outbound group session")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HiClient) postProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) {
|
func (h *HiClient) postProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) {
|
||||||
h.Crypto.HandleOTKCounts(ctx, &resp.DeviceOTKCount)
|
h.Crypto.HandleOTKCounts(ctx, &resp.DeviceOTKCount)
|
||||||
go h.asyncPostProcessSyncResponse(ctx, resp, since)
|
go h.asyncPostProcessSyncResponse(ctx, resp, since)
|
||||||
|
@ -297,6 +361,10 @@ func (h *HiClient) processSyncLeftRoom(ctx context.Context, roomID id.RoomID, ro
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete invited room: %w", err)
|
return fmt.Errorf("failed to delete invited room: %w", err)
|
||||||
}
|
}
|
||||||
|
err = h.CryptoStore.RemoveOutboundGroupSession(ctx, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to remove outbound group session: %w", err)
|
||||||
|
}
|
||||||
payload := ctx.Value(syncContextKey).(*syncContext).evt
|
payload := ctx.Value(syncContextKey).(*syncContext).evt
|
||||||
payload.LeftRooms = append(payload.LeftRooms, roomID)
|
payload.LeftRooms = append(payload.LeftRooms, roomID)
|
||||||
return nil
|
return nil
|
||||||
|
@ -572,6 +640,14 @@ func (h *HiClient) processEvent(
|
||||||
return dbEvt, nil
|
return dbEvt, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if evt.StateKey != nil && evt.Unsigned.PrevContent == nil && evt.Unsigned.ReplacesState != "" {
|
||||||
|
replacesState, err := h.DB.Event.GetByID(ctx, evt.Unsigned.ReplacesState)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get prev content for %s from %s: %w", evt.ID, evt.Unsigned.ReplacesState, err)
|
||||||
|
} else if replacesState != nil {
|
||||||
|
evt.Unsigned.PrevContent = &event.Content{VeryRaw: replacesState.Content}
|
||||||
|
}
|
||||||
|
}
|
||||||
dbEvt := database.MautrixToEvent(evt)
|
dbEvt := database.MautrixToEvent(evt)
|
||||||
contentWithoutFallback := removeReplyFallback(evt)
|
contentWithoutFallback := removeReplyFallback(evt)
|
||||||
if contentWithoutFallback != nil {
|
if contentWithoutFallback != nil {
|
||||||
|
@ -580,7 +656,7 @@ func (h *HiClient) processEvent(
|
||||||
}
|
}
|
||||||
var decryptionErr error
|
var decryptionErr error
|
||||||
var decryptedMautrixEvt *event.Event
|
var decryptedMautrixEvt *event.Event
|
||||||
if evt.Type == event.EventEncrypted && dbEvt.RedactedBy == "" {
|
if evt.Type == event.EventEncrypted && (dbEvt.RedactedBy == "" || len(dbEvt.Content) > 2) {
|
||||||
decryptedMautrixEvt, decryptionErr = h.decryptEventInto(ctx, evt, dbEvt)
|
decryptedMautrixEvt, decryptionErr = h.decryptEventInto(ctx, evt, dbEvt)
|
||||||
} else if evt.Type == event.EventRedaction {
|
} else if evt.Type == event.EventRedaction {
|
||||||
if evt.Redacts != "" && gjson.GetBytes(evt.Content.VeryRaw, "redacts").Str != evt.Redacts.String() {
|
if evt.Redacts != "" && gjson.GetBytes(evt.Content.VeryRaw, "redacts").Str != evt.Redacts.String() {
|
||||||
|
@ -715,6 +791,7 @@ func (h *HiClient) processStateAndTimeline(
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
megolmSessionDiscarded := false
|
||||||
processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) {
|
processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) {
|
||||||
evt.RoomID = room.ID
|
evt.RoomID = room.ID
|
||||||
dbEvt, err := h.processEvent(ctx, evt, summary, decryptionQueue, evt.Unsigned.TransactionID != "")
|
dbEvt, err := h.processEvent(ctx, evt, summary, decryptionQueue, evt.Unsigned.TransactionID != "")
|
||||||
|
@ -747,6 +824,9 @@ func (h *HiClient) processStateAndTimeline(
|
||||||
if summary != nil && slices.Contains(summary.Heroes, id.UserID(*evt.StateKey)) {
|
if summary != nil && slices.Contains(summary.Heroes, id.UserID(*evt.StateKey)) {
|
||||||
heroesChanged = true
|
heroesChanged = true
|
||||||
}
|
}
|
||||||
|
if !megolmSessionDiscarded && room.EncryptionEvent != nil {
|
||||||
|
megolmSessionDiscarded = h.maybeDiscardOutboundSession(ctx, membership, evt)
|
||||||
|
}
|
||||||
} else if evt.Type == event.StateElementFunctionalMembers {
|
} else if evt.Type == event.StateElementFunctionalMembers {
|
||||||
heroesChanged = true
|
heroesChanged = true
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,7 +69,7 @@ func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration,
|
||||||
c.syncErrors++
|
c.syncErrors++
|
||||||
delay := 1 * time.Second
|
delay := 1 * time.Second
|
||||||
if c.syncErrors > 5 {
|
if c.syncErrors > 5 {
|
||||||
delay = max(time.Duration(c.syncErrors)*time.Second, 30*time.Second)
|
delay = min(time.Duration(c.syncErrors)*time.Second, 30*time.Second)
|
||||||
}
|
}
|
||||||
c.markSyncErrored(err, false)
|
c.markSyncErrored(err, false)
|
||||||
c.Log.Err(err).Dur("retry_in", delay).Msg("Sync failed")
|
c.Log.Err(err).Dur("retry_in", delay).Msg("Sync failed")
|
||||||
|
|
1104
web/package-lock.json
generated
|
@ -13,8 +13,10 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wailsio/runtime": "^3.0.0-alpha.29",
|
"@wailsio/runtime": "^3.0.0-alpha.29",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
"katex": "^0.16.11",
|
"katex": "^0.16.11",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"matrix-widget-api": "^1.13.1",
|
||||||
"monaco-editor": "^0.52.0",
|
"monaco-editor": "^0.52.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-blurhash": "^0.3.0",
|
"react-blurhash": "^0.3.0",
|
||||||
|
@ -37,7 +39,7 @@
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.7.0",
|
"typescript-eslint": "^8.7.0",
|
||||||
"vite": "^5.4.8",
|
"vite": "^6.0.9",
|
||||||
"vite-plugin-svgr": "^4.2.0"
|
"vite-plugin-svgr": "^4.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
import type { MouseEvent } from "react"
|
import type { MouseEvent } from "react"
|
||||||
import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
|
import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
|
||||||
import RPCClient, { SendMessageParams } from "./rpc.ts"
|
import RPCClient, { SendMessageParams } from "./rpc.ts"
|
||||||
import { RoomStateStore, StateStore } from "./statestore"
|
import { RoomStateStore, StateStore, WidgetListener } from "./statestore"
|
||||||
import type {
|
import type {
|
||||||
ClientState,
|
ClientState,
|
||||||
ElementRecentEmoji,
|
ElementRecentEmoji,
|
||||||
|
@ -26,6 +26,7 @@ import type {
|
||||||
ImagePackRooms,
|
ImagePackRooms,
|
||||||
RPCEvent,
|
RPCEvent,
|
||||||
RawDBEvent,
|
RawDBEvent,
|
||||||
|
RelationType,
|
||||||
RoomID,
|
RoomID,
|
||||||
RoomStateGUID,
|
RoomStateGUID,
|
||||||
SyncStatus,
|
SyncStatus,
|
||||||
|
@ -40,6 +41,7 @@ export default class Client {
|
||||||
#stateRequests: RoomStateGUID[] = []
|
#stateRequests: RoomStateGUID[] = []
|
||||||
#stateRequestPromise: Promise<void> | null = null
|
#stateRequestPromise: Promise<void> | null = null
|
||||||
#gcInterval: number | undefined
|
#gcInterval: number | undefined
|
||||||
|
#toDeviceRequested = false
|
||||||
|
|
||||||
constructor(readonly rpc: RPCClient) {
|
constructor(readonly rpc: RPCClient) {
|
||||||
this.rpc.event.listen(this.#handleEvent)
|
this.rpc.event.listen(this.#handleEvent)
|
||||||
|
@ -153,6 +155,22 @@ export default class Client {
|
||||||
navigator.registerProtocolHandler("matrix", "#/uri/%s")
|
navigator.registerProtocolHandler("matrix", "#/uri/%s")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addWidgetListener(listener: WidgetListener): () => void {
|
||||||
|
this.store.widgetListeners.add(listener)
|
||||||
|
// TODO only request to-device events if there are widgets that need them?
|
||||||
|
if (!this.#toDeviceRequested) {
|
||||||
|
this.#toDeviceRequested = true
|
||||||
|
this.rpc.setListenToDevice(true)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
this.store.widgetListeners.delete(listener)
|
||||||
|
if (this.store.widgetListeners.size === 0 && this.#toDeviceRequested) {
|
||||||
|
this.#toDeviceRequested = false
|
||||||
|
this.rpc.setListenToDevice(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start(): () => void {
|
start(): () => void {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
if (window.gomuksAndroid) {
|
if (window.gomuksAndroid) {
|
||||||
|
@ -221,20 +239,42 @@ export default class Client {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) {
|
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID, unredact?: boolean) {
|
||||||
if (typeof room === "string") {
|
if (typeof room === "string") {
|
||||||
room = this.store.rooms.get(room)
|
room = this.store.rooms.get(room)
|
||||||
}
|
}
|
||||||
if (!room || room.eventsByID.has(eventID) || room.requestedEvents.has(eventID)) {
|
if (!room || (!unredact && room.eventsByID.has(eventID)) ||room.requestedEvents.has(eventID)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
room.requestedEvents.add(eventID)
|
room.requestedEvents.add(eventID)
|
||||||
this.rpc.getEvent(room.roomID, eventID).then(
|
this.rpc.getEvent(room.roomID, eventID, unredact).then(
|
||||||
evt => room.applyEvent(evt),
|
evt => {
|
||||||
err => console.error(`Failed to fetch event ${eventID}`, err),
|
room.applyEvent(evt, false, unredact)
|
||||||
|
if (unredact) {
|
||||||
|
room.notifyTimelineSubscribers()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
console.error(`Failed to fetch event ${eventID}`, err)
|
||||||
|
if (unredact) {
|
||||||
|
room.requestedEvents.delete(eventID)
|
||||||
|
window.alert(`Failed to get unredacted content: ${err}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRelatedEvents(room: RoomStateStore | RoomID | undefined, eventID: EventID, relationType?: RelationType) {
|
||||||
|
if (typeof room === "string") {
|
||||||
|
room = this.store.rooms.get(room)
|
||||||
|
}
|
||||||
|
if (!room) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const events = await this.rpc.getRelatedEvents(room.roomID, eventID, relationType)
|
||||||
|
return events.map(evt => room.getOrApplyEvent(evt))
|
||||||
|
}
|
||||||
|
|
||||||
async pinMessage(room: RoomStateStore, evtID: EventID, wantPinned: boolean) {
|
async pinMessage(room: RoomStateStore, evtID: EventID, wantPinned: boolean) {
|
||||||
const pinnedEvents = room.getPinnedEvents()
|
const pinnedEvents = room.getPinnedEvents()
|
||||||
const currentlyPinned = pinnedEvents.includes(evtID)
|
const currentlyPinned = pinnedEvents.includes(evtID)
|
||||||
|
@ -269,12 +309,14 @@ export default class Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendEvent(roomID: RoomID, type: EventType, content: unknown): Promise<void> {
|
async sendEvent(
|
||||||
|
roomID: RoomID, type: EventType, content: unknown, disableEncryption: boolean = false,
|
||||||
|
): Promise<void> {
|
||||||
const room = this.store.rooms.get(roomID)
|
const room = this.store.rooms.get(roomID)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
throw new Error("Room not found")
|
throw new Error("Room not found")
|
||||||
}
|
}
|
||||||
const dbEvent = await this.rpc.sendEvent(roomID, type, content)
|
const dbEvent = await this.rpc.sendEvent(roomID, type, content, disableEncryption)
|
||||||
this.#handleOutgoingEvent(dbEvent, room)
|
this.#handleOutgoingEvent(dbEvent, room)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -78,7 +78,7 @@ function getFallbackCharacter(from: unknown, idx: number): string {
|
||||||
return Array.from(from.slice(0, (idx + 1) * 2))[idx]?.toUpperCase().toWellFormed() ?? ""
|
return Array.from(from.slice(0, (idx + 1) * 2))[idx]?.toUpperCase().toWellFormed() ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAvatarURL = (userID: UserID, content?: UserProfile | null): string | undefined => {
|
export const getAvatarURL = (userID: UserID, content?: UserProfile | null, thumbnail = false): string | undefined => {
|
||||||
const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1)
|
const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1)
|
||||||
const backgroundColor = getUserColor(userID)
|
const backgroundColor = getUserColor(userID)
|
||||||
const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url)
|
const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url)
|
||||||
|
@ -87,7 +87,12 @@ export const getAvatarURL = (userID: UserID, content?: UserProfile | null): stri
|
||||||
}
|
}
|
||||||
const encrypted = !!content?.avatar_file
|
const encrypted = !!content?.avatar_file
|
||||||
const fallback = `${backgroundColor}:${fallbackCharacter}`
|
const fallback = `${backgroundColor}:${fallbackCharacter}`
|
||||||
return `_gomuks/media/${server}/${mediaID}?encrypted=${encrypted}&fallback=${encodeURIComponent(fallback)}`
|
const url = `_gomuks/media/${server}/${mediaID}?encrypted=${encrypted}&fallback=${encodeURIComponent(fallback)}`
|
||||||
|
return thumbnail ? `${url}&thumbnail=avatar` : url
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAvatarThumbnailURL = (userID: UserID, content?: UserProfile | null): string | undefined => {
|
||||||
|
return getAvatarURL(userID, content, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoomForAvatarURL {
|
interface RoomForAvatarURL {
|
||||||
|
@ -98,9 +103,15 @@ interface RoomForAvatarURL {
|
||||||
avatar_url?: ContentURI
|
avatar_url?: ContentURI
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: ContentURI): string | undefined => {
|
export const getRoomAvatarURL = (
|
||||||
|
room: RoomForAvatarURL, avatarOverride?: ContentURI, thumbnail = false,
|
||||||
|
): string | undefined => {
|
||||||
return getAvatarURL(room.dm_user_id ?? room.room_id, {
|
return getAvatarURL(room.dm_user_id ?? room.room_id, {
|
||||||
displayname: room.name,
|
displayname: room.name,
|
||||||
avatar_url: avatarOverride ?? room.avatar ?? room.avatar_url,
|
avatar_url: avatarOverride ?? room.avatar ?? room.avatar_url,
|
||||||
})
|
}, thumbnail)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRoomAvatarThumbnailURL = (room: RoomForAvatarURL, avatarOverride?: ContentURI): string | undefined => {
|
||||||
|
return getRoomAvatarURL(room, avatarOverride, true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,11 +19,11 @@ import type {
|
||||||
ClientWellKnown,
|
ClientWellKnown,
|
||||||
DBPushRegistration,
|
DBPushRegistration,
|
||||||
EventID,
|
EventID,
|
||||||
EventRowID,
|
|
||||||
EventType,
|
EventType,
|
||||||
JSONValue,
|
JSONValue,
|
||||||
LoginFlowsResponse,
|
LoginFlowsResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
|
MembershipAction,
|
||||||
Mentions,
|
Mentions,
|
||||||
MessageEventContent,
|
MessageEventContent,
|
||||||
PaginationResponse,
|
PaginationResponse,
|
||||||
|
@ -33,9 +33,14 @@ import type {
|
||||||
RawDBEvent,
|
RawDBEvent,
|
||||||
ReceiptType,
|
ReceiptType,
|
||||||
RelatesTo,
|
RelatesTo,
|
||||||
|
RelationType,
|
||||||
|
ReqCreateRoom,
|
||||||
ResolveAliasResponse,
|
ResolveAliasResponse,
|
||||||
|
RespCreateRoom,
|
||||||
|
RespMediaConfig,
|
||||||
RespOpenIDToken,
|
RespOpenIDToken,
|
||||||
RespRoomJoin,
|
RespRoomJoin,
|
||||||
|
RespTurnServer,
|
||||||
RoomAlias,
|
RoomAlias,
|
||||||
RoomID,
|
RoomID,
|
||||||
RoomStateGUID,
|
RoomStateGUID,
|
||||||
|
@ -145,8 +150,14 @@ export default abstract class RPCClient {
|
||||||
return this.request("send_message", params)
|
return this.request("send_message", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendEvent(room_id: RoomID, type: EventType, content: unknown): Promise<RawDBEvent> {
|
sendEvent(
|
||||||
return this.request("send_event", { room_id, type, content })
|
room_id: RoomID,
|
||||||
|
type: EventType,
|
||||||
|
content: unknown,
|
||||||
|
disable_encryption: boolean = false,
|
||||||
|
synchronous: boolean = false,
|
||||||
|
): Promise<RawDBEvent> {
|
||||||
|
return this.request("send_event", { room_id, type, content, disable_encryption, synchronous })
|
||||||
}
|
}
|
||||||
|
|
||||||
resendEvent(transaction_id: string): Promise<RawDBEvent> {
|
resendEvent(transaction_id: string): Promise<RawDBEvent> {
|
||||||
|
@ -167,6 +178,10 @@ export default abstract class RPCClient {
|
||||||
return this.request("set_state", { room_id, type, state_key, content })
|
return this.request("set_state", { room_id, type, state_key, content })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMembership(room_id: RoomID, user_id: UserID, action: MembershipAction, reason?: string): Promise<void> {
|
||||||
|
return this.request("set_membership", { room_id, user_id, action, reason })
|
||||||
|
}
|
||||||
|
|
||||||
setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise<boolean> {
|
setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise<boolean> {
|
||||||
return this.request("set_account_data", { type, content, room_id })
|
return this.request("set_account_data", { type, content, room_id })
|
||||||
}
|
}
|
||||||
|
@ -203,6 +218,14 @@ export default abstract class RPCClient {
|
||||||
return this.request("ensure_group_session_shared", { room_id })
|
return this.request("ensure_group_session_shared", { room_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendToDevice(
|
||||||
|
event_type: EventType,
|
||||||
|
messages: { [userId: string]: { [deviceId: string]: object } },
|
||||||
|
encrypted: boolean = false,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.request("send_to_device", { event_type, messages, encrypted })
|
||||||
|
}
|
||||||
|
|
||||||
getSpecificRoomState(keys: RoomStateGUID[]): Promise<RawDBEvent[]> {
|
getSpecificRoomState(keys: RoomStateGUID[]): Promise<RawDBEvent[]> {
|
||||||
return this.request("get_specific_room_state", { keys })
|
return this.request("get_specific_room_state", { keys })
|
||||||
}
|
}
|
||||||
|
@ -213,12 +236,12 @@ export default abstract class RPCClient {
|
||||||
return this.request("get_room_state", { room_id, include_members, fetch_members, refetch })
|
return this.request("get_room_state", { room_id, include_members, fetch_members, refetch })
|
||||||
}
|
}
|
||||||
|
|
||||||
getEvent(room_id: RoomID, event_id: EventID): Promise<RawDBEvent> {
|
getEvent(room_id: RoomID, event_id: EventID, unredact?: boolean): Promise<RawDBEvent> {
|
||||||
return this.request("get_event", { room_id, event_id })
|
return this.request("get_event", { room_id, event_id, unredact })
|
||||||
}
|
}
|
||||||
|
|
||||||
getEventsByRowIDs(row_ids: EventRowID[]): Promise<RawDBEvent[]> {
|
getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise<RawDBEvent[]> {
|
||||||
return this.request("get_events_by_row_ids", { row_ids })
|
return this.request("get_related_events", { room_id, event_id, relation_type })
|
||||||
}
|
}
|
||||||
|
|
||||||
paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise<PaginationResponse> {
|
paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise<PaginationResponse> {
|
||||||
|
@ -241,6 +264,14 @@ export default abstract class RPCClient {
|
||||||
return this.request("leave_room", { room_id, reason })
|
return this.request("leave_room", { room_id, reason })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createRoom(request: ReqCreateRoom): Promise<RespCreateRoom> {
|
||||||
|
return this.request("create_room", request)
|
||||||
|
}
|
||||||
|
|
||||||
|
muteRoom(room_id: RoomID, muted: boolean): Promise<boolean> {
|
||||||
|
return this.request("mute_room", { room_id, muted })
|
||||||
|
}
|
||||||
|
|
||||||
resolveAlias(alias: RoomAlias): Promise<ResolveAliasResponse> {
|
resolveAlias(alias: RoomAlias): Promise<ResolveAliasResponse> {
|
||||||
return this.request("resolve_alias", { alias })
|
return this.request("resolve_alias", { alias })
|
||||||
}
|
}
|
||||||
|
@ -272,4 +303,16 @@ export default abstract class RPCClient {
|
||||||
registerPush(reg: DBPushRegistration): Promise<boolean> {
|
registerPush(reg: DBPushRegistration): Promise<boolean> {
|
||||||
return this.request("register_push", reg)
|
return this.request("register_push", reg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTurnServers(): Promise<RespTurnServer> {
|
||||||
|
return this.request("get_turn_servers", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
getMediaConfig(): Promise<RespMediaConfig> {
|
||||||
|
return this.request("get_media_config", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
setListenToDevice(listen: boolean): Promise<void> {
|
||||||
|
return this.request("listen_to_device", listen)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { getAvatarURL } from "@/api/media.ts"
|
import { getAvatarThumbnailURL } from "@/api/media.ts"
|
||||||
import { Preferences, getLocalStoragePreferences, getPreferenceProxy } from "@/api/types/preferences"
|
import { Preferences, getLocalStoragePreferences, getPreferenceProxy } from "@/api/types/preferences"
|
||||||
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
||||||
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||||
|
@ -32,6 +32,7 @@ import {
|
||||||
SendCompleteData,
|
SendCompleteData,
|
||||||
SyncCompleteData,
|
SyncCompleteData,
|
||||||
SyncRoom,
|
SyncRoom,
|
||||||
|
SyncToDevice,
|
||||||
TypingEventData,
|
TypingEventData,
|
||||||
UnknownEventContent,
|
UnknownEventContent,
|
||||||
UserID,
|
UserID,
|
||||||
|
@ -61,6 +62,13 @@ export interface GCSettings {
|
||||||
lastOpenedCutoff: number,
|
lastOpenedCutoff: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WidgetListener {
|
||||||
|
onTimelineEvent(evt: MemDBEvent): void
|
||||||
|
onStateEvent(evt: MemDBEvent): void
|
||||||
|
onToDeviceEvent(evt: SyncToDevice): void
|
||||||
|
onRoomChange(roomID: RoomID | null): void
|
||||||
|
}
|
||||||
|
|
||||||
window.gcSettings ??= {
|
window.gcSettings ??= {
|
||||||
// Run garbage collection every 15 minutes.
|
// Run garbage collection every 15 minutes.
|
||||||
interval: 15 * 60 * 1000,
|
interval: 15 * 60 * 1000,
|
||||||
|
@ -98,9 +106,19 @@ export class StateStore {
|
||||||
readonly localPreferenceCache: Preferences = getLocalStoragePreferences("global_prefs", this.preferenceSub.notify)
|
readonly localPreferenceCache: Preferences = getLocalStoragePreferences("global_prefs", this.preferenceSub.notify)
|
||||||
serverPreferenceCache: Preferences = {}
|
serverPreferenceCache: Preferences = {}
|
||||||
switchRoom?: (roomID: RoomID | null) => void
|
switchRoom?: (roomID: RoomID | null) => void
|
||||||
activeRoomID: RoomID | null = null
|
#activeRoomID: RoomID | null = null
|
||||||
activeRoomIsPreview: boolean = false
|
activeRoomIsPreview: boolean = false
|
||||||
imageAuthToken?: string
|
imageAuthToken?: string
|
||||||
|
readonly widgetListeners: Set<WidgetListener> = new Set()
|
||||||
|
|
||||||
|
get activeRoomID(): RoomID | null {
|
||||||
|
return this.#activeRoomID
|
||||||
|
}
|
||||||
|
|
||||||
|
set activeRoomID(roomID: RoomID | null) {
|
||||||
|
this.#activeRoomID = roomID
|
||||||
|
this.widgetListeners.forEach(listener => listener.onRoomChange(roomID))
|
||||||
|
}
|
||||||
|
|
||||||
#roomListFilterFunc = (entry: RoomListEntry) => {
|
#roomListFilterFunc = (entry: RoomListEntry) => {
|
||||||
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
|
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
|
||||||
|
@ -243,6 +261,11 @@ export class StateStore {
|
||||||
}
|
}
|
||||||
const resyncRoomList = this.roomList.current.length === 0
|
const resyncRoomList = this.roomList.current.length === 0
|
||||||
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
|
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
|
||||||
|
if (sync.to_device?.length && this.widgetListeners.size > 0) {
|
||||||
|
for (const listener of this.widgetListeners) {
|
||||||
|
sync.to_device.forEach(listener.onToDeviceEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const data of sync.invited_rooms ?? []) {
|
for (const data of sync.invited_rooms ?? []) {
|
||||||
const room = new InvitedRoomStore(data, this)
|
const room = new InvitedRoomStore(data, this)
|
||||||
this.inviteRooms.set(room.room_id, room)
|
this.inviteRooms.set(room.room_id, room)
|
||||||
|
@ -469,7 +492,7 @@ export class StateStore {
|
||||||
body = body.slice(0, 350) + " […]"
|
body = body.slice(0, 350) + " […]"
|
||||||
}
|
}
|
||||||
const memberEvt = room.getStateEvent("m.room.member", evt.sender)
|
const memberEvt = room.getStateEvent("m.room.member", evt.sender)
|
||||||
const icon = `${getAvatarURL(evt.sender, memberEvt?.content)}&image_auth=${this.imageAuthToken}`
|
const icon = `${getAvatarThumbnailURL(evt.sender, memberEvt?.content)}&image_auth=${this.imageAuthToken}`
|
||||||
const roomName = room.meta.current.name ?? "Unnamed room"
|
const roomName = room.meta.current.name ?? "Unnamed room"
|
||||||
const senderName = memberEvt?.content.displayname ?? evt.sender
|
const senderName = memberEvt?.content.displayname ?? evt.sender
|
||||||
const title = senderName === roomName ? senderName : `${senderName} (${roomName})`
|
const title = senderName === roomName ? senderName : `${senderName} (${roomName})`
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
||||||
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||||
import toSearchableString from "@/util/searchablestring.ts"
|
import toSearchableString from "@/util/searchablestring.ts"
|
||||||
import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts"
|
import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts"
|
||||||
import { getDisplayname } from "@/util/validation.ts"
|
import { getDisplayname, getServerName } from "@/util/validation.ts"
|
||||||
import {
|
import {
|
||||||
ContentURI,
|
ContentURI,
|
||||||
DBReceipt,
|
DBReceipt,
|
||||||
|
@ -246,6 +246,35 @@ export class RoomStateStore {
|
||||||
return this.#membersCache ?? []
|
return this.#membersCache ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getViaServers(): string[] {
|
||||||
|
const ownServerName = getServerName(this.parent.userID)
|
||||||
|
const vias = [ownServerName]
|
||||||
|
const members = this.getMembers()
|
||||||
|
const memberCount = new Map<string, number>()
|
||||||
|
const powerLevels: PowerLevelEventContent = this.getStateEvent("m.room.power_levels", "")?.content ?? {}
|
||||||
|
const usersDefault = powerLevels.users_default ?? 0
|
||||||
|
let powerServer: string | undefined = undefined
|
||||||
|
for (const member of members) {
|
||||||
|
const serverName = getServerName(member.userID)
|
||||||
|
if (serverName !== ownServerName) {
|
||||||
|
if (!powerServer && (powerLevels?.users?.[member.userID] ?? usersDefault) > usersDefault) {
|
||||||
|
powerServer = serverName
|
||||||
|
vias.push(powerServer)
|
||||||
|
}
|
||||||
|
memberCount.set(serverName, (memberCount.get(serverName) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const servers = Array.from(memberCount.entries())
|
||||||
|
servers.sort(([, a], [, b]) => b - a)
|
||||||
|
for (const [serverName] of servers) {
|
||||||
|
if (serverName !== ownServerName && serverName !== powerServer) {
|
||||||
|
vias.push(serverName)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vias
|
||||||
|
}
|
||||||
|
|
||||||
getPinnedEvents(): EventID[] {
|
getPinnedEvents(): EventID[] {
|
||||||
const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned
|
const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned
|
||||||
if (Array.isArray(pinnedList)) {
|
if (Array.isArray(pinnedList)) {
|
||||||
|
@ -315,13 +344,35 @@ export class RoomStateStore {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
applyEvent(evt: RawDBEvent, pending: boolean = false) {
|
getOrApplyEvent(evt: RawDBEvent) {
|
||||||
|
const existing = this.eventsByRowID.get(evt.rowid)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
return this.applyEvent(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewingRedacted(evt: MemDBEvent, view: boolean) {
|
||||||
|
const modified = {
|
||||||
|
...evt,
|
||||||
|
viewing_redacted: view,
|
||||||
|
}
|
||||||
|
this.eventsByRowID.set(evt.rowid, modified)
|
||||||
|
this.eventsByID.set(evt.event_id, modified)
|
||||||
|
this.eventSubs.notify(evt.event_id)
|
||||||
|
this.notifyTimelineSubscribers()
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEvent(evt: RawDBEvent, pending: boolean = false, viewRedacted: boolean = false) {
|
||||||
const memEvt = evt as MemDBEvent
|
const memEvt = evt as MemDBEvent
|
||||||
memEvt.mem = true
|
memEvt.mem = true
|
||||||
memEvt.pending = pending
|
memEvt.pending = pending
|
||||||
if (pending) {
|
if (pending) {
|
||||||
memEvt.timeline_rowid = UNSENT_TIMELINE_ROWID_BASE + memEvt.timestamp
|
memEvt.timeline_rowid = UNSENT_TIMELINE_ROWID_BASE + memEvt.timestamp
|
||||||
}
|
}
|
||||||
|
if (viewRedacted) {
|
||||||
|
memEvt.viewing_redacted = true
|
||||||
|
}
|
||||||
if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
|
if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
|
||||||
memEvt.type = evt.decrypted_type
|
memEvt.type = evt.decrypted_type
|
||||||
memEvt.encrypted = evt.content as EncryptedEventContent
|
memEvt.encrypted = evt.content as EncryptedEventContent
|
||||||
|
@ -332,20 +383,24 @@ export class RoomStateStore {
|
||||||
if (memEvt.last_edit_rowid) {
|
if (memEvt.last_edit_rowid) {
|
||||||
memEvt.last_edit = this.eventsByRowID.get(memEvt.last_edit_rowid)
|
memEvt.last_edit = this.eventsByRowID.get(memEvt.last_edit_rowid)
|
||||||
if (memEvt.last_edit) {
|
if (memEvt.last_edit) {
|
||||||
memEvt.orig_content = memEvt.content
|
memEvt.orig_content = memEvt.orig_content ?? memEvt.content
|
||||||
memEvt.content = memEvt.last_edit.content["m.new_content"]
|
memEvt.orig_local_content = memEvt.orig_local_content ?? memEvt.local_content
|
||||||
|
memEvt.content = memEvt.last_edit.content["m.new_content"] ?? memEvt.last_edit.content
|
||||||
memEvt.local_content = memEvt.last_edit.local_content
|
memEvt.local_content = memEvt.last_edit.local_content
|
||||||
}
|
}
|
||||||
} else if (memEvt.relation_type === "m.replace" && memEvt.relates_to) {
|
} else if (memEvt.relation_type === "m.replace" && memEvt.relates_to) {
|
||||||
const editTarget = this.eventsByID.get(memEvt.relates_to)
|
const editTarget = this.eventsByID.get(memEvt.relates_to)
|
||||||
if (editTarget?.last_edit_rowid === memEvt.rowid && !editTarget.last_edit) {
|
if (editTarget?.last_edit_rowid === memEvt.rowid) {
|
||||||
this.eventsByRowID.set(editTarget.rowid, {
|
const modified: MemDBEvent = {
|
||||||
...editTarget,
|
...editTarget,
|
||||||
last_edit: memEvt,
|
last_edit: memEvt,
|
||||||
orig_content: editTarget.content,
|
orig_local_content: editTarget.orig_local_content ?? editTarget.local_content,
|
||||||
content: memEvt.content["m.new_content"],
|
orig_content: editTarget.orig_content ?? editTarget.content,
|
||||||
|
content: memEvt.content["m.new_content"] ?? memEvt.content,
|
||||||
local_content: memEvt.local_content,
|
local_content: memEvt.local_content,
|
||||||
})
|
}
|
||||||
|
this.eventsByRowID.set(editTarget.rowid, modified)
|
||||||
|
this.eventsByID.set(editTarget.event_id, modified)
|
||||||
this.eventSubs.notify(editTarget.event_id)
|
this.eventSubs.notify(editTarget.event_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -402,6 +457,8 @@ export class RoomStateStore {
|
||||||
for (const evt of sync.events ?? []) {
|
for (const evt of sync.events ?? []) {
|
||||||
this.applyEvent(evt)
|
this.applyEvent(evt)
|
||||||
}
|
}
|
||||||
|
const hasWidgets = this.parent.widgetListeners.size > 0
|
||||||
|
const newState: MemDBEvent[] = []
|
||||||
for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
|
for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
|
||||||
let stateMap = this.state.get(evtType)
|
let stateMap = this.state.get(evtType)
|
||||||
if (!stateMap) {
|
if (!stateMap) {
|
||||||
|
@ -411,6 +468,12 @@ export class RoomStateStore {
|
||||||
for (const [key, rowID] of Object.entries(changedEvts)) {
|
for (const [key, rowID] of Object.entries(changedEvts)) {
|
||||||
stateMap.set(key, rowID)
|
stateMap.set(key, rowID)
|
||||||
this.invalidateStateCaches(evtType, key)
|
this.invalidateStateCaches(evtType, key)
|
||||||
|
if (hasWidgets) {
|
||||||
|
const evt = this.eventsByRowID.get(rowID)
|
||||||
|
if (evt) {
|
||||||
|
newState.push(evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.stateSubs.notify(evtType)
|
this.stateSubs.notify(evtType)
|
||||||
}
|
}
|
||||||
|
@ -430,6 +493,13 @@ export class RoomStateStore {
|
||||||
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
|
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
|
||||||
this.applyReceipts(receipts, evtID, false)
|
this.applyReceipts(receipts, evtID, false)
|
||||||
}
|
}
|
||||||
|
if (hasWidgets && ((sync.timeline && sync.timeline.length > 0) || newState.length > 0)) {
|
||||||
|
const evts = sync.timeline?.map(evt => this.eventsByRowID.get(evt.event_rowid)).filter(evt => !!evt)
|
||||||
|
this.parent.widgetListeners.forEach(listener => {
|
||||||
|
evts?.forEach(listener.onTimelineEvent)
|
||||||
|
newState.forEach(listener.onStateEvent)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyState(evt: RawDBEvent) {
|
applyState(evt: RawDBEvent) {
|
||||||
|
|
|
@ -86,6 +86,13 @@ export interface SyncNotification {
|
||||||
sound: boolean
|
sound: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncToDevice {
|
||||||
|
sender: UserID
|
||||||
|
type: EventType
|
||||||
|
content: Record<string, unknown>
|
||||||
|
encrypted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface SyncCompleteData {
|
export interface SyncCompleteData {
|
||||||
rooms: Record<RoomID, SyncRoom> | null
|
rooms: Record<RoomID, SyncRoom> | null
|
||||||
invited_rooms: DBInvitedRoom[] | null
|
invited_rooms: DBInvitedRoom[] | null
|
||||||
|
@ -95,6 +102,7 @@ export interface SyncCompleteData {
|
||||||
top_level_spaces: RoomID[] | null
|
top_level_spaces: RoomID[] | null
|
||||||
since?: string
|
since?: string
|
||||||
clear_state?: boolean
|
clear_state?: boolean
|
||||||
|
to_device?: SyncToDevice[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncCompleteEvent extends BaseRPCCommand<SyncCompleteData> {
|
export interface SyncCompleteEvent extends BaseRPCCommand<SyncCompleteData> {
|
||||||
|
|
|
@ -156,7 +156,9 @@ export interface MemDBEvent extends BaseDBEvent {
|
||||||
pending: boolean
|
pending: boolean
|
||||||
encrypted?: EncryptedEventContent
|
encrypted?: EncryptedEventContent
|
||||||
orig_content?: UnknownEventContent
|
orig_content?: UnknownEventContent
|
||||||
|
orig_local_content?: LocalContent
|
||||||
last_edit?: MemDBEvent
|
last_edit?: MemDBEvent
|
||||||
|
viewing_redacted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DBAccountData {
|
export interface DBAccountData {
|
||||||
|
@ -292,3 +294,5 @@ export interface DBPushRegistration {
|
||||||
encryption: { key: string }
|
encryption: { key: string }
|
||||||
expiration?: number
|
expiration?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MembershipAction = "invite" | "kick" | "ban" | "unban"
|
||||||
|
|
|
@ -218,6 +218,10 @@ export interface ReactionEventContent {
|
||||||
"com.beeper.reaction.shortcode"?: string
|
"com.beeper.reaction.shortcode"?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IgnoredUsersEventContent {
|
||||||
|
ignored_users: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export interface EncryptedFile {
|
export interface EncryptedFile {
|
||||||
url: ContentURI
|
url: ContentURI
|
||||||
k: string
|
k: string
|
||||||
|
@ -316,3 +320,41 @@ export interface RespOpenIDToken {
|
||||||
matrix_server_name: string
|
matrix_server_name: string
|
||||||
token_type: "Bearer"
|
token_type: "Bearer"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RoomVisibility = "public" | "private"
|
||||||
|
export type RoomPreset = "private_chat" | "public_chat" | "trusted_private_chat"
|
||||||
|
|
||||||
|
export interface ReqCreateRoom {
|
||||||
|
visibility?: RoomVisibility
|
||||||
|
room_alias_name?: string
|
||||||
|
name?: string
|
||||||
|
topic?: string
|
||||||
|
invite?: UserID[]
|
||||||
|
preset?: RoomPreset
|
||||||
|
is_direct?: boolean
|
||||||
|
initial_state?: {
|
||||||
|
type: EventType
|
||||||
|
state_key?: string
|
||||||
|
content: Record<string, unknown>
|
||||||
|
}[]
|
||||||
|
room_version?: string
|
||||||
|
creation_content?: Record<string, unknown>
|
||||||
|
power_level_content_override?: Record<string, unknown>
|
||||||
|
"fi.mau.room_id"?: RoomID
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RespCreateRoom {
|
||||||
|
room_id: RoomID
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RespTurnServer {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
ttl: number
|
||||||
|
uris: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RespMediaConfig {
|
||||||
|
"m.upload.size": number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
|
@ -139,6 +139,12 @@ export const preferences = {
|
||||||
allowedContexts: anyContext,
|
allowedContexts: anyContext,
|
||||||
defaultValue: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
defaultValue: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
}),
|
}),
|
||||||
|
element_call_base_url: new Preference<string>({
|
||||||
|
displayName: "Element call base URL",
|
||||||
|
description: "The widget base URL for Element calls.",
|
||||||
|
allowedContexts: anyContext,
|
||||||
|
defaultValue: "https://call.element.io",
|
||||||
|
}),
|
||||||
gif_provider: new Preference<GIFProvider>({
|
gif_provider: new Preference<GIFProvider>({
|
||||||
displayName: "GIF provider",
|
displayName: "GIF provider",
|
||||||
description: "The service to use to search for GIFs",
|
description: "The service to use to search for GIFs",
|
||||||
|
|
1
web/src/icons/block.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q54 0 104-17.5t92-50.5L228-676q-33 42-50.5 92T160-480q0 134 93 227t227 93Zm252-124q33-42 50.5-92T800-480q0-134-93-227t-227-93q-54 0-104 17.5T284-732l448 448Z"/></svg>
|
After Width: | Height: | Size: 477 B |
1
web/src/icons/chat.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M240-400h320v-80H240v80Zm0-120h480v-80H240v80Zm0-120h480v-80H240v80ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H240L80-80Zm126-240h594v-480H160v525l46-45Zm-46 0v-480 480Z"/></svg>
|
After Width: | Height: | Size: 341 B |
1
web/src/icons/door-open.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M440-440q17 0 28.5-11.5T480-480q0-17-11.5-28.5T440-520q-17 0-28.5 11.5T400-480q0 17 11.5 28.5T440-440ZM280-120v-80l240-40v-445q0-15-9-27t-23-14l-208-34v-80l220 36q44 8 72 41t28 77v512l-320 54Zm-160 0v-80h80v-560q0-34 23.5-57t56.5-23h400q34 0 57 23t23 57v560h80v80H120Zm160-80h400v-560H280v560Z"/></svg>
|
After Width: | Height: | Size: 419 B |
1
web/src/icons/gavel.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M160-120v-80h480v80H160Zm226-194L160-540l84-86 228 226-86 86Zm254-254L414-796l86-84 226 226-86 86Zm184 408L302-682l56-56 522 522-56 56Z"/></svg>
|
After Width: | Height: | Size: 261 B |
1
web/src/icons/mark-read.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M694-160 553-302l56-56 85 85 170-170 56 57-226 226ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v280h-80v-280H160v525l46-45h274v80H240L80-80Zm80-240v-480 480Z"/></svg>
|
After Width: | Height: | Size: 300 B |
1
web/src/icons/mark-unread.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M80-80v-720q0-33 23.5-56.5T160-880h404q-4 20-4 40t4 40H160v525l46-45h594v-324q23-5 43-13.5t37-22.5v360q0 33-23.5 56.5T800-240H240L80-80Zm80-720v480-480Zm600 80q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Z"/></svg>
|
After Width: | Height: | Size: 357 B |
1
web/src/icons/person-add.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M720-400v-120H600v-80h120v-120h80v120h120v80H800v120h-80Zm-360-80q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-160v-112q0-34 17.5-62.5T104-378q62-31 126-46.5T360-440q66 0 130 15.5T616-378q29 15 46.5 43.5T680-272v112H40Zm80-80h480v-32q0-11-5.5-20T580-306q-54-27-109-40.5T360-360q-56 0-111 13.5T140-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T440-640q0-33-23.5-56.5T360-720q-33 0-56.5 23.5T280-640q0 33 23.5 56.5T360-560Zm0-80Zm0 400Z"/></svg>
|
After Width: | Height: | Size: 604 B |
1
web/src/icons/person-remove.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M640-520v-80h240v80H640Zm-280 40q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-160v-112q0-34 17.5-62.5T104-378q62-31 126-46.5T360-440q66 0 130 15.5T616-378q29 15 46.5 43.5T680-272v112H40Zm80-80h480v-32q0-11-5.5-20T580-306q-54-27-109-40.5T360-360q-56 0-111 13.5T140-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T440-640q0-33-23.5-56.5T360-720q-33 0-56.5 23.5T280-640q0 33 23.5 56.5T360-560Zm0-80Zm0 400Z"/></svg>
|
After Width: | Height: | Size: 571 B |
1
web/src/icons/restore-trash.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M440-320h80v-166l64 62 56-56-160-160-160 160 56 56 64-62v166ZM280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520Zm-400 0v520-520Z"/></svg>
|
After Width: | Height: | Size: 332 B |
1
web/src/icons/share.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm480-280q17 0 28.5-11.5T720-760q0-17-11.5-28.5T680-800q-17 0-28.5 11.5T640-760q0 17 11.5 28.5T680-720Zm0 520ZM200-480Zm480-280Z"/></svg>
|
After Width: | Height: | Size: 793 B |
1
web/src/icons/widgets.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M666-440 440-666l226-226 226 226-226 226Zm-546-80v-320h320v320H120Zm400 400v-320h320v320H520Zm-400 0v-320h320v320H120Zm80-480h160v-160H200v160Zm467 48 113-113-113-113-113 113 113 113Zm-67 352h160v-160H600v160Zm-400 0h160v-160H200v160Zm160-400Zm194-65ZM360-360Zm240 0Z"/></svg>
|
After Width: | Height: | Size: 393 B |
|
@ -86,6 +86,7 @@
|
||||||
--timeline-message-gap-small-event: 0;
|
--timeline-message-gap-small-event: 0;
|
||||||
--timeline-sender-name-timestamp-gap: .25rem;
|
--timeline-sender-name-timestamp-gap: .25rem;
|
||||||
--timeline-sender-name-content-gap: 0;
|
--timeline-sender-name-content-gap: 0;
|
||||||
|
--timeline-vertical-padding: 0;
|
||||||
--timeline-horizontal-padding: 1.5rem;
|
--timeline-horizontal-padding: 1.5rem;
|
||||||
--timeline-status-size: 4rem;
|
--timeline-status-size: 4rem;
|
||||||
|
|
||||||
|
@ -207,6 +208,8 @@ button, a.button, span.button {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
/* Buttons sometimes have their own fonts? */
|
||||||
|
font-family: var(--font-stack);
|
||||||
|
|
||||||
&:hover, &:focus {
|
&:hover, &:focus {
|
||||||
background-color: var(--button-hover-color);
|
background-color: var(--button-hover-color);
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import equal from "fast-deep-equal"
|
||||||
import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react"
|
import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react"
|
||||||
import { SyncLoader } from "react-spinners"
|
import { SyncLoader } from "react-spinners"
|
||||||
import Client from "@/api/client.ts"
|
import Client from "@/api/client.ts"
|
||||||
|
@ -24,7 +25,7 @@ import ClientContext from "./ClientContext.ts"
|
||||||
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
|
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
|
||||||
import StylePreferences from "./StylePreferences.tsx"
|
import StylePreferences from "./StylePreferences.tsx"
|
||||||
import Keybindings from "./keybindings.ts"
|
import Keybindings from "./keybindings.ts"
|
||||||
import { ModalWrapper } from "./modal"
|
import { ModalContext, ModalWrapper, NestableModalContext } from "./modal"
|
||||||
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
|
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
|
||||||
import RoomList from "./roomlist/RoomList.tsx"
|
import RoomList from "./roomlist/RoomList.tsx"
|
||||||
import RoomPreview, { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
|
import RoomPreview, { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
|
||||||
|
@ -32,19 +33,6 @@ import RoomView from "./roomview/RoomView.tsx"
|
||||||
import { useResizeHandle } from "./util/useResizeHandle.tsx"
|
import { useResizeHandle } from "./util/useResizeHandle.tsx"
|
||||||
import "./MainScreen.css"
|
import "./MainScreen.css"
|
||||||
|
|
||||||
function objectIsEqual(a: RightPanelProps | null, b: RightPanelProps | null): boolean {
|
|
||||||
if (a === null || b === null) {
|
|
||||||
return a === null && b === null
|
|
||||||
}
|
|
||||||
for (const key of Object.keys(a)) {
|
|
||||||
// @ts-expect-error 3:<
|
|
||||||
if (a[key] !== b[key]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
class ContextFields implements MainScreenContextFields {
|
class ContextFields implements MainScreenContextFields {
|
||||||
public keybindings: Keybindings
|
public keybindings: Keybindings
|
||||||
private rightPanelStack: RightPanelProps[] = []
|
private rightPanelStack: RightPanelProps[] = []
|
||||||
|
@ -64,10 +52,10 @@ class ContextFields implements MainScreenContextFields {
|
||||||
}
|
}
|
||||||
|
|
||||||
setRightPanel = (props: RightPanelProps | null, pushState = true) => {
|
setRightPanel = (props: RightPanelProps | null, pushState = true) => {
|
||||||
if ((props?.type === "members" || props?.type === "pinned-messages") && !this.client.store.activeRoomID) {
|
if ((props?.type !== "user") && !this.client.store.activeRoomID) {
|
||||||
props = null
|
props = null
|
||||||
}
|
}
|
||||||
const isEqual = objectIsEqual(this.currentRightPanel, props)
|
const isEqual = equal(this.currentRightPanel, props)
|
||||||
if (isEqual && !pushState) {
|
if (isEqual && !pushState) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -81,7 +69,7 @@ class ContextFields implements MainScreenContextFields {
|
||||||
} else {
|
} else {
|
||||||
this.directSetRightPanel(props)
|
this.directSetRightPanel(props)
|
||||||
for (let i = this.rightPanelStack.length - 1; i >= 0; i--) {
|
for (let i = this.rightPanelStack.length - 1; i >= 0; i--) {
|
||||||
if (objectIsEqual(this.rightPanelStack[i], props)) {
|
if (equal(this.rightPanelStack[i], props)) {
|
||||||
this.rightPanelStack = this.rightPanelStack.slice(0, i + 1)
|
this.rightPanelStack = this.rightPanelStack.slice(0, i + 1)
|
||||||
if (pushState) {
|
if (pushState) {
|
||||||
history.go(i - this.rightPanelStack.length)
|
history.go(i - this.rightPanelStack.length)
|
||||||
|
@ -219,7 +207,7 @@ class ContextFields implements MainScreenContextFields {
|
||||||
clickRightPanelOpener = (evt: React.MouseEvent) => {
|
clickRightPanelOpener = (evt: React.MouseEvent) => {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
const type = evt.currentTarget.getAttribute("data-target-panel")
|
const type = evt.currentTarget.getAttribute("data-target-panel")
|
||||||
if (type === "pinned-messages" || type === "members") {
|
if (type === "pinned-messages" || type === "members" || type === "widgets") {
|
||||||
this.setRightPanel({ type })
|
this.setRightPanel({ type })
|
||||||
} else if (type === "user") {
|
} else if (type === "user") {
|
||||||
this.setRightPanel({ type, userID: evt.currentTarget.getAttribute("data-target-user")! })
|
this.setRightPanel({ type, userID: evt.currentTarget.getAttribute("data-target-user")! })
|
||||||
|
@ -422,10 +410,7 @@ const MainScreen = () => {
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
}, [activeRoom, prevActiveRoom])
|
}, [activeRoom, prevActiveRoom])
|
||||||
return <MainScreenContext value={context}>
|
const mainContent = <main className={classNames.join(" ")} style={extraStyle}>
|
||||||
<ModalWrapper>
|
|
||||||
<StylePreferences client={client} activeRoom={activeRealRoom}/>
|
|
||||||
<main className={classNames.join(" ")} style={extraStyle}>
|
|
||||||
<RoomList activeRoomID={activeRoom?.roomID ?? null} space={space}/>
|
<RoomList activeRoomID={activeRoom?.roomID ?? null} space={space}/>
|
||||||
{resizeHandle1}
|
{resizeHandle1}
|
||||||
{renderedRoom
|
{renderedRoom
|
||||||
|
@ -443,8 +428,14 @@ const MainScreen = () => {
|
||||||
{rightPanel && <RightPanel {...rightPanel}/>}
|
{rightPanel && <RightPanel {...rightPanel}/>}
|
||||||
</>}
|
</>}
|
||||||
</main>
|
</main>
|
||||||
|
return <MainScreenContext value={context}>
|
||||||
|
<ModalWrapper ContextType={ModalContext} historyStateKey="modal">
|
||||||
|
<ModalWrapper ContextType={NestableModalContext} historyStateKey="nestable_modal">
|
||||||
|
<StylePreferences client={client} activeRoom={activeRealRoom}/>
|
||||||
|
{mainContent}
|
||||||
{syncLoader}
|
{syncLoader}
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
|
</ModalWrapper>
|
||||||
</MainScreenContext>
|
</MainScreenContext>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { JSX, RefObject, use, useEffect } from "react"
|
import { JSX, RefObject, use, useEffect } from "react"
|
||||||
import { getAvatarURL, getMediaURL } from "@/api/media.ts"
|
import { getAvatarThumbnailURL, getMediaURL } from "@/api/media.ts"
|
||||||
import { AutocompleteMemberEntry, RoomStateStore, useCustomEmojis } from "@/api/statestore"
|
import { AutocompleteMemberEntry, RoomStateStore, useCustomEmojis } from "@/api/statestore"
|
||||||
import { Emoji, emojiToMarkdown, useSortedAndFilteredEmojis } from "@/util/emoji"
|
import { Emoji, emojiToMarkdown, useSortedAndFilteredEmojis } from "@/util/emoji"
|
||||||
import { escapeMarkdown } from "@/util/markdown.ts"
|
import { escapeMarkdown } from "@/util/markdown.ts"
|
||||||
|
@ -138,7 +138,7 @@ const userFuncs = {
|
||||||
<img
|
<img
|
||||||
className="small avatar"
|
className="small avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getAvatarURL(user.userID, { displayname: user.displayName, avatar_url: user.avatarURL })}
|
src={getAvatarThumbnailURL(user.userID, { displayname: user.displayName, avatar_url: user.avatarURL })}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
{user.displayName}
|
{user.displayName}
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import { RefCallback, useState } from "react"
|
||||||
import Client from "@/api/client.ts"
|
import Client from "@/api/client.ts"
|
||||||
import { RoomStateStore, usePreference } from "@/api/statestore"
|
import { RoomStateStore, usePreference } from "@/api/statestore"
|
||||||
import type { MediaMessageEventContent } from "@/api/types"
|
import type { MediaMessageEventContent } from "@/api/types"
|
||||||
|
@ -27,10 +28,19 @@ export interface ComposerMediaProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
|
export const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
|
||||||
|
const defaultMaxWidth = 360
|
||||||
|
const paddingAndButtonWidth = 16 + 40
|
||||||
|
const [maxWidth, setMaxWidth] = useState(defaultMaxWidth)
|
||||||
const [mediaContent, containerClass, containerStyle] = useMediaContent(
|
const [mediaContent, containerClass, containerStyle] = useMediaContent(
|
||||||
content, "m.room.message", { height: 120, width: 360 },
|
content, "m.room.message", { height: 120, width: maxWidth },
|
||||||
)
|
)
|
||||||
return <div className="composer-media">
|
const containerRef: RefCallback<HTMLDivElement> = elem => {
|
||||||
|
setMaxWidth(Math.min(
|
||||||
|
(elem?.getBoundingClientRect().width ?? defaultMaxWidth) - paddingAndButtonWidth,
|
||||||
|
defaultMaxWidth,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return <div className="composer-media" ref={containerRef}>
|
||||||
<div className={`media-container ${containerClass}`} style={containerStyle}>
|
<div className={`media-container ${containerClass}`} style={containerStyle}>
|
||||||
{mediaContent}
|
{mediaContent}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -55,6 +55,7 @@ export interface ComposerState {
|
||||||
replyTo: EventID | null
|
replyTo: EventID | null
|
||||||
silentReply: boolean
|
silentReply: boolean
|
||||||
explicitReplyInThread: boolean
|
explicitReplyInThread: boolean
|
||||||
|
startNewThread: boolean
|
||||||
uninited?: boolean
|
uninited?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,6 +68,7 @@ const emptyComposer: ComposerState = {
|
||||||
location: null,
|
location: null,
|
||||||
silentReply: false,
|
silentReply: false,
|
||||||
explicitReplyInThread: false,
|
explicitReplyInThread: false,
|
||||||
|
startNewThread: false,
|
||||||
}
|
}
|
||||||
const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true }
|
const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true }
|
||||||
const composerReducer = (
|
const composerReducer = (
|
||||||
|
@ -116,7 +118,7 @@ const MessageComposer = () => {
|
||||||
document.execCommand("insertText", false, text)
|
document.execCommand("insertText", false, text)
|
||||||
}, [])
|
}, [])
|
||||||
roomCtx.setReplyTo = useCallback((evt: EventID | null) => {
|
roomCtx.setReplyTo = useCallback((evt: EventID | null) => {
|
||||||
setState({ replyTo: evt, silentReply: false, explicitReplyInThread: false })
|
setState({ replyTo: evt, silentReply: false, explicitReplyInThread: false, startNewThread: false })
|
||||||
textInput.current?.focus()
|
textInput.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
const setSilentReply = useCallback((newVal: boolean | React.MouseEvent) => {
|
const setSilentReply = useCallback((newVal: boolean | React.MouseEvent) => {
|
||||||
|
@ -135,6 +137,14 @@ const MessageComposer = () => {
|
||||||
setState(state => ({ explicitReplyInThread: !state.explicitReplyInThread }))
|
setState(state => ({ explicitReplyInThread: !state.explicitReplyInThread }))
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
const setStartNewThread = useCallback((newVal: boolean | React.MouseEvent) => {
|
||||||
|
if (typeof newVal === "boolean") {
|
||||||
|
setState({ startNewThread: newVal })
|
||||||
|
} else {
|
||||||
|
newVal.stopPropagation()
|
||||||
|
setState(state => ({ startNewThread: !state.startNewThread }))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => {
|
roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => {
|
||||||
if (evt === null) {
|
if (evt === null) {
|
||||||
rawSetEditing(null)
|
rawSetEditing(null)
|
||||||
|
@ -160,13 +170,14 @@ const MessageComposer = () => {
|
||||||
replyTo: null,
|
replyTo: null,
|
||||||
silentReply: false,
|
silentReply: false,
|
||||||
explicitReplyInThread: false,
|
explicitReplyInThread: false,
|
||||||
|
startNewThread: false,
|
||||||
})
|
})
|
||||||
textInput.current?.focus()
|
textInput.current?.focus()
|
||||||
}, [room.roomID])
|
}, [room.roomID])
|
||||||
const canSend = Boolean(state.text || state.media || state.location)
|
const canSend = Boolean(state.text || state.media || state.location)
|
||||||
const onClickSend = (evt: React.FormEvent) => {
|
const onClickSend = (evt: React.FormEvent) => {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
if (!canSend) {
|
if (!canSend || loadingMedia) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
doSendMessage(state)
|
doSendMessage(state)
|
||||||
|
@ -204,6 +215,10 @@ const MessageComposer = () => {
|
||||||
relates_to.rel_type = "m.thread"
|
relates_to.rel_type = "m.thread"
|
||||||
relates_to.event_id = replyToEvt.content?.["m.relates_to"].event_id
|
relates_to.event_id = replyToEvt.content?.["m.relates_to"].event_id
|
||||||
relates_to.is_falling_back = !state.explicitReplyInThread
|
relates_to.is_falling_back = !state.explicitReplyInThread
|
||||||
|
} else if (state.startNewThread) {
|
||||||
|
relates_to.rel_type = "m.thread"
|
||||||
|
relates_to.event_id = replyToEvt.event_id
|
||||||
|
relates_to.is_falling_back = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let base_content: MessageEventContent | undefined
|
let base_content: MessageEventContent | undefined
|
||||||
|
@ -553,7 +568,7 @@ const MessageComposer = () => {
|
||||||
style.left = style.right
|
style.left = style.right
|
||||||
delete style.right
|
delete style.right
|
||||||
openModal({
|
openModal({
|
||||||
content: <div className="event-context-menu" style={style}>
|
content: <div className="context-menu event-context-menu" style={style}>
|
||||||
{makeAttachmentButtons(true)}
|
{makeAttachmentButtons(true)}
|
||||||
</div>,
|
</div>,
|
||||||
})
|
})
|
||||||
|
@ -580,6 +595,8 @@ const MessageComposer = () => {
|
||||||
onSetSilent={setSilentReply}
|
onSetSilent={setSilentReply}
|
||||||
isExplicitInThread={state.explicitReplyInThread}
|
isExplicitInThread={state.explicitReplyInThread}
|
||||||
onSetExplicitInThread={setExplicitReplyInThread}
|
onSetExplicitInThread={setExplicitReplyInThread}
|
||||||
|
startNewThread={state.startNewThread}
|
||||||
|
onSetStartNewThread={setStartNewThread}
|
||||||
/>}
|
/>}
|
||||||
{editing && <ReplyBody
|
{editing && <ReplyBody
|
||||||
room={room}
|
room={room}
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { JSX, use } from "react"
|
import { JSX, use } from "react"
|
||||||
import { PulseLoader } from "react-spinners"
|
import { PulseLoader } from "react-spinners"
|
||||||
import { getAvatarURL } from "@/api/media.ts"
|
import { getAvatarThumbnailURL } from "@/api/media.ts"
|
||||||
import { useMultipleRoomMembers, useRoomTyping } from "@/api/statestore"
|
import { useMultipleRoomMembers, useRoomTyping } from "@/api/statestore"
|
||||||
import { humanJoin } from "@/util/join.ts"
|
import { humanJoin } from "@/util/join.ts"
|
||||||
import { getDisplayname } from "@/util/validation.ts"
|
import { getDisplayname } from "@/util/validation.ts"
|
||||||
|
@ -40,7 +40,7 @@ const TypingNotifications = () => {
|
||||||
key={sender}
|
key={sender}
|
||||||
className="small avatar"
|
className="small avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getAvatarURL(sender, member)}
|
src={getAvatarThumbnailURL(sender, member)}
|
||||||
alt=""
|
alt=""
|
||||||
/>)
|
/>)
|
||||||
memberNames.push(getDisplayname(sender, member))
|
memberNames.push(getDisplayname(sender, member))
|
||||||
|
|
|
@ -34,6 +34,7 @@ export function filter(users: AutocompleteMemberEntry[], query: string): Autocom
|
||||||
interface filteredUserCache {
|
interface filteredUserCache {
|
||||||
query: string
|
query: string
|
||||||
result: AutocompleteMemberEntry[]
|
result: AutocompleteMemberEntry[]
|
||||||
|
slicedResult?: AutocompleteMemberEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFilteredMembers(
|
export function useFilteredMembers(
|
||||||
|
@ -44,15 +45,16 @@ export function useFilteredMembers(
|
||||||
if (!query) {
|
if (!query) {
|
||||||
prev.current.query = ""
|
prev.current.query = ""
|
||||||
prev.current.result = allMembers
|
prev.current.result = allMembers
|
||||||
|
prev.current.slicedResult = slice && allMembers.length > 100 ? allMembers.slice(0, 100) : undefined
|
||||||
} else if (prev.current.query !== query) {
|
} else if (prev.current.query !== query) {
|
||||||
prev.current.result = (sort ? filterAndSort : filter)(
|
prev.current.result = (sort ? filterAndSort : filter)(
|
||||||
query.startsWith(prev.current.query) ? prev.current.result : allMembers,
|
query.startsWith(prev.current.query) ? prev.current.result : allMembers,
|
||||||
query,
|
query,
|
||||||
)
|
)
|
||||||
if (prev.current.result.length > 100 && slice) {
|
prev.current.slicedResult = prev.current.result.length > 100 && slice
|
||||||
prev.current.result = prev.current.result.slice(0, 100)
|
? prev.current.result.slice(0, 100)
|
||||||
}
|
: undefined
|
||||||
prev.current.query = query
|
prev.current.query = query
|
||||||
}
|
}
|
||||||
return prev.current.result
|
return prev.current.slicedResult ?? prev.current.result
|
||||||
}
|
}
|
||||||
|
|
|
@ -226,7 +226,7 @@ div.emoji-picker, div.sticker-picker {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 37.5rem) {
|
@media screen and (max-width: 37.5rem) {
|
||||||
div.emoji-picker, div.gif-picker {
|
div.emoji-picker, div.gif-picker, div.sticker-picker {
|
||||||
inset: 0 0 3rem 0 !important;
|
inset: 0 0 3rem 0 !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100% - 3rem);
|
height: calc(100% - 3rem);
|
||||||
|
|
|
@ -13,16 +13,16 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import React, { use, useState } from "react"
|
import React, { JSX, use, useState } from "react"
|
||||||
import { MemDBEvent } from "@/api/types"
|
import { MemDBEvent } from "@/api/types"
|
||||||
import { isMobileDevice } from "@/util/ismobile.ts"
|
import { isMobileDevice } from "@/util/ismobile.ts"
|
||||||
import { ModalCloseContext } from "../../modal"
|
import { ModalCloseContext } from "../modal"
|
||||||
import TimelineEvent from "../TimelineEvent.tsx"
|
import TimelineEvent from "../timeline/TimelineEvent.tsx"
|
||||||
|
|
||||||
interface ConfirmWithMessageProps {
|
interface ConfirmWithMessageProps {
|
||||||
evt: MemDBEvent
|
evt?: MemDBEvent
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string | JSX.Element
|
||||||
placeholder: string
|
placeholder: string
|
||||||
confirmButton: string
|
confirmButton: string
|
||||||
onConfirm: (reason: string) => void
|
onConfirm: (reason: string) => void
|
||||||
|
@ -40,9 +40,9 @@ const ConfirmWithMessageModal = ({
|
||||||
}
|
}
|
||||||
return <form onSubmit={onConfirmWrapped}>
|
return <form onSubmit={onConfirmWrapped}>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<div className="timeline-event-container">
|
{evt && <div className="timeline-event-container">
|
||||||
<TimelineEvent evt={evt} prevEvt={null} disableMenu={true} />
|
<TimelineEvent evt={evt} prevEvt={null} disableMenu={true} />
|
||||||
</div>
|
</div>}
|
||||||
<div className="confirm-description">
|
<div className="confirm-description">
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
|
@ -15,8 +15,8 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { CSSProperties, use } from "react"
|
import { CSSProperties, use } from "react"
|
||||||
import { MemDBEvent } from "@/api/types"
|
import { MemDBEvent } from "@/api/types"
|
||||||
import ClientContext from "../../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import { RoomContextData } from "../../roomview/roomcontext.ts"
|
import { RoomContextData } from "../roomview/roomcontext.ts"
|
||||||
import { usePrimaryItems } from "./usePrimaryItems.tsx"
|
import { usePrimaryItems } from "./usePrimaryItems.tsx"
|
||||||
import { useSecondaryItems } from "./useSecondaryItems.tsx"
|
import { useSecondaryItems } from "./useSecondaryItems.tsx"
|
||||||
import CloseIcon from "@/icons/close.svg?react"
|
import CloseIcon from "@/icons/close.svg?react"
|
||||||
|
@ -41,14 +41,14 @@ interface EventContextMenuProps extends BaseEventMenuProps {
|
||||||
|
|
||||||
export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
||||||
const elements = useSecondaryItems(use(ClientContext)!, roomCtx, evt)
|
const elements = useSecondaryItems(use(ClientContext)!, roomCtx, evt)
|
||||||
return <div style={style} className="event-context-menu extra">{elements}</div>
|
return <div style={style} className="context-menu event-context-menu extra">{elements}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined)
|
const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined)
|
||||||
const secondary = useSecondaryItems(client, roomCtx, evt)
|
const secondary = useSecondaryItems(client, roomCtx, evt)
|
||||||
return <div style={style} className="event-context-menu full">
|
return <div style={style} className="context-menu event-context-menu full">
|
||||||
{primary}
|
{primary}
|
||||||
<hr/>
|
<hr/>
|
||||||
{secondary}
|
{secondary}
|
3
web/src/ui/menu/RoomMenu.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
div.room-list-menu {
|
||||||
|
|
||||||
|
}
|
121
web/src/ui/menu/RoomMenu.tsx
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import { CSSProperties, use } from "react"
|
||||||
|
import { RoomListEntry, RoomStateStore, useAccountData } from "@/api/statestore"
|
||||||
|
import { RoomID } from "@/api/types"
|
||||||
|
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
|
import ClientContext from "../ClientContext.ts"
|
||||||
|
import { ModalCloseContext } from "../modal"
|
||||||
|
import SettingsView from "../settings/SettingsView.tsx"
|
||||||
|
import DoorOpenIcon from "@/icons/door-open.svg?react"
|
||||||
|
import MarkReadIcon from "@/icons/mark-read.svg?react"
|
||||||
|
import MarkUnreadIcon from "@/icons/mark-unread.svg?react"
|
||||||
|
import NotificationsOffIcon from "@/icons/notifications-off.svg?react"
|
||||||
|
import NotificationsIcon from "@/icons/notifications.svg?react"
|
||||||
|
import SettingsIcon from "@/icons/settings.svg?react"
|
||||||
|
import "./RoomMenu.css"
|
||||||
|
|
||||||
|
interface RoomMenuProps {
|
||||||
|
room: RoomStateStore
|
||||||
|
entry: RoomListEntry
|
||||||
|
style: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNotifyingActions = (actions: unknown) => {
|
||||||
|
return Array.isArray(actions) && actions.length > 0 && actions.includes("notify")
|
||||||
|
}
|
||||||
|
|
||||||
|
const MuteButton = ({ roomID }: { roomID: RoomID }) => {
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
const closeModal = use(ModalCloseContext)
|
||||||
|
const roomRules = useAccountData(client.store, "m.push_rules")?.global?.room
|
||||||
|
const pushRule = Array.isArray(roomRules) ? roomRules.find(rule => rule?.rule_id === roomID) : null
|
||||||
|
const muted = pushRule?.enabled === true && !hasNotifyingActions(pushRule.actions)
|
||||||
|
const toggleMute = () => {
|
||||||
|
client.rpc.muteRoom(roomID, !muted).catch(err => {
|
||||||
|
console.error("Failed to mute room", err)
|
||||||
|
window.alert(`Failed to ${muted ? "unmute" : "mute"} room: ${err}`)
|
||||||
|
})
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
return <button onClick={toggleMute}>
|
||||||
|
{muted ? <NotificationsIcon/> : <NotificationsOffIcon/>}
|
||||||
|
{muted ? "Unmute" : "Mute"}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarkReadButton = ({ room }: { room: RoomStateStore }) => {
|
||||||
|
const meta = useEventAsState(room.meta)
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
const closeModal = use(ModalCloseContext)
|
||||||
|
const read = !meta.marked_unread && meta.unread_messages === 0
|
||||||
|
const markRead = () => {
|
||||||
|
const evt = room.eventsByRowID.get(
|
||||||
|
room.timeline[room.timeline.length-1]?.event_rowid ?? meta.preview_event_rowid,
|
||||||
|
)
|
||||||
|
if (!evt) {
|
||||||
|
window.alert("Can't mark room as read: last event not found in cache")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rrType = room.preferences.send_read_receipts ? "m.read" : "m.read.private"
|
||||||
|
client.rpc.markRead(room.roomID, evt.event_id, rrType).catch(err => {
|
||||||
|
console.error("Failed to mark room as read", err)
|
||||||
|
window.alert(`Failed to mark room as read: ${err}`)
|
||||||
|
})
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
const markUnread = () => {
|
||||||
|
client.rpc.setAccountData("m.marked_unread", { unread: true }, room.roomID).catch(err => {
|
||||||
|
console.error("Failed to mark room as unread", err)
|
||||||
|
window.alert(`Failed to mark room as unread: ${err}`)
|
||||||
|
})
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
return <button onClick={read ? markUnread : markRead}>
|
||||||
|
{read ? <MarkUnreadIcon/> : <MarkReadIcon/>}
|
||||||
|
Mark {read ? "unread" : "read"}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoomMenu = ({ room, style }: RoomMenuProps) => {
|
||||||
|
const closeModal = use(ModalCloseContext)
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
const openSettings = () => {
|
||||||
|
closeModal()
|
||||||
|
window.openNestableModal({
|
||||||
|
dimmed: true,
|
||||||
|
boxed: true,
|
||||||
|
innerBoxClass: "settings-view",
|
||||||
|
content: <SettingsView room={room} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const leaveRoom = () => {
|
||||||
|
if (!window.confirm(`Really leave ${room.meta.current.name}?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.rpc.leaveRoom(room.roomID).catch(err => {
|
||||||
|
console.error("Failed to leave room", err)
|
||||||
|
window.alert(`Failed to leave room: ${err}`)
|
||||||
|
})
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
return <div className="context-menu room-list-menu" style={style}>
|
||||||
|
<MarkReadButton room={room} />
|
||||||
|
<MuteButton roomID={room.roomID}/>
|
||||||
|
<button onClick={openSettings}><SettingsIcon /> Settings</button>
|
||||||
|
<button onClick={leaveRoom}><DoorOpenIcon /> Leave room</button>
|
||||||
|
</div>
|
||||||
|
}
|
65
web/src/ui/menu/ShareModal.tsx
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import React, { use, useState } from "react"
|
||||||
|
import { MemDBEvent } from "@/api/types"
|
||||||
|
import { ModalCloseContext } from "@/ui/modal"
|
||||||
|
import TimelineEvent from "@/ui/timeline/TimelineEvent.tsx"
|
||||||
|
import Toggle from "@/ui/util/Toggle.tsx"
|
||||||
|
|
||||||
|
interface ConfirmWithMessageProps {
|
||||||
|
evt: MemDBEvent
|
||||||
|
title: string
|
||||||
|
confirmButton: string
|
||||||
|
onConfirm: (useMatrixTo: boolean, includeEvent: boolean) => void
|
||||||
|
generateLink: (useMatrixTo: boolean, includeEvent: boolean) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShareModal = ({ evt, title, confirmButton, onConfirm, generateLink }: ConfirmWithMessageProps) => {
|
||||||
|
const [useMatrixTo, setUseMatrixTo] = useState(false)
|
||||||
|
const [includeEvent, setIncludeEvent] = useState(true)
|
||||||
|
const closeModal = use(ModalCloseContext)
|
||||||
|
const onConfirmWrapped = (evt: React.FormEvent) => {
|
||||||
|
evt.preventDefault()
|
||||||
|
closeModal()
|
||||||
|
onConfirm(useMatrixTo, includeEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = generateLink(useMatrixTo, includeEvent)
|
||||||
|
return <form onSubmit={onConfirmWrapped}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<div className="timeline-event-container">
|
||||||
|
<TimelineEvent evt={evt} prevEvt={null} disableMenu={true}/>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Use matrix.to link</td>
|
||||||
|
<td>
|
||||||
|
<Toggle
|
||||||
|
id="useMatrixTo"
|
||||||
|
checked={useMatrixTo}
|
||||||
|
onChange={evt => setUseMatrixTo(evt.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Link to this specific event</td>
|
||||||
|
<td>
|
||||||
|
<Toggle
|
||||||
|
id="shareEvent"
|
||||||
|
checked={includeEvent}
|
||||||
|
onChange={evt => setIncludeEvent(evt.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="output-preview">
|
||||||
|
<span className="no-select">Preview: </span><code>{link}</code>
|
||||||
|
</div>
|
||||||
|
<div className="confirm-buttons">
|
||||||
|
<button type="button" onClick={closeModal}>Cancel</button>
|
||||||
|
<button type="submit">{confirmButton}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShareModal
|
|
@ -43,7 +43,7 @@ div.event-fixed-menu {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.event-context-menu {
|
div.context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border-radius: .5rem;
|
border-radius: .5rem;
|
||||||
|
@ -80,6 +80,10 @@ div.event-context-menu {
|
||||||
color: var(--error-color);
|
color: var(--error-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.event-context-menu, &.room-list-menu {
|
||||||
|
width: 10rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.confirm-message-modal > form {
|
div.confirm-message-modal > form {
|
||||||
|
@ -101,6 +105,7 @@ div.confirm-message-modal > form {
|
||||||
|
|
||||||
> div.timeline-event {
|
> div.timeline-event {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,4 +123,14 @@ div.confirm-message-modal > form {
|
||||||
padding: .5rem 1rem;
|
padding: .5rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> div.output-preview {
|
||||||
|
> span.no-select {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> code {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -14,4 +14,5 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
|
export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
|
||||||
|
export { RoomMenu } from "./RoomMenu.tsx"
|
||||||
export { getModalStyleFromMouse } from "./util.ts"
|
export { getModalStyleFromMouse } from "./util.ts"
|
|
@ -18,9 +18,9 @@ import Client from "@/api/client.ts"
|
||||||
import { MemDBEvent } from "@/api/types"
|
import { MemDBEvent } from "@/api/types"
|
||||||
import { emojiToReactionContent } from "@/util/emoji"
|
import { emojiToReactionContent } from "@/util/emoji"
|
||||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
import EmojiPicker from "../../emojipicker/EmojiPicker.tsx"
|
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
|
||||||
import { ModalCloseContext, ModalContext } from "../../modal"
|
import { ModalCloseContext, ModalContext } from "../modal"
|
||||||
import { RoomContextData } from "../../roomview/roomcontext.ts"
|
import { RoomContextData } from "../roomview/roomcontext.ts"
|
||||||
import { EventExtraMenu } from "./EventMenu.tsx"
|
import { EventExtraMenu } from "./EventMenu.tsx"
|
||||||
import { getEncryption, getModalStyleFromButton, getPending, getPowerLevels } from "./util.ts"
|
import { getEncryption, getModalStyleFromButton, getPending, getPowerLevels } from "./util.ts"
|
||||||
import EditIcon from "@/icons/edit.svg?react"
|
import EditIcon from "@/icons/edit.svg?react"
|
||||||
|
@ -79,7 +79,7 @@ export const usePrimaryItems = (
|
||||||
.catch(err => window.alert(`Failed to resend message: ${err}`))
|
.catch(err => window.alert(`Failed to resend message: ${err}`))
|
||||||
}
|
}
|
||||||
const onClickMore = (mevt: React.MouseEvent<HTMLButtonElement>) => {
|
const onClickMore = (mevt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
const moreMenuHeight = 4 * 40
|
const moreMenuHeight = 5 * 40
|
||||||
setForceOpen!(true)
|
setForceOpen!(true)
|
||||||
openModal({
|
openModal({
|
||||||
content: <EventExtraMenu
|
content: <EventExtraMenu
|
|
@ -17,15 +17,18 @@ import { use } from "react"
|
||||||
import Client from "@/api/client.ts"
|
import Client from "@/api/client.ts"
|
||||||
import { useRoomState } from "@/api/statestore"
|
import { useRoomState } from "@/api/statestore"
|
||||||
import { MemDBEvent } from "@/api/types"
|
import { MemDBEvent } from "@/api/types"
|
||||||
import { ModalCloseContext, ModalContext } from "../../modal"
|
import { ModalCloseContext, ModalContext } from "../modal"
|
||||||
import { RoomContext, RoomContextData } from "../../roomview/roomcontext.ts"
|
import { RoomContext, RoomContextData } from "../roomview/roomcontext.ts"
|
||||||
import JSONView from "../../util/JSONView.tsx"
|
import JSONView from "../util/JSONView.tsx"
|
||||||
import ConfirmWithMessageModal from "./ConfirmWithMessageModal.tsx"
|
import ConfirmWithMessageModal from "./ConfirmWithMessageModal.tsx"
|
||||||
|
import ShareModal from "./ShareModal.tsx"
|
||||||
import { getPending, getPowerLevels } from "./util.ts"
|
import { getPending, getPowerLevels } from "./util.ts"
|
||||||
import ViewSourceIcon from "@/icons/code.svg?react"
|
import ViewSourceIcon from "@/icons/code.svg?react"
|
||||||
import DeleteIcon from "@/icons/delete.svg?react"
|
import DeleteIcon from "@/icons/delete.svg?react"
|
||||||
import PinIcon from "@/icons/pin.svg?react"
|
import PinIcon from "@/icons/pin.svg?react"
|
||||||
import ReportIcon from "@/icons/report.svg?react"
|
import ReportIcon from "@/icons/report.svg?react"
|
||||||
|
import RestoreTrashIcon from "@/icons/restore-trash.svg?react"
|
||||||
|
import ShareIcon from "@/icons/share.svg?react"
|
||||||
import UnpinIcon from "@/icons/unpin.svg?react"
|
import UnpinIcon from "@/icons/unpin.svg?react"
|
||||||
|
|
||||||
export const useSecondaryItems = (
|
export const useSecondaryItems = (
|
||||||
|
@ -83,12 +86,67 @@ export const useSecondaryItems = (
|
||||||
</RoomContext>,
|
</RoomContext>,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const onClickHideUnredacted = () => {
|
||||||
|
closeModal()
|
||||||
|
roomCtx.store.setViewingRedacted(evt, false)
|
||||||
|
}
|
||||||
|
const onClickUnredact = () => {
|
||||||
|
closeModal()
|
||||||
|
if (Object.entries(evt.content).length > 0) {
|
||||||
|
roomCtx.store.setViewingRedacted(evt, true)
|
||||||
|
} else {
|
||||||
|
client.requestEvent(roomCtx.store, evt.event_id, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
const onClickPin = (pin: boolean) => () => {
|
const onClickPin = (pin: boolean) => () => {
|
||||||
closeModal()
|
closeModal()
|
||||||
client.pinMessage(roomCtx.store, evt.event_id, pin)
|
client.pinMessage(roomCtx.store, evt.event_id, pin)
|
||||||
.catch(err => window.alert(`Failed to ${pin ? "pin" : "unpin"} message: ${err}`))
|
.catch(err => window.alert(`Failed to ${pin ? "pin" : "unpin"} message: ${err}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onClickShareEvent = () => {
|
||||||
|
const generateLink = (useMatrixTo: boolean, includeEvent: boolean) => {
|
||||||
|
const isRoomIDLink = true
|
||||||
|
let generatedURL = useMatrixTo ? "https://matrix.to/#/" : "matrix:roomid/"
|
||||||
|
if (useMatrixTo) {
|
||||||
|
generatedURL += evt.room_id
|
||||||
|
} else {
|
||||||
|
generatedURL += `${evt.room_id.slice(1)}`
|
||||||
|
}
|
||||||
|
if (includeEvent) {
|
||||||
|
if (useMatrixTo) {
|
||||||
|
generatedURL += `/${evt.event_id}`
|
||||||
|
} else {
|
||||||
|
generatedURL += `/e/${evt.event_id.slice(1)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRoomIDLink) {
|
||||||
|
generatedURL += "?" + new URLSearchParams(
|
||||||
|
roomCtx.store.getViaServers().map(server => ["via", server]),
|
||||||
|
).toString()
|
||||||
|
}
|
||||||
|
return generatedURL
|
||||||
|
}
|
||||||
|
openModal({
|
||||||
|
dimmed: true,
|
||||||
|
boxed: true,
|
||||||
|
innerBoxClass: "confirm-message-modal",
|
||||||
|
content: <RoomContext value={roomCtx}>
|
||||||
|
<ShareModal
|
||||||
|
evt={evt}
|
||||||
|
title="Share Message"
|
||||||
|
confirmButton="Copy to clipboard"
|
||||||
|
onConfirm={(useMatrixTo: boolean, includeEvent: boolean) => {
|
||||||
|
navigator.clipboard.writeText(generateLink(useMatrixTo, includeEvent)).catch(
|
||||||
|
err => window.alert(`Failed to copy link: ${err}`),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
generateLink={generateLink}
|
||||||
|
/>
|
||||||
|
</RoomContext>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const [isPending, pendingTitle] = getPending(evt)
|
const [isPending, pendingTitle] = getPending(evt)
|
||||||
useRoomState(roomCtx.store, "m.room.power_levels", "")
|
useRoomState(roomCtx.store, "m.room.power_levels", "")
|
||||||
// We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes
|
// We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes
|
||||||
|
@ -101,9 +159,12 @@ export const useSecondaryItems = (
|
||||||
const canRedact = !evt.redacted_by
|
const canRedact = !evt.redacted_by
|
||||||
&& ownPL >= redactEvtPL
|
&& ownPL >= redactEvtPL
|
||||||
&& (evt.sender === client.userID || ownPL >= redactOtherPL)
|
&& (evt.sender === client.userID || ownPL >= redactOtherPL)
|
||||||
|
// TODO check server admin status and room PLs
|
||||||
|
const canUnredact = Boolean(evt.redacted_by)
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<button onClick={onClickViewSource}><ViewSourceIcon/>{names && "View source"}</button>
|
<button onClick={onClickViewSource}><ViewSourceIcon/>{names && "View source"}</button>
|
||||||
|
<button onClick={onClickShareEvent}><ShareIcon/>{names && "Share"}</button>
|
||||||
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
||||||
? <button onClick={onClickPin(false)}>
|
? <button onClick={onClickPin(false)}>
|
||||||
<UnpinIcon/>{names && "Unpin message"}
|
<UnpinIcon/>{names && "Unpin message"}
|
||||||
|
@ -120,5 +181,10 @@ export const useSecondaryItems = (
|
||||||
title={pendingTitle}
|
title={pendingTitle}
|
||||||
className="redact-button"
|
className="redact-button"
|
||||||
><DeleteIcon/>{names && "Remove"}</button>}
|
><DeleteIcon/>{names && "Remove"}</button>}
|
||||||
|
{canUnredact && (evt.viewing_redacted ? <button onClick={onClickHideUnredacted}>
|
||||||
|
<DeleteIcon/>{names && "Hide content"}
|
||||||
|
</button> : <button onClick={onClickUnredact}>
|
||||||
|
<RestoreTrashIcon/>{names && "View content"}
|
||||||
|
</button>)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
|
@ -39,10 +39,10 @@ export const getEncryption = (room: RoomStateStore): boolean =>{
|
||||||
export function getModalStyleFromMouse(
|
export function getModalStyleFromMouse(
|
||||||
evt: React.MouseEvent, modalHeight: number, modalWidth = 10 * 16,
|
evt: React.MouseEvent, modalHeight: number, modalWidth = 10 * 16,
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const style: CSSProperties = { right: window.innerWidth - evt.clientX }
|
const style: CSSProperties = { left: evt.clientX }
|
||||||
if (evt.clientX - modalWidth < 4) {
|
if (evt.clientX + modalWidth > window.innerWidth) {
|
||||||
delete style.right
|
delete style.left
|
||||||
style.left = "4px"
|
style.right = "4px"
|
||||||
}
|
}
|
||||||
if (evt.clientY + modalHeight > window.innerHeight) {
|
if (evt.clientY + modalHeight > window.innerHeight) {
|
||||||
style.bottom = window.innerHeight - evt.clientY
|
style.bottom = window.innerHeight - evt.clientY
|
|
@ -19,6 +19,16 @@ div.overlay {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
|
&.full-screen-mobile {
|
||||||
|
@media screen and (max-width: 30rem) {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> div.modal-box-inner {
|
> div.modal-box-inner {
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ const LightboxWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
params = {
|
params = {
|
||||||
src: target.src,
|
src: target.getAttribute("data-full-src") ?? target.src,
|
||||||
alt: target.alt,
|
alt: target.alt,
|
||||||
}
|
}
|
||||||
setParams(params)
|
setParams(params)
|
||||||
|
@ -75,6 +75,11 @@ export interface LightboxProps extends LightboxParams {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
export class Lightbox extends Component<LightboxProps> {
|
export class Lightbox extends Component<LightboxProps> {
|
||||||
translate = { x: 0, y: 0 }
|
translate = { x: 0, y: 0 }
|
||||||
zoom = 1
|
zoom = 1
|
||||||
|
@ -82,6 +87,9 @@ export class Lightbox extends Component<LightboxProps> {
|
||||||
maybePanning = false
|
maybePanning = false
|
||||||
readonly ref = createRef<HTMLImageElement>()
|
readonly ref = createRef<HTMLImageElement>()
|
||||||
readonly wrapperRef = createRef<HTMLDivElement>()
|
readonly wrapperRef = createRef<HTMLDivElement>()
|
||||||
|
prevTouch1: Point | null = null
|
||||||
|
prevTouch2: Point | null = null
|
||||||
|
prevTouchDist: number | null = null
|
||||||
|
|
||||||
get style() {
|
get style() {
|
||||||
return {
|
return {
|
||||||
|
@ -91,6 +99,14 @@ export class Lightbox extends Component<LightboxProps> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get orientation(): number {
|
||||||
|
let rot = (this.rotate / 90) % 4
|
||||||
|
if (rot < 0) {
|
||||||
|
rot += 4
|
||||||
|
}
|
||||||
|
return rot
|
||||||
|
}
|
||||||
|
|
||||||
close = () => {
|
close = () => {
|
||||||
this.translate = { x: 0, y: 0 }
|
this.translate = { x: 0, y: 0 }
|
||||||
this.rotate = 0
|
this.rotate = 0
|
||||||
|
@ -115,18 +131,55 @@ export class Lightbox extends Component<LightboxProps> {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
const oldZoom = this.zoom
|
this.#doZoom(-evt.deltaY / 1000, evt.nativeEvent.offsetX, evt.nativeEvent.offsetY, false)
|
||||||
const delta = -evt.deltaY / 1000
|
|
||||||
const newDelta = this.zoom + delta * this.zoom
|
|
||||||
this.zoom = Math.min(Math.max(newDelta, 0.01), 10)
|
|
||||||
const zoomDelta = this.zoom - oldZoom
|
|
||||||
this.translate.x += zoomDelta * (this.ref.current.clientWidth / 2 - evt.nativeEvent.offsetX)
|
|
||||||
this.translate.y += zoomDelta * (this.ref.current.clientHeight / 2 - evt.nativeEvent.offsetY)
|
|
||||||
const style = this.style
|
const style = this.style
|
||||||
this.ref.current.style.translate = style.translate
|
this.ref.current.style.translate = style.translate
|
||||||
this.ref.current.style.scale = style.scale
|
this.ref.current.style.scale = style.scale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#getTouchDistance(p1: Point, p2: Point): number {
|
||||||
|
return Math.hypot(p1.x - p2.x, p1.y - p2.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
#getTouchMidpoint(p1: Point, p2: Point): Point {
|
||||||
|
const contentRect = this.ref.current!.getBoundingClientRect()
|
||||||
|
const p1X = p1.x - contentRect.left
|
||||||
|
const p1Y = p1.y - contentRect.top
|
||||||
|
const p2X = p2.x - contentRect.left
|
||||||
|
const p2Y = p2.y - contentRect.top
|
||||||
|
const point = {
|
||||||
|
x: (p1X + p2X) / 2 / this.zoom,
|
||||||
|
y: (p1Y + p2Y) / 2 / this.zoom,
|
||||||
|
}
|
||||||
|
const orientation = this.orientation
|
||||||
|
if (orientation === 1 || orientation === 3) {
|
||||||
|
// This is slightly weird because doZoom will flip the x and y values again,
|
||||||
|
// but maybe the flipped subtraction from clientWidth/Height is important.
|
||||||
|
return { x: point.y, y: point.x }
|
||||||
|
}
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
|
||||||
|
#doZoom(delta: number, offsetX: number, offsetY: number, touch: boolean) {
|
||||||
|
if (!this.ref.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const oldZoom = this.zoom
|
||||||
|
const newDelta = oldZoom + delta * this.zoom
|
||||||
|
this.zoom = Math.min(Math.max(newDelta, 0.01), 10)
|
||||||
|
const zoomDelta = this.zoom - oldZoom
|
||||||
|
|
||||||
|
const orientation = this.orientation
|
||||||
|
const negateX = !touch && (orientation === 2 || orientation == 3) ? -1 : 1
|
||||||
|
const negateY = !touch && (orientation === 2 || orientation == 1) ? -1 : 1
|
||||||
|
const flipXY = orientation === 1 || orientation === 3
|
||||||
|
|
||||||
|
const deltaX = zoomDelta * (this.ref.current.clientWidth / 2 - offsetX) * negateX
|
||||||
|
const deltaY = zoomDelta * (this.ref.current.clientHeight / 2 - offsetY) * negateY
|
||||||
|
this.translate.x += flipXY ? deltaY : deltaX
|
||||||
|
this.translate.y += flipXY ? deltaX : deltaY
|
||||||
|
}
|
||||||
|
|
||||||
onMouseDown = (evt: React.MouseEvent) => {
|
onMouseDown = (evt: React.MouseEvent) => {
|
||||||
if (evt.buttons === 1) {
|
if (evt.buttons === 1) {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
|
@ -150,6 +203,57 @@ export class Lightbox extends Component<LightboxProps> {
|
||||||
this.ref.current.style.cursor = "grabbing"
|
this.ref.current.style.cursor = "grabbing"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTouchStart = (evt: React.TouchEvent) => {
|
||||||
|
if (evt.touches.length === 1) {
|
||||||
|
this.maybePanning = true
|
||||||
|
this.prevTouch1 = { x: evt.touches[0].pageX, y: evt.touches[0].pageY }
|
||||||
|
this.prevTouch2 = null
|
||||||
|
} else if (evt.touches.length === 2) {
|
||||||
|
this.prevTouch1 = { x: evt.touches[0].pageX, y: evt.touches[0].pageY }
|
||||||
|
this.prevTouch2 = { x: evt.touches[1].pageX, y: evt.touches[1].pageY }
|
||||||
|
this.prevTouchDist = this.#getTouchDistance(this.prevTouch1, this.prevTouch2)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd = () => {
|
||||||
|
this.prevTouch1 = null
|
||||||
|
this.prevTouch2 = null
|
||||||
|
this.prevTouchDist = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove = (evt: React.TouchEvent) => {
|
||||||
|
if (!this.ref.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (evt.touches.length > 0 && this.prevTouch1) {
|
||||||
|
this.translate.x += evt.touches[0].pageX - this.prevTouch1.x
|
||||||
|
this.translate.y += evt.touches[0].pageY - this.prevTouch1.y
|
||||||
|
this.prevTouch1 = { x: evt.touches[0].pageX, y: evt.touches[0].pageY }
|
||||||
|
if (evt.touches.length === 1) {
|
||||||
|
this.ref.current.style.translate = this.style.translate
|
||||||
|
this.ref.current.style.cursor = "grabbing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (evt.touches.length > 1 && this.prevTouch1 && this.prevTouch2 && this.prevTouchDist) {
|
||||||
|
this.prevTouch2 = { x: evt.touches[1].pageX, y: evt.touches[1].pageY }
|
||||||
|
const newDist = this.#getTouchDistance(this.prevTouch1, this.prevTouch2)
|
||||||
|
const midpoint = this.#getTouchMidpoint(
|
||||||
|
{ x: evt.touches[0].clientX, y: evt.touches[0].clientY },
|
||||||
|
{ x: evt.touches[1].clientX, y: evt.touches[1].clientY },
|
||||||
|
)
|
||||||
|
this.#doZoom((newDist - this.prevTouchDist) / 100, midpoint.x, midpoint.y, true)
|
||||||
|
this.prevTouchDist = newDist
|
||||||
|
const style = this.style
|
||||||
|
this.ref.current.style.translate = style.translate
|
||||||
|
this.ref.current.style.scale = style.scale
|
||||||
|
}
|
||||||
|
evt.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
onKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
onKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
const key = keyToString(evt)
|
const key = keyToString(evt)
|
||||||
if (key === "Escape") {
|
if (key === "Escape") {
|
||||||
|
@ -189,6 +293,10 @@ export class Lightbox extends Component<LightboxProps> {
|
||||||
className="overlay dimmed lightbox"
|
className="overlay dimmed lightbox"
|
||||||
onClick={this.onClick}
|
onClick={this.onClick}
|
||||||
onMouseMove={isTouchDevice ? undefined : this.onMouseMove}
|
onMouseMove={isTouchDevice ? undefined : this.onMouseMove}
|
||||||
|
onTouchStart={isTouchDevice ? this.onTouchStart : undefined}
|
||||||
|
onTouchMove={isTouchDevice ? this.onTouchMove : undefined}
|
||||||
|
onTouchEnd={isTouchDevice ? this.onTouchEnd : undefined}
|
||||||
|
onTouchCancel={isTouchDevice ? this.onTouchEnd : undefined}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
ref={this.wrapperRef}
|
ref={this.wrapperRef}
|
||||||
|
|
|
@ -13,10 +13,17 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import React, { JSX, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react"
|
import React, { Context, JSX, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from "react"
|
||||||
import { ModalCloseContext, ModalContext, ModalState } from "./contexts.ts"
|
import ErrorBoundary from "../util/ErrorBoundary.tsx"
|
||||||
|
import { ModalCloseContext, ModalState, openModal } from "./contexts.ts"
|
||||||
|
|
||||||
const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
interface ModalWrapperProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
ContextType: Context<openModal>
|
||||||
|
historyStateKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperProps) => {
|
||||||
const [state, setState] = useReducer((prevState: ModalState | null, newState: ModalState | null) => {
|
const [state, setState] = useReducer((prevState: ModalState | null, newState: ModalState | null) => {
|
||||||
prevState?.onClose?.()
|
prevState?.onClose?.()
|
||||||
return newState
|
return newState
|
||||||
|
@ -25,26 +32,27 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
if (evt && evt.target !== evt.currentTarget) {
|
if (evt && evt.target !== evt.currentTarget) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
evt?.stopPropagation()
|
||||||
setState(null)
|
setState(null)
|
||||||
if (history.state?.modal) {
|
if (history.state?.[historyStateKey]) {
|
||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [historyStateKey])
|
||||||
const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (evt.key === "Escape") {
|
if (evt.key === "Escape") {
|
||||||
setState(null)
|
setState(null)
|
||||||
if (history.state?.modal) {
|
if (history.state?.[historyStateKey]) {
|
||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
evt.stopPropagation()
|
evt.stopPropagation()
|
||||||
}
|
}
|
||||||
const openModal = useCallback((newState: ModalState) => {
|
const openModal = useCallback((newState: ModalState) => {
|
||||||
if (!history.state?.modal && newState.captureInput !== false) {
|
if (!history.state?.[historyStateKey] && newState.captureInput !== false) {
|
||||||
history.pushState({ ...(history.state ?? {}), modal: true }, "")
|
history.pushState({ ...(history.state ?? {}), [historyStateKey]: true }, "")
|
||||||
}
|
}
|
||||||
setState(newState)
|
setState(newState)
|
||||||
}, [])
|
}, [historyStateKey])
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
|
if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
|
||||||
|
@ -54,16 +62,20 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.closeModal = onClickWrapper
|
window.closeModal = onClickWrapper
|
||||||
const listener = (evt: PopStateEvent) => {
|
const listener = (evt: PopStateEvent) => {
|
||||||
if (!evt.state?.modal) {
|
if (!evt.state?.[historyStateKey]) {
|
||||||
setState(null)
|
setState(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("popstate", listener)
|
window.addEventListener("popstate", listener)
|
||||||
return () => window.removeEventListener("popstate", listener)
|
return () => window.removeEventListener("popstate", listener)
|
||||||
}, [onClickWrapper])
|
}, [historyStateKey, onClickWrapper])
|
||||||
let modal: JSX.Element | null = null
|
let modal: JSX.Element | null = null
|
||||||
if (state) {
|
if (state) {
|
||||||
let content = <ModalCloseContext value={onClickWrapper}>{state.content}</ModalCloseContext>
|
let content = <ModalCloseContext value={onClickWrapper}>
|
||||||
|
<ErrorBoundary thing="modal">
|
||||||
|
{state.content}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</ModalCloseContext>
|
||||||
if (state.boxed) {
|
if (state.boxed) {
|
||||||
content = <div className={`modal-box ${state.boxClass ?? ""}`}>
|
content = <div className={`modal-box ${state.boxClass ?? ""}`}>
|
||||||
<div className={`modal-box-inner ${state.innerBoxClass ?? ""}`}>
|
<div className={`modal-box-inner ${state.innerBoxClass ?? ""}`}>
|
||||||
|
@ -85,10 +97,13 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
modal = content
|
modal = content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return <ModalContext value={openModal}>
|
if (historyStateKey === "nestable_modal") {
|
||||||
|
window.openNestableModal = openModal
|
||||||
|
}
|
||||||
|
return <ContextType value={openModal}>
|
||||||
{children}
|
{children}
|
||||||
{modal}
|
{modal}
|
||||||
</ModalContext>
|
</ContextType>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ModalWrapper
|
export default ModalWrapper
|
||||||
|
|
|
@ -35,9 +35,12 @@ export interface ModalState {
|
||||||
captureInput?: boolean
|
captureInput?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type openModal = (state: ModalState) => void
|
export type openModal = (state: ModalState) => void
|
||||||
|
|
||||||
export const ModalContext = createContext<openModal>(() =>
|
export const ModalContext = createContext<openModal>(() =>
|
||||||
console.error("Tried to open modal without being inside context"))
|
console.error("Tried to open modal without being inside context"))
|
||||||
|
|
||||||
|
export const NestableModalContext = createContext<openModal>(() =>
|
||||||
|
console.error("Tried to open nestable modal without being inside context"))
|
||||||
|
|
||||||
export const ModalCloseContext = createContext<() => void>(() => {})
|
export const ModalCloseContext = createContext<() => void>(() => {})
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import React, { use, useState } from "react"
|
import React, { use, useState } from "react"
|
||||||
import { getAvatarURL } from "@/api/media.ts"
|
import { getAvatarThumbnailURL } from "@/api/media.ts"
|
||||||
import { MemDBEvent, MemberEventContent } from "@/api/types"
|
import { MemDBEvent, MemberEventContent } from "@/api/types"
|
||||||
import { getDisplayname } from "@/util/validation.ts"
|
import { getDisplayname } from "@/util/validation.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
|
@ -33,7 +33,7 @@ const MemberRow = ({ evt, onClick }: MemberRowProps) => {
|
||||||
return <div className="member" data-target-panel="user" data-target-user={userID} onClick={onClick}>
|
return <div className="member" data-target-panel="user" data-target-user={userID} onClick={onClick}>
|
||||||
<img
|
<img
|
||||||
className="avatar"
|
className="avatar"
|
||||||
src={getAvatarURL(userID, content)}
|
src={getAvatarThumbnailURL(userID, content)}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
@ -50,7 +50,7 @@ const MemberList = () => {
|
||||||
roomCtx.store.membersRequested = true
|
roomCtx.store.membersRequested = true
|
||||||
use(ClientContext)?.loadRoomState(roomCtx.store.roomID, { omitMembers: false, refetch: false })
|
use(ClientContext)?.loadRoomState(roomCtx.store.roomID, { omitMembers: false, refetch: false })
|
||||||
}
|
}
|
||||||
const memberEvents = useFilteredMembers(roomCtx?.store, filter)
|
const memberEvents = useFilteredMembers(roomCtx?.store, filter, false, false)
|
||||||
if (!roomCtx) {
|
if (!roomCtx) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,6 +51,22 @@ div.right-panel-content.pinned-messages {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.right-panel-content.widgets {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .5rem;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
padding: .5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div.separator {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
div.right-panel-content.user {
|
div.right-panel-content.user {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -91,6 +107,12 @@ div.right-panel-content.user {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.userid, div.extended-profile, div.devices, div.user-moderation, div.mutual-rooms, div.errors {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: .5rem;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
div.extended-profile {
|
div.extended-profile {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
@ -192,6 +214,25 @@ div.right-panel-content.user {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.user-moderation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
button.moderation-action {
|
||||||
|
padding: .5rem;
|
||||||
|
width: 100%;
|
||||||
|
gap: .5rem;
|
||||||
|
justify-content: left;
|
||||||
|
|
||||||
|
&.dangerous {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
&.positive {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
div.errors {
|
div.errors {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
@ -13,20 +13,30 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import type { IWidget } from "matrix-widget-api"
|
||||||
import { JSX, use } from "react"
|
import { JSX, use } from "react"
|
||||||
import type { UserID } from "@/api/types"
|
import type { UserID } from "@/api/types"
|
||||||
import MainScreenContext from "../MainScreenContext.ts"
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
|
import ErrorBoundary from "../util/ErrorBoundary.tsx"
|
||||||
|
import ElementCall from "../widget/ElementCall.tsx"
|
||||||
|
import LazyWidget from "../widget/LazyWidget.tsx"
|
||||||
import MemberList from "./MemberList.tsx"
|
import MemberList from "./MemberList.tsx"
|
||||||
import PinnedMessages from "./PinnedMessages.tsx"
|
import PinnedMessages from "./PinnedMessages.tsx"
|
||||||
import UserInfo from "./UserInfo.tsx"
|
import UserInfo from "./UserInfo.tsx"
|
||||||
|
import WidgetList from "./WidgetList.tsx"
|
||||||
import BackIcon from "@/icons/back.svg?react"
|
import BackIcon from "@/icons/back.svg?react"
|
||||||
import CloseIcon from "@/icons/close.svg?react"
|
import CloseIcon from "@/icons/close.svg?react"
|
||||||
import "./RightPanel.css"
|
import "./RightPanel.css"
|
||||||
|
|
||||||
export type RightPanelType = "pinned-messages" | "members" | "user"
|
export type RightPanelType = "pinned-messages" | "members" | "widgets" | "widget" | "user" | "element-call"
|
||||||
|
|
||||||
interface RightPanelSimpleProps {
|
interface RightPanelSimpleProps {
|
||||||
type: "pinned-messages" | "members"
|
type: "pinned-messages" | "members" | "widgets" | "element-call"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RightPanelWidgetProps {
|
||||||
|
type: "widget"
|
||||||
|
info: IWidget
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RightPanelUserProps {
|
interface RightPanelUserProps {
|
||||||
|
@ -34,14 +44,20 @@ interface RightPanelUserProps {
|
||||||
userID: UserID
|
userID: UserID
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RightPanelProps = RightPanelUserProps | RightPanelSimpleProps
|
export type RightPanelProps = RightPanelUserProps | RightPanelWidgetProps | RightPanelSimpleProps
|
||||||
|
|
||||||
function getTitle(type: RightPanelType): string {
|
function getTitle(props: RightPanelProps): string {
|
||||||
switch (type) {
|
switch (props.type) {
|
||||||
case "pinned-messages":
|
case "pinned-messages":
|
||||||
return "Pinned Messages"
|
return "Pinned Messages"
|
||||||
case "members":
|
case "members":
|
||||||
return "Room Members"
|
return "Room Members"
|
||||||
|
case "widgets":
|
||||||
|
return "Widgets in room"
|
||||||
|
case "widget":
|
||||||
|
return props.info.name || "Widget"
|
||||||
|
case "element-call":
|
||||||
|
return "Element Call"
|
||||||
case "user":
|
case "user":
|
||||||
return "User Info"
|
return "User Info"
|
||||||
}
|
}
|
||||||
|
@ -53,6 +69,12 @@ function renderRightPanelContent(props: RightPanelProps): JSX.Element | null {
|
||||||
return <PinnedMessages />
|
return <PinnedMessages />
|
||||||
case "members":
|
case "members":
|
||||||
return <MemberList />
|
return <MemberList />
|
||||||
|
case "widgets":
|
||||||
|
return <WidgetList />
|
||||||
|
case "element-call":
|
||||||
|
return <ElementCall />
|
||||||
|
case "widget":
|
||||||
|
return <LazyWidget info={props.info} />
|
||||||
case "user":
|
case "user":
|
||||||
return <UserInfo userID={props.userID} />
|
return <UserInfo userID={props.userID} />
|
||||||
}
|
}
|
||||||
|
@ -66,17 +88,24 @@ const RightPanel = (props: RightPanelProps) => {
|
||||||
data-target-panel="members"
|
data-target-panel="members"
|
||||||
onClick={mainScreen.clickRightPanelOpener}
|
onClick={mainScreen.clickRightPanelOpener}
|
||||||
><BackIcon/></button>
|
><BackIcon/></button>
|
||||||
|
} else if (props.type === "element-call" || props.type === "widget") {
|
||||||
|
backButton = <button
|
||||||
|
data-target-panel="widgets"
|
||||||
|
onClick={mainScreen.clickRightPanelOpener}
|
||||||
|
><BackIcon/></button>
|
||||||
}
|
}
|
||||||
return <div className="right-panel">
|
return <div className="right-panel">
|
||||||
<div className="right-panel-header">
|
<div className="right-panel-header">
|
||||||
<div className="left-side">
|
<div className="left-side">
|
||||||
{backButton}
|
{backButton}
|
||||||
<div className="panel-name">{getTitle(props.type)}</div>
|
<div className="panel-name">{getTitle(props)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={mainScreen.closeRightPanel}><CloseIcon/></button>
|
<button onClick={mainScreen.closeRightPanel}><CloseIcon/></button>
|
||||||
</div>
|
</div>
|
||||||
<div className={`right-panel-content ${props.type}`}>
|
<div className={`right-panel-content ${props.type}`}>
|
||||||
|
<ErrorBoundary thing="right panel content">
|
||||||
{renderRightPanelContent(props)}
|
{renderRightPanelContent(props)}
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
102
web/src/ui/rightpanel/StartDMButton.tsx
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import { use, useMemo, useState } from "react"
|
||||||
|
import Client from "@/api/client.ts"
|
||||||
|
import { UserID } from "@/api/types"
|
||||||
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
|
import ChatIcon from "@/icons/chat.svg?react"
|
||||||
|
|
||||||
|
const StartDMButton = ({ userID, client }: { userID: UserID; client: Client }) => {
|
||||||
|
const mainScreen = use(MainScreenContext)!
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
|
||||||
|
const findExistingRoom = () => {
|
||||||
|
for (const room of client.store.rooms.values()) {
|
||||||
|
if (room.meta.current.dm_user_id === userID) {
|
||||||
|
return room.roomID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existingRoom = useMemo(findExistingRoom, [userID, client])
|
||||||
|
|
||||||
|
const startDM = async () => {
|
||||||
|
if (existingRoom) {
|
||||||
|
mainScreen.setActiveRoom(existingRoom)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!window.confirm(`Are you sure you want to start a chat with ${userID}?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existingRoomRelookup = findExistingRoom()
|
||||||
|
if (existingRoomRelookup) {
|
||||||
|
mainScreen.setActiveRoom(existingRoomRelookup)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsCreating(true)
|
||||||
|
|
||||||
|
let shouldEncrypt = false
|
||||||
|
const initialState = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
shouldEncrypt = (await client.rpc.trackUserDevices(userID)).devices.length > 0
|
||||||
|
|
||||||
|
if (shouldEncrypt) {
|
||||||
|
console.log("User has encryption devices, creating encrypted room")
|
||||||
|
initialState.push({
|
||||||
|
type: "m.room.encryption",
|
||||||
|
content: {
|
||||||
|
algorithm: "m.megolm.v1.aes-sha2",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to check user encryption status:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the room with encryption if needed
|
||||||
|
const response = await client.rpc.createRoom({
|
||||||
|
is_direct: true,
|
||||||
|
preset: "trusted_private_chat",
|
||||||
|
invite: [userID],
|
||||||
|
initial_state: initialState,
|
||||||
|
})
|
||||||
|
console.log("Created DM room:", response.room_id)
|
||||||
|
|
||||||
|
// FIXME this is a hacky way to work around the room taking time to come down /sync
|
||||||
|
setTimeout(() => {
|
||||||
|
mainScreen.setActiveRoom(response.room_id)
|
||||||
|
}, 1000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create DM room:", err)
|
||||||
|
window.alert(`Failed to create DM room: ${err}`)
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button
|
||||||
|
className="moderation-action positive"
|
||||||
|
onClick={startDM}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
<ChatIcon />
|
||||||
|
<span>{existingRoom ? "Go to DM" : isCreating ? "Creating..." : "Create DM"}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StartDMButton
|
|
@ -4,7 +4,7 @@ import { PronounSet, UserProfile } from "@/api/types"
|
||||||
import { ensureArray, ensureString } from "@/util/validation.ts"
|
import { ensureArray, ensureString } from "@/util/validation.ts"
|
||||||
|
|
||||||
interface ExtendedProfileProps {
|
interface ExtendedProfileProps {
|
||||||
profile: UserProfile
|
profile: UserProfile | null
|
||||||
refreshProfile: () => void
|
refreshProfile: () => void
|
||||||
client: Client
|
client: Client
|
||||||
userID: string
|
userID: string
|
||||||
|
@ -27,14 +27,19 @@ const currentTimeAdjusted = (tz: string) => {
|
||||||
timeZoneName: "short",
|
timeZoneName: "short",
|
||||||
timeZone: tz,
|
timeZone: tz,
|
||||||
}).format(new Date())
|
}).format(new Date())
|
||||||
} catch (e) {
|
} catch {
|
||||||
return `${e}`
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClockElement = ({ tz }: { tz: string }) => {
|
const ClockElement = ({ tz }: { tz: string }) => {
|
||||||
const [time, setTime] = useState(currentTimeAdjusted(tz))
|
const cta = currentTimeAdjusted(tz)
|
||||||
|
const isValidTZ = cta !== null
|
||||||
|
const [time, setTime] = useState(cta)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isValidTZ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
let interval: number | undefined
|
let interval: number | undefined
|
||||||
const updateTime = () => setTime(currentTimeAdjusted(tz))
|
const updateTime = () => setTime(currentTimeAdjusted(tz))
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
@ -42,8 +47,11 @@ const ClockElement = ({ tz }: { tz: string }) => {
|
||||||
updateTime()
|
updateTime()
|
||||||
}, (1001 - Date.now() % 1000))
|
}, (1001 - Date.now() % 1000))
|
||||||
return () => interval ? clearInterval(interval) : clearTimeout(timeout)
|
return () => interval ? clearInterval(interval) : clearTimeout(timeout)
|
||||||
}, [tz])
|
}, [tz, isValidTZ])
|
||||||
|
|
||||||
|
if (!isValidTZ) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
return <>
|
return <>
|
||||||
<div title={tz}>Time:</div>
|
<div title={tz}>Time:</div>
|
||||||
<div title={tz}>{time}</div>
|
<div title={tz}>{time}</div>
|
||||||
|
@ -97,9 +105,7 @@ const UserExtendedProfile = ({ profile, refreshProfile, client, userID }: Extend
|
||||||
|
|
||||||
const pronouns = ensureArray(profile["io.fsky.nyx.pronouns"]) as PronounSet[]
|
const pronouns = ensureArray(profile["io.fsky.nyx.pronouns"]) as PronounSet[]
|
||||||
const userTimeZone = ensureString(profile["us.cloke.msc4175.tz"])
|
const userTimeZone = ensureString(profile["us.cloke.msc4175.tz"])
|
||||||
return <>
|
return <div className="extended-profile">
|
||||||
<hr/>
|
|
||||||
<div className="extended-profile">
|
|
||||||
{userTimeZone && <ClockElement tz={userTimeZone} />}
|
{userTimeZone && <ClockElement tz={userTimeZone} />}
|
||||||
{userID === client.userID &&
|
{userID === client.userID &&
|
||||||
<SetTimeZoneElement tz={userTimeZone} client={client} refreshProfile={refreshProfile} />}
|
<SetTimeZoneElement tz={userTimeZone} client={client} refreshProfile={refreshProfile} />}
|
||||||
|
@ -108,7 +114,6 @@ const UserExtendedProfile = ({ profile, refreshProfile, client, userID }: Extend
|
||||||
<div>{pronouns.map(pronounSet => ensureString(pronounSet.summary)).join(", ")}</div>
|
<div>{pronouns.map(pronounSet => ensureString(pronounSet.summary)).join(", ")}</div>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UserExtendedProfile
|
export default UserExtendedProfile
|
||||||
|
|
55
web/src/ui/rightpanel/UserIgnoreButton.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 Nexus Nicholson
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import Client from "@/api/client.ts"
|
||||||
|
import { useAccountData } from "@/api/statestore"
|
||||||
|
import { IgnoredUsersEventContent } from "@/api/types"
|
||||||
|
import IgnoreIcon from "@/icons/block.svg?react"
|
||||||
|
|
||||||
|
const UserIgnoreButton = ({ userID, client }: { userID: string; client: Client }) => {
|
||||||
|
const ignoredUsers = useAccountData(client.store, "m.ignored_user_list") as IgnoredUsersEventContent | null
|
||||||
|
|
||||||
|
const isIgnored = Boolean(ignoredUsers?.ignored_users?.[userID])
|
||||||
|
const ignoreUser = () => {
|
||||||
|
if (!window.confirm(`Are you sure you want to ignore ${userID}?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newIgnoredUsers = { ...(ignoredUsers || { ignored_users: {}}) }
|
||||||
|
newIgnoredUsers.ignored_users[userID] = {}
|
||||||
|
client.rpc.setAccountData("m.ignored_user_list", newIgnoredUsers).catch(err => {
|
||||||
|
console.error("Failed to ignore user", err)
|
||||||
|
window.alert(`Failed to ignore ${userID}: ${err}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const unignoreUser = () => {
|
||||||
|
const newIgnoredUsers = { ...(ignoredUsers || { ignored_users: {}}) }
|
||||||
|
delete newIgnoredUsers.ignored_users[userID]
|
||||||
|
client.rpc.setAccountData("m.ignored_user_list", newIgnoredUsers).catch(err => {
|
||||||
|
console.error("Failed to unignore user", err)
|
||||||
|
window.alert(`Failed to unignore ${userID}: ${err}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={"moderation-action " + (isIgnored ? "positive" : "dangerous")}
|
||||||
|
onClick={isIgnored ? unignoreUser : ignoreUser}>
|
||||||
|
<IgnoreIcon/>
|
||||||
|
<span>{isIgnored ? "Unignore" : "Ignore"}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserIgnoreButton
|
|
@ -18,7 +18,7 @@ import { PuffLoader } from "react-spinners"
|
||||||
import { getAvatarURL } from "@/api/media.ts"
|
import { getAvatarURL } from "@/api/media.ts"
|
||||||
import { useRoomMember } from "@/api/statestore"
|
import { useRoomMember } from "@/api/statestore"
|
||||||
import { MemberEventContent, UserID, UserProfile } from "@/api/types"
|
import { MemberEventContent, UserID, UserProfile } from "@/api/types"
|
||||||
import { getLocalpart } from "@/util/validation.ts"
|
import { ensureString, getLocalpart } from "@/util/validation.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import { LightboxContext } from "../modal"
|
import { LightboxContext } from "../modal"
|
||||||
import { RoomContext } from "../roomview/roomcontext.ts"
|
import { RoomContext } from "../roomview/roomcontext.ts"
|
||||||
|
@ -26,6 +26,7 @@ import UserExtendedProfile from "./UserExtendedProfile.tsx"
|
||||||
import DeviceList from "./UserInfoDeviceList.tsx"
|
import DeviceList from "./UserInfoDeviceList.tsx"
|
||||||
import UserInfoError from "./UserInfoError.tsx"
|
import UserInfoError from "./UserInfoError.tsx"
|
||||||
import MutualRooms from "./UserInfoMutualRooms.tsx"
|
import MutualRooms from "./UserInfoMutualRooms.tsx"
|
||||||
|
import UserModeration from "./UserModeration.tsx"
|
||||||
|
|
||||||
interface UserInfoProps {
|
interface UserInfoProps {
|
||||||
userID: UserID
|
userID: UserID
|
||||||
|
@ -50,8 +51,9 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
||||||
)
|
)
|
||||||
}, [userID, client])
|
}, [userID, client])
|
||||||
useEffect(() => refreshProfile(true), [refreshProfile])
|
useEffect(() => refreshProfile(true), [refreshProfile])
|
||||||
|
const displayname = ensureString(member?.displayname)
|
||||||
const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID)
|
|| ensureString(globalProfile?.displayname)
|
||||||
|
|| getLocalpart(userID)
|
||||||
return <>
|
return <>
|
||||||
<div className="avatar-container">
|
<div className="avatar-container">
|
||||||
{member === null && globalProfile === null && errors == null ? <PuffLoader
|
{member === null && globalProfile === null && errors == null ? <PuffLoader
|
||||||
|
@ -60,6 +62,7 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
||||||
className="avatar-loader"
|
className="avatar-loader"
|
||||||
/> : <img
|
/> : <img
|
||||||
className="avatar"
|
className="avatar"
|
||||||
|
// this is a big avatar (236px by default), use full resolution
|
||||||
src={getAvatarURL(userID, member ?? globalProfile)}
|
src={getAvatarURL(userID, member ?? globalProfile)}
|
||||||
onClick={openLightbox}
|
onClick={openLightbox}
|
||||||
alt=""
|
alt=""
|
||||||
|
@ -67,20 +70,13 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
||||||
</div>
|
</div>
|
||||||
<div className="displayname" title={displayname}>{displayname}</div>
|
<div className="displayname" title={displayname}>{displayname}</div>
|
||||||
<div className="userid" title={userID}>{userID}</div>
|
<div className="userid" title={userID}>{userID}</div>
|
||||||
{globalProfile && <UserExtendedProfile
|
<UserExtendedProfile profile={globalProfile} refreshProfile={refreshProfile} client={client} userID={userID}/>
|
||||||
profile={globalProfile} refreshProfile={refreshProfile} client={client} userID={userID}
|
<DeviceList client={client} room={roomCtx?.store} userID={userID}/>
|
||||||
/>}
|
|
||||||
<hr/>
|
|
||||||
{userID !== client.userID && <>
|
{userID !== client.userID && <>
|
||||||
<MutualRooms client={client} userID={userID}/>
|
<MutualRooms client={client} userID={userID}/>
|
||||||
<hr/>
|
<UserModeration client={client} room={roomCtx?.store} member={memberEvt} userID={userID}/>
|
||||||
</>}
|
</>}
|
||||||
<DeviceList client={client} room={roomCtx?.store} userID={userID}/>
|
|
||||||
<hr/>
|
|
||||||
{errors?.length ? <>
|
|
||||||
<UserInfoError errors={errors}/>
|
<UserInfoError errors={errors}/>
|
||||||
<hr/>
|
|
||||||
</> : null}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
118
web/src/ui/rightpanel/UserModeration.tsx
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 Nexus Nicholson
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import { use } from "react"
|
||||||
|
import Client from "@/api/client.ts"
|
||||||
|
import { RoomStateStore } from "@/api/statestore"
|
||||||
|
import { MemDBEvent, MembershipAction } from "@/api/types"
|
||||||
|
import ConfirmWithMessageModal from "../menu/ConfirmWithMessageModal.tsx"
|
||||||
|
import { getPowerLevels } from "../menu/util.ts"
|
||||||
|
import { ModalContext } from "../modal"
|
||||||
|
import StartDMButton from "./StartDMButton.tsx"
|
||||||
|
import UserIgnoreButton from "./UserIgnoreButton.tsx"
|
||||||
|
import BanIcon from "@/icons/gavel.svg?react"
|
||||||
|
import InviteIcon from "@/icons/person-add.svg?react"
|
||||||
|
import KickIcon from "@/icons/person-remove.svg?react"
|
||||||
|
|
||||||
|
interface UserModerationProps {
|
||||||
|
userID: string;
|
||||||
|
client: Client;
|
||||||
|
room: RoomStateStore | undefined;
|
||||||
|
member: MemDBEvent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserModeration = ({ userID, client, member, room }: UserModerationProps) => {
|
||||||
|
const openModal = use(ModalContext)
|
||||||
|
const hasPL = (action: "invite" | "kick" | "ban") => {
|
||||||
|
if (!room) {
|
||||||
|
throw new Error("hasPL called without room")
|
||||||
|
}
|
||||||
|
const [pls, ownPL] = getPowerLevels(room, client)
|
||||||
|
if(action === "invite") {
|
||||||
|
return ownPL >= (pls.invite ?? 0)
|
||||||
|
}
|
||||||
|
const otherUserPL = pls.users?.[userID] ?? pls.users_default ?? 0
|
||||||
|
return ownPL >= (pls[action] ?? 50) && ownPL > otherUserPL
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAction = (action: MembershipAction) => {
|
||||||
|
if (!room) {
|
||||||
|
throw new Error("runAction called without room")
|
||||||
|
}
|
||||||
|
const callback = (reason: string) => {
|
||||||
|
client.rpc.setMembership(room.roomID, userID, action, reason).then(
|
||||||
|
() => console.debug("Actioned", userID),
|
||||||
|
err => {
|
||||||
|
console.error("Failed to action", err)
|
||||||
|
window.alert(`Failed to ${action} ${userID}: ${err}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const titleCasedAction = action.charAt(0).toUpperCase() + action.slice(1)
|
||||||
|
return () => {
|
||||||
|
openModal({
|
||||||
|
dimmed: true,
|
||||||
|
boxed: true,
|
||||||
|
innerBoxClass: "confirm-message-modal",
|
||||||
|
content: <ConfirmWithMessageModal
|
||||||
|
title={`${titleCasedAction} user`}
|
||||||
|
description={<>Are you sure you want to {action} <code>{userID}</code>?</>}
|
||||||
|
placeholder="Reason (optional)"
|
||||||
|
confirmButton={titleCasedAction}
|
||||||
|
onConfirm={callback}
|
||||||
|
/>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const membership = member?.content.membership || "leave"
|
||||||
|
|
||||||
|
return <div className="user-moderation">
|
||||||
|
<h4>Actions</h4>
|
||||||
|
{!room || room.meta.current.dm_user_id !== userID ? <StartDMButton userID={userID} client={client} /> : null}
|
||||||
|
{room && (["knock", "leave"].includes(membership) || !member) && hasPL("invite") && (
|
||||||
|
<button className="moderation-action positive" onClick={runAction("invite")}>
|
||||||
|
<InviteIcon />
|
||||||
|
<span>{membership === "knock" ? "Accept join request" : "Invite"}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{room && ["knock", "invite", "join"].includes(membership) && hasPL("kick") && (
|
||||||
|
<button className="moderation-action dangerous" onClick={runAction("kick")}>
|
||||||
|
<KickIcon />
|
||||||
|
<span>{
|
||||||
|
membership === "join"
|
||||||
|
? "Kick"
|
||||||
|
: membership === "invite"
|
||||||
|
? "Revoke invitation"
|
||||||
|
: "Reject join request"
|
||||||
|
}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{room && membership !== "ban" && hasPL("ban") && (
|
||||||
|
<button className="moderation-action dangerous" onClick={runAction("ban")}>
|
||||||
|
<BanIcon />
|
||||||
|
<span>Ban</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{room && membership === "ban" && hasPL("ban") && (
|
||||||
|
<button className="moderation-action positive" onClick={runAction("unban")}>
|
||||||
|
<BanIcon />
|
||||||
|
<span>Unban</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<UserIgnoreButton userID={userID} client={client} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserModeration
|
55
web/src/ui/rightpanel/WidgetList.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import type { IWidget } from "matrix-widget-api"
|
||||||
|
import { use } from "react"
|
||||||
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
|
import { RoomContext } from "../roomview/roomcontext.ts"
|
||||||
|
|
||||||
|
const WidgetList = () => {
|
||||||
|
const roomCtx = use(RoomContext)
|
||||||
|
const mainScreen = use(MainScreenContext)
|
||||||
|
const widgets = roomCtx?.store.state.get("im.vector.modular.widgets") ?? new Map()
|
||||||
|
const widgetElements = []
|
||||||
|
for (const [stateKey, rowid] of widgets.entries()) {
|
||||||
|
const evt = roomCtx?.store.eventsByRowID.get(rowid)
|
||||||
|
if (!evt || !evt.content.url) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const onClick = () => mainScreen.setRightPanel({
|
||||||
|
type: "widget",
|
||||||
|
info: {
|
||||||
|
id: stateKey,
|
||||||
|
creatorUserId: evt.sender,
|
||||||
|
...evt.content,
|
||||||
|
} as IWidget,
|
||||||
|
})
|
||||||
|
widgetElements.push(<button key={rowid} onClick={onClick}>
|
||||||
|
{evt.content.name || stateKey}
|
||||||
|
</button>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openElementCall = () => {
|
||||||
|
mainScreen.setRightPanel({ type: "element-call" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{widgetElements}
|
||||||
|
<div className="separator" />
|
||||||
|
<button onClick={openElementCall}>Element Call</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WidgetList
|
|
@ -13,14 +13,16 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { JSX, memo, use } from "react"
|
import React, { JSX, memo, use } from "react"
|
||||||
import { getRoomAvatarURL } from "@/api/media.ts"
|
import { getRoomAvatarThumbnailURL } from "@/api/media.ts"
|
||||||
import type { RoomListEntry } from "@/api/statestore"
|
import type { RoomListEntry } from "@/api/statestore"
|
||||||
import type { MemDBEvent, MemberEventContent } from "@/api/types"
|
import type { MemDBEvent, MemberEventContent } from "@/api/types"
|
||||||
import useContentVisibility from "@/util/contentvisibility.ts"
|
import useContentVisibility from "@/util/contentvisibility.ts"
|
||||||
import { getDisplayname } from "@/util/validation.ts"
|
import { getDisplayname } from "@/util/validation.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import MainScreenContext from "../MainScreenContext.ts"
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
|
import { RoomMenu, getModalStyleFromMouse } from "../menu"
|
||||||
|
import { ModalContext } from "../modal"
|
||||||
import UnreadCount from "./UnreadCount.tsx"
|
import UnreadCount from "./UnreadCount.tsx"
|
||||||
|
|
||||||
export interface RoomListEntryProps {
|
export interface RoomListEntryProps {
|
||||||
|
@ -63,7 +65,7 @@ function renderEntry(room: RoomListEntry) {
|
||||||
<img
|
<img
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="avatar room-avatar"
|
className="avatar room-avatar"
|
||||||
src={getRoomAvatarURL(room)}
|
src={getRoomAvatarThumbnailURL(room)}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -77,10 +79,30 @@ function renderEntry(room: RoomListEntry) {
|
||||||
|
|
||||||
const Entry = ({ room, isActive, hidden }: RoomListEntryProps) => {
|
const Entry = ({ room, isActive, hidden }: RoomListEntryProps) => {
|
||||||
const [isVisible, divRef] = useContentVisibility<HTMLDivElement>()
|
const [isVisible, divRef] = useContentVisibility<HTMLDivElement>()
|
||||||
|
const openModal = use(ModalContext)
|
||||||
|
const mainScreen = use(MainScreenContext)
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
const onContextMenu = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const realRoom = client.store.rooms.get(room.room_id)
|
||||||
|
if (!realRoom) {
|
||||||
|
// TODO implement separate menu for invite rooms
|
||||||
|
console.error("Room state store not found for", room.room_id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openModal({
|
||||||
|
content: <RoomMenu
|
||||||
|
room={realRoom}
|
||||||
|
entry={room}
|
||||||
|
style={getModalStyleFromMouse(evt, 6 * 40)}
|
||||||
|
/>,
|
||||||
|
})
|
||||||
|
evt.preventDefault()
|
||||||
|
}
|
||||||
return <div
|
return <div
|
||||||
ref={divRef}
|
ref={divRef}
|
||||||
className={`room-entry ${isActive ? "active" : ""} ${hidden ? "hidden" : ""}`}
|
className={`room-entry ${isActive ? "active" : ""} ${hidden ? "hidden" : ""}`}
|
||||||
onClick={use(MainScreenContext).clickRoom}
|
onClick={mainScreen.clickRoom}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
data-room-id={room.room_id}
|
data-room-id={room.room_id}
|
||||||
>
|
>
|
||||||
{isVisible ? renderEntry(room) : null}
|
{isVisible ? renderEntry(room) : null}
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import Client from "@/api/client.ts"
|
import Client from "@/api/client.ts"
|
||||||
import { getRoomAvatarURL } from "@/api/media.ts"
|
import { getRoomAvatarThumbnailURL } from "@/api/media.ts"
|
||||||
import type { RoomID } from "@/api/types"
|
import type { RoomID } from "@/api/types"
|
||||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
import UnreadCount from "./UnreadCount.tsx"
|
import UnreadCount from "./UnreadCount.tsx"
|
||||||
|
@ -37,7 +37,7 @@ const Space = ({ roomID, client, onClick, isActive, onClickUnread }: SpaceProps)
|
||||||
}
|
}
|
||||||
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={onClick} data-target-space={roomID}>
|
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={onClick} data-target-space={roomID}>
|
||||||
<UnreadCount counts={unreads} space={true} onClick={onClickUnread} />
|
<UnreadCount counts={unreads} space={true} onClick={onClickUnread} />
|
||||||
<img src={getRoomAvatarURL(room)} alt={room.name} title={room.name} className="avatar" />
|
<img src={getRoomAvatarThumbnailURL(room)} alt={room.name} title={room.name} className="avatar" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { use, useEffect, useState } from "react"
|
import { use, useEffect, useState } from "react"
|
||||||
import { ScaleLoader } from "react-spinners"
|
import { ScaleLoader } from "react-spinners"
|
||||||
import { getAvatarURL, getRoomAvatarURL } from "@/api/media.ts"
|
import { getAvatarThumbnailURL, getAvatarURL, getRoomAvatarURL } from "@/api/media.ts"
|
||||||
import { InvitedRoomStore } from "@/api/statestore/invitedroom.ts"
|
import { InvitedRoomStore } from "@/api/statestore/invitedroom.ts"
|
||||||
import { RoomID, RoomSummary } from "@/api/types"
|
import { RoomID, RoomSummary } from "@/api/types"
|
||||||
import { getDisplayname, getServerName } from "@/util/validation.ts"
|
import { getDisplayname, getServerName } from "@/util/validation.ts"
|
||||||
|
@ -90,7 +90,8 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
|
||||||
<img
|
<img
|
||||||
className="small avatar"
|
className="small avatar"
|
||||||
onClick={use(LightboxContext)}
|
onClick={use(LightboxContext)}
|
||||||
src={getAvatarURL(invite.invited_by, invite.inviter_profile)}
|
src={getAvatarThumbnailURL(invite.invited_by, invite.inviter_profile)}
|
||||||
|
data-full-src={getAvatarURL(invite.invited_by, invite.inviter_profile)}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
<span className="inviter-name" title={invite.invited_by}>
|
<span className="inviter-name" title={invite.invited_by}>
|
||||||
|
@ -100,6 +101,7 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
|
||||||
</div> : null}
|
</div> : null}
|
||||||
<h2 className="room-name">{name}</h2>
|
<h2 className="room-name">{name}</h2>
|
||||||
<img
|
<img
|
||||||
|
// this is a big avatar (120px), use full resolution
|
||||||
src={getRoomAvatarURL(invite ?? summary ?? { room_id: roomID })}
|
src={getRoomAvatarURL(invite ?? summary ?? { room_id: roomID })}
|
||||||
className="large avatar"
|
className="large avatar"
|
||||||
onClick={use(LightboxContext)}
|
onClick={use(LightboxContext)}
|
||||||
|
|
|
@ -18,6 +18,13 @@ div.room-view {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> div.room-view-error {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div#mobile-event-menu-container {
|
div#mobile-event-menu-container {
|
||||||
|
|
|
@ -19,6 +19,7 @@ import MessageComposer from "../composer/MessageComposer.tsx"
|
||||||
import TypingNotifications from "../composer/TypingNotifications.tsx"
|
import TypingNotifications from "../composer/TypingNotifications.tsx"
|
||||||
import RightPanel, { RightPanelProps } from "../rightpanel/RightPanel.tsx"
|
import RightPanel, { RightPanelProps } from "../rightpanel/RightPanel.tsx"
|
||||||
import TimelineView from "../timeline/TimelineView.tsx"
|
import TimelineView from "../timeline/TimelineView.tsx"
|
||||||
|
import ErrorBoundary from "../util/ErrorBoundary.tsx"
|
||||||
import RoomViewHeader from "./RoomViewHeader.tsx"
|
import RoomViewHeader from "./RoomViewHeader.tsx"
|
||||||
import { RoomContext, RoomContextData } from "./roomcontext.ts"
|
import { RoomContext, RoomContextData } from "./roomcontext.ts"
|
||||||
import "./RoomView.css"
|
import "./RoomView.css"
|
||||||
|
@ -49,11 +50,13 @@ const RoomView = ({ room, rightPanelResizeHandle, rightPanel }: RoomViewProps) =
|
||||||
}
|
}
|
||||||
return <RoomContext value={roomContextData}>
|
return <RoomContext value={roomContextData}>
|
||||||
<div className="room-view" onClick={onClick}>
|
<div className="room-view" onClick={onClick}>
|
||||||
|
<ErrorBoundary thing="room view" wrapperClassName="room-view-error">
|
||||||
<div id="mobile-event-menu-container"/>
|
<div id="mobile-event-menu-container"/>
|
||||||
<RoomViewHeader room={room}/>
|
<RoomViewHeader room={room}/>
|
||||||
<TimelineView/>
|
<TimelineView/>
|
||||||
<MessageComposer/>
|
<MessageComposer/>
|
||||||
<TypingNotifications/>
|
<TypingNotifications/>
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
{rightPanelResizeHandle}
|
{rightPanelResizeHandle}
|
||||||
{rightPanel && <RightPanel {...rightPanel}/>}
|
{rightPanel && <RightPanel {...rightPanel}/>}
|
||||||
|
|
|
@ -14,17 +14,19 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { use } from "react"
|
import { use } from "react"
|
||||||
import { getRoomAvatarURL } from "@/api/media.ts"
|
import { getRoomAvatarThumbnailURL, getRoomAvatarURL } from "@/api/media.ts"
|
||||||
import { RoomStateStore } from "@/api/statestore"
|
import { RoomStateStore } from "@/api/statestore"
|
||||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
import MainScreenContext from "../MainScreenContext.ts"
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
import { LightboxContext } from "../modal"
|
import { LightboxContext, NestableModalContext } from "../modal"
|
||||||
import { ModalContext } from "../modal"
|
import RoomStateExplorer from "../settings/RoomStateExplorer.tsx"
|
||||||
import SettingsView from "../settings/SettingsView.tsx"
|
import SettingsView from "../settings/SettingsView.tsx"
|
||||||
import BackIcon from "@/icons/back.svg?react"
|
import BackIcon from "@/icons/back.svg?react"
|
||||||
|
import CodeIcon from "@/icons/code.svg?react"
|
||||||
import PeopleIcon from "@/icons/group.svg?react"
|
import PeopleIcon from "@/icons/group.svg?react"
|
||||||
import PinIcon from "@/icons/pin.svg?react"
|
import PinIcon from "@/icons/pin.svg?react"
|
||||||
import SettingsIcon from "@/icons/settings.svg?react"
|
import SettingsIcon from "@/icons/settings.svg?react"
|
||||||
|
import WidgetIcon from "@/icons/widgets.svg?react"
|
||||||
import "./RoomViewHeader.css"
|
import "./RoomViewHeader.css"
|
||||||
|
|
||||||
interface RoomViewHeaderProps {
|
interface RoomViewHeaderProps {
|
||||||
|
@ -34,7 +36,7 @@ interface RoomViewHeaderProps {
|
||||||
const RoomViewHeader = ({ room }: RoomViewHeaderProps) => {
|
const RoomViewHeader = ({ room }: RoomViewHeaderProps) => {
|
||||||
const roomMeta = useEventAsState(room.meta)
|
const roomMeta = useEventAsState(room.meta)
|
||||||
const mainScreen = use(MainScreenContext)
|
const mainScreen = use(MainScreenContext)
|
||||||
const openModal = use(ModalContext)
|
const openModal = use(NestableModalContext)
|
||||||
const openSettings = () => {
|
const openSettings = () => {
|
||||||
openModal({
|
openModal({
|
||||||
dimmed: true,
|
dimmed: true,
|
||||||
|
@ -43,12 +45,21 @@ const RoomViewHeader = ({ room }: RoomViewHeaderProps) => {
|
||||||
content: <SettingsView room={room} />,
|
content: <SettingsView room={room} />,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const openRoomStateExplorer = () => {
|
||||||
|
openModal({
|
||||||
|
dimmed: true,
|
||||||
|
boxed: true,
|
||||||
|
innerBoxClass: "room-state-explorer-box",
|
||||||
|
content: <RoomStateExplorer room={room} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
return <div className="room-header">
|
return <div className="room-header">
|
||||||
<button className="back" onClick={mainScreen.clearActiveRoom}><BackIcon/></button>
|
<button className="back" onClick={mainScreen.clearActiveRoom}><BackIcon/></button>
|
||||||
<img
|
<img
|
||||||
className="avatar"
|
className="avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getRoomAvatarURL(roomMeta)}
|
src={getRoomAvatarThumbnailURL(roomMeta)}
|
||||||
|
data-full-src={getRoomAvatarURL(roomMeta)}
|
||||||
onClick={use(LightboxContext)}
|
onClick={use(LightboxContext)}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
|
@ -71,6 +82,12 @@ const RoomViewHeader = ({ room }: RoomViewHeaderProps) => {
|
||||||
onClick={mainScreen.clickRightPanelOpener}
|
onClick={mainScreen.clickRightPanelOpener}
|
||||||
title="Room Members"
|
title="Room Members"
|
||||||
><PeopleIcon/></button>
|
><PeopleIcon/></button>
|
||||||
|
<button
|
||||||
|
data-target-panel="widgets"
|
||||||
|
onClick={mainScreen.clickRightPanelOpener}
|
||||||
|
title="Widgets in room"
|
||||||
|
><WidgetIcon/></button>
|
||||||
|
<button title="Explore room state" onClick={openRoomStateExplorer}><CodeIcon/></button>
|
||||||
<button title="Room Settings" onClick={openSettings}><SettingsIcon/></button>
|
<button title="Room Settings" onClick={openSettings}><SettingsIcon/></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
90
web/src/ui/settings/RoomStateExplorer.css
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
div.state-explorer-box {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.state-explorer {
|
||||||
|
width: min(50rem, 80vw);
|
||||||
|
max-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.state-button-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
padding: .5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div.nav-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top: .5rem;
|
||||||
|
|
||||||
|
> div.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
> label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button {
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.state-event-view {
|
||||||
|
> div.state-event-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
> textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: .5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
outline: none;
|
||||||
|
border-radius: .5rem;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div.state-header > div.new-event-type {
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
|
||||||
|
> input {
|
||||||
|
flex: 1;
|
||||||
|
padding: .5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: .5rem;
|
||||||
|
outline: none;
|
||||||
|
font-family: var(--monospace-font-stack);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
322
web/src/ui/settings/RoomStateExplorer.tsx
Normal file
|
@ -0,0 +1,322 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import { use, useCallback, useState } from "react"
|
||||||
|
import { RoomStateStore, useRoomState } from "@/api/statestore"
|
||||||
|
import ClientContext from "../ClientContext.ts"
|
||||||
|
import JSONView from "../util/JSONView"
|
||||||
|
import "./RoomStateExplorer.css"
|
||||||
|
|
||||||
|
interface StateExplorerProps {
|
||||||
|
room: RoomStateStore
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateEventViewProps {
|
||||||
|
room: RoomStateStore
|
||||||
|
type?: string
|
||||||
|
stateKey?: string
|
||||||
|
onBack: () => void
|
||||||
|
onDone?: (type: string, stateKey: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewMessageEventViewProps {
|
||||||
|
room: RoomStateStore
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateKeyListProps {
|
||||||
|
room: RoomStateStore
|
||||||
|
type: string
|
||||||
|
onSelectStateKey: (stateKey: string) => void
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const StateEventView = ({ room, type, stateKey, onBack, onDone }: StateEventViewProps) => {
|
||||||
|
const event = useRoomState(room, type, stateKey)
|
||||||
|
const isNewEvent = type === undefined
|
||||||
|
const [editingContent, setEditingContent] = useState<string | null>(isNewEvent ? "{\n\n}" : null)
|
||||||
|
const [newType, setNewType] = useState<string>("")
|
||||||
|
const [newStateKey, setNewStateKey] = useState<string>("")
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
|
||||||
|
const sendEdit = () => {
|
||||||
|
let parsedContent
|
||||||
|
try {
|
||||||
|
parsedContent = JSON.parse(editingContent || "{}")
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(`Failed to parse JSON: ${err}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.rpc.setState(
|
||||||
|
room.roomID,
|
||||||
|
type ?? newType,
|
||||||
|
stateKey ?? newStateKey,
|
||||||
|
parsedContent,
|
||||||
|
).then(
|
||||||
|
() => {
|
||||||
|
console.log("Updated room state", room.roomID, type, stateKey)
|
||||||
|
setEditingContent(null)
|
||||||
|
if (isNewEvent) {
|
||||||
|
onDone?.(newType, newStateKey)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
console.error("Failed to update room state", err)
|
||||||
|
window.alert(`Failed to update room state: ${err}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const stopEdit = () => setEditingContent(null)
|
||||||
|
const startEdit = () => setEditingContent(JSON.stringify(event?.content || {}, null, 4))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="state-explorer state-event-view">
|
||||||
|
<div className="state-header">
|
||||||
|
{isNewEvent
|
||||||
|
? <>
|
||||||
|
<h3>New state event</h3>
|
||||||
|
<div className="new-event-type">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={newType}
|
||||||
|
onChange={evt => setNewType(evt.target.value)}
|
||||||
|
placeholder="Event type"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newStateKey}
|
||||||
|
onChange={evt => setNewStateKey(evt.target.value)}
|
||||||
|
placeholder="State key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
: <h3><code>{type}</code> ({stateKey ? <code>{stateKey}</code> : "no state key"})</h3>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="state-event-content">
|
||||||
|
{editingContent !== null
|
||||||
|
? <textarea rows={10} value={editingContent} onChange={evt => setEditingContent(evt.target.value)}/>
|
||||||
|
: <JSONView data={event}/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="nav-buttons">
|
||||||
|
{editingContent !== null ? <>
|
||||||
|
<button onClick={isNewEvent ? onBack : stopEdit}>Back</button>
|
||||||
|
<div className="spacer"/>
|
||||||
|
<button onClick={sendEdit}>Send</button>
|
||||||
|
</> : <>
|
||||||
|
<button onClick={onBack}>Back</button>
|
||||||
|
<div className="spacer"/>
|
||||||
|
<button onClick={startEdit}>Edit</button>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewMessageEventView = ({ room, onBack }: NewMessageEventViewProps) => {
|
||||||
|
const [content, setContent] = useState<string>("{\n\n}")
|
||||||
|
const [type, setType] = useState<string>("")
|
||||||
|
const [disableEncryption, setDisableEncryption] = useState<boolean>(false)
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
|
||||||
|
const sendEvent = () => {
|
||||||
|
let parsedContent
|
||||||
|
try {
|
||||||
|
parsedContent = JSON.parse(content || "{}")
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(`Failed to parse JSON: ${err}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.sendEvent(room.roomID, type, parsedContent, disableEncryption).then(
|
||||||
|
() => {
|
||||||
|
console.log("Successfully sent message event", room.roomID, type)
|
||||||
|
onBack()
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
console.error("Failed to send message event", err)
|
||||||
|
window.alert(`Failed to send message event: ${err}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="state-explorer state-event-view">
|
||||||
|
<div className="state-header">
|
||||||
|
<h3>New message event</h3>
|
||||||
|
<div className="new-event-type">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={type}
|
||||||
|
onChange={evt => setType(evt.target.value)}
|
||||||
|
placeholder="Event type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="state-event-content">
|
||||||
|
<textarea rows={10} value={content} onChange={evt => setContent(evt.target.value)}/>
|
||||||
|
</div>
|
||||||
|
<div className="nav-buttons">
|
||||||
|
<button onClick={onBack}>Back</button>
|
||||||
|
<button onClick={sendEvent}>Send</button>
|
||||||
|
{room.meta.current.encryption_event ? <label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={disableEncryption}
|
||||||
|
onChange={evt => setDisableEncryption(evt.target.checked)}
|
||||||
|
/>
|
||||||
|
Disable encryption
|
||||||
|
</label> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StateKeyList = ({ room, type, onSelectStateKey, onBack }: StateKeyListProps) => {
|
||||||
|
const stateMap = room.state.get(type)
|
||||||
|
return (
|
||||||
|
<div className="state-explorer state-key-list">
|
||||||
|
<div className="state-header">
|
||||||
|
<h3>State keys under <code>{type}</code></h3>
|
||||||
|
</div>
|
||||||
|
<div className="state-button-list">
|
||||||
|
{Array.from(stateMap?.keys().map(stateKey => (
|
||||||
|
<button key={stateKey} onClick={() => onSelectStateKey(stateKey)}>
|
||||||
|
{stateKey ? <code>{stateKey}</code> : "<empty>"}
|
||||||
|
</button>
|
||||||
|
)) ?? [])}
|
||||||
|
</div>
|
||||||
|
<div className="nav-buttons">
|
||||||
|
<button onClick={onBack}>Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StateExplorer = ({ room }: StateExplorerProps) => {
|
||||||
|
const [creatingNew, setCreatingNew] = useState<"message" | "state" | null>(null)
|
||||||
|
const [selectedType, setSelectedType] = useState<string | null>(null)
|
||||||
|
const [selectedStateKey, setSelectedStateKey] = useState<string | null>(null)
|
||||||
|
const [loadingState, setLoadingState] = useState(false)
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
|
||||||
|
const handleTypeSelect = (type: string) => {
|
||||||
|
const stateKeysMap = room.state.get(type)
|
||||||
|
if (!stateKeysMap) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateKeys = Array.from(stateKeysMap.keys())
|
||||||
|
if (stateKeys.length === 1 && stateKeys[0] === "") {
|
||||||
|
// If there's only one state event with an empty key, view it directly
|
||||||
|
setSelectedType(type)
|
||||||
|
setSelectedStateKey("")
|
||||||
|
} else {
|
||||||
|
// Otherwise show the list of state keys
|
||||||
|
setSelectedType(type)
|
||||||
|
setSelectedStateKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (creatingNew) {
|
||||||
|
setCreatingNew(null)
|
||||||
|
} else if (selectedStateKey !== null && selectedType !== null) {
|
||||||
|
setSelectedStateKey(null)
|
||||||
|
const stateKeysMap = room.state.get(selectedType)
|
||||||
|
if (stateKeysMap?.size === 1 && stateKeysMap.has("")) {
|
||||||
|
setSelectedType(null)
|
||||||
|
}
|
||||||
|
} else if (selectedType !== null) {
|
||||||
|
setSelectedType(null)
|
||||||
|
}
|
||||||
|
}, [selectedType, selectedStateKey, creatingNew, room])
|
||||||
|
const handleNewEventDone = useCallback((type: string, stateKey?: string) => {
|
||||||
|
setCreatingNew(null)
|
||||||
|
if (stateKey !== undefined) {
|
||||||
|
setSelectedType(type)
|
||||||
|
setSelectedStateKey(stateKey)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (creatingNew === "state") {
|
||||||
|
return <StateEventView
|
||||||
|
room={room}
|
||||||
|
onBack={handleBack}
|
||||||
|
onDone={handleNewEventDone}
|
||||||
|
/>
|
||||||
|
} else if (creatingNew === "message") {
|
||||||
|
return <NewMessageEventView
|
||||||
|
room={room}
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
} else if (selectedType !== null && selectedStateKey !== null) {
|
||||||
|
return <StateEventView
|
||||||
|
room={room}
|
||||||
|
type={selectedType}
|
||||||
|
stateKey={selectedStateKey}
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
} else if (selectedType !== null) {
|
||||||
|
return <StateKeyList
|
||||||
|
room={room}
|
||||||
|
type={selectedType}
|
||||||
|
onSelectStateKey={setSelectedStateKey}
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
const loadRoomState = () => {
|
||||||
|
setLoadingState(true)
|
||||||
|
client.loadRoomState(room.roomID, {
|
||||||
|
omitMembers: false,
|
||||||
|
refetch: room.stateLoaded && room.fullMembersLoaded,
|
||||||
|
}).then(
|
||||||
|
() => {
|
||||||
|
console.log("Room state loaded from devtools", room.roomID)
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
console.error("Failed to fetch room state", err)
|
||||||
|
window.alert(`Failed to fetch room state: ${err}`)
|
||||||
|
},
|
||||||
|
).finally(() => setLoadingState(false))
|
||||||
|
}
|
||||||
|
return <div className="state-explorer">
|
||||||
|
<h3>Room State Explorer</h3>
|
||||||
|
<div className="state-button-list">
|
||||||
|
{Array.from(room.state?.keys().map(type => (
|
||||||
|
<button key={type} onClick={() => handleTypeSelect(type)}>
|
||||||
|
<code>{type}</code>
|
||||||
|
</button>
|
||||||
|
)) ?? [])}
|
||||||
|
</div>
|
||||||
|
<div className="nav-buttons">
|
||||||
|
<button onClick={loadRoomState} disabled={loadingState}>
|
||||||
|
{room.stateLoaded
|
||||||
|
? room.fullMembersLoaded
|
||||||
|
? "Resync full room state"
|
||||||
|
: "Load room members"
|
||||||
|
: "Load room state and members"}
|
||||||
|
</button>
|
||||||
|
<div className="spacer"/>
|
||||||
|
<button onClick={() => setCreatingNew("message")}>Send new message event</button>
|
||||||
|
<button onClick={() => setCreatingNew("state")}>Send new state event</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StateExplorer
|
|
@ -14,15 +14,22 @@ div.settings-view {
|
||||||
text-wrap: nowrap;
|
text-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.leave-room {
|
div.room-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
|
||||||
|
button {
|
||||||
padding: .5rem 1rem;
|
padding: .5rem 1rem;
|
||||||
|
|
||||||
|
&.leave-room {
|
||||||
&:hover, &:focus {
|
&:hover, &:focus {
|
||||||
background-color: var(--error-color);
|
background-color: var(--error-color);
|
||||||
color: var(--inverted-text-color);
|
color: var(--inverted-text-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
width: min(60rem, 80vw);
|
width: min(60rem, 80vw);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -113,6 +120,39 @@ div.settings-view {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> div.key-export {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
margin: 0 .5rem;
|
||||||
|
max-width: 25rem;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: .5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: .5rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
|
||||||
|
&[type="file"] {
|
||||||
|
padding: .25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div.export-buttons, > form.import-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
|
||||||
|
> form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> div.misc-buttons > button {
|
> div.misc-buttons > button {
|
||||||
padding: .5rem 1rem;
|
padding: .5rem 1rem;
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -126,4 +166,9 @@ div.settings-view {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> hr {
|
||||||
|
width: 100%;
|
||||||
|
opacity: .2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
import { Suspense, lazy, use, useCallback, useRef, useState } from "react"
|
import { Suspense, lazy, use, useCallback, useRef, useState } from "react"
|
||||||
import { ScaleLoader } from "react-spinners"
|
import { ScaleLoader } from "react-spinners"
|
||||||
import Client from "@/api/client.ts"
|
import Client from "@/api/client.ts"
|
||||||
import { getRoomAvatarURL } from "@/api/media.ts"
|
import { getRoomAvatarThumbnailURL, getRoomAvatarURL } from "@/api/media.ts"
|
||||||
import { RoomStateStore, usePreferences } from "@/api/statestore"
|
import { RoomStateStore, usePreferences } from "@/api/statestore"
|
||||||
import {
|
import {
|
||||||
Preference,
|
Preference,
|
||||||
|
@ -29,10 +29,10 @@ import {
|
||||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
import useEvent from "@/util/useEvent.ts"
|
import useEvent from "@/util/useEvent.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import { LightboxContext } from "../modal"
|
import { LightboxContext, ModalCloseContext, ModalContext } from "../modal"
|
||||||
import { ModalCloseContext } from "../modal"
|
|
||||||
import JSONView from "../util/JSONView.tsx"
|
import JSONView from "../util/JSONView.tsx"
|
||||||
import Toggle from "../util/Toggle.tsx"
|
import Toggle from "../util/Toggle.tsx"
|
||||||
|
import RoomStateExplorer from "./RoomStateExplorer.tsx"
|
||||||
import CloseIcon from "@/icons/close.svg?react"
|
import CloseIcon from "@/icons/close.svg?react"
|
||||||
import "./SettingsView.css"
|
import "./SettingsView.css"
|
||||||
|
|
||||||
|
@ -284,10 +284,54 @@ const AppliedSettingsView = ({ room }: SettingsViewProps) => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KeyExportView = ({ room }: SettingsViewProps) => {
|
||||||
|
const [passphrase, setPassphrase] = useState("")
|
||||||
|
const [hasFile, setHasFile] = useState(false)
|
||||||
|
return <div className="key-export">
|
||||||
|
<h3>Key export/import</h3>
|
||||||
|
<input
|
||||||
|
className="passphrase"
|
||||||
|
type="password"
|
||||||
|
value={passphrase}
|
||||||
|
onChange={evt => setPassphrase(evt.target.value)}
|
||||||
|
placeholder="Passphrase"
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
className="import-buttons"
|
||||||
|
action="_gomuks/keys/import"
|
||||||
|
encType="multipart/form-data"
|
||||||
|
method="post"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<input type="password" name="passphrase" hidden readOnly value={passphrase} />
|
||||||
|
<input
|
||||||
|
className="import-file"
|
||||||
|
type="file"
|
||||||
|
accept="text/plain"
|
||||||
|
name="export"
|
||||||
|
defaultValue=""
|
||||||
|
onChange={evt => setHasFile(!!evt.target.files?.length)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={passphrase == "" || !hasFile}>Import keys</button>
|
||||||
|
</form>
|
||||||
|
<div className="export-buttons">
|
||||||
|
<form action="_gomuks/keys/export" method="post" target="_blank">
|
||||||
|
<input type="password" name="passphrase" hidden readOnly value={passphrase} />
|
||||||
|
<button type="submit" disabled={passphrase == ""}>Export all keys</button>
|
||||||
|
</form>
|
||||||
|
<form action={`_gomuks/keys/export/${encodeURIComponent(room.roomID)}`} method="post" target="_blank">
|
||||||
|
<input type="password" name="passphrase" hidden readOnly value={passphrase} />
|
||||||
|
<button type="submit" disabled={passphrase == ""}>Export room keys</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
const SettingsView = ({ room }: SettingsViewProps) => {
|
const SettingsView = ({ room }: SettingsViewProps) => {
|
||||||
const roomMeta = useEventAsState(room.meta)
|
const roomMeta = useEventAsState(room.meta)
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const closeModal = use(ModalCloseContext)
|
const closeModal = use(ModalCloseContext)
|
||||||
|
const openModal = use(ModalContext)
|
||||||
const setPref = useCallback((
|
const setPref = useCallback((
|
||||||
context: PreferenceContext, key: keyof Preferences, value: PreferenceValueType | undefined,
|
context: PreferenceContext, key: keyof Preferences, value: PreferenceValueType | undefined,
|
||||||
) => {
|
) => {
|
||||||
|
@ -334,6 +378,14 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const openDevtools = () => {
|
||||||
|
openModal({
|
||||||
|
dimmed: true,
|
||||||
|
boxed: true,
|
||||||
|
innerBoxClass: "state-explorer-box",
|
||||||
|
content: <RoomStateExplorer room={room} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
const onClickOpenCSSApp = () => {
|
const onClickOpenCSSApp = () => {
|
||||||
client.rpc.requestOpenIDToken().then(
|
client.rpc.requestOpenIDToken().then(
|
||||||
resp => window.open(
|
resp => window.open(
|
||||||
|
@ -355,7 +407,8 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
||||||
<img
|
<img
|
||||||
className="avatar large"
|
className="avatar large"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getRoomAvatarURL(roomMeta)}
|
src={getRoomAvatarThumbnailURL(roomMeta)}
|
||||||
|
data-full-src={getRoomAvatarURL(roomMeta)}
|
||||||
onClick={use(LightboxContext)}
|
onClick={use(LightboxContext)}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
|
@ -363,7 +416,10 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
||||||
{roomMeta.name && <div className="room-name">{roomMeta.name}</div>}
|
{roomMeta.name && <div className="room-name">{roomMeta.name}</div>}
|
||||||
<code>{room.roomID}</code>
|
<code>{room.roomID}</code>
|
||||||
<div>{roomMeta.topic}</div>
|
<div>{roomMeta.topic}</div>
|
||||||
|
<div className="room-buttons">
|
||||||
<button className="leave-room" onClick={onClickLeave}>Leave room</button>
|
<button className="leave-room" onClick={onClickLeave}>Leave room</button>
|
||||||
|
<button className="devtools" onClick={openDevtools}>Explore room state</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
|
@ -392,6 +448,9 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
||||||
</table>
|
</table>
|
||||||
<CustomCSSInput setPref={setPref} room={room} />
|
<CustomCSSInput setPref={setPref} room={room} />
|
||||||
<AppliedSettingsView room={room} />
|
<AppliedSettingsView room={room} />
|
||||||
|
<hr/>
|
||||||
|
<KeyExportView room={room} />
|
||||||
|
<hr/>
|
||||||
<div className="misc-buttons">
|
<div className="misc-buttons">
|
||||||
<button onClick={onClickOpenCSSApp}>Sign into css.gomuks.app</button>
|
<button onClick={onClickOpenCSSApp}>Sign into css.gomuks.app</button>
|
||||||
{window.Notification && !window.gomuksAndroid && <button onClick={client.requestNotificationPermission}>
|
{window.Notification && !window.gomuksAndroid && <button onClick={client.requestNotificationPermission}>
|
||||||
|
|
20
web/src/ui/timeline/EventEditHistory.css
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
div.event-edit-history-wrapper {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.event-edit-history-modal {
|
||||||
|
--timeline-status-size: 0;
|
||||||
|
--timeline-horizontal-padding: 0;
|
||||||
|
min-width: 20rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
@media screen and (max-width: 30rem) {
|
||||||
|
min-width: 100%;
|
||||||
|
padding: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
81
web/src/ui/timeline/EventEditHistory.tsx
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import { use, useEffect, useState } from "react"
|
||||||
|
import { ScaleLoader } from "react-spinners"
|
||||||
|
import { MemDBEvent } from "@/api/types"
|
||||||
|
import ClientContext from "../ClientContext.ts"
|
||||||
|
import { RoomContext, RoomContextData } from "../roomview/roomcontext.ts"
|
||||||
|
import TimelineEvent from "./TimelineEvent.tsx"
|
||||||
|
import "./EventEditHistory.css"
|
||||||
|
|
||||||
|
interface EventEditHistoryProps {
|
||||||
|
evt: MemDBEvent
|
||||||
|
roomCtx: RoomContextData
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventEditHistory = ({ evt, roomCtx }: EventEditHistoryProps) => {
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
const [revisions, setRevisions] = useState<MemDBEvent[]>([])
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
setError("")
|
||||||
|
setRevisions([])
|
||||||
|
client.getRelatedEvents(roomCtx.store, evt.event_id, "m.replace").then(
|
||||||
|
edits => {
|
||||||
|
setRevisions([{
|
||||||
|
...evt,
|
||||||
|
content: evt.orig_content ?? evt.content,
|
||||||
|
local_content: evt.orig_local_content ?? evt.local_content,
|
||||||
|
last_edit: undefined,
|
||||||
|
reactions: undefined,
|
||||||
|
orig_content: undefined,
|
||||||
|
orig_local_content: undefined,
|
||||||
|
}, ...edits.map(editEvt => ({
|
||||||
|
...editEvt,
|
||||||
|
content: editEvt.content["m.new_content"] ?? editEvt.content,
|
||||||
|
orig_content: editEvt.content,
|
||||||
|
relation_type: undefined,
|
||||||
|
reactions: undefined,
|
||||||
|
}))])
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
console.error("Failed to get event edit history", err)
|
||||||
|
setError(`${err}`)
|
||||||
|
},
|
||||||
|
).finally(() => setLoading(false))
|
||||||
|
}, [client, roomCtx, evt])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <ScaleLoader color="var(--primary-color)"/>
|
||||||
|
} else if (error) {
|
||||||
|
return <div>Failed to load :( {error}</div>
|
||||||
|
}
|
||||||
|
return <>
|
||||||
|
<RoomContext value={roomCtx}>
|
||||||
|
<p>Event has {revisions.length} revisions</p>
|
||||||
|
{revisions.map((rev, i) => <TimelineEvent
|
||||||
|
key={rev.rowid}
|
||||||
|
evt={rev}
|
||||||
|
prevEvt={revisions[i-1] ?? null}
|
||||||
|
editHistoryView={true}
|
||||||
|
/>)}
|
||||||
|
</RoomContext>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventEditHistory
|
|
@ -14,7 +14,7 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { use } from "react"
|
import { use } from "react"
|
||||||
import { getAvatarURL } from "@/api/media.ts"
|
import { getAvatarThumbnailURL } from "@/api/media.ts"
|
||||||
import { RoomStateStore, useMultipleRoomMembers, useReadReceipts } from "@/api/statestore"
|
import { RoomStateStore, useMultipleRoomMembers, useReadReceipts } from "@/api/statestore"
|
||||||
import { EventID } from "@/api/types"
|
import { EventID } from "@/api/types"
|
||||||
import { humanJoin } from "@/util/join.ts"
|
import { humanJoin } from "@/util/join.ts"
|
||||||
|
@ -37,7 +37,7 @@ const ReadReceipts = ({ room, eventID }: { room: RoomStateStore, eventID: EventI
|
||||||
key={userID}
|
key={userID}
|
||||||
className="small avatar"
|
className="small avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getAvatarURL(userID, member)}
|
src={getAvatarThumbnailURL(userID, member)}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
})
|
})
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { use } from "react"
|
import { use } from "react"
|
||||||
import { getAvatarURL, getUserColorIndex } from "@/api/media.ts"
|
import { getAvatarThumbnailURL, getUserColorIndex } from "@/api/media.ts"
|
||||||
import { RoomStateStore, useRoomEvent, useRoomMember } from "@/api/statestore"
|
import { RoomStateStore, useRoomEvent, useRoomMember } from "@/api/statestore"
|
||||||
import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
|
import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
|
||||||
import { getDisplayname } from "@/util/validation.ts"
|
import { getDisplayname } from "@/util/validation.ts"
|
||||||
|
@ -39,6 +39,8 @@ interface ReplyBodyProps {
|
||||||
onSetSilent?: (evt: React.MouseEvent) => void
|
onSetSilent?: (evt: React.MouseEvent) => void
|
||||||
isExplicitInThread?: boolean
|
isExplicitInThread?: boolean
|
||||||
onSetExplicitInThread?: (evt: React.MouseEvent) => void
|
onSetExplicitInThread?: (evt: React.MouseEvent) => void
|
||||||
|
startNewThread?: boolean
|
||||||
|
onSetStartNewThread?: (evt: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReplyIDBodyProps {
|
interface ReplyIDBodyProps {
|
||||||
|
@ -83,7 +85,10 @@ const onClickReply = (evt: React.MouseEvent) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReplyBody = ({
|
export const ReplyBody = ({
|
||||||
room, event, onClose, isThread, isEditing, isSilent, onSetSilent, isExplicitInThread, onSetExplicitInThread, small,
|
room, event, onClose, isThread, isEditing, small,
|
||||||
|
isSilent, onSetSilent,
|
||||||
|
isExplicitInThread, onSetExplicitInThread,
|
||||||
|
startNewThread, onSetStartNewThread,
|
||||||
}: ReplyBodyProps) => {
|
}: ReplyBodyProps) => {
|
||||||
const client = use(ClientContext)
|
const client = use(ClientContext)
|
||||||
const memberEvt = useRoomMember(client, room, event.sender)
|
const memberEvt = useRoomMember(client, room, event.sender)
|
||||||
|
@ -124,7 +129,7 @@ export const ReplyBody = ({
|
||||||
<img
|
<img
|
||||||
className="small avatar"
|
className="small avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getAvatarURL(perMessageSender?.id ?? event.sender, renderMemberEvtContent)}
|
src={getAvatarThumbnailURL(perMessageSender?.id ?? event.sender, renderMemberEvtContent)}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -164,6 +169,16 @@ export const ReplyBody = ({
|
||||||
>
|
>
|
||||||
{isExplicitInThread ? <ReplyIcon /> : <ThreadIcon />}
|
{isExplicitInThread ? <ReplyIcon /> : <ThreadIcon />}
|
||||||
</TooltipButton>}
|
</TooltipButton>}
|
||||||
|
{!isThread && onSetStartNewThread && <TooltipButton
|
||||||
|
tooltipText={startNewThread
|
||||||
|
? "Click to reply in main timeline instead of starting a new thread"
|
||||||
|
: "Click to start a new thread instead of replying"}
|
||||||
|
tooltipDirection="left"
|
||||||
|
className="thread-explicit-reply"
|
||||||
|
onClick={onSetStartNewThread}
|
||||||
|
>
|
||||||
|
{startNewThread ? <ThreadIcon /> : <ReplyIcon />}
|
||||||
|
</TooltipButton>}
|
||||||
{onClose && <button className="close-reply" onClick={onClose}><CloseIcon/></button>}
|
{onClose && <button className="close-reply" onClick={onClose}><CloseIcon/></button>}
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,7 +2,7 @@ div.timeline-event {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 var(--timeline-horizontal-padding);
|
padding: var(--timeline-vertical-padding) var(--timeline-horizontal-padding);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template:
|
grid-template:
|
||||||
"cmc cmc cmc empty" 0
|
"cmc cmc cmc empty" 0
|
||||||
|
@ -79,7 +79,7 @@ div.timeline-event {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> span.event-time, > span.event-edited {
|
> span.event-time {
|
||||||
font-size: .7rem;
|
font-size: .7rem;
|
||||||
color: var(--secondary-text-color);
|
color: var(--secondary-text-color);
|
||||||
}
|
}
|
||||||
|
@ -100,6 +100,13 @@ div.timeline-event {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
contain: content;
|
contain: content;
|
||||||
|
|
||||||
|
> div.event-edited {
|
||||||
|
font-size: .7rem;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
user-select: none;
|
||||||
|
cursor: var(--clickable-cursor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> div.event-send-status {
|
> div.event-send-status {
|
||||||
|
|
|
@ -15,20 +15,21 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import React, { JSX, use, useState } from "react"
|
import React, { JSX, use, useState } from "react"
|
||||||
import { createPortal } from "react-dom"
|
import { createPortal } from "react-dom"
|
||||||
import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
|
import { getAvatarThumbnailURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
|
||||||
import { useRoomMember } from "@/api/statestore"
|
import { useRoomMember } from "@/api/statestore"
|
||||||
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
|
import { MemDBEvent, UnreadType, UserProfile } from "@/api/types"
|
||||||
import { isMobileDevice } from "@/util/ismobile.ts"
|
import { isMobileDevice } from "@/util/ismobile.ts"
|
||||||
import { getDisplayname, isEventID } from "@/util/validation.ts"
|
import { getDisplayname, isEventID } from "@/util/validation.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import MainScreenContext from "../MainScreenContext.ts"
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
import { ModalContext } from "../modal"
|
import { EventFixedMenu, EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "../menu"
|
||||||
|
import { ModalContext, NestableModalContext } from "../modal"
|
||||||
import { useRoomContext } from "../roomview/roomcontext.ts"
|
import { useRoomContext } from "../roomview/roomcontext.ts"
|
||||||
|
import EventEditHistory from "./EventEditHistory.tsx"
|
||||||
import ReadReceipts from "./ReadReceipts.tsx"
|
import ReadReceipts from "./ReadReceipts.tsx"
|
||||||
import { ReplyIDBody } from "./ReplyBody.tsx"
|
import { ReplyIDBody } from "./ReplyBody.tsx"
|
||||||
import URLPreviews from "./URLPreviews.tsx"
|
import URLPreviews from "./URLPreviews.tsx"
|
||||||
import { ContentErrorBoundary, HiddenEvent, getBodyType, getPerMessageProfile, isSmallEvent } from "./content"
|
import { ContentErrorBoundary, HiddenEvent, getBodyType, getPerMessageProfile, isSmallEvent } from "./content"
|
||||||
import { EventFixedMenu, EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
|
|
||||||
import ErrorIcon from "@/icons/error.svg?react"
|
import ErrorIcon from "@/icons/error.svg?react"
|
||||||
import PendingIcon from "@/icons/pending.svg?react"
|
import PendingIcon from "@/icons/pending.svg?react"
|
||||||
import SentIcon from "@/icons/sent.svg?react"
|
import SentIcon from "@/icons/sent.svg?react"
|
||||||
|
@ -40,6 +41,7 @@ export interface TimelineEventProps {
|
||||||
disableMenu?: boolean
|
disableMenu?: boolean
|
||||||
smallReplies?: boolean
|
smallReplies?: boolean
|
||||||
isFocused?: boolean
|
isFocused?: boolean
|
||||||
|
editHistoryView?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
|
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
|
||||||
|
@ -75,11 +77,14 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: TimelineEventProps) => {
|
const TimelineEvent = ({
|
||||||
|
evt, prevEvt, disableMenu, smallReplies, isFocused, editHistoryView,
|
||||||
|
}: TimelineEventProps) => {
|
||||||
const roomCtx = useRoomContext()
|
const roomCtx = useRoomContext()
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const mainScreen = use(MainScreenContext)
|
const mainScreen = use(MainScreenContext)
|
||||||
const openModal = use(ModalContext)
|
const openModal = use(ModalContext)
|
||||||
|
const openNestableModal = use(NestableModalContext)
|
||||||
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
|
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
|
||||||
const onContextMenu = (mouseEvt: React.MouseEvent) => {
|
const onContextMenu = (mouseEvt: React.MouseEvent) => {
|
||||||
const targetElem = mouseEvt.target as HTMLElement
|
const targetElem = mouseEvt.target as HTMLElement
|
||||||
|
@ -87,6 +92,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
!roomCtx.store.preferences.message_context_menu
|
!roomCtx.store.preferences.message_context_menu
|
||||||
|| targetElem.tagName === "A"
|
|| targetElem.tagName === "A"
|
||||||
|| targetElem.tagName === "IMG"
|
|| targetElem.tagName === "IMG"
|
||||||
|
|| targetElem.tagName === "VIDEO"
|
||||||
|| window.getSelection()?.type === "Range"
|
|| window.getSelection()?.type === "Range"
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
|
@ -105,6 +111,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
if (
|
if (
|
||||||
targetElem.tagName === "A"
|
targetElem.tagName === "A"
|
||||||
|| targetElem.tagName === "IMG"
|
|| targetElem.tagName === "IMG"
|
||||||
|
|| targetElem.tagName === "VIDEO"
|
||||||
|
|| targetElem.tagName === "SUMMARY"
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -112,8 +120,15 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
mouseEvt.stopPropagation()
|
mouseEvt.stopPropagation()
|
||||||
roomCtx.setFocusedEventRowID(roomCtx.focusedEventRowID === evt.rowid ? null : evt.rowid)
|
roomCtx.setFocusedEventRowID(roomCtx.focusedEventRowID === evt.rowid ? null : evt.rowid)
|
||||||
}
|
}
|
||||||
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
|
const openEditHistory = () => {
|
||||||
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
openNestableModal({
|
||||||
|
content: <EventEditHistory evt={evt} roomCtx={roomCtx}/>,
|
||||||
|
dimmed: true,
|
||||||
|
boxed: true,
|
||||||
|
boxClass: "full-screen-mobile event-edit-history-wrapper",
|
||||||
|
innerBoxClass: "event-edit-history-modal",
|
||||||
|
})
|
||||||
|
}
|
||||||
const BodyType = getBodyType(evt)
|
const BodyType = getBodyType(evt)
|
||||||
const eventTS = new Date(evt.timestamp)
|
const eventTS = new Date(evt.timestamp)
|
||||||
const editEventTS = evt.last_edit ? new Date(evt.last_edit.timestamp) : null
|
const editEventTS = evt.last_edit ? new Date(evt.last_edit.timestamp) : null
|
||||||
|
@ -121,7 +136,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
if (evt.unread_type & UnreadType.Highlight) {
|
if (evt.unread_type & UnreadType.Highlight) {
|
||||||
wrapperClassNames.push("highlight")
|
wrapperClassNames.push("highlight")
|
||||||
}
|
}
|
||||||
if (evt.redacted_by) {
|
const isRedacted = evt.redacted_by && !evt.viewing_redacted
|
||||||
|
if (isRedacted) {
|
||||||
wrapperClassNames.push("redacted-event")
|
wrapperClassNames.push("redacted-event")
|
||||||
}
|
}
|
||||||
if (evt.type === "m.room.member") {
|
if (evt.type === "m.room.member") {
|
||||||
|
@ -133,7 +149,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
if (evt.sender === client.userID) {
|
if (evt.sender === client.userID) {
|
||||||
wrapperClassNames.push("own-event")
|
wrapperClassNames.push("own-event")
|
||||||
}
|
}
|
||||||
if (isMobileDevice || disableMenu) {
|
if ((isMobileDevice && !editHistoryView) || disableMenu) {
|
||||||
wrapperClassNames.push("no-hover")
|
wrapperClassNames.push("no-hover")
|
||||||
}
|
}
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
|
@ -156,7 +172,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
|
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
|
||||||
let replyAboveMessage: JSX.Element | null = null
|
let replyAboveMessage: JSX.Element | null = null
|
||||||
let replyInMessage: JSX.Element | null = null
|
let replyInMessage: JSX.Element | null = null
|
||||||
if (isEventID(replyTo) && BodyType !== HiddenEvent && !evt.redacted_by) {
|
if (isEventID(replyTo) && BodyType !== HiddenEvent && !isRedacted && !editHistoryView) {
|
||||||
const replyElem = <ReplyIDBody
|
const replyElem = <ReplyIDBody
|
||||||
room={roomCtx.store}
|
room={roomCtx.store}
|
||||||
eventID={replyTo}
|
eventID={replyTo}
|
||||||
|
@ -172,10 +188,21 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
}
|
}
|
||||||
const perMessageSender = getPerMessageProfile(evt)
|
const perMessageSender = getPerMessageProfile(evt)
|
||||||
const prevPerMessageSender = getPerMessageProfile(prevEvt)
|
const prevPerMessageSender = getPerMessageProfile(prevEvt)
|
||||||
|
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
|
||||||
|
let memberEvtContent = memberEvt?.content as UserProfile | undefined
|
||||||
|
if (memberEvt?.redacted_by && !memberEvt?.viewing_redacted) {
|
||||||
|
memberEvtContent = {}
|
||||||
|
} else if (
|
||||||
|
memberEvtContent?.displayname === undefined
|
||||||
|
&& memberEvtContent?.avatar_url === undefined
|
||||||
|
&& memberEvt?.content.membership === "leave"
|
||||||
|
&& memberEvt.unsigned.prev_content
|
||||||
|
) {
|
||||||
|
memberEvtContent = memberEvt.unsigned.prev_content as UserProfile | undefined
|
||||||
|
}
|
||||||
let renderMemberEvtContent = memberEvtContent
|
let renderMemberEvtContent = memberEvtContent
|
||||||
if (perMessageSender) {
|
if (perMessageSender) {
|
||||||
renderMemberEvtContent = {
|
renderMemberEvtContent = {
|
||||||
membership: "join",
|
|
||||||
displayname: perMessageSender.displayname ?? memberEvtContent?.displayname,
|
displayname: perMessageSender.displayname ?? memberEvtContent?.displayname,
|
||||||
avatar_url: perMessageSender.avatar_url ?? memberEvtContent?.avatar_url,
|
avatar_url: perMessageSender.avatar_url ?? memberEvtContent?.avatar_url,
|
||||||
avatar_file: perMessageSender.avatar_file ?? memberEvtContent?.avatar_file,
|
avatar_file: perMessageSender.avatar_file ?? memberEvtContent?.avatar_file,
|
||||||
|
@ -201,16 +228,18 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
eventTimeOnly = true
|
eventTimeOnly = true
|
||||||
renderAvatar = false
|
renderAvatar = false
|
||||||
}
|
}
|
||||||
|
if (editHistoryView) {
|
||||||
|
wrapperClassNames.push("edit-history-event")
|
||||||
|
}
|
||||||
const fullTime = fullTimeFormatter.format(eventTS)
|
const fullTime = fullTimeFormatter.format(eventTS)
|
||||||
const shortTime = formatShortTime(eventTS)
|
const shortTime = formatShortTime(eventTS)
|
||||||
const editTime = editEventTS ? `Edited at ${fullTimeFormatter.format(editEventTS)}` : null
|
|
||||||
const mainEvent = <div
|
const mainEvent = <div
|
||||||
data-event-id={evt.event_id}
|
data-event-id={evt.event_id}
|
||||||
className={wrapperClassNames.join(" ")}
|
className={wrapperClassNames.join(" ")}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
onClick={!disableMenu && isMobileDevice ? onClick : undefined}
|
onClick={!disableMenu && !editHistoryView && isMobileDevice ? onClick : undefined}
|
||||||
>
|
>
|
||||||
{!disableMenu && !isMobileDevice && <div
|
{!disableMenu && (!isMobileDevice || editHistoryView) && <div
|
||||||
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
|
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
|
||||||
>
|
>
|
||||||
<EventHoverMenu evt={evt} roomCtx={roomCtx} setForceOpen={setForceContextMenuOpen}/>
|
<EventHoverMenu evt={evt} roomCtx={roomCtx} setForceOpen={setForceContextMenuOpen}/>
|
||||||
|
@ -230,7 +259,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
<img
|
<img
|
||||||
className={`${smallAvatar ? "small" : ""} avatar`}
|
className={`${smallAvatar ? "small" : ""} avatar`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getAvatarURL(perMessageSender?.id ?? evt.sender, renderMemberEvtContent)}
|
src={getAvatarThumbnailURL(perMessageSender?.id ?? evt.sender, renderMemberEvtContent)}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
|
@ -255,11 +284,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
</span>
|
</span>
|
||||||
</div>}
|
</div>}
|
||||||
<span className="event-time" title={fullTime}>{shortTime}</span>
|
<span className="event-time" title={fullTime}>{shortTime}</span>
|
||||||
{(editEventTS && editTime) ? <span className="event-edited" title={editTime}>
|
|
||||||
(edited at {formatShortTime(editEventTS)})
|
|
||||||
</span> : null}
|
|
||||||
</div> : <div className="event-time-only">
|
</div> : <div className="event-time-only">
|
||||||
<span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span>
|
<span className="event-time" title={fullTime}>{shortTime}</span>
|
||||||
</div>}
|
</div>}
|
||||||
<div className="event-content">
|
<div className="event-content">
|
||||||
{replyInMessage}
|
{replyInMessage}
|
||||||
|
@ -267,11 +293,18 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
|
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
|
||||||
{!isSmallBodyType && <URLPreviews room={roomCtx.store} event={evt}/>}
|
{!isSmallBodyType && <URLPreviews room={roomCtx.store} event={evt}/>}
|
||||||
</ContentErrorBoundary>
|
</ContentErrorBoundary>
|
||||||
|
{(!editHistoryView && editEventTS) ? <div
|
||||||
|
className="event-edited"
|
||||||
|
title={`Edited at ${fullTimeFormatter.format(editEventTS)}`}
|
||||||
|
onClick={openEditHistory}
|
||||||
|
>
|
||||||
|
(edited at {formatShortTime(editEventTS)})
|
||||||
|
</div> : null}
|
||||||
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
||||||
</div>
|
</div>
|
||||||
{!evt.event_id.startsWith("~") && roomCtx.store.preferences.display_read_receipts &&
|
{!evt.event_id.startsWith("~") && roomCtx.store.preferences.display_read_receipts && !editHistoryView &&
|
||||||
<ReadReceipts room={roomCtx.store} eventID={evt.event_id} />}
|
<ReadReceipts room={roomCtx.store} eventID={evt.event_id} />}
|
||||||
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
|
{evt.sender === client.userID && evt.transaction_id && !editHistoryView ? <EventSendStatus evt={evt}/> : null}
|
||||||
</div>
|
</div>
|
||||||
return <>
|
return <>
|
||||||
{dateSeparator}
|
{dateSeparator}
|
||||||
|
|
|
@ -2,7 +2,7 @@ div.url-previews {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
overflow-x: scroll;
|
overflow-x: auto;
|
||||||
|
|
||||||
> div.url-preview {
|
> div.url-preview {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
|
|
|
@ -33,7 +33,7 @@ const URLPreviews = ({ event, room }: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const previews = (event.content["com.beeper.linkpreviews"] ?? event.content["m.url_previews"]) as URLPreview[]
|
const previews = (event.content["com.beeper.linkpreviews"] ?? event.content["m.url_previews"]) as URLPreview[]
|
||||||
if (!previews) {
|
if (!previews || !previews.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return <div className="url-previews">
|
return <div className="url-previews">
|
||||||
|
|
|
@ -14,27 +14,12 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
import ErrorBoundary from "@/ui/util/ErrorBoundary.tsx"
|
||||||
|
|
||||||
export default class ContentErrorBoundary extends React.Component<{ children: React.ReactNode }, { error?: Error }> {
|
export default class ContentErrorBoundary extends ErrorBoundary {
|
||||||
constructor(props: { children: React.ReactNode }) {
|
renderError(message: string): React.JSX.Element {
|
||||||
super(props)
|
|
||||||
this.state = { error: undefined }
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
error = new Error(`${error}`)
|
|
||||||
}
|
|
||||||
return { error }
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.error) {
|
|
||||||
return <div className="render-error-body">
|
return <div className="render-error-body">
|
||||||
Failed to render event: {this.state.error.message.replace(/^Error: /, "")}
|
Failed to render event: {message}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.props.children
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,9 +17,12 @@ import EventContentProps from "./props.ts"
|
||||||
import LockIcon from "../../../icons/lock.svg?react"
|
import LockIcon from "../../../icons/lock.svg?react"
|
||||||
import LockClockIcon from "../../../icons/lock.svg?react"
|
import LockClockIcon from "../../../icons/lock.svg?react"
|
||||||
|
|
||||||
|
const unknownSessionErrorPrefix = "failed to decrypt megolm event: no session with given ID found"
|
||||||
|
|
||||||
const EncryptedBody = ({ event }: EventContentProps) => {
|
const EncryptedBody = ({ event }: EventContentProps) => {
|
||||||
if (event.decryption_error) {
|
const decryptionError = event.last_edit?.decryption_error ?? event.decryption_error
|
||||||
return <div className="decryption-error-body"><LockIcon/> Failed to decrypt: {event.decryption_error}</div>
|
if (decryptionError && !decryptionError.startsWith(unknownSessionErrorPrefix)) {
|
||||||
|
return <div className="decryption-error-body"><LockIcon/> Failed to decrypt: {decryptionError}</div>
|
||||||
}
|
}
|
||||||
return <div className="decryption-pending-body"><LockClockIcon/> Waiting for message</div>
|
return <div className="decryption-pending-body"><LockClockIcon/> Waiting for message</div>
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,28 +14,45 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import React, { use } from "react"
|
import React, { use } from "react"
|
||||||
import { getAvatarURL } from "@/api/media.ts"
|
import { getAvatarThumbnailURL, getAvatarURL } from "@/api/media.ts"
|
||||||
import { MemberEventContent, UserID } from "@/api/types"
|
import { MemberEventContent, UserID } from "@/api/types"
|
||||||
|
import MainScreenContext from "../../MainScreenContext.ts"
|
||||||
import { LightboxContext } from "../../modal"
|
import { LightboxContext } from "../../modal"
|
||||||
import EventContentProps from "./props.ts"
|
import EventContentProps from "./props.ts"
|
||||||
|
|
||||||
function useChangeDescription(
|
function useChangeDescription(
|
||||||
sender: UserID, target: UserID, content: MemberEventContent, prevContent?: MemberEventContent,
|
sender: UserID, target: UserID, content: MemberEventContent, prevContent?: MemberEventContent,
|
||||||
): string | React.ReactElement {
|
): string | React.ReactElement {
|
||||||
const targetAvatar = <img
|
const openLightbox = use(LightboxContext)!
|
||||||
|
const mainScreen = use(MainScreenContext)
|
||||||
|
const makeTargetAvatar = () => <img
|
||||||
className="small avatar"
|
className="small avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getAvatarURL(target, content)}
|
src={getAvatarThumbnailURL(target, content)}
|
||||||
onClick={use(LightboxContext)!}
|
data-full-src={getAvatarURL(target, content)}
|
||||||
|
onClick={openLightbox}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
const targetElem = <>
|
const makeTargetElem = () => {
|
||||||
{content.avatar_url && targetAvatar} <span className="name">
|
return <>
|
||||||
|
<img
|
||||||
|
className="small avatar"
|
||||||
|
loading="lazy"
|
||||||
|
src={getAvatarThumbnailURL(target, content)}
|
||||||
|
data-full-src={getAvatarURL(target, content)}
|
||||||
|
data-target-panel="user"
|
||||||
|
data-target-user={target}
|
||||||
|
onClick={mainScreen.clickRightPanelOpener}
|
||||||
|
alt=""
|
||||||
|
/> <span className="name">
|
||||||
{content.displayname ?? target}
|
{content.displayname ?? target}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
}
|
||||||
if (content.membership === prevContent?.membership) {
|
if (content.membership === prevContent?.membership) {
|
||||||
if (content.displayname !== prevContent.displayname) {
|
if (sender !== target) {
|
||||||
|
return <>made no change to {makeTargetElem()}</>
|
||||||
|
} else if (content.displayname !== prevContent.displayname) {
|
||||||
if (content.avatar_url !== prevContent.avatar_url) {
|
if (content.avatar_url !== prevContent.avatar_url) {
|
||||||
return <>changed their displayname and avatar</>
|
return <>changed their displayname and avatar</>
|
||||||
} else if (!content.displayname) {
|
} else if (!content.displayname) {
|
||||||
|
@ -52,41 +69,47 @@ function useChangeDescription(
|
||||||
if (!content.avatar_url) {
|
if (!content.avatar_url) {
|
||||||
return "removed their avatar"
|
return "removed their avatar"
|
||||||
} else if (!prevContent.avatar_url) {
|
} else if (!prevContent.avatar_url) {
|
||||||
return <>set their avatar to {targetAvatar}</>
|
return <>set their avatar to {makeTargetAvatar()}</>
|
||||||
}
|
}
|
||||||
return <>
|
return <>
|
||||||
changed their avatar from <img
|
changed their avatar from <img
|
||||||
className="small avatar"
|
className="small avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
height={16}
|
height={16}
|
||||||
src={getAvatarURL(target, prevContent)}
|
src={getAvatarThumbnailURL(target, prevContent)}
|
||||||
|
data-full-src={getAvatarURL(target, prevContent)}
|
||||||
onClick={use(LightboxContext)!}
|
onClick={use(LightboxContext)!}
|
||||||
alt=""
|
alt=""
|
||||||
/> to {targetAvatar}
|
/> to {makeTargetAvatar()}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
return "made no change"
|
return "made no change"
|
||||||
} else if (content.membership === "join") {
|
} else if (content.membership === "join") {
|
||||||
return "joined the room"
|
return "joined the room"
|
||||||
} else if (content.membership === "invite") {
|
} else if (content.membership === "invite") {
|
||||||
return <>invited {targetElem}</>
|
if (prevContent?.membership === "knock") {
|
||||||
|
return <>accepted {makeTargetElem()}'s join request</>
|
||||||
|
}
|
||||||
|
return <>invited {makeTargetElem()}</>
|
||||||
} else if (content.membership === "ban") {
|
} else if (content.membership === "ban") {
|
||||||
return <>banned {targetElem}</>
|
return <>banned {makeTargetElem()}</>
|
||||||
} else if (content.membership === "knock") {
|
} else if (content.membership === "knock") {
|
||||||
return "knocked on the room"
|
return "requested to join the room"
|
||||||
} else if (content.membership === "leave") {
|
} else if (content.membership === "leave") {
|
||||||
if (sender === target) {
|
if (sender === target) {
|
||||||
if (prevContent?.membership === "knock") {
|
if (prevContent?.membership === "knock") {
|
||||||
return "cancelled their knock"
|
return "cancelled their join request"
|
||||||
}
|
}
|
||||||
return "left the room"
|
return "left the room"
|
||||||
}
|
}
|
||||||
if (prevContent?.membership === "ban") {
|
if (prevContent?.membership === "ban") {
|
||||||
return <>unbanned {targetElem}</>
|
return <>unbanned {makeTargetElem()}</>
|
||||||
} else if (prevContent?.membership === "invite") {
|
} else if (prevContent?.membership === "invite") {
|
||||||
return <>disinvited {targetElem}</>
|
return <>disinvited {makeTargetElem()}</>
|
||||||
|
} else if (prevContent?.membership === "knock") {
|
||||||
|
return <>rejected {makeTargetElem()}'s join request</>
|
||||||
}
|
}
|
||||||
return <>kicked {targetElem}</>
|
return <>kicked {makeTargetElem()}</>
|
||||||
}
|
}
|
||||||
return "made an unknown membership change"
|
return "made an unknown membership change"
|
||||||
}
|
}
|
||||||
|
|