1
0
Fork 0
forked from Mirrors/gomuks

Compare commits

..

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
147 changed files with 6209 additions and 1642 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]
env:
GOTOOLCHAIN: local
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.23"]
name: Lint Go ${{ matrix.go-version == '1.23' && '(latest)' || '(old)' }}
go-version: ["1.23", "1.24"]
name: Lint Go ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }}
steps:
- uses: actions/checkout@v4

View file

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

View file

@ -1,6 +1,6 @@
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
COPY $EXECUTABLE /usr/bin/gomuks

View file

@ -1,18 +1,7 @@
# gomuks
![Languages](https://img.shields.io/github/languages/top/tulir/gomuks.svg)
[![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases)
[![GitLab CI](https://mau.dev/tulir/gomuks/badges/main/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines)
# nyxmuks
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
[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>.
# why?
## Docs
For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/).
## Discussion
Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net)
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.

View file

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

View file

@ -1,5 +1,5 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@ -7,12 +7,14 @@ github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@ -31,39 +33,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/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
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/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@ -71,8 +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@ -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/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
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.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.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/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
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/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@ -130,11 +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/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ=
github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -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/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
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.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/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wailsapp/go-webview2 v1.0.15 h1:IeQFoWmsHp32y64I41J+Zod3SopjHs918KSO4jUqEnY=
github.com/wailsapp/go-webview2 v1.0.15/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
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/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v3 v3.0.0-alpha.7 h1:LNX2EnbxTEYJYICJT8UkuzoGVNalRizTNGBY47endmk=
github.com/wailsapp/wails/v3 v3.0.0-alpha.7/go.mod h1:lBz4zedFxreJBoVpMe9u89oo4IE3IlyHJg5rOWnGNR0=
github.com/wailsapp/wails/v3 v3.0.0-alpha.9 h1:b8CfRrhPno8Fra0xFp4Ifyj+ogmXBc35rsQWvcrHtsI=
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/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
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/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
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.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
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/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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -208,20 +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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
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.3/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.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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
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=
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.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.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
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=
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
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/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 h1:3aZCApUF3wlboN1BcAX6u6MGhju4OpsmhyfvxdO6tO0=
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
maunium.net/go/mautrix v0.23.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg=
maunium.net/go/mautrix v0.23.2/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
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
toolchain go1.23.4
toolchain go1.24.1
require (
github.com/alecthomas/chroma/v2 v2.14.0
github.com/alecthomas/chroma/v2 v2.15.0
github.com/buckket/go-blurhash v1.1.0
github.com/chzyer/readline v1.5.1
github.com/coder/websocket v1.8.12
github.com/gabriel-vasile/mimetype v1.4.7
github.com/coder/websocket v1.8.13
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/mattn/go-sqlite3 v1.14.24
github.com/rivo/uniseg v0.4.7
@ -17,29 +18,30 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/yuin/goldmark v1.7.8
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
go.mau.fi/util v0.8.6
go.mau.fi/webp v0.2.0
go.mau.fi/zeroconfig v0.1.3
golang.org/x/crypto v0.31.0
golang.org/x/image v0.23.0
golang.org/x/net v0.33.0
golang.org/x/text v0.21.0
golang.org/x/crypto v0.36.0
golang.org/x/image v0.25.0
golang.org/x/net v0.37.0
golang.org/x/text v0.23.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5
mvdan.cc/xurls/v2 v2.5.0
maunium.net/go/mautrix v0.23.2
mvdan.cc/xurls/v2 v2.6.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
golang.org/x/sys v0.28.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.31.0 // 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=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
@ -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/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
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.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
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/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
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/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.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.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.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/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-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
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.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/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
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/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5 h1:3aZCApUF3wlboN1BcAX6u6MGhju4OpsmhyfvxdO6tO0=
maunium.net/go/mautrix v0.22.2-0.20241222121030-33b4e823c5e5/go.mod h1:1rhqwH34Rz54ZqzdQYkmNW6rQUymNeTdaLA4l9LK6AI=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
maunium.net/go/mautrix v0.23.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg=
maunium.net/go/mautrix v0.23.2/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
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)
if err != nil {
panic(fmt.Errorf("failed to marshal event %T: %w", evt, err))

View file

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

View file

@ -34,6 +34,7 @@ import (
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/ptr"
"golang.org/x/net/http2"
"go.mau.fi/gomuks/pkg/hicli"
@ -171,7 +172,7 @@ func (gmx *Gomuks) StartClient() {
nil,
gmx.Log.With().Str("component", "hicli").Logger(),
[]byte("meow"),
gmx.EventBuffer.HicliEventHandler,
gmx.HandleEvent,
)
gmx.Client.LogoutFunc = gmx.Logout
httpClient := gmx.Client.Client.Client
@ -197,6 +198,14 @@ func (gmx *Gomuks) StartClient() {
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() {
gmx.stopOnce.Do(func() {
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"
"github.com/buckket/go-blurhash"
"github.com/disintegration/imaging"
"github.com/gabriel-vasile/mimetype"
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
_ "golang.org/x/image/webp"
"go.mau.fi/util/exhttp"
"go.mau.fi/util/ffmpeg"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
cwebp "go.mau.fi/webp"
_ "golang.org/x/image/webp"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
@ -59,7 +60,7 @@ var ErrBadGateway = mautrix.RespError{
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 force {
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
}
etag := entry.ETag(useThumbnail)
if entry.Error != nil {
w.Header().Set("Mau-Cached-Error", "true")
entry.Error.Write(w)
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)
return true
} 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
}
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 errors.Is(err, os.ErrNotExist) && !force {
return false
@ -91,7 +129,7 @@ func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWr
defer func() {
_ = cacheFile.Close()
}()
cacheEntryToHeaders(w, entry)
cacheEntryToHeaders(w, entry, useThumbnail)
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, cacheFile)
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:])
}
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-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-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; media-src 'self';")
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 {
@ -191,6 +292,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
}
encrypted, _ := strconv.ParseBool(query.Get("encrypted"))
useThumbnail := query.Get("thumbnail") == "avatar"
logVal := zerolog.Ctx(r.Context()).With().
Stringer("mxc_uri", mxc).
@ -211,7 +313,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
return
}
if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false) {
if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false, useThumbnail) {
return
}
@ -301,8 +403,8 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
cacheEntry.Size = resp.ContentLength
fileHasher := sha256.New()
wrappedReader := io.TeeReader(reader, fileHasher)
if cacheEntry.Size > 0 && cacheEntry.EncFile == nil {
cacheEntryToHeaders(w, cacheEntry)
if cacheEntry.Size > 0 && cacheEntry.EncFile == nil && !useThumbnail {
cacheEntryToHeaders(w, cacheEntry, useThumbnail)
w.WriteHeader(http.StatusOK)
wrappedReader = io.TeeReader(wrappedReader, &noErrorWriter{w})
w = nil
@ -342,7 +444,7 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
return
}
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("POST /sso", gmx.PrepareSSO)
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)
return exhttp.ApplyMiddleware(
api,
@ -190,10 +193,10 @@ func (gmx *Gomuks) generateToken() (string, time.Time) {
}), expiry
}
func (gmx *Gomuks) generateImageToken() string {
func (gmx *Gomuks) generateImageToken(expiry time.Duration) string {
return gmx.signToken(tokenData{
Username: gmx.Config.Web.Username,
Expiry: jsontime.U(time.Now().Add(1 * time.Hour)),
Expiry: jsontime.U(time.Now().Add(expiry)),
ImageOnly: true,
})
}
@ -206,8 +209,9 @@ func (gmx *Gomuks) signToken(td any) string {
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()
if !jsonOutput {
http.SetCookie(w, &http.Cookie{
Name: "gomuks_auth",
Value: token,
@ -217,36 +221,55 @@ func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) {
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) {
if gmx.DisableAuth {
w.WriteHeader(http.StatusOK)
return
}
jsonOutput := r.URL.Query().Get("output") == "json"
allowPrompt := r.URL.Query().Get("no_prompt") != "true"
authCookie, err := r.Cookie("gomuks_auth")
if err == nil && gmx.validateAuth(authCookie.Value, false) {
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
gmx.writeTokenCookie(w)
w.WriteHeader(http.StatusOK)
} else if username, password, ok := r.BasicAuth(); !ok {
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
gmx.writeTokenCookie(w, false, jsonOutput)
} else if found, correct := gmx.doBasicAuth(r); found && correct {
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
gmx.writeTokenCookie(w, true, jsonOutput)
} else {
if !found {
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
} 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))
expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username))
usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:])
passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil
if usernameCorrect && passwordCorrect {
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
gmx.writeTokenCookie(w)
w.WriteHeader(http.StatusCreated)
} else {
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials")
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
}
}
correct = passwordCorrect && usernameCorrect
return
}
func isImageFetch(header http.Header) bool {

View file

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

View file

@ -28,6 +28,7 @@ type Database struct {
Receipt *ReceiptQuery
Media *MediaQuery
SpaceEdge *SpaceEdgeQuery
PushRegistration *PushRegistrationQuery
}
func New(rawDB *dbutil.Database) *Database {
@ -47,6 +48,7 @@ func New(rawDB *dbutil.Database) *Database {
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
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 {
return &SpaceEdge{}
}
func newPushRegistration(_ *dbutil.QueryHelper[*PushRegistration]) *PushRegistration {
return &PushRegistration{}
}

View file

@ -36,6 +36,10 @@ const (
getEventByID = getEventBaseQuery + `WHERE event_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`
getRelatedEventsQuery = getEventBaseQuery + `
WHERE room_id = $1 AND relates_to = $2 AND ($3 = '' OR relation_type = $3)
ORDER BY timestamp ASC
`
insertEventBaseQuery = `
INSERT INTO event (
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)
}
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) {
query, params := buildMultiEventGetFunction(nil, rowIDs, getManyEventsByRowID)
return eq.QueryMany(ctx, query, params...)

View file

@ -23,24 +23,27 @@ import (
const (
insertMediaQuery = `
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error)
VALUES ($1, $2, $3, $4, $5, $6, $7)
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, $8, $9, $10)
ON CONFLICT (mxc) DO NOTHING
`
upsertMediaQuery = `
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error)
VALUES ($1, $2, $3, $4, $5, $6, $7)
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, $8, $9, $10)
ON CONFLICT (mxc) DO UPDATE
SET enc_file = COALESCE(excluded.enc_file, media.enc_file),
file_name = COALESCE(excluded.file_name, media.file_name),
mime_type = COALESCE(excluded.mime_type, media.mime_type),
size = COALESCE(excluded.size, media.size),
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
`
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
WHERE mxc = $1
`
@ -137,9 +140,22 @@ type Media struct {
Size int64
Hash *[32]byte
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 {
return ""
}
@ -151,14 +167,18 @@ func (m *Media) UseCache() bool {
}
func (m *Media) sqlVariables() []any {
var hash []byte
var hash, thumbnailHash []byte
if m.Hash != nil {
hash = m.Hash[:]
}
if m.ThumbnailHash != nil {
thumbnailHash = m.ThumbnailHash[:]
}
return []any{
&m.MXC, dbutil.JSONPtr(m.EncFile),
dbutil.StrPtr(m.FileName), dbutil.StrPtr(m.MimeType), dbutil.NumPtr(m.Size),
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) {
var mimeType, fileName sql.NullString
var size sql.NullInt64
var hash []byte
err := row.Scan(&m.MXC, dbutil.JSON{Data: &m.EncFile}, &fileName, &mimeType, &size, &hash, dbutil.JSON{Data: &m.Error})
var mimeType, fileName, thumbnailError sql.NullString
var size, thumbnailSize sql.NullInt64
var hash, thumbnailHash []byte
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 {
return nil, err
}
m.MimeType = mimeType.String
m.FileName = fileName.String
m.Size = size.Int64
m.ThumbnailSize = thumbnailSize.Int64
m.ThumbnailError = thumbnailError.String
if len(hash) == 32 {
m.Hash = (*[32]byte)(hash)
}
if len(thumbnailHash) == 32 {
m.ThumbnailHash = (*[32]byte)(thumbnailHash)
}
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 (
getRoomBaseQuery = `
SELECT room_id, creation_content, tombstone_content, name, name_quality, avatar, explicit_avatar, topic, canonical_alias,
SELECT room_id, creation_content, tombstone_content, name, name_quality,
avatar, explicit_avatar, dm_user_id, topic, canonical_alias,
lazy_load_summary, encryption_event, has_member_list, preview_event_rowid, sorting_timestamp,
unread_highlights, unread_notifications, unread_messages, marked_unread, prev_batch
FROM room
@ -42,18 +43,19 @@ const (
name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END,
avatar = COALESCE($6, room.avatar),
explicit_avatar = CASE WHEN $6 IS NOT NULL THEN $7 ELSE room.explicit_avatar END,
topic = COALESCE($8, room.topic),
canonical_alias = COALESCE($9, room.canonical_alias),
lazy_load_summary = COALESCE($10, room.lazy_load_summary),
encryption_event = COALESCE($11, room.encryption_event),
has_member_list = room.has_member_list OR $12,
preview_event_rowid = COALESCE($13, room.preview_event_rowid),
sorting_timestamp = COALESCE($14, room.sorting_timestamp),
unread_highlights = COALESCE($15, room.unread_highlights),
unread_notifications = COALESCE($16, room.unread_notifications),
unread_messages = COALESCE($17, room.unread_messages),
marked_unread = COALESCE($18, room.marked_unread),
prev_batch = COALESCE($19, room.prev_batch)
dm_user_id = COALESCE($8, room.dm_user_id),
topic = COALESCE($9, room.topic),
canonical_alias = COALESCE($10, room.canonical_alias),
lazy_load_summary = COALESCE($11, room.lazy_load_summary),
encryption_event = COALESCE($12, room.encryption_event),
has_member_list = room.has_member_list OR $13,
preview_event_rowid = COALESCE($14, room.preview_event_rowid),
sorting_timestamp = COALESCE($15, room.sorting_timestamp),
unread_highlights = COALESCE($16, room.unread_highlights),
unread_notifications = COALESCE($17, room.unread_notifications),
unread_messages = COALESCE($18, room.unread_messages),
marked_unread = COALESCE($19, room.marked_unread),
prev_batch = COALESCE($20, room.prev_batch)
WHERE room_id = $1
`
setRoomPrevBatchQuery = `
@ -78,7 +80,7 @@ const (
AND (type IN ('m.room.message', 'm.sticker')
OR (type = 'm.room.encrypted'
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
ORDER BY timestamp DESC
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) {
err = rq.GetDB().QueryRow(ctx, recalculateRoomPreviewEventQuery, roomID).Scan(&rowID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
return
}
@ -153,6 +158,7 @@ type Room struct {
NameQuality NameQuality `json:"name_quality"`
Avatar *id.ContentURI `json:"avatar,omitempty"`
ExplicitAvatar bool `json:"explicit_avatar"`
DMUserID *id.UserID `json:"dm_user_id,omitempty"`
Topic *string `json:"topic,omitempty"`
CanonicalAlias *id.RoomAlias `json:"canonical_alias,omitempty"`
@ -188,6 +194,10 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) {
other.ExplicitAvatar = r.ExplicitAvatar
hasChanges = true
}
if r.DMUserID != nil {
other.DMUserID = r.DMUserID
hasChanges = true
}
if r.Topic != nil {
other.Topic = r.Topic
hasChanges = true
@ -208,7 +218,7 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) {
hasChanges = true
other.HasMemberList = true
}
if r.PreviewEventRowID > other.PreviewEventRowID {
if r.PreviewEventRowID != 0 {
other.PreviewEventRowID = r.PreviewEventRowID
hasChanges = true
}
@ -250,6 +260,7 @@ func (r *Room) Scan(row dbutil.Scannable) (*Room, error) {
&r.NameQuality,
&r.Avatar,
&r.ExplicitAvatar,
&r.DMUserID,
&r.Topic,
&r.CanonicalAlias,
dbutil.JSON{Data: &r.LazyLoadSummary},
@ -281,6 +292,7 @@ func (r *Room) sqlVariables() []any {
r.NameQuality,
r.Avatar,
r.ExplicitAvatar,
r.DMUserID,
r.Topic,
r.CanonicalAlias,
dbutil.JSONPtr(r.LazyLoadSummary),

View file

@ -39,6 +39,7 @@ const (
`
getCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1`
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)`
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) {
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 (
user_id TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
@ -18,6 +18,7 @@ CREATE TABLE room (
name_quality INTEGER NOT NULL DEFAULT 0,
avatar TEXT,
explicit_avatar INTEGER NOT NULL DEFAULT 0,
dm_user_id TEXT,
topic TEXT,
canonical_alias TEXT,
lazy_load_summary TEXT,
@ -217,7 +218,11 @@ CREATE TABLE media (
mime_type TEXT,
size INTEGER,
hash BLOB,
error TEXT
error TEXT,
thumbnail_size INTEGER,
thumbnail_hash BLOB,
thumbnail_error TEXT
) STRICT;
CREATE TABLE media_reference (
@ -300,3 +305,13 @@ CREATE TABLE space_edge (
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
) STRICT;
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
import (
"encoding/json"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@ -21,13 +23,25 @@ type SyncRoom struct {
AccountData map[event.Type]*database.AccountData `json:"account_data"`
Events []*database.Event `json:"events"`
Reset bool `json:"reset"`
Notifications []SyncNotification `json:"notifications"`
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
DismissNotifications bool `json:"dismiss_notifications"`
Notifications []SyncNotification `json:"notifications"`
}
type SyncNotification struct {
RowID database.EventRowID `json:"event_rowid"`
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 {
@ -39,6 +53,18 @@ type SyncComplete struct {
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
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 {

View file

@ -50,6 +50,8 @@ type HiClient struct {
syncErrors int
lastSync time.Time
ToDeviceInSync atomic.Bool
EventHandler func(evt any)
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{
Body: strings.Join(fields[2:], " "),
MsgType: event.MsgText,
})
}, false, false)
_, _ = fmt.Fprintln(rl, err)
_, _ = 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 {
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")
writeAttribute(w, "alt", alt)
if title != "" {
writeAttribute(w, "title", title)
}
mxc := id.ContentURIString(src).ParseOrIgnore()
if !mxc.IsValid() {
return id.ContentURI{}
}
writeAttribute(w, "src", fmt.Sprintf(HTMLSanitizerImgSrcTemplate, mxc.Homeserver, mxc.FileID))
writeAttribute(w, "src", url)
writeAttribute(w, "loading", "lazy")
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 {
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))
} else {
writeAttribute(w, "class", "hicli-sizeless-inline-img")
writeAttribute(w, "class", "hicli-inline-img hicli-sizeless-inline-img")
}
return mxc
}

View file

@ -120,8 +120,11 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
payload := SyncComplete{
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 roomIdx == 0 {
batchSize *= 2
}
break
}
maxTS = room.SortingTimestamp.Time

View file

@ -17,6 +17,7 @@ import (
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
"go.mau.fi/gomuks/pkg/hicli/database"
)
@ -46,7 +47,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
})
case "send_event":
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":
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":
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":
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 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":
return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) {
return h.GetMutualRooms(ctx, params.UserID)
@ -104,12 +133,15 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
})
case "get_event":
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)
})
//case "get_events_by_rowids":
// return unmarshalAndCall(req.Data, func(params *getEventsByRowIDsParams) ([]*database.Event, error) {
// return h.GetEventsByRowIDs(ctx, params.RowIDs)
// })
case "get_related_events":
return unmarshalAndCall(req.Data, func(params *getRelatedEventsParams) ([]*database.Event, error) {
return h.DB.Event.GetRelatedEvents(ctx, params.RoomID, params.EventID, params.RelationType)
})
case "get_room_state":
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
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 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":
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
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":
return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
return h.Client.ResolveAlias(ctx, params.Alias)
})
case "request_openid_token":
return h.Client.RequestOpenIDToken(ctx)
case "logout":
if h.LogoutFunc == nil {
return nil, errors.New("logout not supported")
@ -195,6 +248,18 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
}
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:
return nil, fmt.Errorf("unknown command %q", req.Command)
}
@ -227,6 +292,8 @@ type sendEventParams struct {
RoomID id.RoomID `json:"room_id"`
EventType event.Type `json:"type"`
Content json.RawMessage `json:"content"`
DisableEncryption bool `json:"disable_encryption"`
Synchronous bool `json:"synchronous"`
}
type resendEventParams struct {
@ -250,6 +317,19 @@ type sendStateEventParams struct {
EventType event.Type `json:"type"`
StateKey string `json:"state_key"`
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 {
@ -273,14 +353,23 @@ type getProfileParams struct {
UserID id.UserID `json:"user_id"`
}
type setProfileFieldParams struct {
Field string `json:"field"`
Value any `json:"value"`
}
type getEventParams struct {
RoomID id.RoomID `json:"room_id"`
EventID id.EventID `json:"event_id"`
Unredact bool `json:"unredact"`
}
//type getEventsByRowIDsParams struct {
// RowIDs []database.EventRowID `json:"row_ids"`
//}
type getRelatedEventsParams struct {
RoomID id.RoomID `json:"room_id"`
EventID id.EventID `json:"event_id"`
RelationType event.RelationType `json:"relation_type"`
}
type getRoomStateParams struct {
RoomID id.RoomID `json:"room_id"`
@ -297,6 +386,12 @@ type ensureGroupSessionSharedParams struct {
RoomID id.RoomID `json:"room_id"`
}
type sendToDeviceParams struct {
*mautrix.ReqSendToDevice
EventType event.Type `json:"event_type"`
Encrypted bool `json:"encrypted"`
}
type resolveAliasParams struct {
Alias id.RoomAlias `json:"alias"`
}
@ -345,3 +440,8 @@ type getReceiptsParams struct {
RoomID id.RoomID `json:"room_id"`
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")
/*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) {
if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
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 {
var evts []*event.Event
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) {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(context.Canceled)
h.paginationInterrupterLock.Lock()
if _, alreadyPaginating := h.paginationInterrupter[roomID]; alreadyPaginating {
h.paginationInterrupterLock.Unlock()
@ -306,12 +296,12 @@ func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit i
if resp.End == "" {
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)
if err != nil {
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
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 {
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,
mentions *event.Mentions,
) (*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
if strings.HasPrefix(text, "/unencrypted ") {
text = strings.TrimPrefix(text, "/unencrypted ")
@ -90,7 +97,7 @@ func (h *HiClient) SendMessage(
if !json.Valid(content) {
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 ") {
parts := strings.SplitN(text, " ", 4)
if len(parts) < 4 || len(parts[1]) == 0 {
@ -154,12 +161,18 @@ func (h *HiClient) SendMessage(
Body: "",
MsgType: contentCopy.MsgType,
URL: contentCopy.URL,
GeoURI: contentCopy.GeoURI,
NewContent: &contentCopy,
RelatesTo: relatesTo,
}
if contentCopy.File != nil {
content.URL = contentCopy.File.URL
}
if extra != nil {
extra = map[string]any{
"m.new_content": extra,
}
}
} else {
content.RelatesTo = relatesTo
}
@ -169,7 +182,7 @@ func (h *HiClient) SendMessage(
content.MsgType = ""
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 {
@ -213,6 +226,7 @@ func (h *HiClient) SetState(
evtType event.Type,
stateKey string,
content any,
extra ...mautrix.ReqSendEvent,
) (id.EventID, error) {
room, err := h.DB.Room.Get(ctx, roomID)
if err != nil {
@ -220,10 +234,14 @@ func (h *HiClient) SetState(
} else if room == nil {
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 {
return "", err
}
if resp.UnstableDelayID != "" {
// Mildly hacky, but it's fine'
return id.EventID(resp.UnstableDelayID), nil
}
return resp.EventID, nil
}
@ -232,8 +250,14 @@ func (h *HiClient) Send(
roomID id.RoomID,
evtType event.Type,
content any,
disableEncryption bool,
synchronous bool,
) (*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) {
@ -252,7 +276,7 @@ func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, e
return nil, fmt.Errorf("unknown room")
}
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
}
@ -263,6 +287,7 @@ func (h *HiClient) send(
content any,
overrideEditSource string,
disableEncryption bool,
synchronous bool,
) (*database.Event, error) {
room, err := h.DB.Room.Get(ctx, roomID)
if err != nil {
@ -321,11 +346,15 @@ func (h *HiClient) send(
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
}
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
defer func() {
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")
}
}
if !synchronous {
h.EventHandler(&SendComplete{
Event: dbEvt,
Error: err,
})
}
}()
if dbEvt.Decrypted != nil && len(dbEvt.Content) <= 2 {
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 {
if room.HasMemberList {
return nil
@ -473,6 +516,9 @@ func (h *HiClient) shouldShareKeysToInvitedUsers(ctx context.Context, roomID id.
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get history visibility event")
return false
} else if historyVisibility == nil {
zerolog.Ctx(ctx).Warn().Msg("History visibility event not found")
return false
}
mautrixEvt := historyVisibility.AsRawMautrix()
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 {
log := zerolog.Ctx(ctx)
listenToDevice := h.ToDeviceInSync.Load()
var syncTD []*SyncToDevice
postponedToDevices := resp.ToDevice.Events[:0]
for _, evt := range resp.ToDevice.Events {
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) {
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:
// 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)
default:
}
case *event.SecretRequestEventContent, *event.RoomKeyRequestEventContent:
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
if len(syncTD) > 0 {
ctx.Value(syncContextKey).(*syncContext).evt.ToDevice = syncTD
}
h.Crypto.MarkOlmHashSavePoint(ctx)
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) {
h.Crypto.HandleOTKCounts(ctx, &resp.DeviceOTKCount)
go h.asyncPostProcessSyncResponse(ctx, resp, since)
@ -297,6 +361,10 @@ func (h *HiClient) processSyncLeftRoom(ctx context.Context, roomID id.RoomID, ro
if err != nil {
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.LeftRooms = append(payload.LeftRooms, roomID)
return nil
@ -530,7 +598,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
return nil, nil
}
const CurrentHTMLSanitizerVersion = 8
const CurrentHTMLSanitizerVersion = 10
func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) {
if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) ||
@ -572,6 +640,14 @@ func (h *HiClient) processEvent(
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)
contentWithoutFallback := removeReplyFallback(evt)
if contentWithoutFallback != nil {
@ -580,7 +656,7 @@ func (h *HiClient) processEvent(
}
var decryptionErr error
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)
} else if evt.Type == event.EventRedaction {
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)
}
}
if updatedRoom.PreviewEventRowID == dbEvt.RowID {
if updatedRoom.PreviewEventRowID == dbEvt.RowID || (updatedRoom.PreviewEventRowID == 0 && room.PreviewEventRowID == dbEvt.RowID) {
updatedRoom.PreviewEventRowID = 0
recalculatePreviewEvent = true
}
return nil
}
megolmSessionDiscarded := false
processNewEvent := func(evt *event.Event, isTimeline, isUnread bool) (database.EventRowID, error) {
evt.RoomID = room.ID
dbEvt, err := h.processEvent(ctx, evt, summary, decryptionQueue, evt.Unsigned.TransactionID != "")
@ -726,6 +803,9 @@ func (h *HiClient) processStateAndTimeline(
newNotifications = append(newNotifications, SyncNotification{
RowID: dbEvt.RowID,
Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound),
Highlight: dbEvt.UnreadType.Is(database.UnreadTypeHighlight),
Event: dbEvt,
Room: room,
})
}
newUnreadCounts.AddOne(dbEvt.UnreadType)
@ -744,6 +824,9 @@ func (h *HiClient) processStateAndTimeline(
if summary != nil && slices.Contains(summary.Heroes, id.UserID(*evt.StateKey)) {
heroesChanged = true
}
if !megolmSessionDiscarded && room.EncryptionEvent != nil {
megolmSessionDiscarded = h.maybeDiscardOutboundSession(ctx, membership, evt)
}
} else if evt.Type == event.StateElementFunctionalMembers {
heroesChanged = true
}
@ -886,18 +969,20 @@ func (h *HiClient) processStateAndTimeline(
updatedRoom.PreviewEventRowID, err = h.DB.Room.RecalculatePreview(ctx, room.ID)
if err != nil {
return fmt.Errorf("failed to recalculate preview event: %w", err)
}
} else if updatedRoom.PreviewEventRowID != 0 {
_, err = addOldEvent(updatedRoom.PreviewEventRowID, "")
if err != nil {
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
if (heroesChanged && updatedRoom.NameQuality <= database.NameQualityParticipants) || updatedRoom.NameQuality == database.NameQualityNil {
name, dmAvatarURL, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
name, dmAvatarURL, dmUserID, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
if err != nil {
return fmt.Errorf("failed to calculate room name: %w", err)
}
updatedRoom.DMUserID = &dmUserID
updatedRoom.Name = &name
updatedRoom.NameQuality = database.NameQualityParticipants
if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar {
@ -923,6 +1008,7 @@ func (h *HiClient) processStateAndTimeline(
} else {
updatedRoom.UnreadCounts.Add(newUnreadCounts)
}
dismissNotifications := room.UnreadNotifications > 0 && updatedRoom.UnreadNotifications == 0 && len(newNotifications) == 0
if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) {
updatedRoom.PrevBatch = timeline.PrevBatch
}
@ -949,8 +1035,10 @@ func (h *HiClient) processStateAndTimeline(
State: changedState,
Reset: timeline.Limited,
Events: allNewEvents,
Notifications: newNotifications,
Receipts: receiptMap,
Notifications: newNotifications,
DismissNotifications: dismissNotifications,
}
}
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
if summary == nil || len(summary.Heroes) == 0 {
return "Empty room", primaryAvatarURL, nil
return "Empty room", primaryAvatarURL, "", nil
}
var functionalMembers []id.UserID
functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "")
if err != nil {
return "", primaryAvatarURL, fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err)
return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err)
} else if functionalMembersEvt != nil {
mautrixEvt := functionalMembersEvt.AsRawMautrix()
_ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)
@ -990,16 +1078,21 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
} else if summary.InvitedMemberCount != nil {
memberCount = *summary.InvitedMemberCount
}
var dmUserID id.UserID
for _, hero := range summary.Heroes {
if slices.Contains(functionalMembers, hero) {
// TODO save member count so push rule evaluation would use the subtracted one?
memberCount--
continue
} else if len(members) >= 5 {
break
}
if dmUserID == "" {
dmUserID = hero
}
heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String())
if err != nil {
return "", primaryAvatarURL, fmt.Errorf("failed to get %s's member event: %w", hero, err)
return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s's member event: %w", hero, err)
} else if heroEvt == nil {
leftMembers = append(leftMembers, hero.String())
continue
@ -1015,19 +1108,28 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
}
if membership == "join" || membership == "invite" {
members = append(members, name)
dmUserID = hero
} else {
leftMembers = append(leftMembers, name)
}
}
if len(members)+len(leftMembers) > 1 || !primaryAvatarURL.IsValid() {
if !primaryAvatarURL.IsValid() {
primaryAvatarURL = id.ContentURI{}
}
if len(members) > 0 {
return joinMemberNames(members, memberCount), primaryAvatarURL, nil
if len(members) > 1 {
primaryAvatarURL = id.ContentURI{}
dmUserID = ""
}
return joinMemberNames(members, memberCount), primaryAvatarURL, dmUserID, nil
} else if len(leftMembers) > 0 {
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, nil
if len(leftMembers) > 1 {
primaryAvatarURL = id.ContentURI{}
dmUserID = ""
}
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, "", nil
} else {
return "Empty room", primaryAvatarURL, nil
return "Empty room", primaryAvatarURL, "", nil
}
}

View file

@ -69,7 +69,7 @@ func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration,
c.syncErrors++
delay := 1 * time.Second
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.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"],
"quotes": ["error", "double", {allowTemplateLiterals: true}],
"semi": ["error", "never"],
"curly": ["error", "all"],
"comma-dangle": ["error", "always-multiline"],
"max-len": ["warn", 120],
"max-len": ["error", 120],
"space-before-function-paren": ["error", {
"anonymous": "never",
"named": "never",

View file

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

1631
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -16,15 +16,17 @@
import type { MouseEvent } from "react"
import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
import RPCClient, { SendMessageParams } from "./rpc.ts"
import { RoomStateStore, StateStore } from "./statestore"
import { RoomStateStore, StateStore, WidgetListener } from "./statestore"
import type {
ClientState,
ElementRecentEmoji,
EventID,
EventType,
GomuksAndroidMessageToWeb,
ImagePackRooms,
RPCEvent,
RawDBEvent,
RelationType,
RoomID,
RoomStateGUID,
SyncStatus,
@ -39,6 +41,7 @@ export default class Client {
#stateRequests: RoomStateGUID[] = []
#stateRequestPromise: Promise<void> | null = null
#gcInterval: number | undefined
#toDeviceRequested = false
constructor(readonly rpc: RPCClient) {
this.rpc.event.listen(this.#handleEvent)
@ -71,6 +74,74 @@ export default class Client {
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) => {
window.Notification?.requestPermission().then(permission => {
console.log("Notification permission:", permission)
@ -84,9 +155,29 @@ export default class Client {
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 {
const abort = new AbortController()
if (window.gomuksAndroid) {
this.#reallyStartAndroid(abort.signal)
} else {
this.#reallyStart(abort.signal)
}
this.#gcInterval = setInterval(() => {
console.log("Garbage collection completed:", this.store.doGarbageCollection())
}, 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") {
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
}
room.requestedEvents.add(eventID)
this.rpc.getEvent(room.roomID, eventID).then(
evt => room.applyEvent(evt),
err => console.error(`Failed to fetch event ${eventID}`, err),
this.rpc.getEvent(room.roomID, eventID, unredact).then(
evt => {
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) {
const pinnedEvents = room.getPinnedEvents()
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)
if (!room) {
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)
}

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { parseMXC } from "@/util/validation.ts"
import { ContentURI, LazyLoadSummary, RoomID, UserID, UserProfile } from "./types"
import { ContentURI, RoomID, UserID, UserProfile } from "./types"
export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => {
const [server, mediaID] = parseMXC(mxc)
@ -78,36 +78,56 @@ function getFallbackCharacter(from: unknown, idx: number): string {
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 backgroundColor = getUserColor(userID)
const [server, mediaID] = parseMXC(content?.avatar_url)
if (!mediaID) {
const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url)
if (!mediaID || forceFallback) {
return makeFallbackAvatar(backgroundColor, fallbackCharacter)
}
const encrypted = !!content?.avatar_file
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 {
room_id: RoomID
name?: string
dm_user_id?: UserID
lazy_load_summary?: LazyLoadSummary
avatar?: ContentURI
avatar_url?: ContentURI
}
export const getRoomAvatarURL = (room: RoomForAvatarURL, avatarOverride?: ContentURI): string | undefined => {
let dmUserID: UserID | undefined
if ("dm_user_id" in room) {
dmUserID = room.dm_user_id
} else if ("lazy_load_summary" in room) {
dmUserID = room.lazy_load_summary?.["m.heroes"]?.length === 1
? room.lazy_load_summary["m.heroes"][0] : undefined
}
return getAvatarURL(dmUserID ?? room.room_id, {
export const getRoomAvatarURL = (
room: RoomForAvatarURL,
avatarOverride?: ContentURI,
thumbnail = false,
forceFallback = false,
): string | undefined => {
return getAvatarURL(room.dm_user_id ?? room.room_id, {
displayname: room.name,
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 type {
ClientWellKnown,
DBPushRegistration,
EventID,
EventRowID,
EventType,
JSONValue,
LoginFlowsResponse,
LoginRequest,
MembershipAction,
Mentions,
MessageEventContent,
PaginationResponse,
@ -31,8 +33,14 @@ import type {
RawDBEvent,
ReceiptType,
RelatesTo,
RelationType,
ReqCreateRoom,
ResolveAliasResponse,
RespCreateRoom,
RespMediaConfig,
RespOpenIDToken,
RespRoomJoin,
RespTurnServer,
RoomAlias,
RoomID,
RoomStateGUID,
@ -142,8 +150,14 @@ export default abstract class RPCClient {
return this.request("send_message", params)
}
sendEvent(room_id: RoomID, type: EventType, content: unknown): Promise<RawDBEvent> {
return this.request("send_event", { room_id, type, content })
sendEvent(
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> {
@ -160,8 +174,17 @@ export default abstract class RPCClient {
setState(
room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>,
extra: { delay_ms?: number } = {},
): 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> {
@ -180,6 +203,10 @@ export default abstract class RPCClient {
return this.request("get_profile", { user_id })
}
setProfileField(field: string, value: JSONValue): Promise<boolean> {
return this.request("set_profile_field", { field, value })
}
getMutualRooms(user_id: UserID): Promise<RoomID[]> {
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 })
}
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[]> {
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 })
}
getEvent(room_id: RoomID, event_id: EventID): Promise<RawDBEvent> {
return this.request("get_event", { room_id, event_id })
getEvent(room_id: RoomID, event_id: EventID, unredact?: boolean): Promise<RawDBEvent> {
return this.request("get_event", { room_id, event_id, unredact })
}
getEventsByRowIDs(row_ids: EventRowID[]): Promise<RawDBEvent[]> {
return this.request("get_events_by_row_ids", { row_ids })
getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise<RawDBEvent[]> {
return this.request("get_related_events", { room_id, event_id, relation_type })
}
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 })
}
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> {
return this.request("resolve_alias", { alias })
}
@ -257,4 +300,24 @@ export default abstract class RPCClient {
verify(recovery_key: string): Promise<boolean> {
return this.request("verify", { recovery_key })
}
requestOpenIDToken(): Promise<RespOpenIDToken> {
return this.request("request_openid_token", {})
}
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 "./room.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 inviter_profile?: MemberEventContent
readonly is_direct: boolean
readonly is_invite = true
constructor(public readonly meta: DBInvitedRoom, parent: StateStore) {
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
// 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 { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
@ -32,6 +32,7 @@ import {
SendCompleteData,
SyncCompleteData,
SyncRoom,
SyncToDevice,
TypingEventData,
UnknownEventContent,
UserID,
@ -39,7 +40,7 @@ import {
} from "../types"
import { InvitedRoomStore } from "./invitedroom.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 {
room_id: RoomID
@ -54,6 +55,7 @@ export interface RoomListEntry {
unread_notifications: number
unread_highlights: number
marked_unread: boolean
is_invite?: boolean
}
export interface GCSettings {
@ -61,6 +63,13 @@ export interface GCSettings {
lastOpenedCutoff: number,
}
export interface WidgetListener {
onTimelineEvent(evt: MemDBEvent): void
onStateEvent(evt: MemDBEvent): void
onToDeviceEvent(evt: SyncToDevice): void
onRoomChange(roomID: RoomID | null): void
}
window.gcSettings ??= {
// Run garbage collection every 15 minutes.
interval: 15 * 60 * 1000,
@ -73,6 +82,7 @@ export class StateStore {
readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
readonly roomListEntries = new Map<RoomID, RoomListEntry>()
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
readonly spaceOrphans = new SpaceOrphansSpace(this)
@ -97,9 +107,19 @@ export class StateStore {
readonly localPreferenceCache: Preferences = getLocalStoragePreferences("global_prefs", this.preferenceSub.notify)
serverPreferenceCache: Preferences = {}
switchRoom?: (roomID: RoomID | null) => void
activeRoomID: RoomID | null = null
#activeRoomID: RoomID | null = null
activeRoomIsPreview: boolean = false
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) => {
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
@ -110,6 +130,39 @@ export class StateStore {
return true
}
getSpaceByID(spaceID: string | undefined): RoomListFilter | null {
if (!spaceID) {
return null
}
const realSpace = this.spaceEdges.get(spaceID)
if (realSpace) {
return realSpace
}
for (const pseudoSpace of this.pseudoSpaces) {
if (pseudoSpace.id === spaceID) {
return pseudoSpace
}
}
console.warn("Failed to find space", spaceID)
return null
}
findMatchingSpace(room: RoomListEntry): Space | null {
if (this.spaceOrphans.include(room)) {
return this.spaceOrphans
}
for (const spaceID of this.topLevelSpaces.current) {
const space = this.spaceEdges.get(spaceID)
if (space?.include(room)) {
return space
}
}
if (this.directChatsSpace.include(room)) {
return this.directChatsSpace
}
return null
}
get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
return null
@ -169,8 +222,7 @@ export class StateStore {
const name = entry.meta.name ?? "Unnamed room"
return {
room_id: entry.meta.room_id,
dm_user_id: entry.meta.lazy_load_summary?.["m.heroes"]?.length === 1
? entry.meta.lazy_load_summary["m.heroes"][0] : undefined,
dm_user_id: entry.meta.dm_user_id,
sorting_timestamp: entry.meta.sorting_timestamp,
preview_event,
preview_sender,
@ -204,19 +256,26 @@ export class StateStore {
}
applySync(sync: SyncCompleteData) {
let prevActiveRoom: RoomID | null = null
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")
prevActiveRoom = this.activeRoomID
this.clear()
}
const resyncRoomList = this.roomList.current.length === 0
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 ?? []) {
const room = new InvitedRoomStore(data, this)
const oldEntry = this.inviteRooms.get(room.room_id)
this.inviteRooms.set(room.room_id, room)
if (!resyncRoomList) {
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) {
this.switchRoom?.(room.room_id)
@ -239,8 +298,12 @@ export class StateStore {
if (roomListEntryChanged) {
const entry = this.#makeRoomListEntry(data, room)
changedRoomListEntries.set(roomID, entry)
this.#applyUnreadModification(entry, room.roomListEntry)
room.roomListEntry = entry
this.#applyUnreadModification(entry, this.roomListEntries.get(roomID))
if (entry) {
this.roomListEntries.set(roomID, entry)
} else {
this.roomListEntries.delete(roomID)
}
}
if (!resyncRoomList) {
// When we join a valid replacement room, hide the tombstoned room.
@ -278,6 +341,7 @@ export class StateStore {
}
this.rooms.delete(roomID)
changedRoomListEntries.set(roomID, null)
this.#applyUnreadModification(null, this.roomListEntries.get(roomID))
}
let updatedRoomList: RoomListEntry[] | undefined
@ -289,7 +353,7 @@ export class StateStore {
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
for (const entry of updatedRoomList) {
this.#applyUnreadModification(entry, undefined)
this.rooms.get(entry.room_id)!.roomListEntry = entry
this.roomListEntries.set(entry.room_id, entry)
}
} else if (changedRoomListEntries.size > 0) {
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.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() {
@ -431,7 +499,7 @@ export class StateStore {
body = body.slice(0, 350) + " […]"
}
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 senderName = memberEvt?.content.displayname ?? evt.sender
const title = senderName === roomName ? senderName : `${senderName} (${roomName})`
@ -515,6 +583,7 @@ export class StateStore {
this.rooms.clear()
this.inviteRooms.clear()
this.spaceEdges.clear()
this.pseudoSpaces.forEach(space => space.clearUnreads())
this.roomList.emit([])
this.topLevelSpaces.emit([])
this.accountData.clear()

View file

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

View file

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

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

View file

@ -54,6 +54,7 @@ export interface DBRoom {
name_quality: RoomNameQuality
avatar?: ContentURI
explicit_avatar: boolean
dm_user_id?: UserID
topic?: string
canonical_alias?: RoomAlias
lazy_load_summary?: LazyLoadSummary
@ -155,7 +156,9 @@ export interface MemDBEvent extends BaseDBEvent {
pending: boolean
encrypted?: EncryptedEventContent
orig_content?: UnknownEventContent
orig_local_content?: LocalContent
last_edit?: MemDBEvent
viewing_redacted?: boolean
}
export interface DBAccountData {
@ -283,3 +286,13 @@ export interface ProfileEncryptionInfo {
user_trusted: boolean
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 "./hitypes.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 RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread"
export type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| {[key: string]: JSONValue}
export interface RoomPredecessor {
room_id: RoomID
event_id: EventID
@ -65,9 +73,20 @@ export interface EncryptedEventContent {
export interface UserProfile {
displayname?: string
avatar_url?: ContentURI
avatar_file?: EncryptedFile
[custom: string]: unknown
}
export interface PronounSet {
subject?: string
object?: string
possessive_determiner?: string
possessive_pronoun?: string
reflexive?: string
summary: string
language: string
}
export type Membership = "join" | "leave" | "ban" | "invite" | "knock"
export interface MemberEventContent extends UserProfile {
@ -93,6 +112,15 @@ export interface ACLEventContent {
deny?: string[]
}
export interface PolicyRuleContent {
entity: string
reason: string
recommendation: string
"org.matrix.msc4205.hashes"?: {
sha256: string
}
}
export interface PowerLevelEventContent {
users?: Record<UserID, number>
users_default?: number
@ -153,6 +181,10 @@ export interface URLPreview {
"og:description"?: string
}
export interface BeeperPerMessageProfile extends UserProfile {
id: string
}
export interface BaseMessageEventContent {
msgtype: string
body: string
@ -165,6 +197,7 @@ export interface BaseMessageEventContent {
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string
"m.url_previews"?: URLPreview[]
"com.beeper.linkpreviews"?: URLPreview[]
"com.beeper.per_message_profile"?: BeeperPerMessageProfile
}
export interface TextMessageEventContent extends BaseMessageEventContent {
@ -188,6 +221,10 @@ export interface ReactionEventContent {
"com.beeper.reaction.shortcode"?: string
}
export interface IgnoredUsersEventContent {
ignored_users: Record<string, unknown>
}
export interface EncryptedFile {
url: ContentURI
k: string
@ -279,3 +316,48 @@ export interface RoomSummary {
export interface RespRoomJoin {
room_id: RoomID
}
export interface RespOpenIDToken {
access_token: string
expires_in: number
matrix_server_name: string
token_type: "Bearer"
}
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>({
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,
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>({
displayName: "Code block line wrap",
description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.",
@ -139,6 +151,12 @@ export const preferences = {
allowedContexts: anyContext,
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>({
displayName: "GIF provider",
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-sender-name-timestamp-gap: .25rem;
--timeline-sender-name-content-gap: 0;
--timeline-vertical-padding: 0;
--timeline-horizontal-padding: 1.5rem;
--timeline-status-size: 4rem;
@ -166,7 +167,6 @@ body {
padding: 0;
background-color: var(--background-color);
line-height: 1.5;
font-size: 16px;
touch-action: none;
color: var(--text-color);
min-height: 100vh;
@ -175,6 +175,7 @@ body {
html {
touch-action: none;
background-color: var(--background-color);
font-size: 16px;
}
#root {
@ -207,6 +208,8 @@ button, a.button, span.button {
justify-content: center;
align-items: center;
color: inherit;
/* Buttons sometimes have their own fonts? */
font-family: var(--font-stack);
&:hover, &:focus {
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
// 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 { SyncLoader } from "react-spinners"
import Client from "@/api/client.ts"
import { RoomStateStore } from "@/api/statestore"
import { RoomListFilter, RoomStateStore } from "@/api/statestore"
import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts"
@ -24,7 +25,7 @@ import ClientContext from "./ClientContext.ts"
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
import StylePreferences from "./StylePreferences.tsx"
import Keybindings from "./keybindings.ts"
import { ModalWrapper } from "./modal"
import { ModalContext, ModalWrapper, NestableModalContext } from "./modal"
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
import RoomList from "./roomlist/RoomList.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 "./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 {
public keybindings: Keybindings
private rightPanelStack: RightPanelProps[] = []
@ -52,6 +40,7 @@ class ContextFields implements MainScreenContextFields {
constructor(
private directSetRightPanel: (props: RightPanelProps | null) => void,
private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void,
private directSetSpace: (space: RoomListFilter | null) => void,
private client: Client,
) {
this.keybindings = new Keybindings(client.store, this)
@ -63,10 +52,10 @@ class ContextFields implements MainScreenContextFields {
}
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
}
const isEqual = objectIsEqual(this.currentRightPanel, props)
const isEqual = equal(this.currentRightPanel, props)
if (isEqual && !pushState) {
return
}
@ -80,7 +69,7 @@ class ContextFields implements MainScreenContextFields {
} else {
this.directSetRightPanel(props)
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)
if (pushState) {
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)
if (roomID) {
const room = this.client.store.rooms.get(roomID)
if (room) {
this.#setActiveRoom(room, pushState)
this.#setActiveRoom(room, toSpace, pushState)
} else {
this.#setPreviewRoom(roomID, pushState, previewMeta)
}
@ -109,6 +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>) {
const invite = this.client.store.inviteRooms.get(roomID)
this.#closeActiveRoom(false)
@ -120,6 +132,7 @@ class ContextFields implements MainScreenContextFields {
room_id: roomID,
source_via: meta?.via,
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!)
}
#setActiveRoom(room: RoomStateStore, pushState: boolean) {
#setActiveRoom(room: RoomStateStore, space: RoomListFilter | undefined | null, pushState: boolean) {
window.activeRoom = room
this.directSetActiveRoom(room)
this.directSetRightPanel(null)
if (!space && this.client.store.currentRoomListFilter) {
const roomListEntry = this.client.store.roomListEntries.get(room.roomID)
if (roomListEntry && !this.client.store.currentRoomListFilter.include(roomListEntry)) {
space = this.client.store.findMatchingSpace(roomListEntry)
}
}
if (space && space !== this.client.store.currentRoomListFilter) {
console.log("Switching to space", space?.id)
this.directSetSpace(space)
this.client.store.currentRoomListFilter = space
}
this.rightPanelStack = []
this.client.store.activeRoomID = room.roomID
this.client.store.activeRoomIsPreview = false
@ -148,7 +172,7 @@ class ContextFields implements MainScreenContextFields {
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
?.scrollIntoView({ block: "nearest" })
if (pushState) {
history.pushState({ room_id: room.roomID }, "")
history.pushState({ room_id: room.roomID, space_id: space?.id ?? history.state?.space_id }, "")
}
let roomNameForTitle = room.meta.current.name
if (roomNameForTitle && roomNameForTitle.length > 48) {
@ -166,7 +190,7 @@ class ContextFields implements MainScreenContextFields {
this.client.store.activeRoomIsPreview = false
this.keybindings.activeRoom = null
if (pushState) {
history.pushState({}, "")
history.pushState({ space_id: history.state?.space_id }, "")
}
document.title = this.#getWindowTitle()
}
@ -181,8 +205,9 @@ class ContextFields implements MainScreenContextFields {
}
clickRightPanelOpener = (evt: React.MouseEvent) => {
evt.preventDefault()
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 })
} else if (type === "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 handleURLHash = (client: Client) => {
const handleURLHash = (client: Client, context: MainScreenContextFields, hashOnly = false) => {
if (!location.hash.startsWith("#/uri/")) {
if (hashOnly) {
return null
}
if (location.search) {
const currentETag = (
document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement
@ -224,7 +252,7 @@ const handleURLHash = (client: Client) => {
const uri = parseMatrixURI(decodedURI)
if (!uri) {
console.error("Invalid matrix URI", decodedURI)
return history.state
return hashOnly ? null : history.state
}
console.log("Handling URI", uri)
const newURL = new URL(location.href)
@ -248,7 +276,7 @@ const handleURLHash = (client: Client) => {
// TODO loading indicator or something for this?
client.rpc.resolveAlias(uri.identifier).then(
res => {
window.mainScreenContext.setActiveRoom(res.room_id, {
context.setActiveRoom(res.room_id, {
alias: uri.identifier,
via: res.servers.slice(0, 3),
})
@ -258,8 +286,9 @@ const handleURLHash = (client: Client) => {
return null
} else {
console.error("Invalid matrix URI", uri)
history.replaceState(history.state, "", newURL.toString())
}
return history.state
return hashOnly ? null : history.state
}
type ActiveRoomType = [RoomStateStore | RoomPreviewProps | null, RoomStateStore | RoomPreviewProps | null]
@ -279,30 +308,42 @@ const activeRoomReducer = (
const MainScreen = () => {
const [[prevActiveRoom, activeRoom], directSetActiveRoom] = useReducer(activeRoomReducer, [null, null])
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
const skipNextTransitionRef = useRef(false)
const [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(null)
const client = use(ClientContext)!
const syncStatus = useEventAsState(client.syncStatus)
const context = useMemo(
() => new ContextFields(directSetRightPanel, directSetActiveRoom, client),
() => new ContextFields(directSetRightPanel, directSetActiveRoom, directSetSpace, client),
[client],
)
useEffect(() => {
window.mainScreenContext = context
const listener = (evt: PopStateEvent) => {
const listener = (evt: Pick<PopStateEvent, "state" | "hasUAVisualTransition">) => {
skipNextTransitionRef.current = evt.hasUAVisualTransition
const roomID = evt.state?.room_id ?? null
const spaceID = evt.state?.space_id ?? undefined
if (spaceID !== client.store.currentRoomListFilter?.id) {
context.setSpace(client.store.getSpaceByID(spaceID), false)
}
if (roomID !== client.store.activeRoomID) {
context.setActiveRoom(roomID, {
alias: ensureString(evt.state?.source_alias) || undefined,
via: ensureStringArray(evt.state?.source_via),
}, false)
}, undefined, false)
}
context.setRightPanel(evt.state?.right_panel ?? null, false)
}
const hashListener = () => {
const state = handleURLHash(client, context, true)
if (state !== null) {
listener({ state, hasUAVisualTransition: false })
}
}
window.addEventListener("hashchange", hashListener)
window.addEventListener("popstate", listener)
const initHandle = () => {
const state = handleURLHash(client)
const state = handleURLHash(client, context)
listener({ state } as PopStateEvent)
}
let cancel = () => {}
@ -313,6 +354,7 @@ const MainScreen = () => {
}
return () => {
window.removeEventListener("popstate", listener)
window.removeEventListener("hashchange", hashListener)
cancel()
}
}, [context, client])
@ -368,11 +410,8 @@ const MainScreen = () => {
return () => clearTimeout(timeout)
}
}, [activeRoom, prevActiveRoom])
return <MainScreenContext value={context}>
<ModalWrapper>
<StylePreferences client={client} activeRoom={activeRealRoom}/>
<main className={classNames.join(" ")} style={extraStyle}>
<RoomList activeRoomID={activeRoom?.roomID ?? null}/>
const mainContent = <main className={classNames.join(" ")} style={extraStyle}>
<RoomList activeRoomID={activeRoom?.roomID ?? null} space={space}/>
{resizeHandle1}
{renderedRoom
? renderedRoom instanceof RoomStateStore
@ -389,8 +428,14 @@ const MainScreen = () => {
{rightPanel && <RightPanel {...rightPanel}/>}
</>}
</main>
return <MainScreenContext value={context}>
<ModalWrapper ContextType={ModalContext} historyStateKey="modal">
<ModalWrapper ContextType={NestableModalContext} historyStateKey="nestable_modal">
<StylePreferences client={client} activeRoom={activeRealRoom}/>
{mainContent}
{syncLoader}
</ModalWrapper>
</ModalWrapper>
</MainScreenContext>
}

View file

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

View file

@ -117,6 +117,15 @@ const StylePreferences = ({ client, activeRoom }: StylePreferencesProps) => {
--timeline-status-size: 2rem;
}
`, [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" ? `
@import url("_gomuks/codeblock/github.css") (prefers-color-scheme: light);
@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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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 { Emoji, emojiToMarkdown, useSortedAndFilteredEmojis } from "@/util/emoji"
import { escapeMarkdown } from "@/util/markdown.ts"
@ -138,7 +138,7 @@ const userFuncs = {
<img
className="small avatar"
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=""
/>
{user.displayName}

View file

@ -13,6 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { RefCallback, useState } from "react"
import Client from "@/api/client.ts"
import { RoomStateStore, usePreference } from "@/api/statestore"
import type { MediaMessageEventContent } from "@/api/types"
@ -27,10 +28,19 @@ export interface ComposerMediaProps {
}
export const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
const defaultMaxWidth = 360
const paddingAndButtonWidth = 16 + 40
const [maxWidth, setMaxWidth] = useState(defaultMaxWidth)
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}>
{mediaContent}
</div>

View file

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

View file

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

View file

@ -34,6 +34,7 @@ export function filter(users: AutocompleteMemberEntry[], query: string): Autocom
interface filteredUserCache {
query: string
result: AutocompleteMemberEntry[]
slicedResult?: AutocompleteMemberEntry[]
}
export function useFilteredMembers(
@ -44,15 +45,16 @@ export function useFilteredMembers(
if (!query) {
prev.current.query = ""
prev.current.result = allMembers
prev.current.slicedResult = slice && allMembers.length > 100 ? allMembers.slice(0, 100) : undefined
} else if (prev.current.query !== query) {
prev.current.result = (sort ? filterAndSort : filter)(
query.startsWith(prev.current.query) ? prev.current.result : allMembers,
query,
)
if (prev.current.result.length > 100 && slice) {
prev.current.result = prev.current.result.slice(0, 100)
}
prev.current.slicedResult = prev.current.result.length > 100 && slice
? prev.current.result.slice(0, 100)
: undefined
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) {
div.emoji-picker, div.gif-picker {
div.emoji-picker, div.gif-picker, div.sticker-picker {
inset: 0 0 3rem 0 !important;
width: 100%;
height: calc(100% - 3rem);

View file

@ -13,16 +13,16 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useState } from "react"
import React, { JSX, use, useState } from "react"
import { MemDBEvent } from "@/api/types"
import { isMobileDevice } from "@/util/ismobile.ts"
import { ModalCloseContext } from "../../modal"
import TimelineEvent from "../TimelineEvent.tsx"
import { ModalCloseContext } from "../modal"
import TimelineEvent from "../timeline/TimelineEvent.tsx"
interface ConfirmWithMessageProps {
evt: MemDBEvent
evt?: MemDBEvent
title: string
description: string
description: string | JSX.Element
placeholder: string
confirmButton: string
onConfirm: (reason: string) => void
@ -40,9 +40,9 @@ const ConfirmWithMessageModal = ({
}
return <form onSubmit={onConfirmWrapped}>
<h3>{title}</h3>
<div className="timeline-event-container">
{evt && <div className="timeline-event-container">
<TimelineEvent evt={evt} prevEvt={null} disableMenu={true} />
</div>
</div>}
<div className="confirm-description">
{description}
</div>

View file

@ -15,8 +15,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CSSProperties, use } from "react"
import { MemDBEvent } from "@/api/types"
import ClientContext from "../../ClientContext.ts"
import { RoomContextData } from "../../roomview/roomcontext.ts"
import ClientContext from "../ClientContext.ts"
import { RoomContextData } from "../roomview/roomcontext.ts"
import { usePrimaryItems } from "./usePrimaryItems.tsx"
import { useSecondaryItems } from "./useSecondaryItems.tsx"
import CloseIcon from "@/icons/close.svg?react"
@ -41,14 +41,14 @@ interface EventContextMenuProps extends BaseEventMenuProps {
export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
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) => {
const client = use(ClientContext)!
const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined)
const secondary = useSecondaryItems(client, roomCtx, evt)
return <div style={style} className="event-context-menu full">
return <div style={style} className="context-menu event-context-menu full">
{primary}
<hr/>
{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;
background-color: var(--background-color);
border-radius: .5rem;
@ -80,6 +80,10 @@ div.event-context-menu {
color: var(--error-color);
}
}
&.event-context-menu, &.room-list-menu {
width: 10rem;
}
}
div.confirm-message-modal > form {
@ -101,6 +105,7 @@ div.confirm-message-modal > form {
> div.timeline-event {
margin: 0;
padding: 0;
}
}
@ -118,4 +123,14 @@ div.confirm-message-modal > form {
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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
export { RoomMenu } from "./RoomMenu.tsx"
export { getModalStyleFromMouse } from "./util.ts"

View file

@ -18,9 +18,9 @@ import Client from "@/api/client.ts"
import { MemDBEvent } from "@/api/types"
import { emojiToReactionContent } from "@/util/emoji"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import EmojiPicker from "../../emojipicker/EmojiPicker.tsx"
import { ModalCloseContext, ModalContext } from "../../modal"
import { RoomContextData } from "../../roomview/roomcontext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
import { ModalCloseContext, ModalContext } from "../modal"
import { RoomContextData } from "../roomview/roomcontext.ts"
import { EventExtraMenu } from "./EventMenu.tsx"
import { getEncryption, getModalStyleFromButton, getPending, getPowerLevels } from "./util.ts"
import EditIcon from "@/icons/edit.svg?react"
@ -79,7 +79,7 @@ export const usePrimaryItems = (
.catch(err => window.alert(`Failed to resend message: ${err}`))
}
const onClickMore = (mevt: React.MouseEvent<HTMLButtonElement>) => {
const moreMenuHeight = 4 * 40
const moreMenuHeight = 5 * 40
setForceOpen!(true)
openModal({
content: <EventExtraMenu

View file

@ -17,15 +17,18 @@ import { use } from "react"
import Client from "@/api/client.ts"
import { useRoomState } from "@/api/statestore"
import { MemDBEvent } from "@/api/types"
import { ModalCloseContext, ModalContext } from "../../modal"
import { RoomContext, RoomContextData } from "../../roomview/roomcontext.ts"
import JSONView from "../../util/JSONView.tsx"
import { ModalCloseContext, ModalContext } from "../modal"
import { RoomContext, RoomContextData } from "../roomview/roomcontext.ts"
import JSONView from "../util/JSONView.tsx"
import ConfirmWithMessageModal from "./ConfirmWithMessageModal.tsx"
import ShareModal from "./ShareModal.tsx"
import { getPending, getPowerLevels } from "./util.ts"
import ViewSourceIcon from "@/icons/code.svg?react"
import DeleteIcon from "@/icons/delete.svg?react"
import PinIcon from "@/icons/pin.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"
export const useSecondaryItems = (
@ -83,12 +86,67 @@ export const useSecondaryItems = (
</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) => () => {
closeModal()
client.pinMessage(roomCtx.store, evt.event_id, pin)
.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)
useRoomState(roomCtx.store, "m.room.power_levels", "")
// 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
&& ownPL >= redactEvtPL
&& (evt.sender === client.userID || ownPL >= redactOtherPL)
// TODO check server admin status and room PLs
const canUnredact = Boolean(evt.redacted_by)
return <>
<button onClick={onClickViewSource}><ViewSourceIcon/>{names && "View source"}</button>
<button onClick={onClickShareEvent}><ShareIcon/>{names && "Share"}</button>
{ownPL >= pinPL && (pins.includes(evt.event_id)
? <button onClick={onClickPin(false)}>
<UnpinIcon/>{names && "Unpin message"}

View file

@ -39,10 +39,10 @@ export const getEncryption = (room: RoomStateStore): boolean =>{
export function getModalStyleFromMouse(
evt: React.MouseEvent, modalHeight: number, modalWidth = 10 * 16,
): CSSProperties {
const style: CSSProperties = { right: window.innerWidth - evt.clientX }
if (evt.clientX - modalWidth < 4) {
delete style.right
style.left = "4px"
const style: CSSProperties = { left: evt.clientX }
if (evt.clientX + modalWidth > window.innerWidth) {
delete style.left
style.right = "4px"
}
if (evt.clientY + modalHeight > window.innerHeight) {
style.bottom = window.innerHeight - evt.clientY

View file

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

View file

@ -36,7 +36,7 @@ const LightboxWrapper = ({ children }: { children: React.ReactNode }) => {
return
}
params = {
src: target.src,
src: target.getAttribute("data-full-src") ?? target.src,
alt: target.alt,
}
setParams(params)
@ -75,6 +75,11 @@ export interface LightboxProps extends LightboxParams {
onClose: () => void
}
interface Point {
x: number
y: number
}
export class Lightbox extends Component<LightboxProps> {
translate = { x: 0, y: 0 }
zoom = 1
@ -82,6 +87,9 @@ export class Lightbox extends Component<LightboxProps> {
maybePanning = false
readonly ref = createRef<HTMLImageElement>()
readonly wrapperRef = createRef<HTMLDivElement>()
prevTouch1: Point | null = null
prevTouch2: Point | null = null
prevTouchDist: number | null = null
get style() {
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 = () => {
this.translate = { x: 0, y: 0 }
this.rotate = 0
@ -115,18 +131,55 @@ export class Lightbox extends Component<LightboxProps> {
return
}
evt.preventDefault()
const oldZoom = this.zoom
const delta = -evt.deltaY / 1000
const newDelta = this.zoom + delta * this.zoom
this.zoom = Math.min(Math.max(newDelta, 0.01), 10)
const zoomDelta = this.zoom - oldZoom
this.translate.x += zoomDelta * (this.ref.current.clientWidth / 2 - evt.nativeEvent.offsetX)
this.translate.y += zoomDelta * (this.ref.current.clientHeight / 2 - evt.nativeEvent.offsetY)
this.#doZoom(-evt.deltaY / 1000, evt.nativeEvent.offsetX, evt.nativeEvent.offsetY, false)
const style = this.style
this.ref.current.style.translate = style.translate
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) => {
if (evt.buttons === 1) {
evt.preventDefault()
@ -150,6 +203,57 @@ export class Lightbox extends Component<LightboxProps> {
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>) => {
const key = keyToString(evt)
if (key === "Escape") {
@ -189,6 +293,10 @@ export class Lightbox extends Component<LightboxProps> {
className="overlay dimmed lightbox"
onClick={this.onClick}
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}
onKeyDown={this.onKeyDown}
ref={this.wrapperRef}

View file

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

View file

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

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useState } from "react"
import { getAvatarURL } from "@/api/media.ts"
import { getAvatarThumbnailURL } from "@/api/media.ts"
import { MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.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}>
<img
className="avatar"
src={getAvatarURL(userID, content)}
src={getAvatarThumbnailURL(userID, content)}
alt=""
loading="lazy"
/>
@ -50,7 +50,7 @@ const MemberList = () => {
roomCtx.store.membersRequested = true
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) {
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;
flex-direction: column;
gap: .5rem;
padding: .5rem;
> button {
padding: .5rem;
width: 100%;
}
> div.separator {
flex: 1;
}
}
div.right-panel-content.user {
padding: 1rem;
div.avatar-container {
@ -63,7 +77,6 @@ div.right-panel-content.user {
justify-content: center;
align-items: center;
padding: 1rem;
flex-shrink: 0;
margin: 0 auto;
> img {
@ -89,6 +102,27 @@ div.right-panel-content.user {
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
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 {
@ -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 {
display: flex;
flex-direction: column;

View file

@ -13,20 +13,30 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import type { IWidget } from "matrix-widget-api"
import { JSX, use } from "react"
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 PinnedMessages from "./PinnedMessages.tsx"
import UserInfo from "./UserInfo.tsx"
import WidgetList from "./WidgetList.tsx"
import BackIcon from "@/icons/back.svg?react"
import CloseIcon from "@/icons/close.svg?react"
import "./RightPanel.css"
export type RightPanelType = "pinned-messages" | "members" | "user"
export type RightPanelType = "pinned-messages" | "members" | "widgets" | "widget" | "user" | "element-call"
interface RightPanelSimpleProps {
type: "pinned-messages" | "members"
type: "pinned-messages" | "members" | "widgets" | "element-call"
}
interface RightPanelWidgetProps {
type: "widget"
info: IWidget
}
interface RightPanelUserProps {
@ -34,25 +44,37 @@ interface RightPanelUserProps {
userID: UserID
}
export type RightPanelProps = RightPanelUserProps | RightPanelSimpleProps
export type RightPanelProps = RightPanelUserProps | RightPanelWidgetProps | RightPanelSimpleProps
function getTitle(type: RightPanelType): string {
switch (type) {
function getTitle(props: RightPanelProps): string {
switch (props.type) {
case "pinned-messages":
return "Pinned Messages"
case "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":
return "User Info"
}
}
function renderRightPanelContent(props: RightPanelProps): JSX.Element | null {
function renderRightPanelContent(props: RightPanelProps, mainScreen: MainScreenContextFields): JSX.Element | null {
switch (props.type) {
case "pinned-messages":
return <PinnedMessages />
case "members":
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":
return <UserInfo userID={props.userID} />
}
@ -66,17 +88,24 @@ const RightPanel = (props: RightPanelProps) => {
data-target-panel="members"
onClick={mainScreen.clickRightPanelOpener}
><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">
<div className="right-panel-header">
<div className="left-side">
{backButton}
<div className="panel-name">{getTitle(props.type)}</div>
<div className="panel-name">{getTitle(props)}</div>
</div>
<button onClick={mainScreen.closeRightPanel}><CloseIcon/></button>
</div>
<div className={`right-panel-content ${props.type}`}>
{renderRightPanelContent(props)}
<ErrorBoundary thing="right panel content">
{renderRightPanelContent(props, mainScreen)}
</ErrorBoundary>
</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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useEffect, useState } from "react"
import { use, useCallback, useEffect, useState } from "react"
import { PuffLoader } from "react-spinners"
import { getAvatarURL } from "@/api/media.ts"
import { useRoomMember } from "@/api/statestore"
import { MemberEventContent, UserID, UserProfile } from "@/api/types"
import { getLocalpart } from "@/util/validation.ts"
import { ensureString, getLocalpart } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import { LightboxContext } from "../modal"
import { RoomContext } from "../roomview/roomcontext.ts"
import UserExtendedProfile from "./UserExtendedProfile.tsx"
import DeviceList from "./UserInfoDeviceList.tsx"
import UserInfoError from "./UserInfoError.tsx"
import MutualRooms from "./UserInfoMutualRooms.tsx"
import UserModeration from "./UserModeration.tsx"
interface UserInfoProps {
userID: UserID
@ -38,16 +40,20 @@ const UserInfo = ({ userID }: UserInfoProps) => {
const member = (memberEvt?.content ?? null) as MemberEventContent | null
const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null)
const [errors, setErrors] = useState<string[] | null>(null)
useEffect(() => {
const refreshProfile = useCallback((clearState = false) => {
if (clearState) {
setErrors(null)
setGlobalProfile(null)
}
client.rpc.getProfile(userID).then(
setGlobalProfile,
err => setErrors([`${err}`]),
)
}, [roomCtx, userID, client])
const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID)
}, [userID, client])
useEffect(() => refreshProfile(true), [refreshProfile])
const displayname = ensureString(member?.displayname)
|| ensureString(globalProfile?.displayname)
|| getLocalpart(userID)
return <>
<div className="avatar-container">
{member === null && globalProfile === null && errors == null ? <PuffLoader
@ -56,6 +62,7 @@ const UserInfo = ({ userID }: UserInfoProps) => {
className="avatar-loader"
/> : <img
className="avatar"
// this is a big avatar (236px by default), use full resolution
src={getAvatarURL(userID, member ?? globalProfile)}
onClick={openLightbox}
alt=""
@ -63,17 +70,13 @@ const UserInfo = ({ userID }: UserInfoProps) => {
</div>
<div className="displayname" title={displayname}>{displayname}</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 && <>
<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}/>
<hr/>
</> : null}
</>
}

View file

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

View file

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