1
0
Fork 0
forked from Mirrors/gomuks

Compare commits

...
Sign in to create a new pull request.

135 commits

Author SHA1 Message Date
n
d5666f9f00 Test a runner
Signed-off-by: n <me@everypizza.im>
2025-03-19 13:53:22 -05:00
n
338c7d9adc Remove button for unredact
Signed-off-by: n <me@everypizza.im>
2025-03-19 13:46:30 -05:00
n
7c5ab68cf2 Update README.md
Signed-off-by: n <me@everypizza.im>
2025-03-19 13:43:09 -05:00
Tulir Asokan
295d1f156e
readme: update 2025-03-16 23:02:32 +02:00
Tulir Asokan
c312a2b523 dependencies: update 2025-03-16 21:36:00 +02:00
Tulir Asokan
99b3fd0e5e web/index: move font size to html root 2025-03-16 21:31:58 +02:00
Tulir Asokan
c48f24d2de web/timeline: hide overflow in small replies 2025-03-15 12:32:43 +02:00
Tulir Asokan
696687f60c hicli/sync: ignore failing to recalculate preview 2025-03-15 12:32:31 +02:00
Tulir Asokan
4262b5abfa dependencies: update 2025-03-14 02:07:37 +02:00
Tulir Asokan
87ec9d60a5 hicli/paginate: fix handling non-empty final chunk 2025-03-14 02:06:19 +02:00
Tulir Asokan
9c17ce001d readme: update room alias 2025-03-14 02:00:57 +02:00
Tulir Asokan
0f2263ce8d web/timeline: implement MSC4205 for policy list rendering 2025-03-13 02:25:18 +02:00
Tulir Asokan
92d3ab64bf web/timeline: limit name size in small replies 2025-03-11 19:30:40 +02:00
Tulir Asokan
746e26fbc1 web/timeline: fix avatars in replies shrinking with long names 2025-03-11 19:28:25 +02:00
Tulir Asokan
3041fb18e3 web/rightpanel: fix displayname overflow in user info 2025-03-11 19:26:47 +02:00
Tulir Asokan
ede1c92906 dependencies: update mautrix-go 2025-03-10 01:26:33 +02:00
Tulir Asokan
0ed5dfcc12 hicli/sync: fix updating preview event rowid on redaction 2025-03-09 18:12:34 +02:00
Tulir Asokan
7f80301276 web/statestore: reload room view if state store is cleared 2025-03-09 18:05:39 +02:00
Tulir Asokan
3f4333003d web/preferences: fix letter 2025-03-09 17:31:04 +02:00
Tulir Asokan
218481f3a4 web/timeline: disable url preview images if image previews are disabled 2025-03-09 17:21:21 +02:00
Tulir Asokan
ef05bc71f9 web/roomlist: add option to hide invite avatars 2025-03-09 17:18:27 +02:00
Tulir Asokan
86843d61f6 web/preferences: add option to disable inline images 2025-03-09 16:55:55 +02:00
Tulir Asokan
6b9f6bebd5 web/widget: move iframe css outside right panel content 2025-03-09 16:41:50 +02:00
Tulir Asokan
aee4cff572 hicli/sync: clear outbound session on invite accept if history visibility is set to joined 2025-03-08 19:22:36 +02:00
Tulir Asokan
678940618f hicli/send: fix panic if history visibility event is not found 2025-03-08 18:43:19 +02:00
Tulir Asokan
6425f68c88 web/widget: add basic permission prompt
Fixes #605
2025-03-08 18:15:09 +02:00
Tulir Asokan
0db18f1e94 web/modal: allow non-dismissable modals 2025-03-08 16:37:27 +02:00
Tulir Asokan
fbad48129b web/widget: implement handling close event 2025-03-08 15:59:46 +02:00
Sumner Evans
b3b255e71c
web/modal: fix scrollbars (#606)
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-03-08 13:55:42 +02:00
Tulir Asokan
5da85acfe0 web/timeline: fix message for rejecting invites 2025-03-07 22:53:06 +02:00
Tulir Asokan
f79678a87f web/widget: implement delayed state events 2025-03-06 01:29:40 +02:00
Tulir Asokan
23f2699909 push: log number of events in push payloads 2025-03-05 22:55:17 +02:00
Tulir Asokan
6e8dd0e591 web/widget/call: update parameters 2025-03-05 02:23:21 +02:00
Tulir Asokan
9a88df0cc1 web/widget: fix sendEvent not returning event ID properly 2025-03-05 02:10:29 +02:00
Tulir Asokan
3ef311d9d6 web/widget/call: use real homeserver base URL 2025-03-04 23:06:45 +02:00
Tulir Asokan
508355f2bf web/widgets: add initial support 2025-03-04 22:54:20 +02:00
Tulir Asokan
d234981604 web/timeline: add better messages for knocks 2025-03-03 23:55:30 +02:00
Tulir Asokan
e20f3161a2 web/css: fix button font 2025-03-03 12:57:16 +02:00
Tulir Asokan
c78ef2e97b web/devtools: add space between back and send buttons 2025-03-03 00:02:20 +02:00
Tulir Asokan
d093ea2f90 web/devtools: add send message event button 2025-03-02 21:06:29 +02:00
Tulir Asokan
e6242a9c37 web/roomview: add explore state button to header 2025-03-02 20:31:25 +02:00
Tulir Asokan
aeabda449d web/settings: add room state explorer
Fixes #516
Closes #526
2025-03-02 19:07:56 +02:00
Tulir Asokan
5aacc3424d web/settings: add readOnly flags to hidden fields to fix warnings 2025-03-02 17:16:21 +02:00
Tulir Asokan
d06ed8637c hicli/send: include geo_uri in edit fallback 2025-03-01 21:51:39 +02:00
Tulir Asokan
1ffb44fc27 web/timeline: fix message for unknown session errors 2025-03-01 21:45:41 +02:00
Tulir Asokan
83ea1c12ad web/statestore: fix applying undecryptable edits 2025-03-01 21:44:40 +02:00
Tulir Asokan
014c63f0e7 hicli/sync: ignore m.unavailable withheld events from own devices 2025-03-01 21:43:09 +02:00
Tulir Asokan
91fa59d5ba web/statestore: fix updating view when viewing redacted events 2025-03-01 21:39:11 +02:00
Tulir Asokan
b48c285f5c hicli/send: move extra to m.new_content when editing 2025-02-28 19:33:51 +02:00
Tulir Asokan
7168683a4b dependencies: update mautrix-go 2025-02-26 22:57:21 +02:00
Tulir Asokan
4247e17160 web/menu/roomlist: add confirmation for leaving 2025-02-25 23:24:43 +02:00
Tulir Asokan
62d8d06129 web/menu/roomlist: implement marking as read/unread and leaving room
Fixes #523
2025-02-25 22:15:11 +02:00
Tulir Asokan
1ba4d532c9 web/menu: move directory out of timeline 2025-02-25 22:15:11 +02:00
Tulir Asokan
bbce3df381 web/roomlist: add context menu for entries 2025-02-25 22:15:11 +02:00
Tulir Asokan
2203c18e15 web/rightpanel: add start DM button
Fixes #592
2025-02-25 22:15:11 +02:00
Tulir Asokan
bdae0c416f web/rightpanel: use border-bottom instead of hr for separators 2025-02-25 22:15:11 +02:00
Sumner Evans
b7cc6aff86
timeline: fix scrollbar issues on URL previews (#597)
* Prevents rendering an empty url-previews div
* Sets overflow-x to "auto" instead of "scroll"

Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-02-25 17:33:13 +02:00
Tulir Asokan
5d41b49462 web/composer: add option to start new thread when replying
Fixes #594
2025-02-24 00:08:32 +02:00
Tulir Asokan
548c8a9a94 web/timeline: remove unnecessary variable 2025-02-24 00:00:45 +02:00
Tulir Asokan
f9a8d3e042 web/timeline: use profile from prev_content for left users 2025-02-23 23:35:53 +02:00
Tulir Asokan
7ed0f2633c web/modal: make edit history modal fullscreen on mobile 2025-02-23 23:21:01 +02:00
Tulir Asokan
4a728e187c web/client: allow retrying unredacting 2025-02-23 21:30:36 +02:00
Tulir Asokan
c210f696cc web/timeline: fix server ACL rendering 2025-02-23 21:12:19 +02:00
Tulir Asokan
0c3d3686e4 hicli/sync: fill prev_content with cached event if it's missing 2025-02-23 21:06:53 +02:00
Tulir Asokan
bd2ece9ff2 hicli/sync: fix unredacting content of encrypted events 2025-02-23 18:44:59 +02:00
Tulir Asokan
a14f01a3ec web/timeline: implement MSC2815
Fixes #510
2025-02-23 18:33:19 +02:00
Tulir Asokan
4885dab2e1 web/modal: remove debug print 2025-02-23 18:04:08 +02:00
Tulir Asokan
42a97c8c1b dependencies: update 2025-02-23 18:03:12 +02:00
Tulir Asokan
4074e2ca68 web/modal: add error boundary for content 2025-02-23 18:03:12 +02:00
Tulir Asokan
4fc9b88ec6 web/timeline: add edit history modal 2025-02-23 18:03:12 +02:00
Tulir Asokan
a9e459b448 web/modal: allow two layers of modals 2025-02-23 18:03:12 +02:00
Tulir Asokan
7c664c2700 hicli/sync: fix sync backoff calculation 2025-02-23 18:03:12 +02:00
Tulir Asokan
c228b7f183 web/timeline: split body types by state key 2025-02-23 18:03:12 +02:00
Sumner Evans
5c27592b8c
web/timeline: add variable for vertical padding (#595)
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-02-21 19:44:35 +02:00
Tulir Asokan
db709896d6 ci: add missing environment variable 2025-02-16 20:53:09 +02:00
Tulir Asokan
d1fd7f576a ci: update go version 2025-02-16 18:14:39 +02:00
Tulir Asokan
ce60eb8a94 hicli/paginate: add missing context cancel 2025-02-16 17:34:11 +02:00
Tulir Asokan
289b428644 dependencies: update 2025-02-16 17:33:22 +02:00
Tulir Asokan
2461cad4f2 web/all: add more error boundaries 2025-02-10 22:51:50 +02:00
Tulir Asokan
8ba9279cc7 web/rightpanel: fix handling non-string displaynames in member events 2025-02-10 22:33:16 +02:00
Tulir Asokan
3075156884 hicli/sync: ignore incorrectly detected member changes for megolm invalidation 2025-02-08 16:21:26 +02:00
Tulir Asokan
7df4d7c6f9 dependencies: update mautrix-go to fix content push rule details 2025-02-08 16:20:57 +02:00
Tulir Asokan
4060383efa hicli/sync: invalidate outbound sessions on member change 2025-02-07 19:36:48 +02:00
Tulir Asokan
14c9291c8d hicli/send: add discardsession command 2025-02-07 19:23:36 +02:00
Tulir Asokan
36ad528124 web/timeline: allow opening member event target user in right panel 2025-02-04 01:02:50 +02:00
Tulir Asokan
2665956654 web/timeline: handle making no change to someone else 2025-02-04 00:53:27 +02:00
Tulir Asokan
1e22e62a9a web/settings: add support for manual key import/export
Fixes #593
2025-02-04 00:40:08 +02:00
Tulir Asokan
717f2989a8 web/rightpanel: fix filtering member list 2025-01-30 14:50:40 +02:00
Tulir Asokan
9fd50a6ae3 media: fix saving thumbnail hashes 2025-01-28 14:52:10 +02:00
Tulir Asokan
947a853bae media: make thumbnail size configurable 2025-01-28 14:27:53 +02:00
Tulir Asokan
6d12e6e009 web: use thumbnails for small avatars 2025-01-27 23:14:09 +02:00
Tulir Asokan
1b5467cf0e media: add support for generating avatar thumbnails 2025-01-27 23:14:09 +02:00
Tulir Asokan
66c850717a web/stickerpicker: fix size on small screens 2025-01-27 17:37:37 +02:00
Tulir Asokan
2c489fa582 hicli/init: add safety for too many empty rooms 2025-01-27 12:48:57 +02:00
Tulir Asokan
e8c8a44f38 push: ignore missing members 2025-01-27 12:48:45 +02:00
Tulir Asokan
23fb7db2b9 web/lightbox: add support for touch panning/zooming 2025-01-26 22:50:21 +02:00
Tulir Asokan
e11c398a57 web/vite: allow all hosts by default 2025-01-26 21:57:21 +02:00
Tulir Asokan
19a4913a3f web/lightbox: fix zooming in when image is rotated 2025-01-26 21:55:21 +02:00
Tulir Asokan
0da4dede52 hicli/database: add get members method 2025-01-26 21:17:59 +02:00
Tulir Asokan
97add30a39 docker: add ffmpeg 2025-01-25 21:11:14 +02:00
Tulir Asokan
c2ab65e5c0 web/composer: don't allow media to be too wide 2025-01-25 16:03:04 +02:00
Tulir Asokan
15238b66f9 web/timeline: fix clicking spoilers and summaries on mobile 2025-01-24 01:47:03 +02:00
Sumner Evans
ce728417e5
web/timeline: disable right click context menu on videos (#587)
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-01-24 01:39:36 +02:00
nexy7574
b7f939f480
web: add share event button (#589)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-01-24 01:39:10 +02:00
Tulir Asokan
865b2e4fdf web/rightpanel: don't show ignore button for self 2025-01-24 01:15:07 +02:00
Tulir Asokan
fabf3404af web/rightpanel: ignore timezone if value is unsupported 2025-01-24 01:04:28 +02:00
nexy7574
9cff332671
web: implement user moderation actions (#588)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-01-24 01:03:53 +02:00
Tulir Asokan
4649689b72 dependencies: update 2025-01-24 00:14:58 +02:00
Sumner Evans
5bb28d3216
web/composer: prevent sending when loading media (#590) 2025-01-24 00:14:49 +02:00
Tulir Asokan
f94d84b044 web/timeline: add validation for per-message profiles 2025-01-13 18:13:06 +02:00
Tulir Asokan
9e63da1b6b all: add FCM push support 2025-01-12 23:26:29 +02:00
Tulir Asokan
d4fc883736 web/timeline: fix typo in power level body 2025-01-11 20:45:48 +02:00
Tulir Asokan
5ab60cb816 web/mainscreen: handle url fragment change 2025-01-10 01:58:55 +02:00
Tulir Asokan
40e7d63453 desktop/dependencies: update wails 2025-01-10 01:57:35 +02:00
Tulir Asokan
b4ad603ea3 web/index: add user-scalable=no 2025-01-09 01:49:41 +02:00
Tulir Asokan
31bc7a6f24 dependencies: update 2025-01-06 17:26:37 +02:00
Sumner Evans
5b5df65f39
web/timeline: render MSC4144 per-message profiles (#566)
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-01-06 15:08:34 +02:00
nexy7574
bdc823742e
web/timeline: render policy list events (#586)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-01-06 12:51:58 +00:00
Tulir Asokan
158745b7a0 web/statestore: clear unreads when rejecting invite or leaving room 2025-01-05 01:30:42 +02:00
Tulir Asokan
cb08f43535 web/rightpanel: use comma instead of slash as separator for pronoun sets 2025-01-03 16:29:21 +02:00
nexy7574
a1a006bf6b
web/rightpanel: show extended profile info for users (#574)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-01-03 14:27:02 +02:00
Tulir Asokan
f766b786ee web/eslint: add curly rule 2025-01-03 14:20:00 +02:00
Tulir Asokan
5d25d839f8 web: switch to first matching space when opening room
Fixes #582
2025-01-03 12:19:12 +02:00
Tulir Asokan
39cb5f28a0 hicli/database: store DM user ID in database 2025-01-02 23:23:30 +02:00
Tulir Asokan
ac6f2713e5 web/eslint: make line max length an error 2025-01-02 11:34:14 +02:00
Tulir Asokan
d8f0a82ffc web/roomlist: fix unread counter overflow condition 2025-01-02 11:17:22 +02:00
Tulir Asokan
3c3e2456e2 web/roomlist: switch space in unread click handler
On desktop it worked without this via event propagation, but that
doesn't work on mobile (possibly because the room view opens immediately
and hides the space bar?)
2025-01-02 11:16:30 +02:00
Tulir Asokan
021236592f web/statestore: clear unread counts when clearing state 2025-01-02 11:07:04 +02:00
Tulir Asokan
c3899d0b50 web/settings: add button to log into css.gomuks.app 2025-01-01 18:07:57 +02:00
Tulir Asokan
7f94bbf39e web/timeline: add background for read receipt avatars 2025-01-01 15:51:15 +02:00
Tulir Asokan
8c9925959a web/roomlist: don't allow selecting unread counter text 2025-01-01 15:47:08 +02:00
Tulir Asokan
ddf20b34d2 web/mainscreen: fix pushing history states when outside a space 2025-01-01 15:44:44 +02:00
Tulir Asokan
6d1c5f6277 web/roomlist: use margin instead of padding for room avatars
This allows adding a background for avatars using

```css
.avatar {
  background-color: var(--background-color);
}
```
2025-01-01 13:12:45 +02:00
Tulir Asokan
8b7d0fe6b6 web/roomlist: restore open space when using browser history 2025-01-01 12:24:22 +02:00
Tulir Asokan
59e1b760d6 web/statestore: fix clearing unread count after accepting invite 2025-01-01 01:22:33 +02:00
144 changed files with 6142 additions and 1520 deletions

11
.forgeo/workflows Normal file
View file

@ -0,0 +1,11 @@
on: [push]
jobs:
test:
runs-on: docker
steps:
- uses: actions/cascading-pr@v1.0.1
with:
GOPATH="$CI_PROJECT_DIR/.cache"
GOCACHE="$CI_PROJECT_DIR/.cache/build"
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
GO_LDFLAGS="-s -w -linkmode external -extldflags -static -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,18 +1,7 @@
# gomuks # nyxmuks
![Languages](https://img.shields.io/github/languages/top/tulir/gomuks.svg)
[![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases)
[![GitLab CI](https://mau.dev/tulir/gomuks/badges/main/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines)
A Matrix client written in Go using [mautrix](https://github.com/mautrix/go). Soft fork of Tulir's Gomuks.
This branch contains gomuks web. For legacy gomuks terminal, see the # why?
[master branch](https://github.com/tulir/gomuks/tree/master). The new
version will get a terminal frontend in the future. See also:
<https://github.com/tulir/gomuks/issues/476>.
## Docs Gomuks is adding unneccesary features, and the developer is acting maliciously. This fork aims to remove a few features that seem to be made with a malicious intent, like the "un-redact" button.
For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/).
## Discussion
Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net)

View file

@ -2,39 +2,41 @@ 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.7 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.20241217231624-e3dc7ee01c86 go.mau.fi/util v0.8.6
) )
require ( require (
dario.cat/mergo v1.0.0 // indirect dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/adrg/xdg v0.5.0 // indirect
github.com/alecthomas/chroma/v2 v2.15.0 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/buckket/go-blurhash v1.1.0 // indirect github.com/buckket/go-blurhash v1.1.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect github.com/chzyer/readline v1.5.1 // indirect
github.com/cloudflare/circl v1.3.7 // indirect github.com/cloudflare/circl v1.3.8 // indirect
github.com/coder/websocket v1.8.12 // indirect github.com/coder/websocket v1.8.13 // 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.4 // indirect github.com/cyphar/filepath-securejoin v0.2.5 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/ebitengine/purego v0.4.0-alpha.4 // indirect github.com/ebitengine/purego v0.4.0-alpha.4 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.7 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-git/go-billy/v5 v5.6.0 // indirect
github.com/go-git/go-git/v5 v5.11.0 // indirect github.com/go-git/go-git/v5 v5.12.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.4.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect
@ -42,41 +44,43 @@ 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-20250303134427-723919f7f203 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/rs/zerolog v1.33.0 // indirect github.com/rs/zerolog v1.33.0 // indirect
github.com/samber/lo v1.38.1 // indirect github.com/samber/lo v1.38.1 // indirect
github.com/sergi/go-diff v1.2.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.1 // 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.15 // 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.31.0 // indirect golang.org/x/crypto v0.36.0 // indirect
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/image v0.23.0 // indirect golang.org/x/image v0.25.0 // indirect
golang.org/x/mod v0.22.0 // indirect golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.33.0 // indirect golang.org/x/net v0.37.0 // indirect
golang.org/x/sync v0.10.0 // indirect golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.28.0 // indirect golang.org/x/tools v0.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/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.20241222121030-33b4e823c5e5 // indirect maunium.net/go/mautrix v0.23.2 // indirect
mvdan.cc/xurls/v2 v2.5.0 // indirect mvdan.cc/xurls/v2 v2.6.0 // indirect
) )
replace go.mau.fi/gomuks => ../ replace go.mau.fi/gomuks => ../

View file

@ -1,5 +1,5 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@ -7,12 +7,14 @@ github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@ -31,39 +33,41 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes= github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@ -71,8 +75,8 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@ -98,18 +102,19 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= 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-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0 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=
@ -121,8 +126,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@ -130,11 +135,11 @@ github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -145,23 +150,26 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/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.15 h1:IeQFoWmsHp32y64I41J+Zod3SopjHs918KSO4jUqEnY= github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
github.com/wailsapp/go-webview2 v1.0.15/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo= 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.7 h1:LNX2EnbxTEYJYICJT8UkuzoGVNalRizTNGBY47endmk= github.com/wailsapp/wails/v3 v3.0.0-alpha.9 h1:b8CfRrhPno8Fra0xFp4Ifyj+ogmXBc35rsQWvcrHtsI=
github.com/wailsapp/wails/v3 v3.0.0-alpha.7/go.mod h1:lBz4zedFxreJBoVpMe9u89oo4IE3IlyHJg5rOWnGNR0= 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.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo= go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY= go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3 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=
@ -169,16 +177,17 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
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.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.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.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-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=
@ -187,15 +196,14 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.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.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -208,20 +216,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-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.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.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=
@ -229,28 +238,30 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
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.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 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.20241222121030-33b4e823c5e5 h1:3aZCApUF3wlboN1BcAX6u6MGhju4OpsmhyfvxdO6tO0= maunium.net/go/mautrix v0.23.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg=
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI= maunium.net/go/mautrix v0.23.2/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=

38
go.mod
View file

@ -2,14 +2,15 @@ module go.mau.fi/gomuks
go 1.23.0 go 1.23.0
toolchain go1.23.4 toolchain go1.24.1
require ( require (
github.com/alecthomas/chroma/v2 v2.14.0 github.com/alecthomas/chroma/v2 v2.15.0
github.com/buckket/go-blurhash v1.1.0 github.com/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.13
github.com/gabriel-vasile/mimetype v1.4.7 github.com/disintegration/imaging v1.6.2
github.com/gabriel-vasile/mimetype v1.4.8
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
github.com/rivo/uniseg v0.4.7 github.com/rivo/uniseg v0.4.7
@ -17,29 +18,30 @@ require (
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5 github.com/tidwall/sjson v1.2.5
github.com/yuin/goldmark v1.7.8 github.com/yuin/goldmark v1.7.8
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 go.mau.fi/util v0.8.6
go.mau.fi/webp v0.2.0
go.mau.fi/zeroconfig v0.1.3 go.mau.fi/zeroconfig v0.1.3
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.36.0
golang.org/x/image v0.23.0 golang.org/x/image v0.25.0
golang.org/x/net v0.33.0 golang.org/x/net v0.37.0
golang.org/x/text v0.21.0 golang.org/x/text v0.23.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.20241222121030-33b4e823c5e5 maunium.net/go/mautrix v0.23.2
mvdan.cc/xurls/v2 v2.5.0 mvdan.cc/xurls/v2 v2.6.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dlclark/regexp2 v1.11.4 // 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/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // 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
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.31.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
) )

75
go.sum
View file

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

View file

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

View file

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

View file

@ -34,6 +34,7 @@ import (
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog" "go.mau.fi/util/exzerolog"
"go.mau.fi/util/ptr"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"go.mau.fi/gomuks/pkg/hicli" "go.mau.fi/gomuks/pkg/hicli"
@ -171,7 +172,7 @@ func (gmx *Gomuks) StartClient() {
nil, nil,
gmx.Log.With().Str("component", "hicli").Logger(), gmx.Log.With().Str("component", "hicli").Logger(),
[]byte("meow"), []byte("meow"),
gmx.EventBuffer.HicliEventHandler, gmx.HandleEvent,
) )
gmx.Client.LogoutFunc = gmx.Logout gmx.Client.LogoutFunc = gmx.Logout
httpClient := gmx.Client.Client.Client httpClient := gmx.Client.Client.Client
@ -197,6 +198,14 @@ func (gmx *Gomuks) StartClient() {
gmx.Log.Info().Stringer("user_id", userID).Msg("Client started") gmx.Log.Info().Stringer("user_id", userID).Msg("Client started")
} }
func (gmx *Gomuks) HandleEvent(evt any) {
gmx.EventBuffer.Push(evt)
syncComplete, ok := evt.(*hicli.SyncComplete)
if ok && ptr.Val(syncComplete.Since) != "" {
go gmx.SendPushNotifications(syncComplete)
}
}
func (gmx *Gomuks) Stop() { func (gmx *Gomuks) Stop() {
gmx.stopOnce.Do(func() { gmx.stopOnce.Do(func() {
close(gmx.stopChan) close(gmx.stopChan)

110
pkg/gomuks/keys.go Normal file
View 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,
})
}

View file

@ -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)
} }
} }

258
pkg/gomuks/push.go Normal file
View file

@ -0,0 +1,258 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
"maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/pkg/hicli/database"
)
type PushNotification struct {
Dismiss []PushDismiss `json:"dismiss,omitempty"`
OrigMessages []*PushNewMessage `json:"-"`
RawMessages []json.RawMessage `json:"messages,omitempty"`
ImageAuth string `json:"image_auth,omitempty"`
ImageAuthExpiry *jsontime.UnixMilli `json:"image_auth_expiry,omitempty"`
HasImportant bool `json:"-"`
}
type PushDismiss struct {
RoomID id.RoomID `json:"room_id"`
}
var pushClient = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
ResponseHeaderTimeout: 10 * time.Second,
Proxy: http.ProxyFromEnvironment,
ForceAttemptHTTP2: true,
MaxIdleConns: 5,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
Timeout: 60 * time.Second,
}
func (gmx *Gomuks) SendPushNotifications(sync *hicli.SyncComplete) {
var ctx context.Context
var push PushNotification
for _, room := range sync.Rooms {
if room.DismissNotifications && len(push.Dismiss) < 10 {
push.Dismiss = append(push.Dismiss, PushDismiss{RoomID: room.Meta.ID})
}
for _, notif := range room.Notifications {
if ctx == nil {
ctx = gmx.Log.With().
Str("action", "send push notification").
Logger().WithContext(context.Background())
}
msg := gmx.formatPushNotificationMessage(ctx, notif)
if msg == nil {
continue
}
msgJSON, err := json.Marshal(msg)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Int64("event_rowid", int64(notif.RowID)).
Stringer("event_id", notif.Event.ID).
Msg("Failed to marshal push notification")
continue
} else if len(msgJSON) > 1500 {
// This should not happen as long as formatPushNotificationMessage doesn't return too long messages
zerolog.Ctx(ctx).Error().
Int64("event_rowid", int64(notif.RowID)).
Stringer("event_id", notif.Event.ID).
Msg("Push notification too long")
continue
}
push.RawMessages = append(push.RawMessages, msgJSON)
push.OrigMessages = append(push.OrigMessages, msg)
}
}
if len(push.Dismiss) == 0 && len(push.RawMessages) == 0 {
return
}
if ctx == nil {
ctx = gmx.Log.With().
Str("action", "send push notification").
Logger().WithContext(context.Background())
}
pushRegs, err := gmx.Client.DB.PushRegistration.GetAll(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get push registrations")
return
}
if len(push.RawMessages) > 0 {
exp := time.Now().Add(24 * time.Hour)
push.ImageAuth = gmx.generateImageToken(24 * time.Hour)
push.ImageAuthExpiry = ptr.Ptr(jsontime.UM(exp))
}
for notif := range push.Split {
gmx.SendPushNotification(ctx, pushRegs, notif)
}
}
func (pn *PushNotification) Split(yield func(*PushNotification) bool) {
const maxSize = 2000
currentSize := 0
offset := 0
hasSound := false
for i, msg := range pn.RawMessages {
if len(msg) >= maxSize {
// This is already checked in SendPushNotifications, so this should never happen
panic("push notification message too long")
}
if currentSize+len(msg) > maxSize {
yield(&PushNotification{
Dismiss: pn.Dismiss,
RawMessages: pn.RawMessages[offset:i],
ImageAuth: pn.ImageAuth,
HasImportant: hasSound,
})
offset = i
currentSize = 0
hasSound = false
}
currentSize += len(msg)
hasSound = hasSound || pn.OrigMessages[i].Sound
}
yield(&PushNotification{
Dismiss: pn.Dismiss,
RawMessages: pn.RawMessages[offset:],
ImageAuth: pn.ImageAuth,
HasImportant: hasSound,
})
}
func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*database.PushRegistration, notif *PushNotification) {
log := zerolog.Ctx(ctx).With().
Bool("important", notif.HasImportant).
Int("message_count", len(notif.RawMessages)).
Int("dismiss_count", len(notif.Dismiss)).
Logger()
ctx = log.WithContext(ctx)
rawPayload, err := json.Marshal(notif)
if err != nil {
log.Err(err).Msg("Failed to marshal push notification")
return
} else if base64.StdEncoding.EncodedLen(len(rawPayload)) >= 4000 {
log.Error().Msg("Generated push payload too long")
return
}
for _, reg := range pushRegs {
devicePayload := rawPayload
encrypted := false
if reg.Encryption.Key != nil {
var err error
devicePayload, err = encryptPush(rawPayload, reg.Encryption.Key)
if err != nil {
log.Err(err).Str("device_id", reg.DeviceID).Msg("Failed to encrypt push payload")
continue
}
encrypted = true
}
switch reg.Type {
case database.PushTypeFCM:
if !encrypted {
log.Warn().
Str("device_id", reg.DeviceID).
Msg("FCM push registration doesn't have encryption key")
continue
}
var token string
err = json.Unmarshal(reg.Data, &token)
if err != nil {
log.Err(err).Str("device_id", reg.DeviceID).Msg("Failed to unmarshal FCM token")
continue
}
gmx.SendFCMPush(ctx, token, devicePayload, notif.HasImportant)
}
}
}
func encryptPush(payload, key []byte) ([]byte, error) {
if len(key) != 32 {
return nil, fmt.Errorf("encryption key must be 32 bytes long")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM cipher: %w", err)
}
iv := random.Bytes(12)
encrypted := make([]byte, 12, 12+len(payload))
copy(encrypted, iv)
return gcm.Seal(encrypted, iv, payload, nil), nil
}
type PushRequest struct {
Token string `json:"token"`
Payload []byte `json:"payload"`
HighPriority bool `json:"high_priority"`
}
func (gmx *Gomuks) SendFCMPush(ctx context.Context, token string, payload []byte, highPriority bool) {
wrappedPayload, _ := json.Marshal(&PushRequest{
Token: token,
Payload: payload,
HighPriority: highPriority,
})
url := fmt.Sprintf("%s/_gomuks/push/fcm", gmx.Config.Push.FCMGateway)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wrappedPayload))
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to create push request")
return
}
resp, err := pushClient.Do(req)
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("push_token", token).Msg("Failed to send push request")
} else if resp.StatusCode != http.StatusOK {
zerolog.Ctx(ctx).Error().
Int("status", resp.StatusCode).
Str("push_token", token).
Msg("Non-200 status while sending push request")
} else {
zerolog.Ctx(ctx).Trace().
Int("status", resp.StatusCode).
Str("push_token", token).
Msg("Sent push request")
}
if resp != nil {
_ = resp.Body.Close()
}
}

171
pkg/gomuks/pushmessage.go Normal file
View file

@ -0,0 +1,171 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"encoding/json"
"fmt"
"net/url"
"unicode/utf8"
"github.com/rs/zerolog"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/pkg/hicli/database"
)
type PushNewMessage struct {
Timestamp jsontime.UnixMilli `json:"timestamp"`
EventID id.EventID `json:"event_id"`
EventRowID database.EventRowID `json:"event_rowid"`
RoomID id.RoomID `json:"room_id"`
RoomName string `json:"room_name"`
RoomAvatar string `json:"room_avatar,omitempty"`
Sender NotificationUser `json:"sender"`
Self NotificationUser `json:"self"`
Text string `json:"text"`
Image string `json:"image,omitempty"`
Mention bool `json:"mention,omitempty"`
Reply bool `json:"reply,omitempty"`
Sound bool `json:"sound,omitempty"`
}
type NotificationUser struct {
ID id.UserID `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
}
func getAvatarLinkForNotification(name, ident string, uri id.ContentURIString) string {
parsed := uri.ParseOrIgnore()
if !parsed.IsValid() {
return ""
}
var fallbackChar rune
if name == "" {
fallbackChar, _ = utf8.DecodeRuneInString(ident[1:])
} else {
fallbackChar, _ = utf8.DecodeRuneInString(name)
}
return fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false&fallback=%s", parsed.Homeserver, parsed.FileID, url.QueryEscape(string(fallbackChar)))
}
func (gmx *Gomuks) getNotificationUser(ctx context.Context, roomID id.RoomID, userID id.UserID) (user NotificationUser) {
user = NotificationUser{ID: userID, Name: userID.Localpart()}
memberEvt, err := gmx.Client.DB.CurrentState.Get(ctx, roomID, event.StateMember, userID.String())
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("of_user_id", userID).Msg("Failed to get member event")
return
} else if memberEvt == nil {
return
}
var memberContent event.MemberEventContent
_ = json.Unmarshal(memberEvt.Content, &memberContent)
if memberContent.Displayname != "" {
user.Name = memberContent.Displayname
}
if len(user.Name) > 50 {
user.Name = user.Name[:50] + "…"
}
if memberContent.AvatarURL != "" {
user.Avatar = getAvatarLinkForNotification(memberContent.Displayname, userID.String(), memberContent.AvatarURL)
}
return
}
func (gmx *Gomuks) formatPushNotificationMessage(ctx context.Context, notif hicli.SyncNotification) *PushNewMessage {
evtType := notif.Event.Type
rawContent := notif.Event.Content
if evtType == event.EventEncrypted.Type {
evtType = notif.Event.DecryptedType
rawContent = notif.Event.Decrypted
}
if evtType != event.EventMessage.Type && evtType != event.EventSticker.Type {
return nil
}
var content event.MessageEventContent
err := json.Unmarshal(rawContent, &content)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Stringer("event_id", notif.Event.ID).
Msg("Failed to unmarshal message content to format push notification")
return nil
}
var roomAvatar, image string
if notif.Room.Avatar != nil {
avatarIdent := notif.Room.ID.String()
if ptr.Val(notif.Room.DMUserID) != "" {
avatarIdent = notif.Room.DMUserID.String()
}
roomAvatar = getAvatarLinkForNotification(ptr.Val(notif.Room.Name), avatarIdent, notif.Room.Avatar.CUString())
}
roomName := ptr.Val(notif.Room.Name)
if roomName == "" {
roomName = "Unnamed room"
}
if len(roomName) > 50 {
roomName = roomName[:50] + "…"
}
text := content.Body
if len(text) > 400 {
text = text[:350] + "[…]"
}
if content.MsgType == event.MsgImage || evtType == event.EventSticker.Type {
if content.File != nil && content.File.URL != "" {
parsed := content.File.URL.ParseOrIgnore()
if len(content.File.URL) < 255 && parsed.IsValid() {
image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=true", parsed.Homeserver, parsed.FileID)
}
} else if content.URL != "" {
parsed := content.URL.ParseOrIgnore()
if len(content.URL) < 255 && parsed.IsValid() {
image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false", parsed.Homeserver, parsed.FileID)
}
}
if content.FileName == "" || content.FileName == content.Body {
text = "Sent a photo"
}
} else if content.MsgType.IsMedia() {
if content.FileName == "" || content.FileName == content.Body {
text = "Sent a file: " + text
}
}
return &PushNewMessage{
Timestamp: notif.Event.Timestamp,
EventID: notif.Event.ID,
EventRowID: notif.Event.RowID,
RoomID: notif.Room.ID,
RoomName: roomName,
RoomAvatar: roomAvatar,
Sender: gmx.getNotificationUser(ctx, notif.Room.ID, notif.Event.Sender),
Self: gmx.getNotificationUser(ctx, notif.Room.ID, gmx.Client.Account.UserID),
Text: text,
Image: image,
Mention: content.Mentions.Has(gmx.Client.Account.UserID),
Reply: content.RelatesTo.GetNonFallbackReplyTo() != "",
Sound: notif.Sound,
}
}

View file

@ -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,
@ -190,10 +193,10 @@ func (gmx *Gomuks) generateToken() (string, time.Time) {
}), expiry }), expiry
} }
func (gmx *Gomuks) generateImageToken() string { func (gmx *Gomuks) generateImageToken(expiry time.Duration) string {
return gmx.signToken(tokenData{ return gmx.signToken(tokenData{
Username: gmx.Config.Web.Username, Username: gmx.Config.Web.Username,
Expiry: jsontime.U(time.Now().Add(1 * time.Hour)), Expiry: jsontime.U(time.Now().Add(expiry)),
ImageOnly: true, ImageOnly: true,
}) })
} }
@ -206,8 +209,9 @@ func (gmx *Gomuks) signToken(td any) string {
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum) return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum)
} }
func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) { func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter, created, jsonOutput bool) {
token, expiry := gmx.generateToken() token, expiry := gmx.generateToken()
if !jsonOutput {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "gomuks_auth", Name: "gomuks_auth",
Value: token, Value: token,
@ -216,6 +220,15 @@ func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) {
Secure: true, Secure: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
}
if created {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusOK)
}
if jsonOutput {
_ = json.NewEncoder(w).Encode(map[string]string{"token": token})
}
} }
func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) { func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
@ -223,30 +236,40 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
jsonOutput := r.URL.Query().Get("output") == "json"
allowPrompt := r.URL.Query().Get("no_prompt") != "true"
authCookie, err := r.Cookie("gomuks_auth") authCookie, err := r.Cookie("gomuks_auth")
if err == nil && gmx.validateAuth(authCookie.Value, false) { if err == nil && gmx.validateAuth(authCookie.Value, false) {
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie") hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
gmx.writeTokenCookie(w) gmx.writeTokenCookie(w, false, jsonOutput)
w.WriteHeader(http.StatusOK) } else if found, correct := gmx.doBasicAuth(r); found && correct {
} else if username, password, ok := r.BasicAuth(); !ok { hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request") gmx.writeTokenCookie(w, true, jsonOutput)
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
} else { } else {
if !found {
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
} else {
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials")
}
if allowPrompt {
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
}
w.WriteHeader(http.StatusUnauthorized)
}
}
func (gmx *Gomuks) doBasicAuth(r *http.Request) (found, correct bool) {
var username, password string
username, password, found = r.BasicAuth()
if !found {
return
}
usernameHash := sha256.Sum256([]byte(username)) 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)
w.WriteHeader(http.StatusCreated)
} else {
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials")
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
}
}
} }
func isImageFetch(header http.Header) bool { func isImageFetch(header http.Header) bool {

View file

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

View file

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

View file

@ -36,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...)

View file

@ -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
} }

View file

@ -0,0 +1,78 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"encoding/json"
"time"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/jsontime"
)
const (
getNonExpiredPushTargets = `
SELECT device_id, type, data, encryption, expiration
FROM push_registration
WHERE expiration > $1
`
putPushRegistration = `
INSERT INTO push_registration (device_id, type, data, encryption, expiration)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (device_id) DO UPDATE SET
type = EXCLUDED.type,
data = EXCLUDED.data,
encryption = EXCLUDED.encryption,
expiration = EXCLUDED.expiration
`
)
type PushRegistrationQuery struct {
*dbutil.QueryHelper[*PushRegistration]
}
func (prq *PushRegistrationQuery) Put(ctx context.Context, reg *PushRegistration) error {
return prq.Exec(ctx, putPushRegistration, reg.sqlVariables()...)
}
func (seq *PushRegistrationQuery) GetAll(ctx context.Context) ([]*PushRegistration, error) {
return seq.QueryMany(ctx, getNonExpiredPushTargets, time.Now().Unix())
}
type PushType string
const (
PushTypeFCM PushType = "fcm"
)
type EncryptionKey struct {
Key []byte `json:"key,omitempty"`
}
type PushRegistration struct {
DeviceID string `json:"device_id"`
Type PushType `json:"type"`
Data json.RawMessage `json:"data"`
Encryption EncryptionKey `json:"encryption"`
Expiration jsontime.Unix `json:"expiration"`
}
func (pe *PushRegistration) Scan(row dbutil.Scannable) (*PushRegistration, error) {
err := row.Scan(&pe.DeviceID, &pe.Type, (*[]byte)(&pe.Data), dbutil.JSON{Data: &pe.Encryption}, &pe.Expiration)
if err != nil {
return nil, err
}
return pe, nil
}
func (pe *PushRegistration) sqlVariables() []any {
if pe.Expiration.IsZero() {
pe.Expiration = jsontime.U(time.Now().Add(7 * 24 * time.Hour))
}
return []interface{}{pe.DeviceID, pe.Type, unsafeJSONString(pe.Data), dbutil.JSON{Data: &pe.Encryption}, pe.Expiration}
}

View file

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

View file

@ -39,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)
}

View file

@ -1,4 +1,4 @@
-- v0 -> v10 (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,
@ -18,6 +18,7 @@ CREATE TABLE room (
name_quality INTEGER NOT NULL DEFAULT 0, name_quality INTEGER NOT NULL DEFAULT 0,
avatar TEXT, avatar TEXT,
explicit_avatar INTEGER NOT NULL DEFAULT 0, explicit_avatar INTEGER NOT NULL DEFAULT 0,
dm_user_id TEXT,
topic TEXT, topic TEXT,
canonical_alias TEXT, canonical_alias TEXT,
lazy_load_summary TEXT, lazy_load_summary TEXT,
@ -217,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 (
@ -300,3 +305,13 @@ CREATE TABLE space_edge (
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid) CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
) STRICT; ) STRICT;
CREATE INDEX space_edge_child_idx ON space_edge (child_id); CREATE INDEX space_edge_child_idx ON space_edge (child_id);
CREATE TABLE push_registration (
device_id TEXT NOT NULL,
type TEXT NOT NULL,
data TEXT NOT NULL,
encryption TEXT NOT NULL,
expiration INTEGER NOT NULL,
PRIMARY KEY (device_id)
) STRICT;

View file

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

View file

@ -0,0 +1,10 @@
-- v12 (compatible with v10+): Add table for push registrations
CREATE TABLE push_registration (
device_id TEXT NOT NULL,
type TEXT NOT NULL,
data TEXT NOT NULL,
encryption TEXT NOT NULL,
expiration INTEGER NOT NULL,
PRIMARY KEY (device_id)
) STRICT;

View 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;

View file

@ -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"
@ -21,13 +23,25 @@ type SyncRoom struct {
AccountData map[event.Type]*database.AccountData `json:"account_data"` AccountData map[event.Type]*database.AccountData `json:"account_data"`
Events []*database.Event `json:"events"` Events []*database.Event `json:"events"`
Reset bool `json:"reset"` Reset bool `json:"reset"`
Notifications []SyncNotification `json:"notifications"`
Receipts map[id.EventID][]*database.Receipt `json:"receipts"` Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
DismissNotifications bool `json:"dismiss_notifications"`
Notifications []SyncNotification `json:"notifications"`
} }
type SyncNotification struct { type SyncNotification struct {
RowID database.EventRowID `json:"event_rowid"` RowID database.EventRowID `json:"event_rowid"`
Sound bool `json:"sound"` Sound bool `json:"sound"`
Highlight bool `json:"highlight"`
Event *database.Event `json:"-"`
Room *database.Room `json:"-"`
}
type SyncToDevice struct {
Sender id.UserID `json:"sender"`
Type event.Type `json:"type"`
Content json.RawMessage `json:"content"`
Encrypted bool `json:"encrypted"`
} }
type SyncComplete struct { type SyncComplete struct {
@ -39,6 +53,18 @@ type SyncComplete struct {
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"` InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"` SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
TopLevelSpaces []id.RoomID `json:"top_level_spaces"` TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
ToDevice []*SyncToDevice `json:"to_device,omitempty"`
}
func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) {
for _, room := range c.Rooms {
for _, notif := range room.Notifications {
if !yield(notif) {
return
}
}
}
} }
func (c *SyncComplete) IsEmpty() bool { func (c *SyncComplete) IsEmpty() bool {

View file

@ -50,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

View file

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

View file

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

View file

@ -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

View file

@ -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) {
@ -64,7 +65,31 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
}) })
case "set_state": case "set_state":
return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) { return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) {
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content) return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content, mautrix.ReqSendEvent{
UnstableDelay: time.Duration(params.DelayMS) * time.Millisecond,
})
})
case "update_delayed_event":
return unmarshalAndCall(req.Data, func(params *updateDelayedEventParams) (*mautrix.RespUpdateDelayedEvent, error) {
return h.Client.UpdateDelayedEvent(ctx, &mautrix.ReqUpdateDelayedEvent{
DelayID: params.DelayID,
Action: params.Action,
})
})
case "set_membership":
return unmarshalAndCall(req.Data, func(params *setMembershipParams) (any, error) {
switch params.Action {
case "invite":
return h.Client.InviteUser(ctx, params.RoomID, &mautrix.ReqInviteUser{UserID: params.UserID, Reason: params.Reason})
case "kick":
return h.Client.KickUser(ctx, params.RoomID, &mautrix.ReqKickUser{UserID: params.UserID, Reason: params.Reason})
case "ban":
return h.Client.BanUser(ctx, params.RoomID, &mautrix.ReqBanUser{UserID: params.UserID, Reason: params.Reason})
case "unban":
return h.Client.UnbanUser(ctx, params.RoomID, &mautrix.ReqUnbanUser{UserID: params.UserID, Reason: params.Reason})
default:
return nil, fmt.Errorf("unknown action %q", params.Action)
}
}) })
case "set_account_data": case "set_account_data":
return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) { return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) {
@ -86,6 +111,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) { return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) {
return h.Client.GetProfile(ctx, params.UserID) return h.Client.GetProfile(ctx, params.UserID)
}) })
case "set_profile_field":
return unmarshalAndCall(req.Data, func(params *setProfileFieldParams) (bool, error) {
return true, h.Client.UnstableSetProfileField(ctx, params.Field, params.Value)
})
case "get_mutual_rooms": case "get_mutual_rooms":
return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) { return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) {
return h.GetMutualRooms(ctx, params.UserID) return h.GetMutualRooms(ctx, params.UserID)
@ -104,12 +133,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)
@ -145,14 +177,35 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) { return unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) {
return h.Client.LeaveRoom(ctx, params.RoomID, &mautrix.ReqLeave{Reason: params.Reason}) return h.Client.LeaveRoom(ctx, params.RoomID, &mautrix.ReqLeave{Reason: params.Reason})
}) })
case "create_room":
return unmarshalAndCall(req.Data, func(params *mautrix.ReqCreateRoom) (*mautrix.RespCreateRoom, error) {
return h.Client.CreateRoom(ctx, params)
})
case "mute_room":
return unmarshalAndCall(req.Data, func(params *muteRoomParams) (bool, error) {
if params.Muted {
return true, h.Client.PutPushRule(ctx, "global", pushrules.RoomRule, string(params.RoomID), &mautrix.ReqPutPushRule{
Actions: []pushrules.PushActionType{},
})
} else {
return false, h.Client.DeletePushRule(ctx, "global", pushrules.RoomRule, string(params.RoomID))
}
})
case "ensure_group_session_shared": case "ensure_group_session_shared":
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) { return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
return true, h.EnsureGroupSessionShared(ctx, params.RoomID) return true, h.EnsureGroupSessionShared(ctx, params.RoomID)
}) })
case "send_to_device":
return unmarshalAndCall(req.Data, func(params *sendToDeviceParams) (*mautrix.RespSendToDevice, error) {
params.EventType.Class = event.ToDeviceEventType
return h.SendToDevice(ctx, params.EventType, params.ReqSendToDevice, params.Encrypted)
})
case "resolve_alias": case "resolve_alias":
return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) { return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
return h.Client.ResolveAlias(ctx, params.Alias) return h.Client.ResolveAlias(ctx, params.Alias)
}) })
case "request_openid_token":
return h.Client.RequestOpenIDToken(ctx)
case "logout": case "logout":
if h.LogoutFunc == nil { if h.LogoutFunc == nil {
return nil, errors.New("logout not supported") return nil, errors.New("logout not supported")
@ -195,6 +248,18 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
} }
return cli.GetLoginFlows(ctx) return cli.GetLoginFlows(ctx)
}) })
case "register_push":
return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
return true, h.DB.PushRegistration.Put(ctx, params)
})
case "listen_to_device":
return unmarshalAndCall(req.Data, func(listen *bool) (bool, error) {
return h.ToDeviceInSync.Swap(*listen), nil
})
case "get_turn_servers":
return h.Client.TurnServer(ctx)
case "get_media_config":
return h.Client.GetMediaConfig(ctx)
default: default:
return nil, fmt.Errorf("unknown command %q", req.Command) return nil, fmt.Errorf("unknown command %q", req.Command)
} }
@ -227,6 +292,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 {
@ -250,6 +317,19 @@ type sendStateEventParams struct {
EventType event.Type `json:"type"` EventType event.Type `json:"type"`
StateKey string `json:"state_key"` StateKey string `json:"state_key"`
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
DelayMS int `json:"delay_ms"`
}
type updateDelayedEventParams struct {
DelayID string `json:"delay_id"`
Action string `json:"action"`
}
type setMembershipParams struct {
Action string `json:"action"`
RoomID id.RoomID `json:"room_id"`
UserID id.UserID `json:"user_id"`
Reason string `json:"reason"`
} }
type setAccountDataParams struct { type setAccountDataParams struct {
@ -273,14 +353,23 @@ type getProfileParams struct {
UserID id.UserID `json:"user_id"` UserID id.UserID `json:"user_id"`
} }
type setProfileFieldParams struct {
Field string `json:"field"`
Value any `json:"value"`
}
type getEventParams struct { type getEventParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
EventID id.EventID `json:"event_id"` EventID id.EventID `json:"event_id"`
Unredact bool `json:"unredact"`
} }
//type 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"`
@ -297,6 +386,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"`
} }
@ -345,3 +440,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"`
}

View file

@ -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()
@ -306,12 +296,12 @@ func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit i
if resp.End == "" { if resp.End == "" {
resp.End = database.PrevBatchPaginationComplete resp.End = database.PrevBatchPaginationComplete
} }
if resp.End == database.PrevBatchPaginationComplete || len(resp.Chunk) == 0 { if len(resp.Chunk) == 0 {
err = h.DB.Room.SetPrevBatch(ctx, room.ID, resp.End) err = h.DB.Room.SetPrevBatch(ctx, room.ID, resp.End)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to set prev_batch: %w", err) return nil, fmt.Errorf("failed to set prev_batch: %w", err)
} }
return &PaginationResponse{Events: events, HasMore: resp.End != ""}, nil return &PaginationResponse{Events: events, HasMore: resp.End != database.PrevBatchPaginationComplete}, nil
} }
wakeupSessionRequests := false wakeupSessionRequests := false
err = h.DB.DoTxn(ctx, nil, func(ctx context.Context) error { err = h.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
@ -376,5 +366,5 @@ func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit i
if err == nil && wakeupSessionRequests { if err == nil && wakeupSessionRequests {
h.WakeupRequestQueue() h.WakeupRequestQueue()
} }
return &PaginationResponse{Events: events, HasMore: true}, err return &PaginationResponse{Events: events, HasMore: resp.End != database.PrevBatchPaginationComplete}, err
} }

View file

@ -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 {
@ -213,6 +226,7 @@ func (h *HiClient) SetState(
evtType event.Type, evtType event.Type,
stateKey string, stateKey string,
content any, content any,
extra ...mautrix.ReqSendEvent,
) (id.EventID, error) { ) (id.EventID, error) {
room, err := h.DB.Room.Get(ctx, roomID) room, err := h.DB.Room.Get(ctx, roomID)
if err != nil { if err != nil {
@ -220,10 +234,14 @@ func (h *HiClient) SetState(
} else if room == nil { } else if room == nil {
return "", fmt.Errorf("unknown room") return "", fmt.Errorf("unknown room")
} }
resp, err := h.Client.SendStateEvent(ctx, room.ID, evtType, stateKey, content) resp, err := h.Client.SendStateEvent(ctx, room.ID, evtType, stateKey, content, extra...)
if err != nil { if err != nil {
return "", err return "", err
} }
if resp.UnstableDelayID != "" {
// Mildly hacky, but it's fine'
return id.EventID(resp.UnstableDelayID), nil
}
return resp.EventID, nil return resp.EventID, nil
} }
@ -232,8 +250,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 +276,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 +287,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 +346,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 +364,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 +442,18 @@ func (h *HiClient) EnsureGroupSessionShared(ctx context.Context, roomID id.RoomI
} }
} }
func (h *HiClient) SendToDevice(ctx context.Context, evtType event.Type, content *mautrix.ReqSendToDevice, encrypt bool) (*mautrix.RespSendToDevice, error) {
if encrypt {
var err error
content, err = h.Crypto.EncryptToDevices(ctx, evtType, content)
if err != nil {
return nil, fmt.Errorf("failed to encrypt: %w", err)
}
evtType = event.ToDeviceEncrypted
}
return h.Client.SendToDevice(ctx, evtType, content)
}
func (h *HiClient) loadMembers(ctx context.Context, room *database.Room) error { func (h *HiClient) loadMembers(ctx context.Context, room *database.Room) error {
if room.HasMemberList { if room.HasMemberList {
return nil return nil
@ -473,6 +516,9 @@ func (h *HiClient) shouldShareKeysToInvitedUsers(ctx context.Context, roomID id.
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get history visibility event") zerolog.Ctx(ctx).Err(err).Msg("Failed to get history visibility event")
return false return false
} else if historyVisibility == nil {
zerolog.Ctx(ctx).Warn().Msg("History visibility event not found")
return false
} }
mautrixEvt := historyVisibility.AsRawMautrix() mautrixEvt := historyVisibility.AsRawMautrix()
err = mautrixEvt.Content.ParseRaw(mautrixEvt.Type) err = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)

View file

@ -66,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 && h.shouldShareKeysToInvitedUsers(ctx, evt.RoomID)) ||
(prevMembership == event.MembershipJoin && newMembership == event.MembershipInvite) ||
(prevMembership == event.MembershipBan && newMembership == event.MembershipLeave) ||
(prevMembership == event.MembershipLeave && newMembership == event.MembershipBan) {
return false
}
zerolog.Ctx(ctx).Debug().
Stringer("room_id", evt.RoomID).
Str("user_id", evt.GetStateKey()).
Str("prev_membership", string(prevMembership)).
Str("new_membership", string(newMembership)).
Msg("Got membership state change, invalidating group session in room")
err := h.CryptoStore.RemoveOutboundGroupSession(ctx, evt.RoomID)
if err != nil {
zerolog.Ctx(ctx).Warn().Stringer("room_id", evt.RoomID).Msg("Failed to invalidate outbound group session")
return false
}
return true
}
func (h *HiClient) postProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) { func (h *HiClient) postProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) {
h.Crypto.HandleOTKCounts(ctx, &resp.DeviceOTKCount) h.Crypto.HandleOTKCounts(ctx, &resp.DeviceOTKCount)
go h.asyncPostProcessSyncResponse(ctx, resp, since) go h.asyncPostProcessSyncResponse(ctx, resp, since)
@ -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
@ -530,7 +598,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
return nil, nil return nil, nil
} }
const CurrentHTMLSanitizerVersion = 8 const CurrentHTMLSanitizerVersion = 10
func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) { func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) {
if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) || if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) ||
@ -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() {
@ -709,12 +785,13 @@ func (h *HiClient) processStateAndTimeline(
return fmt.Errorf("failed to get relation target of redaction target: %w", err) return fmt.Errorf("failed to get relation target of redaction target: %w", err)
} }
} }
if updatedRoom.PreviewEventRowID == dbEvt.RowID { if updatedRoom.PreviewEventRowID == dbEvt.RowID || (updatedRoom.PreviewEventRowID == 0 && room.PreviewEventRowID == dbEvt.RowID) {
updatedRoom.PreviewEventRowID = 0 updatedRoom.PreviewEventRowID = 0
recalculatePreviewEvent = true recalculatePreviewEvent = true
} }
return nil return nil
} }
megolmSessionDiscarded := false
processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) { processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) {
evt.RoomID = room.ID evt.RoomID = room.ID
dbEvt, err := h.processEvent(ctx, evt, summary, decryptionQueue, evt.Unsigned.TransactionID != "") dbEvt, err := h.processEvent(ctx, evt, summary, decryptionQueue, evt.Unsigned.TransactionID != "")
@ -726,6 +803,9 @@ func (h *HiClient) processStateAndTimeline(
newNotifications = append(newNotifications, SyncNotification{ newNotifications = append(newNotifications, SyncNotification{
RowID: dbEvt.RowID, RowID: dbEvt.RowID,
Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound), Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound),
Highlight: dbEvt.UnreadType.Is(database.UnreadTypeHighlight),
Event: dbEvt,
Room: room,
}) })
} }
newUnreadCounts.AddOne(dbEvt.UnreadType) newUnreadCounts.AddOne(dbEvt.UnreadType)
@ -744,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
} }
@ -886,18 +969,20 @@ func (h *HiClient) processStateAndTimeline(
updatedRoom.PreviewEventRowID, err = h.DB.Room.RecalculatePreview(ctx, room.ID) updatedRoom.PreviewEventRowID, err = h.DB.Room.RecalculatePreview(ctx, room.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to recalculate preview event: %w", err) return fmt.Errorf("failed to recalculate preview event: %w", err)
} } else if updatedRoom.PreviewEventRowID != 0 {
_, err = addOldEvent(updatedRoom.PreviewEventRowID, "") _, err = addOldEvent(updatedRoom.PreviewEventRowID, "")
if err != nil { if err != nil {
return fmt.Errorf("failed to get preview event: %w", err) return fmt.Errorf("failed to get preview event: %w", err)
} }
} }
}
// Calculate name from participants if participants changed and current name was generated from participants, or if the room name was unset // Calculate name from participants if participants changed and current name was generated from participants, or if the room name was unset
if (heroesChanged && updatedRoom.NameQuality <= database.NameQualityParticipants) || updatedRoom.NameQuality == database.NameQualityNil { if (heroesChanged && updatedRoom.NameQuality <= database.NameQualityParticipants) || updatedRoom.NameQuality == database.NameQualityNil {
name, dmAvatarURL, err := h.calculateRoomParticipantName(ctx, room.ID, summary) name, dmAvatarURL, dmUserID, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
if err != nil { if err != nil {
return fmt.Errorf("failed to calculate room name: %w", err) return fmt.Errorf("failed to calculate room name: %w", err)
} }
updatedRoom.DMUserID = &dmUserID
updatedRoom.Name = &name updatedRoom.Name = &name
updatedRoom.NameQuality = database.NameQualityParticipants updatedRoom.NameQuality = database.NameQualityParticipants
if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar { if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar {
@ -923,6 +1008,7 @@ func (h *HiClient) processStateAndTimeline(
} else { } else {
updatedRoom.UnreadCounts.Add(newUnreadCounts) updatedRoom.UnreadCounts.Add(newUnreadCounts)
} }
dismissNotifications := room.UnreadNotifications > 0 && updatedRoom.UnreadNotifications == 0 && len(newNotifications) == 0
if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) { if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) {
updatedRoom.PrevBatch = timeline.PrevBatch updatedRoom.PrevBatch = timeline.PrevBatch
} }
@ -949,8 +1035,10 @@ func (h *HiClient) processStateAndTimeline(
State: changedState, State: changedState,
Reset: timeline.Limited, Reset: timeline.Limited,
Events: allNewEvents, Events: allNewEvents,
Notifications: newNotifications,
Receipts: receiptMap, Receipts: receiptMap,
Notifications: newNotifications,
DismissNotifications: dismissNotifications,
} }
} }
return nil return nil
@ -966,15 +1054,15 @@ func joinMemberNames(names []string, totalCount int) string {
} }
} }
func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, error) { func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, id.UserID, error) {
var primaryAvatarURL id.ContentURI var primaryAvatarURL id.ContentURI
if summary == nil || len(summary.Heroes) == 0 { if summary == nil || len(summary.Heroes) == 0 {
return "Empty room", primaryAvatarURL, nil return "Empty room", primaryAvatarURL, "", nil
} }
var functionalMembers []id.UserID var functionalMembers []id.UserID
functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "") functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "")
if err != nil { if err != nil {
return "", primaryAvatarURL, fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err) return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err)
} else if functionalMembersEvt != nil { } else if functionalMembersEvt != nil {
mautrixEvt := functionalMembersEvt.AsRawMautrix() mautrixEvt := functionalMembersEvt.AsRawMautrix()
_ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type) _ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)
@ -990,16 +1078,21 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
} else if summary.InvitedMemberCount != nil { } else if summary.InvitedMemberCount != nil {
memberCount = *summary.InvitedMemberCount memberCount = *summary.InvitedMemberCount
} }
var dmUserID id.UserID
for _, hero := range summary.Heroes { for _, hero := range summary.Heroes {
if slices.Contains(functionalMembers, hero) { if slices.Contains(functionalMembers, hero) {
// TODO save member count so push rule evaluation would use the subtracted one?
memberCount-- memberCount--
continue continue
} else if len(members) >= 5 { } else if len(members) >= 5 {
break break
} }
if dmUserID == "" {
dmUserID = hero
}
heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String()) heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String())
if err != nil { if err != nil {
return "", primaryAvatarURL, fmt.Errorf("failed to get %s's member event: %w", hero, err) return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s's member event: %w", hero, err)
} else if heroEvt == nil { } else if heroEvt == nil {
leftMembers = append(leftMembers, hero.String()) leftMembers = append(leftMembers, hero.String())
continue continue
@ -1015,19 +1108,28 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
} }
if membership == "join" || membership == "invite" { if membership == "join" || membership == "invite" {
members = append(members, name) members = append(members, name)
dmUserID = hero
} else { } else {
leftMembers = append(leftMembers, name) leftMembers = append(leftMembers, name)
} }
} }
if len(members)+len(leftMembers) > 1 || !primaryAvatarURL.IsValid() { if !primaryAvatarURL.IsValid() {
primaryAvatarURL = id.ContentURI{} primaryAvatarURL = id.ContentURI{}
} }
if len(members) > 0 { if len(members) > 0 {
return joinMemberNames(members, memberCount), primaryAvatarURL, nil if len(members) > 1 {
primaryAvatarURL = id.ContentURI{}
dmUserID = ""
}
return joinMemberNames(members, memberCount), primaryAvatarURL, dmUserID, nil
} else if len(leftMembers) > 0 { } else if len(leftMembers) > 0 {
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, nil if len(leftMembers) > 1 {
primaryAvatarURL = id.ContentURI{}
dmUserID = ""
}
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, "", nil
} else { } else {
return "Empty room", primaryAvatarURL, nil return "Empty room", primaryAvatarURL, "", nil
} }
} }

View file

@ -69,7 +69,7 @@ func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration,
c.syncErrors++ c.syncErrors++
delay := 1 * time.Second delay := 1 * time.Second
if c.syncErrors > 5 { if c.syncErrors > 5 {
delay = 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")

View file

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

View file

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

1598
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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"
} }
} }

View file

@ -16,15 +16,17 @@
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,
EventID, EventID,
EventType, EventType,
GomuksAndroidMessageToWeb,
ImagePackRooms, ImagePackRooms,
RPCEvent, RPCEvent,
RawDBEvent, RawDBEvent,
RelationType,
RoomID, RoomID,
RoomStateGUID, RoomStateGUID,
SyncStatus, SyncStatus,
@ -39,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)
@ -71,6 +74,74 @@ export default class Client {
this.requestNotificationPermission() this.requestNotificationPermission()
} }
async #reallyStartAndroid(signal: AbortSignal) {
const androidListener = async (evt: CustomEventInit<string>) => {
const evtData = JSON.parse(evt.detail ?? "{}") as GomuksAndroidMessageToWeb
switch (evtData.type) {
case "register_push":
await this.rpc.registerPush({
type: "fcm",
device_id: evtData.device_id,
data: evtData.token,
encryption: evtData.encryption,
expiration: evtData.expiration,
})
return
case "auth":
try {
const resp = await fetch("_gomuks/auth?no_prompt=true", {
method: "POST",
headers: {
Authorization: evtData.authorization,
},
signal,
})
if (!resp.ok && !signal.aborted) {
console.error("Failed to authenticate:", resp.status, resp.statusText)
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
detail: {
event: "auth_fail",
error: `${resp.statusText || resp.status}`,
},
}))
return
}
} catch (err) {
console.error("Failed to authenticate:", err)
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
detail: {
event: "auth_fail",
error: `${err}`.replace(/^Error: /, ""),
},
}))
return
}
if (signal.aborted) {
return
}
console.log("Successfully authenticated, connecting to websocket")
this.rpc.start()
return
}
}
const unsubscribeConnect = this.rpc.connect.listen(evt => {
if (!evt.connected) {
return
}
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
detail: { event: "connected" },
}))
})
window.addEventListener("GomuksAndroidMessageToWeb", androidListener)
signal.addEventListener("abort", () => {
unsubscribeConnect()
window.removeEventListener("GomuksAndroidMessageToWeb", androidListener)
})
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
detail: { event: "ready" },
}))
}
requestNotificationPermission = (evt?: MouseEvent) => { requestNotificationPermission = (evt?: MouseEvent) => {
window.Notification?.requestPermission().then(permission => { window.Notification?.requestPermission().then(permission => {
console.log("Notification permission:", permission) console.log("Notification permission:", permission)
@ -84,9 +155,29 @@ export default class Client {
navigator.registerProtocolHandler("matrix", "#/uri/%s") navigator.registerProtocolHandler("matrix", "#/uri/%s")
} }
addWidgetListener(listener: WidgetListener): () => void {
this.store.widgetListeners.add(listener)
// TODO only request to-device events if there are widgets that need them?
if (!this.#toDeviceRequested) {
this.#toDeviceRequested = true
this.rpc.setListenToDevice(true)
}
return () => {
this.store.widgetListeners.delete(listener)
if (this.store.widgetListeners.size === 0 && this.#toDeviceRequested) {
this.#toDeviceRequested = false
this.rpc.setListenToDevice(false)
}
}
}
start(): () => void { start(): () => void {
const abort = new AbortController() const abort = new AbortController()
if (window.gomuksAndroid) {
this.#reallyStartAndroid(abort.signal)
} else {
this.#reallyStart(abort.signal) this.#reallyStart(abort.signal)
}
this.#gcInterval = setInterval(() => { this.#gcInterval = setInterval(() => {
console.log("Garbage collection completed:", this.store.doGarbageCollection()) console.log("Garbage collection completed:", this.store.doGarbageCollection())
}, window.gcSettings.interval) }, window.gcSettings.interval)
@ -148,20 +239,42 @@ export default class Client {
}) })
} }
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) { requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID, unredact?: boolean) {
if (typeof room === "string") { 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)
@ -196,12 +309,14 @@ export default class Client {
} }
} }
async sendEvent(roomID: RoomID, type: EventType, content: unknown): Promise<void> { async sendEvent(
roomID: RoomID, type: EventType, content: unknown, disableEncryption: boolean = false,
): Promise<void> {
const room = this.store.rooms.get(roomID) 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)
} }

View file

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

View file

@ -17,11 +17,13 @@ import { CachedEventDispatcher, EventDispatcher } from "../util/eventdispatcher.
import { CancellablePromise } from "../util/promise.ts" import { CancellablePromise } from "../util/promise.ts"
import type { import type {
ClientWellKnown, ClientWellKnown,
DBPushRegistration,
EventID, EventID,
EventRowID,
EventType, EventType,
JSONValue,
LoginFlowsResponse, LoginFlowsResponse,
LoginRequest, LoginRequest,
MembershipAction,
Mentions, Mentions,
MessageEventContent, MessageEventContent,
PaginationResponse, PaginationResponse,
@ -31,8 +33,14 @@ import type {
RawDBEvent, RawDBEvent,
ReceiptType, ReceiptType,
RelatesTo, RelatesTo,
RelationType,
ReqCreateRoom,
ResolveAliasResponse, ResolveAliasResponse,
RespCreateRoom,
RespMediaConfig,
RespOpenIDToken,
RespRoomJoin, RespRoomJoin,
RespTurnServer,
RoomAlias, RoomAlias,
RoomID, RoomID,
RoomStateGUID, RoomStateGUID,
@ -142,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> {
@ -160,8 +174,17 @@ export default abstract class RPCClient {
setState( setState(
room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>, room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>,
extra: { delay_ms?: number } = {},
): Promise<EventID> { ): Promise<EventID> {
return this.request("set_state", { room_id, type, state_key, content }) return this.request("set_state", { room_id, type, state_key, content, ...extra })
}
updateDelayedEvent(delay_id: string, action: string): Promise<void> {
return this.request("update_delayed_event", { delay_id, action })
}
setMembership(room_id: RoomID, user_id: UserID, action: MembershipAction, reason?: string): Promise<void> {
return this.request("set_membership", { room_id, user_id, action, reason })
} }
setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise<boolean> { setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise<boolean> {
@ -180,6 +203,10 @@ export default abstract class RPCClient {
return this.request("get_profile", { user_id }) return this.request("get_profile", { user_id })
} }
setProfileField(field: string, value: JSONValue): Promise<boolean> {
return this.request("set_profile_field", { field, value })
}
getMutualRooms(user_id: UserID): Promise<RoomID[]> { getMutualRooms(user_id: UserID): Promise<RoomID[]> {
return this.request("get_mutual_rooms", { user_id }) return this.request("get_mutual_rooms", { user_id })
} }
@ -196,6 +223,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 })
} }
@ -206,12 +241,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> {
@ -234,6 +269,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 })
} }
@ -257,4 +300,24 @@ export default abstract class RPCClient {
verify(recovery_key: string): Promise<boolean> { verify(recovery_key: string): Promise<boolean> {
return this.request("verify", { recovery_key }) return this.request("verify", { recovery_key })
} }
requestOpenIDToken(): Promise<RespOpenIDToken> {
return this.request("request_openid_token", {})
}
registerPush(reg: DBPushRegistration): Promise<boolean> {
return this.request("register_push", reg)
}
getTurnServers(): Promise<RespTurnServer> {
return this.request("get_turn_servers", {})
}
getMediaConfig(): Promise<RespMediaConfig> {
return this.request("get_media_config", {})
}
setListenToDevice(listen: boolean): Promise<void> {
return this.request("listen_to_device", listen)
}
} }

View file

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

View file

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

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { 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,
@ -39,7 +40,7 @@ import {
} from "../types" } from "../types"
import { InvitedRoomStore } from "./invitedroom.ts" import { InvitedRoomStore } from "./invitedroom.ts"
import { RoomStateStore } from "./room.ts" import { RoomStateStore } from "./room.ts"
import { DirectChatSpace, RoomListFilter, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts" import { DirectChatSpace, RoomListFilter, Space, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts"
export interface RoomListEntry { export interface RoomListEntry {
room_id: RoomID room_id: RoomID
@ -54,6 +55,7 @@ export interface RoomListEntry {
unread_notifications: number unread_notifications: number
unread_highlights: number unread_highlights: number
marked_unread: boolean marked_unread: boolean
is_invite?: boolean
} }
export interface GCSettings { export interface GCSettings {
@ -61,6 +63,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,
@ -73,6 +82,7 @@ export class StateStore {
readonly rooms: Map<RoomID, RoomStateStore> = new Map() readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map() readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([]) readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
readonly roomListEntries = new Map<RoomID, RoomListEntry>()
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([]) readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map() readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
readonly spaceOrphans = new SpaceOrphansSpace(this) readonly spaceOrphans = new SpaceOrphansSpace(this)
@ -97,9 +107,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)) {
@ -110,6 +130,39 @@ export class StateStore {
return true return true
} }
getSpaceByID(spaceID: string | undefined): RoomListFilter | null {
if (!spaceID) {
return null
}
const realSpace = this.spaceEdges.get(spaceID)
if (realSpace) {
return realSpace
}
for (const pseudoSpace of this.pseudoSpaces) {
if (pseudoSpace.id === spaceID) {
return pseudoSpace
}
}
console.warn("Failed to find space", spaceID)
return null
}
findMatchingSpace(room: RoomListEntry): Space | null {
if (this.spaceOrphans.include(room)) {
return this.spaceOrphans
}
for (const spaceID of this.topLevelSpaces.current) {
const space = this.spaceEdges.get(spaceID)
if (space?.include(room)) {
return space
}
}
if (this.directChatsSpace.include(room)) {
return this.directChatsSpace
}
return null
}
get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null { get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
if (!this.currentRoomListFilter && !this.currentRoomListQuery) { if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
return null return null
@ -169,8 +222,7 @@ export class StateStore {
const name = entry.meta.name ?? "Unnamed room" const name = entry.meta.name ?? "Unnamed room"
return { return {
room_id: entry.meta.room_id, room_id: entry.meta.room_id,
dm_user_id: entry.meta.lazy_load_summary?.["m.heroes"]?.length === 1 dm_user_id: entry.meta.dm_user_id,
? entry.meta.lazy_load_summary["m.heroes"][0] : undefined,
sorting_timestamp: entry.meta.sorting_timestamp, sorting_timestamp: entry.meta.sorting_timestamp,
preview_event, preview_event,
preview_sender, preview_sender,
@ -204,19 +256,26 @@ export class StateStore {
} }
applySync(sync: SyncCompleteData) { applySync(sync: SyncCompleteData) {
let prevActiveRoom: RoomID | null = null
if (sync.clear_state && this.rooms.size > 0) { if (sync.clear_state && this.rooms.size > 0) {
console.info("Clearing state store as sync told to reset and there are rooms in the store") console.info("Clearing state store as sync told to reset and there are rooms in the store")
prevActiveRoom = this.activeRoomID
this.clear() this.clear()
} }
const resyncRoomList = this.roomList.current.length === 0 const resyncRoomList = this.roomList.current.length === 0
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>() const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
if (sync.to_device?.length && this.widgetListeners.size > 0) {
for (const listener of this.widgetListeners) {
sync.to_device.forEach(listener.onToDeviceEvent)
}
}
for (const data of sync.invited_rooms ?? []) { for (const data of sync.invited_rooms ?? []) {
const room = new InvitedRoomStore(data, this) const room = new InvitedRoomStore(data, this)
const oldEntry = this.inviteRooms.get(room.room_id)
this.inviteRooms.set(room.room_id, room) this.inviteRooms.set(room.room_id, room)
if (!resyncRoomList) { if (!resyncRoomList) {
changedRoomListEntries.set(room.room_id, room) changedRoomListEntries.set(room.room_id, room)
this.#applyUnreadModification(room, oldEntry) this.#applyUnreadModification(room, this.roomListEntries.get(room.room_id))
this.roomListEntries.set(room.room_id, room)
} }
if (this.activeRoomID === room.room_id) { if (this.activeRoomID === room.room_id) {
this.switchRoom?.(room.room_id) this.switchRoom?.(room.room_id)
@ -239,8 +298,12 @@ export class StateStore {
if (roomListEntryChanged) { if (roomListEntryChanged) {
const entry = this.#makeRoomListEntry(data, room) const entry = this.#makeRoomListEntry(data, room)
changedRoomListEntries.set(roomID, entry) changedRoomListEntries.set(roomID, entry)
this.#applyUnreadModification(entry, room.roomListEntry) this.#applyUnreadModification(entry, this.roomListEntries.get(roomID))
room.roomListEntry = entry if (entry) {
this.roomListEntries.set(roomID, entry)
} else {
this.roomListEntries.delete(roomID)
}
} }
if (!resyncRoomList) { if (!resyncRoomList) {
// When we join a valid replacement room, hide the tombstoned room. // When we join a valid replacement room, hide the tombstoned room.
@ -278,6 +341,7 @@ export class StateStore {
} }
this.rooms.delete(roomID) this.rooms.delete(roomID)
changedRoomListEntries.set(roomID, null) changedRoomListEntries.set(roomID, null)
this.#applyUnreadModification(null, this.roomListEntries.get(roomID))
} }
let updatedRoomList: RoomListEntry[] | undefined let updatedRoomList: RoomListEntry[] | undefined
@ -289,7 +353,7 @@ export class StateStore {
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp) updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
for (const entry of updatedRoomList) { for (const entry of updatedRoomList) {
this.#applyUnreadModification(entry, undefined) this.#applyUnreadModification(entry, undefined)
this.rooms.get(entry.room_id)!.roomListEntry = entry this.roomListEntries.set(entry.room_id, entry)
} }
} else if (changedRoomListEntries.size > 0) { } else if (changedRoomListEntries.size > 0) {
updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id)) updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id))
@ -326,6 +390,10 @@ export class StateStore {
this.topLevelSpaces.emit(sync.top_level_spaces) this.topLevelSpaces.emit(sync.top_level_spaces)
this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id })) this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id }))
} }
if (prevActiveRoom) {
// TODO this will fail if the room is not in the top 100 recent rooms
this.switchRoom?.(prevActiveRoom)
}
} }
invalidateEmojiPackKeyCache() { invalidateEmojiPackKeyCache() {
@ -431,7 +499,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})`
@ -515,6 +583,7 @@ export class StateStore {
this.rooms.clear() this.rooms.clear()
this.inviteRooms.clear() this.inviteRooms.clear()
this.spaceEdges.clear() this.spaceEdges.clear()
this.pseudoSpaces.forEach(space => space.clearUnreads())
this.roomList.emit([]) this.roomList.emit([])
this.topLevelSpaces.emit([]) this.topLevelSpaces.emit([])
this.accountData.clear() this.accountData.clear()

View file

@ -18,7 +18,7 @@ import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
import toSearchableString from "@/util/searchablestring.ts" import toSearchableString from "@/util/searchablestring.ts"
import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts" import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts"
import { getDisplayname } from "@/util/validation.ts" import { getDisplayname, getServerName } from "@/util/validation.ts"
import { import {
ContentURI, ContentURI,
DBReceipt, DBReceipt,
@ -42,7 +42,7 @@ import {
UserID, UserID,
roomStateGUIDToString, roomStateGUIDToString,
} from "../types" } from "../types"
import type { RoomListEntry, StateStore } from "./main.ts" import type { StateStore } from "./main.ts"
function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean { function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
if (!arr1 || !arr2) { if (!arr1 || !arr2) {
@ -70,6 +70,7 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
meta1.avatar === meta2.avatar && meta1.avatar === meta2.avatar &&
meta1.topic === meta2.topic && meta1.topic === meta2.topic &&
meta1.canonical_alias === meta2.canonical_alias && meta1.canonical_alias === meta2.canonical_alias &&
meta1.dm_user_id === meta2.dm_user_id &&
llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) && llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) &&
meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm && meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm &&
meta1.has_member_list === meta2.has_member_list meta1.has_member_list === meta2.has_member_list
@ -126,7 +127,6 @@ export class RoomStateStore {
readUpToRow = -1 readUpToRow = -1
hasMoreHistory = true hasMoreHistory = true
hidden = false hidden = false
roomListEntry: RoomListEntry | undefined | null
constructor(meta: DBRoom, private parent: StateStore) { constructor(meta: DBRoom, private parent: StateStore) {
this.roomID = meta.room_id this.roomID = meta.room_id
@ -246,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) {

View file

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

View file

@ -0,0 +1,30 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export interface AndroidRegisterPushEvent {
type: "register_push"
device_id: string
token: string
encryption: { key: string }
expiration?: number
}
export interface AndroidAuthEvent {
type: "auth"
authorization: `Bearer ${string}`
}
export type GomuksAndroidMessageToWeb = AndroidRegisterPushEvent | AndroidAuthEvent

View file

@ -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> {

View file

@ -54,6 +54,7 @@ export interface DBRoom {
name_quality: RoomNameQuality name_quality: RoomNameQuality
avatar?: ContentURI avatar?: ContentURI
explicit_avatar: boolean explicit_avatar: boolean
dm_user_id?: UserID
topic?: string topic?: string
canonical_alias?: RoomAlias canonical_alias?: RoomAlias
lazy_load_summary?: LazyLoadSummary lazy_load_summary?: LazyLoadSummary
@ -155,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 {
@ -283,3 +286,13 @@ export interface ProfileEncryptionInfo {
user_trusted: boolean user_trusted: boolean
errors: string[] errors: string[]
} }
export interface DBPushRegistration {
device_id: string
type: "fcm"
data: unknown
encryption: { key: string }
expiration?: number
}
export type MembershipAction = "invite" | "kick" | "ban" | "unban"

View file

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

View file

@ -25,6 +25,14 @@ export type RoomVersion = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" |
export type RoomType = "" | "m.space" export type RoomType = "" | "m.space"
export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread" export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread"
export type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| {[key: string]: JSONValue}
export interface RoomPredecessor { export interface RoomPredecessor {
room_id: RoomID room_id: RoomID
event_id: EventID event_id: EventID
@ -65,9 +73,20 @@ export interface EncryptedEventContent {
export interface UserProfile { export interface UserProfile {
displayname?: string displayname?: string
avatar_url?: ContentURI avatar_url?: ContentURI
avatar_file?: EncryptedFile
[custom: string]: unknown [custom: string]: unknown
} }
export interface PronounSet {
subject?: string
object?: string
possessive_determiner?: string
possessive_pronoun?: string
reflexive?: string
summary: string
language: string
}
export type Membership = "join" | "leave" | "ban" | "invite" | "knock" export type Membership = "join" | "leave" | "ban" | "invite" | "knock"
export interface MemberEventContent extends UserProfile { export interface MemberEventContent extends UserProfile {
@ -93,6 +112,15 @@ export interface ACLEventContent {
deny?: string[] deny?: string[]
} }
export interface PolicyRuleContent {
entity: string
reason: string
recommendation: string
"org.matrix.msc4205.hashes"?: {
sha256: string
}
}
export interface PowerLevelEventContent { export interface PowerLevelEventContent {
users?: Record<UserID, number> users?: Record<UserID, number>
users_default?: number users_default?: number
@ -153,6 +181,10 @@ export interface URLPreview {
"og:description"?: string "og:description"?: string
} }
export interface BeeperPerMessageProfile extends UserProfile {
id: string
}
export interface BaseMessageEventContent { export interface BaseMessageEventContent {
msgtype: string msgtype: string
body: string body: string
@ -165,6 +197,7 @@ export interface BaseMessageEventContent {
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string "page.codeberg.everypizza.msc4193.spoiler.reason"?: string
"m.url_previews"?: URLPreview[] "m.url_previews"?: URLPreview[]
"com.beeper.linkpreviews"?: URLPreview[] "com.beeper.linkpreviews"?: URLPreview[]
"com.beeper.per_message_profile"?: BeeperPerMessageProfile
} }
export interface TextMessageEventContent extends BaseMessageEventContent { export interface TextMessageEventContent extends BaseMessageEventContent {
@ -188,6 +221,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
@ -279,3 +316,48 @@ export interface RoomSummary {
export interface RespRoomJoin { export interface RespRoomJoin {
room_id: RoomID room_id: RoomID
} }
export interface RespOpenIDToken {
access_token: string
expires_in: number
matrix_server_name: string
token_type: "Bearer"
}
export type RoomVisibility = "public" | "private"
export type RoomPreset = "private_chat" | "public_chat" | "trusted_private_chat"
export interface ReqCreateRoom {
visibility?: RoomVisibility
room_alias_name?: string
name?: string
topic?: string
invite?: UserID[]
preset?: RoomPreset
is_direct?: boolean
initial_state?: {
type: EventType
state_key?: string
content: Record<string, unknown>
}[]
room_version?: string
creation_content?: Record<string, unknown>
power_level_content_override?: Record<string, unknown>
"fi.mau.room_id"?: RoomID
}
export interface RespCreateRoom {
room_id: RoomID
}
export interface RespTurnServer {
username: string
password: string
ttl: number
uris: string[]
}
export interface RespMediaConfig {
"m.upload.size": number
[key: string]: unknown
}

View file

@ -55,10 +55,22 @@ export const preferences = {
}), }),
show_media_previews: new Preference<boolean>({ show_media_previews: new Preference<boolean>({
displayName: "Show image and video previews", displayName: "Show image and video previews",
description: "If disabled, images and videos will only be visible after clicking and will not be downloaded automatically.", description: "If disabled, images and videos will only be visible after clicking and will not be downloaded automatically. This will also disable images in URL previews.",
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: true, defaultValue: true,
}), }),
show_inline_images: new Preference<boolean>({
displayName: "Show inline images",
description: "If disabled, custom emojis and other inline images will not be rendered and the alt attribute will be shown instead.",
allowedContexts: anyContext,
defaultValue: true,
}),
show_invite_avatars: new Preference<boolean>({
displayName: "Show avatars in invites",
description: "If disabled, the avatar of the room or inviter will not be shown in the invite view.",
allowedContexts: anyGlobalContext,
defaultValue: true,
}),
code_block_line_wrap: new Preference<boolean>({ code_block_line_wrap: new Preference<boolean>({
displayName: "Code block line wrap", displayName: "Code block line wrap",
description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.", description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.",
@ -139,6 +151,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
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-80q-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
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M240-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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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

View file

@ -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;
@ -166,7 +167,6 @@ body {
padding: 0; padding: 0;
background-color: var(--background-color); background-color: var(--background-color);
line-height: 1.5; line-height: 1.5;
font-size: 16px;
touch-action: none; touch-action: none;
color: var(--text-color); color: var(--text-color);
min-height: 100vh; min-height: 100vh;
@ -175,6 +175,7 @@ body {
html { html {
touch-action: none; touch-action: none;
background-color: var(--background-color); background-color: var(--background-color);
font-size: 16px;
} }
#root { #root {
@ -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);

View file

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

View file

@ -13,13 +13,15 @@
// //
// 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 { createContext } from "react" import React, { createContext } from "react"
import { RoomListFilter } from "@/api/statestore"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import type { RightPanelProps } from "./rightpanel/RightPanel.tsx" import type { RightPanelProps } from "./rightpanel/RightPanel.tsx"
import type { RoomPreviewProps } from "./roomview/RoomPreview.tsx" import type { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
export interface MainScreenContextFields { export interface MainScreenContextFields {
setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>) => void setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>, toSpace?: RoomListFilter) => void
setSpace: (space: RoomListFilter | null, pushState?: boolean) => void
clickRoom: (evt: React.MouseEvent) => void clickRoom: (evt: React.MouseEvent) => void
clearActiveRoom: () => void clearActiveRoom: () => void
@ -32,6 +34,9 @@ const stubContext = {
get setActiveRoom(): never { get setActiveRoom(): never {
throw new Error("MainScreenContext used outside main screen") throw new Error("MainScreenContext used outside main screen")
}, },
get setSpace(): never {
throw new Error("MainScreenContext used outside main screen")
},
get clickRoom(): never { get clickRoom(): never {
throw new Error("MainScreenContext used outside main screen") throw new Error("MainScreenContext used outside main screen")
}, },

View file

@ -117,6 +117,15 @@ const StylePreferences = ({ client, activeRoom }: StylePreferencesProps) => {
--timeline-status-size: 2rem; --timeline-status-size: 2rem;
} }
`, [preferences.display_read_receipts]) `, [preferences.display_read_receipts])
useStyle(() => !preferences.show_inline_images && css`
a.hicli-inline-img-fallback {
display: inline !important;
}
img.hicli-inline-img {
display: none;
}
`, [preferences.show_inline_images])
useAsyncStyle(() => preferences.code_block_theme === "auto" ? ` useAsyncStyle(() => preferences.code_block_theme === "auto" ? `
@import url("_gomuks/codeblock/github.css") (prefers-color-scheme: light); @import url("_gomuks/codeblock/github.css") (prefers-color-scheme: light);
@import url("_gomuks/codeblock/github-dark.css") (prefers-color-scheme: dark); @import url("_gomuks/codeblock/github-dark.css") (prefers-color-scheme: dark);

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX, RefObject, use, useEffect } from "react" import { JSX, RefObject, use, useEffect } from "react"
import { 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}

View file

@ -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>

View file

@ -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}

View file

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX, use } from "react" import { JSX, use } from "react"
import { PulseLoader } from "react-spinners" import { PulseLoader } from "react-spinners"
import { 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))

View file

@ -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
} }

View file

@ -226,7 +226,7 @@ div.emoji-picker, div.sticker-picker {
} }
@media screen and (max-width: 37.5rem) { @media screen and (max-width: 37.5rem) {
div.emoji-picker, div.gif-picker { div.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);

View file

@ -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>

View file

@ -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}

View file

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

View 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>
}

View 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

View file

@ -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;
}
}
} }

View file

@ -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"

View file

@ -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

View file

@ -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 = (
@ -40,7 +43,7 @@ export const useSecondaryItems = (
openModal({ openModal({
dimmed: true, dimmed: true,
boxed: true, boxed: true,
content: <JSONView data={evt} />, content: <JSONView data={evt}/>,
}) })
} }
const onClickReport = () => { const onClickReport = () => {
@ -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"}

View file

@ -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

View file

@ -19,8 +19,18 @@ 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: auto;
} }
} }
} }

View file

@ -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}

View file

@ -13,57 +13,74 @@
// //
// 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
}, null) }, null)
const onClickWrapper = useCallback((evt?: React.MouseEvent) => { const onClickWrapper = useCallback((evt?: React.MouseEvent) => {
if (evt && evt.target !== evt.currentTarget) { if (evt && (evt.target !== evt.currentTarget || state?.noDismiss)) {
return return
} }
evt?.stopPropagation()
setState(null) setState(null)
if (history.state?.modal) { if (history.state?.[historyStateKey]) {
history.back() history.back()
} }
}, []) }, [historyStateKey, state])
const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => { const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => {
if (evt.key === "Escape") { if (evt.key === "Escape" && !state?.noDismiss) {
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(() => {
window.closeModal = onClickWrapper
if (historyStateKey === "nestable_modal") {
window.openNestableModal = openModal
} else {
window.openModal = openModal
}
if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) { if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
wrapperRef.current.focus() wrapperRef.current.focus()
} }
}, [state]) }, [state, onClickWrapper, historyStateKey, openModal])
useEffect(() => { useEffect(() => {
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)
}, []) }, [historyStateKey])
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 +102,10 @@ const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
modal = content modal = content
} }
} }
return <ModalContext value={openModal}> return <ContextType value={openModal}>
{children} {children}
{modal} {modal}
</ModalContext> </ContextType>
} }
export default ModalWrapper export default ModalWrapper

View file

@ -33,11 +33,15 @@ export interface ModalState {
innerBoxClass?: string innerBoxClass?: string
onClose?: () => void onClose?: () => void
captureInput?: boolean captureInput?: boolean
noDismiss?: 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>(() => {})

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useState } from "react" import React, { use, useState } from "react"
import { 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
} }

View file

@ -51,9 +51,23 @@ div.right-panel-content.pinned-messages {
} }
} }
div.right-panel-content.user { div.right-panel-content.widgets {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .5rem;
padding: .5rem;
> button {
padding: .5rem;
width: 100%;
}
> div.separator {
flex: 1;
}
}
div.right-panel-content.user {
padding: 1rem; padding: 1rem;
div.avatar-container { div.avatar-container {
@ -63,7 +77,6 @@ div.right-panel-content.user {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
flex-shrink: 0;
margin: 0 auto; margin: 0 auto;
> img { > img {
@ -89,6 +102,27 @@ div.right-panel-content.user {
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
word-break: break-word; word-break: break-word;
overflow: hidden;
}
div.userid, div.extended-profile, div.devices, div.user-moderation, div.mutual-rooms, div.errors {
border-bottom: 1px solid var(--border-color);
padding-bottom: .5rem;
margin-bottom: .5rem;
}
div.extended-profile {
display: grid;
gap: 0.25rem;
grid-template-columns: 1fr 1fr;
> input {
border: 0;
padding: 0; /* Necessary to prevent alignment issues with other cells */
width: 100%;
box-sizing: border-box;
border-bottom: 1px solid var(--blockquote-border-color);
}
} }
hr { hr {
@ -178,6 +212,25 @@ div.right-panel-content.user {
} }
} }
div.user-moderation {
display: flex;
flex-direction: column;
button.moderation-action {
padding: .5rem;
width: 100%;
gap: .5rem;
justify-content: left;
&.dangerous {
color: var(--error-color);
}
&.positive {
color: var(--primary-color);
}
}
}
div.errors { div.errors {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -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, { MainScreenContextFields } from "../MainScreenContext.ts"
import ErrorBoundary from "../util/ErrorBoundary.tsx"
import ElementCall from "../widget/ElementCall.tsx"
import LazyWidget from "../widget/LazyWidget.tsx"
import MemberList from "./MemberList.tsx" import 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,25 +44,37 @@ 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"
} }
} }
function renderRightPanelContent(props: RightPanelProps): JSX.Element | null { function renderRightPanelContent(props: RightPanelProps, mainScreen: MainScreenContextFields): JSX.Element | null {
switch (props.type) { switch (props.type) {
case "pinned-messages": case "pinned-messages":
return <PinnedMessages /> return <PinnedMessages />
case "members": case "members":
return <MemberList /> return <MemberList />
case "widgets":
return <WidgetList />
case "element-call":
return <ElementCall onClose={mainScreen.closeRightPanel} />
case "widget":
return <LazyWidget info={props.info} onClose={mainScreen.closeRightPanel} />
case "user": case "user":
return <UserInfo userID={props.userID} /> return <UserInfo userID={props.userID} />
} }
@ -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}`}>
{renderRightPanelContent(props)} <ErrorBoundary thing="right panel content">
{renderRightPanelContent(props, mainScreen)}
</ErrorBoundary>
</div> </div>
</div> </div>
} }

View 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

View file

@ -0,0 +1,119 @@
import { useEffect, useState } from "react"
import Client from "@/api/client.ts"
import { PronounSet, UserProfile } from "@/api/types"
import { ensureArray, ensureString } from "@/util/validation.ts"
interface ExtendedProfileProps {
profile: UserProfile | null
refreshProfile: () => void
client: Client
userID: string
}
interface SetTimezoneProps {
tz?: string
client: Client
refreshProfile: () => void
}
const getCurrentTimezone = () => new Intl.DateTimeFormat().resolvedOptions().timeZone
const currentTimeAdjusted = (tz: string) => {
try {
return new Intl.DateTimeFormat("en-GB", {
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "short",
timeZone: tz,
}).format(new Date())
} catch {
return null
}
}
const ClockElement = ({ tz }: { tz: string }) => {
const cta = currentTimeAdjusted(tz)
const isValidTZ = cta !== null
const [time, setTime] = useState(cta)
useEffect(() => {
if (!isValidTZ) {
return
}
let interval: number | undefined
const updateTime = () => setTime(currentTimeAdjusted(tz))
const timeout = setTimeout(() => {
interval = setInterval(updateTime, 1000)
updateTime()
}, (1001 - Date.now() % 1000))
return () => interval ? clearInterval(interval) : clearTimeout(timeout)
}, [tz, isValidTZ])
if (!isValidTZ) {
return null
}
return <>
<div title={tz}>Time:</div>
<div title={tz}>{time}</div>
</>
}
const SetTimeZoneElement = ({ tz, client, refreshProfile }: SetTimezoneProps) => {
const zones = Intl.supportedValuesOf("timeZone")
const saveTz = (newTz: string) => {
if (!zones.includes(newTz)) {
return
}
client.rpc.setProfileField("us.cloke.msc4175.tz", newTz).then(
() => refreshProfile(),
err => {
console.error("Failed to set time zone:", err)
window.alert(`Failed to set time zone: ${err}`)
},
)
}
const defaultValue = tz || getCurrentTimezone()
return <>
<label htmlFor="userprofile-timezone-input">Set time zone:</label>
<input
list="timezones"
id="userprofile-timezone-input"
defaultValue={defaultValue}
onKeyDown={evt => evt.key === "Enter" && saveTz(evt.currentTarget.value)}
onBlur={evt => evt.currentTarget.value !== defaultValue && saveTz(evt.currentTarget.value)}
/>
<datalist id="timezones">
{zones.map((zone) => <option key={zone} value={zone} />)}
</datalist>
</>
}
const UserExtendedProfile = ({ profile, refreshProfile, client, userID }: ExtendedProfileProps)=> {
if (!profile) {
return null
}
const extendedProfileKeys = ["us.cloke.msc4175.tz", "io.fsky.nyx.pronouns"]
const hasExtendedProfile = extendedProfileKeys.some((key) => profile[key])
if (!hasExtendedProfile && client.userID !== userID) {
return null
}
// Explicitly only return something if the profile has the keys we're looking for.
// otherwise there's an ugly and pointless <hr/> for no real reason.
const pronouns = ensureArray(profile["io.fsky.nyx.pronouns"]) as PronounSet[]
const userTimeZone = ensureString(profile["us.cloke.msc4175.tz"])
return <div className="extended-profile">
{userTimeZone && <ClockElement tz={userTimeZone} />}
{userID === client.userID &&
<SetTimeZoneElement tz={userTimeZone} client={client} refreshProfile={refreshProfile} />}
{pronouns.length > 0 && <>
<div>Pronouns:</div>
<div>{pronouns.map(pronounSet => ensureString(pronounSet.summary)).join(", ")}</div>
</>}
</div>
}
export default UserExtendedProfile

View 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

View file

@ -13,18 +13,20 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useEffect, useState } from "react" import { use, useCallback, useEffect, useState } from "react"
import { PuffLoader } from "react-spinners" import { PuffLoader } from "react-spinners"
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { useRoomMember } from "@/api/statestore" import { useRoomMember } from "@/api/statestore"
import { MemberEventContent, UserID, UserProfile } from "@/api/types" import { MemberEventContent, UserID, UserProfile } from "@/api/types"
import { 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"
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
@ -38,16 +40,20 @@ const UserInfo = ({ userID }: UserInfoProps) => {
const member = (memberEvt?.content ?? null) as MemberEventContent | null const member = (memberEvt?.content ?? null) as MemberEventContent | null
const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null) const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null)
const [errors, setErrors] = useState<string[] | null>(null) const [errors, setErrors] = useState<string[] | null>(null)
useEffect(() => { const refreshProfile = useCallback((clearState = false) => {
if (clearState) {
setErrors(null) setErrors(null)
setGlobalProfile(null) setGlobalProfile(null)
}
client.rpc.getProfile(userID).then( client.rpc.getProfile(userID).then(
setGlobalProfile, setGlobalProfile,
err => setErrors([`${err}`]), err => setErrors([`${err}`]),
) )
}, [roomCtx, userID, client]) }, [userID, client])
useEffect(() => refreshProfile(true), [refreshProfile])
const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID) const displayname = ensureString(member?.displayname)
|| 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
@ -56,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=""
@ -63,17 +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>
<hr/> <UserExtendedProfile profile={globalProfile} refreshProfile={refreshProfile} client={client} userID={userID}/>
<DeviceList client={client} room={roomCtx?.store} userID={userID}/>
{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}
</> </>
} }

View file

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

View file

@ -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

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