Merge branch 'main' into terminal

This commit is contained in:
Tulir Asokan 2025-01-13 14:23:38 +02:00
commit 6a96f3f800
177 changed files with 7554 additions and 2331 deletions

View file

@ -21,6 +21,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
sudo apt-get update
sudo apt-get install libolm-dev libolm3 libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev sudo apt-get install libolm-dev libolm3 libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev
go install golang.org/x/tools/cmd/goimports@latest go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest go install honnef.co/go/tools/cmd/staticcheck@latest

View file

@ -1,6 +1,7 @@
stages: stages:
- frontend - frontend
- build - build
- build desktop
- docker - docker
default: default:
@ -39,7 +40,7 @@ frontend:
- export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') - export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- export 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'" - export 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'"
script: script:
- go build -ldflags "$GO_LDFLAGS" -o gomuks ./cmd/gomuks - go build -ldflags "$GO_LDFLAGS" ./cmd/gomuks
artifacts: artifacts:
paths: paths:
- gomuks - gomuks
@ -81,6 +82,16 @@ linux/arm64:
- linux - linux
- arm64 - arm64
windows/amd64:
<<: *build-linux
image: dock.mau.dev/tulir/gomuks-build-docker:windows-amd64
artifacts:
paths:
- gomuks.exe
tags:
- linux
- amd64
macos/arm64: macos/arm64:
stage: build stage: build
tags: tags:
@ -154,3 +165,89 @@ docker/manifest:
docker manifest create $MANIFEST_NAME $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 docker manifest create $MANIFEST_NAME $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
docker manifest push $MANIFEST_NAME docker manifest push $MANIFEST_NAME
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 - docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
.build-desktop: &build-desktop
stage: build desktop
cache:
paths:
- .cache
before_script:
- mkdir -p .cache
- export GOPATH="$CI_PROJECT_DIR/.cache"
- export GOCACHE="$CI_PROJECT_DIR/.cache/build"
script:
- cd desktop
- wails3 task $PLATFORM:package
- ls bin
artifacts:
paths:
- desktop/bin/*
dependencies:
- frontend
needs:
- frontend
desktop/linux/amd64:
<<: *build-desktop
image: dock.mau.dev/tulir/gomuks-build-docker/wails:linux-amd64
variables:
PLATFORM: linux
after_script:
- mv desktop/bin/gomuks-desktop .
- mv desktop/build/nfpm/bin/gomuks-desktop.deb .
artifacts:
paths:
- gomuks-desktop
- gomuks-desktop.deb
tags:
- linux
- amd64
desktop/linux/arm64:
<<: *build-desktop
image: dock.mau.dev/tulir/gomuks-build-docker/wails:linux-arm64-native
variables:
PLATFORM: linux
after_script:
- mv desktop/bin/gomuks-desktop .
- mv desktop/build/nfpm/bin/gomuks-desktop.deb .
artifacts:
paths:
- gomuks-desktop
- gomuks-desktop.deb
tags:
- linux
- arm64
desktop/windows/amd64:
<<: *build-desktop
image: dock.mau.dev/tulir/gomuks-build-docker/wails:windows-amd64
after_script:
- mv desktop/bin/gomuks-desktop.exe .
artifacts:
paths:
- gomuks-desktop.exe
variables:
PLATFORM: windows
desktop/macos/arm64:
<<: *build-desktop
cache: {}
before_script:
- export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH
- export LIBRARY_PATH=$(brew --prefix)/lib
- export CPATH=$(brew --prefix)/include
after_script:
- hdiutil create -srcFolder ./desktop/bin/gomuks-desktop.app/ -o ./gomuks-desktop.dmg
- codesign -s - --timestamp -i fi.mau.gomuks.desktop.mac gomuks-desktop.dmg
- mv desktop/bin/gomuks-desktop .
artifacts:
paths:
- gomuks-desktop
# TODO generate proper dmgs
#- gomuks-desktop.dmg
variables:
PLATFORM: darwin
tags:
- macos
- arm64

View file

@ -21,7 +21,7 @@ repos:
- id: go-staticcheck-repo-mod - id: go-staticcheck-repo-mod
- repo: https://github.com/beeper/pre-commit-go - repo: https://github.com/beeper/pre-commit-go
rev: v0.3.1 rev: v0.4.2
hooks: hooks:
- id: prevent-literal-http-methods - id: prevent-literal-http-methods

View file

@ -1,4 +1,4 @@
FROM alpine:3.20 FROM alpine:3.21
RUN apk add --no-cache ca-certificates jq curl RUN apk add --no-cache ca-certificates jq curl

1
desktop/.gitignore vendored
View file

@ -1,2 +1,3 @@
.task .task
bin bin
build/appimage

View file

@ -1,448 +1,54 @@
version: '3' version: '3'
includes:
common: ./build/Taskfile.common.yml
windows: ./build/Taskfile.windows.yml
darwin: ./build/Taskfile.darwin.yml
linux: ./build/Taskfile.linux.yml
vars: vars:
APP_NAME: "gomuks-desktop" APP_NAME: "gomuks-desktop"
BIN_DIR: "bin" BIN_DIR: "bin"
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks: tasks:
## -------------------------- Build -------------------------- ##
build: build:
summary: Builds the application summary: Builds the application
cmds: cmds:
# Build for current OS - task: "{{OS}}:build"
- task: build:{{OS}}
# Uncomment to build for specific OSes
# - task: build:linux
# - task: build:windows
# - task: build:darwin
## ------> Windows <-------
build:windows:
summary: Builds the application for Windows
deps:
- task: go:mod:tidy
- task: build:frontend
vars:
BUILD_FLAGS: '{{.BUILD_FLAGS}}'
- task: generate:icons
- task: generate:syso
vars:
ARCH: '{{.ARCH}}'
cmds:
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/gomuks-desktop.exe
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -ldflags="-w -s -H windowsgui"{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: windows
CGO_ENABLED: 0
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
build:windows:prod:arm64:
summary: Creates a production build of the application
cmds:
- task: build:windows
vars:
ARCH: arm64
PRODUCTION: "true"
build:windows:prod:amd64:
summary: Creates a production build of the application
cmds:
- task: build:windows
vars:
ARCH: amd64
PRODUCTION: "true"
build:windows:debug:arm64:
summary: Creates a debug build of the application
cmds:
- task: build:windows
vars:
ARCH: arm64
build:windows:debug:amd64:
summary: Creates a debug build of the application
cmds:
- task: build:windows
vars:
ARCH: amd64
## ------> Darwin <-------
build:darwin:
summary: Creates a production build of the application
deps:
- task: go:mod:tidy
- task: build:frontend
vars:
BUILD_FLAGS: '{{.BUILD_FLAGS}}'
- task: generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -ldflags="-w -s"{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=10.15"
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
MACOSX_DEPLOYMENT_TARGET: "10.15"
PRODUCTION: '{{.PRODUCTION | default "false"}}'
build:darwin:prod:arm64:
summary: Creates a production build of the application
cmds:
- task: build:darwin
vars:
ARCH: arm64
PRODUCTION: "true"
build:darwin:prod:amd64:
summary: Creates a production build of the application
cmds:
- task: build:darwin
vars:
ARCH: amd64
PRODUCTION: "true"
build:darwin:debug:arm64:
summary: Creates a debug build of the application
cmds:
- task: build:darwin
vars:
ARCH: arm64
build:darwin:debug:amd64:
summary: Creates a debug build of the application
cmds:
- task: build:darwin
vars:
ARCH: amd64
## ------> Linux <-------
build:linux:
summary: Builds the application for Linux
deps:
- task: go:mod:tidy
- task: build:frontend
vars:
BUILD_FLAGS: '{{.BUILD_FLAGS}}'
- task: generate:icons
vars:
ARCH: '{{.ARCH}}'
cmds:
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/gomuks-desktop
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -ldflags="-w -s"{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
build:linux:prod:arm64:
summary: Creates a production build of the application
cmds:
- task: build:linux
vars:
ARCH: arm64
PRODUCTION: "true"
build:linux:prod:amd64:
summary: Creates a production build of the application
cmds:
- task: build:linux
vars:
ARCH: amd64
PRODUCTION: "true"
build:linux:debug:arm64:
summary: Creates a debug build of the application
cmds:
- task: build:linux
vars:
ARCH: arm64
build:linux:debug:amd64:
summary: Creates a debug build of the application
cmds:
- task: build:linux
vars:
ARCH: amd64
## -------------------------- Package -------------------------- ##
package: package:
summary: Packages a production build of the application into a bundle summary: Packages a production build of the application
cmds: cmds:
- task: "{{OS}}:package"
# Package for current OS
- task: package:{{OS}}
# Package for specific os/arch
# - task: package:darwin:arm64
# - task: package:darwin:amd64
# - task: package:windows:arm64
# - task: package:windows:amd64
## ------> Windows <------
package:windows:
summary: Packages a production build of the application into a `.exe` bundle
cmds:
- task: create:nsis:installer
vars:
ARCH: '{{.ARCH}}'
vars:
ARCH: '{{.ARCH | default ARCH}}'
package:windows:arm64:
summary: Packages a production build of the application into a `.exe` bundle
cmds:
- task: package:windows
vars:
ARCH: arm64
package:windows:amd64:
summary: Packages a production build of the application into a `.exe` bundle
cmds:
- task: package:windows
vars:
ARCH: amd64
generate:syso:
summary: Generates Windows `.syso` file
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon icon.ico -manifest wails.exe.manifest -info info.json -out ../wails.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: Creates an NSIS installer
label: "NSIS Installer ({{.ARCH}})"
dir: build/nsis
sources:
- "{{.ROOT_DIR}}\\bin\\{{.APP_NAME}}.exe"
generates:
- "{{.ROOT_DIR}}\\bin\\{{.APP_NAME}}-{{.ARCH}}-installer.exe"
deps:
- task: build:windows
vars:
PRODUCTION: "true"
ARCH: '{{.ARCH}}'
cmds:
- makensis -DARG_WAILS_'{{.ARG_FLAG}}'_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
## ------> Darwin <------
package:darwin:
summary: Packages a production build of the application into a `.app` bundle
platforms: [ darwin ]
deps:
- task: build:darwin
vars:
PRODUCTION: "true"
cmds:
- task: create:app:bundle
package:darwin:arm64:
summary: Packages a production build of the application into a `.app` bundle
platforms: [ darwin/arm64 ]
deps:
- task: package:darwin
vars:
ARCH: arm64
package:darwin:amd64:
summary: Packages a production build of the application into a `.app` bundle
platforms: [ darwin/amd64 ]
deps:
- task: package:darwin
vars:
ARCH: amd64
create:app:bundle:
summary: Creates an `.app` bundle
cmds:
- mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/{MacOS,Resources}
- cp build/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources
- cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS
- cp build/Info.plist {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents
## ------> Linux <------
package:linux:
summary: Packages a production build of the application for Linux
platforms: [ linux ]
deps:
- task: build:linux
vars:
PRODUCTION: "true"
cmds:
- task: create:appimage
create:appimage:
summary: Creates an AppImage
dir: build/appimage
platforms: [ linux ]
deps:
- task: build:linux
vars:
PRODUCTION: "true"
- task: generate:linux:dotdesktop
cmds:
# Copy binary + icon to appimage dir
- cp {{.APP_BINARY}} {{.APP_NAME}}
- cp ../appicon.png appicon.png
# Generate AppImage
- wails3 generate appimage -binary {{.APP_NAME}} -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/appimage
vars:
APP_NAME: '{{.APP_NAME}}'
APP_BINARY: '../../bin/{{.APP_NAME}}'
ICON: '../appicon.png'
DESKTOP_FILE: '{{.APP_NAME}}.desktop'
OUTPUT_DIR: '../../bin'
generate:linux:dotdesktop:
summary: Generates a `.desktop` file
dir: build
sources:
- "appicon.png"
generates:
- '{{.ROOT_DIR}}/build/appimage/{{.APP_NAME}}.desktop'
cmds:
- mkdir -p {{.ROOT_DIR}}/build/appimage
# Run `wails3 generate .desktop -help` for all the options
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile {{.ROOT_DIR}}/build/appimage/{{.APP_NAME}}.desktop -categories "{{.CATEGORIES}}"
# -comment "A comment"
# -terminal "true"
# -version "1.0"
# -genericname "Generic Name"
# -keywords "keyword1;keyword2;"
# -startupnotify "true"
# -mimetype "application/x-extension1;application/x-extension2;"
vars:
APP_NAME: '{{.APP_NAME}}'
EXEC: '{{.APP_NAME}}'
ICON: 'appicon'
CATEGORIES: 'Development;'
OUTPUTFILE: '{{.ROOT_DIR}}/build/appimage/{{.APP_NAME}}.desktop'
## -------------------------- Misc -------------------------- ##
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` files from an image
dir: build
sources:
- "appicon.png"
generates:
- "icons.icns"
- "icons.ico"
cmds:
# Generates both .ico and .icns files
- wails3 generate icons -input appicon.png
install:frontend:deps:
summary: Install frontend dependencies
dir: ../web
sources:
- package.json
- package-lock.json
generates:
- node_modules/*
preconditions:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- npm install --silent --no-progress
build:frontend:
summary: Build the frontend project
dir: ../web
sources:
- "**/*"
generates:
- dist/*
deps:
- install:frontend:deps
- task: generate:bindings
vars:
BUILD_FLAGS: '{{.BUILD_FLAGS}}'
cmds:
- npm run build -q
generate:bindings:
summary: Generates bindings for the frontend
sources:
- "**/*.go"
- go.mod
- go.sum
generates: []
#- "../web/src/wails/**/*"
cmds: []
# For a complete list of options, run `wails3 generate bindings -help`
#- wails3 generate bindings -d ../web/src/wails -f '{{.BUILD_FLAGS}}'
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
generates:
- go.sum
sources:
- go.mod
cmds:
- go mod tidy
# ----------------------- dev ----------------------- #
run: run:
summary: Runs the application summary: Runs the application
cmds: cmds:
- task: run:{{OS}} - task: "{{OS}}:run"
run:windows:
cmds:
- '{{.BIN_DIR}}\\{{.APP_NAME}}.exe'
run:linux:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'
run:darwin:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'
dev:frontend:
summary: Runs the frontend in development mode
dir: ../web
deps:
- task: install:frontend:deps
cmds:
- npm run dev -- --port {{.VITE_PORT}} --strictPort
dev: dev:
summary: Runs the application in development mode summary: Runs the application in development mode
cmds: cmds:
- wails3 dev -config ./build/devmode.config.yaml -port {{.VITE_PORT}} - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
dev:reload: darwin:build:universal:
summary: Reloads the application summary: Builds darwin universal binary (arm64 + amd64)
cmds: cmds:
- task: run - task: darwin:build
vars:
ARCH: amd64
- mv {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}-amd64
- task: darwin:build
vars:
ARCH: arm64
- mv {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}-arm64
- lipo -create -output {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}-amd64 {{.BIN_DIR}}/{{.APP_NAME}}-arm64
- rm {{.BIN_DIR}}/{{.APP_NAME}}-amd64 {{.BIN_DIR}}/{{.APP_NAME}}-arm64
darwin:package:universal:
summary: Packages darwin universal binary (arm64 + amd64)
deps:
- darwin:build:universal
cmds:
- task: darwin:create:app:bundle

View file

@ -22,7 +22,7 @@
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>© 2024, Tulir Asokan</string> <string>© 2024, gomuks authors</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>

View file

@ -22,6 +22,6 @@
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>© 2024, Tulir Asokan</string> <string>© 2024, gomuks authors</string>
</dict> </dict>
</plist> </plist>

View file

@ -0,0 +1,75 @@
version: '3'
tasks:
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
generates:
- go.sum
sources:
- go.mod
cmds:
- go mod tidy
install:frontend:deps:
summary: Install frontend dependencies
dir: ../web
sources:
- package.json
- package-lock.json
generates:
- node_modules/*
preconditions:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- npm install
build:frontend:
summary: Build the frontend project
dir: ../web
sources:
- "**/*"
generates:
- dist/*
deps:
- task: install:frontend:deps
#- task: generate:bindings
cmds:
- npm run build -q
generate:bindings:
summary: Generates bindings for the frontend
sources:
- "**/*.go"
- go.mod
- go.sum
generates: []
#- "frontend/bindings/**/*"
cmds: []
#- wails3 generate bindings -f '{{.BUILD_FLAGS}}'{{if .UseTypescript}} -ts{{end}}
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` files from an image
dir: build
sources:
- "appicon.png"
generates:
- "icons.icns"
- "icons.ico"
cmds:
- wails3 generate icons -input appicon.png
dev:frontend:
summary: Runs the frontend in development mode
dir: ../web
deps:
- task: install:frontend:deps
cmds:
- npm run dev -- --port {{.VITE_PORT}} --strictPort
update:build-assets:
summary: Updates the build assets
dir: build
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .

View file

@ -0,0 +1,47 @@
version: '3'
includes:
common: Taskfile.common.yml
tasks:
build:
summary: Creates a production build of the application
deps: []
#- task: common:go:mod:tidy
#- task: common:build:frontend
#- task: common:generate:icons
cmds:
- MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- GO_LDFLAGS="-s -w -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'"
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=11.0"
CGO_LDFLAGS: "-mmacosx-version-min=11.0"
MACOSX_DEPLOYMENT_TARGET: "11.0"
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application into a `.app` bundle
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: create:app:bundle
create:app:bundle:
summary: Creates an `.app` bundle
cmds:
- mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/{MacOS,Resources}
- cp build/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources
- cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS
- cp build/Info.plist {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View file

@ -0,0 +1,117 @@
version: '3'
includes:
common: Taskfile.common.yml
tasks:
build:
summary: Builds the application for Linux
deps: []
#- task: common:go:mod:tidy
#- task: common:build:frontend
#- task: common:generate:icons
cmds:
- MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- GO_LDFLAGS="-s -w -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'"
- go build {{.BUILD_FLAGS}} -ldflags "$GO_LDFLAGS" -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application for Linux
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
#- task: create:appimage
- task: create:deb
#- task: create:rpm
#- task: create:aur
create:appimage:
summary: Creates an AppImage
dir: build/appimage
deps:
- task: build
vars:
PRODUCTION: "true"
- task: generate:dotdesktop
cmds:
- cp {{.APP_BINARY}} {{.APP_NAME}}
- cp ../appicon.png appicon.png
- wails3 generate appimage -binary {{.APP_NAME}} -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/appimage
vars:
APP_NAME: '{{.APP_NAME}}'
APP_BINARY: '../../bin/{{.APP_NAME}}'
ICON: '../appicon.png'
DESKTOP_FILE: '{{.APP_NAME}}.desktop'
OUTPUT_DIR: '../../bin'
create:deb:
summary: Creates a deb package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:deb
create:rpm:
summary: Creates a rpm package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:rpm
create:aur:
summary: Creates a arch linux packager package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:aur
generate:deb:
summary: Creates a deb package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format deb -config ./build/nfpm/nfpm.yaml
generate:rpm:
summary: Creates a rpm package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format rpm -config ./build/nfpm/nfpm.yaml
generate:aur:
summary: Creates a arch linux packager package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format arch -config ./build/nfpm/nfpm.yaml
generate:dotdesktop:
summary: Generates a `.desktop` file
dir: build
cmds:
- mkdir -p {{.ROOT_DIR}}/build/nfpm/bin
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile {{.ROOT_DIR}}/build/{{.APP_NAME}}.desktop -categories "{{.CATEGORIES}}"
- cp {{.ROOT_DIR}}/build/{{.APP_NAME}}.desktop {{.ROOT_DIR}}/build/nfpm/bin/{{.APP_NAME}}.desktop
vars:
APP_NAME: '{{.APP_NAME}}'
EXEC: '{{.APP_NAME}}'
ICON: 'appicon'
CATEGORIES: 'Network;InstantMessaging;Chat;'
OUTPUTFILE: '{{.ROOT_DIR}}/build/{{.APP_NAME}}.desktop'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View file

@ -0,0 +1,62 @@
version: '3'
includes:
common: Taskfile.common.yml
tasks:
build:
summary: Builds the application for Windows
deps:
#- task: common:go:mod:tidy
#- task: common:build:frontend
#- task: common:generate:icons
- task: generate:syso
cmds:
- MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- GO_LDFLAGS="-s -w -H windowsgui -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'"
- go build {{.BUILD_FLAGS}} -ldflags "$GO_LDFLAGS" -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: windows
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application into a `.exe` bundle
cmds:
- task: build
vars:
PRODUCTION: "true"
#cmds:
# - task: create:nsis:installer
generate:syso:
summary: Generates Windows `.syso` file
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon icon.ico -manifest wails.exe.manifest -info info.json -out ../wails_windows_{{.ARCH}}.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: Creates an NSIS installer
dir: build/nsis
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
run:
cmds:
- '{{.BIN_DIR}}\\{{.APP_NAME}}.exe'

60
desktop/build/config.yml Normal file
View file

@ -0,0 +1,60 @@
# This file contains the configuration for this project.
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
# Note that this will overwrite any changes you have made to the assets.
version: '3'
info:
companyName: ""
productName: "gomuks desktop"
productIdentifier: "fi.mau.gomuks.desktop"
description: "A Matrix client written in Go and React"
copyright: "© 2024, gomuks authors"
comments: ""
version: "0.4.0"
# Dev mode configuration
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
watched_extension:
- "*.go"
git_ignore: true
executes:
- cmd: wails3 task common:install:frontend:deps
type: once
- cmd: wails3 task common:dev:frontend
type: background
- cmd: go mod tidy
type: blocking
- cmd: wails3 task build
type: blocking
- cmd: wails3 task run
type: primary
# File Associations
# More information at: https://v3alpha.wails.io/noit/done/yet
fileAssociations:
# - ext: wails
# name: Wails
# description: Wails Application File
# iconName: wailsFileIcon
# role: Editor
# - ext: jpg
# name: JPEG
# description: Image File
# iconName: jpegFileIcon
# role: Editor
# Other data
other: []

View file

@ -1,28 +0,0 @@
config:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
watched_extension:
- "*.go"
git_ignore: true
executes:
- cmd: wails3 task install:frontend:deps
type: once
- cmd: wails3 task dev:frontend
type: background
- cmd: go mod tidy
type: blocking
- cmd: wails3 task build
type: blocking
- cmd: wails3 task run
type: primary

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

View file

@ -6,8 +6,8 @@
"0000": { "0000": {
"ProductVersion": "0.4.0", "ProductVersion": "0.4.0",
"CompanyName": "", "CompanyName": "",
"FileDescription": "", "FileDescription": "A Matrix client written in Go and React",
"LegalCopyright": "© 2024, Tulir Asokan", "LegalCopyright": "© 2024, gomuks authors",
"ProductName": "gomuks desktop", "ProductName": "gomuks desktop",
"Comments": "" "Comments": ""
} }

View file

@ -0,0 +1,50 @@
# Feel free to remove those if you don't want/need to use them.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
name: "gomuks-desktop"
arch: ${GOARCH}
platform: "linux"
version: "0.4.0"
section: "default"
priority: "extra"
maintainer: Tulir Asokan <tulir@maunium.net>
description: "A Matrix client written in Go and React"
vendor: ""
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/gomuks-desktop"
dst: "/usr/local/bin/gomuks-desktop"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/gomuks-desktop.png"
- src: "./build/gomuks-desktop.desktop"
dst: "/usr/share/applications/gomuks-desktop.desktop"
depends:
- gtk3
- libwebkit2gtk
# replaces:
# - foobar
# provides:
# - bar
# depends:
# - gtk3
# - libwebkit2gtk
recommends:
- ffmpeg
# suggests:
# - something-else
# conflicts:
# - not-foo
# - not-bar
# changelog: "changelog.yaml"
# scripts:
# preinstall: ./build/nfpm/scripts/preinstall.sh
# postinstall: ./build/nfpm/scripts/postinstall.sh
# preremove: ./build/nfpm/scripts/preremove.sh
# postremove: ./build/nfpm/scripts/postremove.sh

View file

@ -0,0 +1 @@
#!/bin/bash

View file

@ -0,0 +1 @@
#!/bin/bash

View file

@ -0,0 +1 @@
#!/bin/bash

View file

@ -0,0 +1 @@
#!/bin/bash

View file

@ -20,10 +20,10 @@ Unicode true
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here. ## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
#### ####
## !define INFO_PROJECTNAME "my-project" # Default "gomuks-desktop" ## !define INFO_PROJECTNAME "my-project" # Default "gomuks-desktop"
## !define INFO_COMPANYNAME "My Company" # Default "My Company" ## !define INFO_COMPANYNAME "My Company" # Default ""
## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product" ## !define INFO_PRODUCTNAME "My Product Name" # Default "gomuks desktop"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0" ## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© now, My Company" ## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© gomuks authors"
### ###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" ## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" ## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
@ -91,6 +91,8 @@ Section
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.writeUninstaller !insertmacro wails.writeUninstaller
SectionEnd SectionEnd
@ -104,5 +106,7 @@ Section "uninstall"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.deleteUninstaller !insertmacro wails.deleteUninstaller
SectionEnd SectionEnd

View file

@ -8,16 +8,16 @@
!define INFO_PROJECTNAME "gomuks-desktop" !define INFO_PROJECTNAME "gomuks-desktop"
!endif !endif
!ifndef INFO_COMPANYNAME !ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "My Company" !define INFO_COMPANYNAME ""
!endif !endif
!ifndef INFO_PRODUCTNAME !ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "My Product" !define INFO_PRODUCTNAME "gomuks desktop"
!endif !endif
!ifndef INFO_PRODUCTVERSION !ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.1.0" !define INFO_PRODUCTVERSION "0.4.0"
!endif !endif
!ifndef INFO_COPYRIGHT !ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© now, My Company" !define INFO_COPYRIGHT "© 2024, gomuks authors"
!endif !endif
!ifndef PRODUCT_EXECUTABLE !ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
@ -177,3 +177,36 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
SetDetailsPrint both SetDetailsPrint both
ok: ok:
!macroend !macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
!macroend
!macro wails.unassociateFiles
; Delete app associations
!macroend

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.gomuks-desktop" version="0.1.0" processorArchitecture="*"/> <assemblyIdentity type="win32" name="fi.mau.gomuks.desktop" version="0.4.0" processorArchitecture="*"/>
<dependency> <dependency>
<dependentAssembly> <dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/> <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>

View file

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

View file

@ -1,5 +1,5 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@ -7,12 +7,14 @@ github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@ -31,39 +33,39 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes= github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@ -71,8 +73,8 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@ -106,10 +108,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A= github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
@ -121,8 +123,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@ -130,16 +132,16 @@ github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@ -149,19 +151,19 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wailsapp/go-webview2 v1.0.15 h1:IeQFoWmsHp32y64I41J+Zod3SopjHs918KSO4jUqEnY= github.com/wailsapp/go-webview2 v1.0.18 h1:SSSCoLA+MYikSp1U0WmvELF/4c3x5kH8Vi31TKyZ4yk=
github.com/wailsapp/go-webview2 v1.0.15/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo= github.com/wailsapp/go-webview2 v1.0.18/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v3 v3.0.0-alpha.7 h1:LNX2EnbxTEYJYICJT8UkuzoGVNalRizTNGBY47endmk= github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3 h1:9aCL0IXD60A5iscQ/ps6f3ti3IlaoG6LQe0RZ9JkueU=
github.com/wailsapp/wails/v3 v3.0.0-alpha.7/go.mod h1:lBz4zedFxreJBoVpMe9u89oo4IE3IlyHJg5rOWnGNR0= github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3/go.mod h1:9Ca1goy5oqxmy8Oetb8Tchkezcx4tK03DK+SqYByu5Y=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.3-0.20241207221539-07bba6a0c5eb h1:/iKi+4aRvd8LZJ3z1UQjxmFdDVfJuDWClc/4MToWnSY= go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a h1:D9RCHBFjxah9F/YB7amvRJjT2IEOFWcz8jpcEY8dBV0=
go.mau.fi/util v0.8.3-0.20241207221539-07bba6a0c5eb/go.mod h1:BHHC9R2WLMJd1bwTZfTcFxUgRFmUgUmiWcT4RbzUgiA= go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -169,12 +171,12 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
@ -187,15 +189,14 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -208,20 +209,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -229,14 +231,14 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -247,10 +249,10 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837 h1:v3cRnMfhKxpnKjhikZ5HY72MKIsgYzldL2s3cqbkNbY= maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f h1:+nAznNCSCm+P4am9CmguSfNfcbpA7qtTzJQrwCP8aHM=
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM= maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f/go.mod h1:FmwzK7RSzrd1OfGDgJzFWXl7nYmYm8/P0Y77sy/A1Uw=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=

View file

@ -123,7 +123,7 @@ func main() {
ch := &CommandHandler{Gomuks: gmx, Ctx: cmdCtx} ch := &CommandHandler{Gomuks: gmx, Ctx: cmdCtx}
app := application.New(application.Options{ app := application.New(application.Options{
Name: "gomuks-desktop", Name: "gomuks-desktop",
Description: "A Matrix client written in Go", Description: "A Matrix client written in Go and React",
Services: []application.Service{ Services: []application.Service{
application.NewService( application.NewService(
&PointableHandler{gmx.CreateAPIRouter()}, &PointableHandler{gmx.CreateAPIRouter()},

30
go.mod
View file

@ -2,14 +2,14 @@ module go.mau.fi/gomuks
go 1.23.0 go 1.23.0
toolchain go1.23.3 toolchain go1.23.4
require ( require (
github.com/alecthomas/chroma/v2 v2.14.0 github.com/alecthomas/chroma/v2 v2.15.0
github.com/buckket/go-blurhash v1.1.0 github.com/buckket/go-blurhash v1.1.0
github.com/chzyer/readline v1.5.1 github.com/chzyer/readline v1.5.1
github.com/coder/websocket v1.8.12 github.com/coder/websocket v1.8.12
github.com/gabriel-vasile/mimetype v1.4.7 github.com/gabriel-vasile/mimetype v1.4.8
github.com/gdamore/tcell/v2 v2.7.4 github.com/gdamore/tcell/v2 v2.7.4
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
@ -19,33 +19,33 @@ require (
github.com/tidwall/sjson v1.2.5 github.com/tidwall/sjson v1.2.5
github.com/yuin/goldmark v1.7.8 github.com/yuin/goldmark v1.7.8
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5 go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5
go.mau.fi/util v0.8.3-0.20241207221539-07bba6a0c5eb go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a
go.mau.fi/zeroconfig v0.1.3 go.mau.fi/zeroconfig v0.1.3
golang.org/x/crypto v0.29.0 golang.org/x/crypto v0.32.0
golang.org/x/image v0.22.0 golang.org/x/image v0.23.0
golang.org/x/net v0.31.0 golang.org/x/net v0.33.0
golang.org/x/text v0.20.0 golang.org/x/text v0.21.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837 maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f
mvdan.cc/xurls/v2 v2.5.0 mvdan.cc/xurls/v2 v2.6.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/gdamore/encoding v1.0.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 // indirect github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
github.com/zyedidia/clipboard v1.0.4 // indirect github.com/zyedidia/clipboard v1.0.4 // indirect
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect
golang.org/x/sys v0.27.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.26.0 // indirect golang.org/x/term v0.28.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
) )

64
go.sum
View file

@ -2,10 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do= github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
@ -22,10 +22,10 @@ github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
@ -45,8 +45,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A= github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -59,8 +59,8 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@ -77,26 +77,26 @@ github.com/zyedidia/clipboard v1.0.4 h1:r6GUQOyPtIaApRLeD56/U+2uJbXis6ANGbKWCljU
github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA= github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA=
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5 h1:apKftqeRRyj/Vpd5s81fNhS8UErwgfs07KG3NSHB/4Q= go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5 h1:apKftqeRRyj/Vpd5s81fNhS8UErwgfs07KG3NSHB/4Q=
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5/go.mod h1:G0Qkfwt84f+5tagHsaRdiTuUFeTlIZu61MN/JL9D8Qo= go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5/go.mod h1:G0Qkfwt84f+5tagHsaRdiTuUFeTlIZu61MN/JL9D8Qo=
go.mau.fi/util v0.8.3-0.20241207221539-07bba6a0c5eb h1:/iKi+4aRvd8LZJ3z1UQjxmFdDVfJuDWClc/4MToWnSY= go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a h1:D9RCHBFjxah9F/YB7amvRJjT2IEOFWcz8jpcEY8dBV0=
go.mau.fi/util v0.8.3-0.20241207221539-07bba6a0c5eb/go.mod h1:BHHC9R2WLMJd1bwTZfTcFxUgRFmUgUmiWcT4RbzUgiA= go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -111,21 +111,21 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@ -139,7 +139,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837 h1:v3cRnMfhKxpnKjhikZ5HY72MKIsgYzldL2s3cqbkNbY= maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f h1:+nAznNCSCm+P4am9CmguSfNfcbpA7qtTzJQrwCP8aHM=
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM= maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f/go.mod h1:FmwzK7RSzrd1OfGDgJzFWXl7nYmYm8/P0Y77sy/A1Uw=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=

View file

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

View file

@ -34,6 +34,7 @@ import (
type Config struct { type Config struct {
Web WebConfig `yaml:"web"` Web WebConfig `yaml:"web"`
Matrix MatrixConfig `yaml:"matrix"` Matrix MatrixConfig `yaml:"matrix"`
Push PushConfig `yaml:"push"`
Logging zeroconfig.Config `yaml:"logging"` Logging zeroconfig.Config `yaml:"logging"`
} }
@ -41,13 +42,18 @@ type MatrixConfig struct {
DisableHTTP2 bool `yaml:"disable_http2"` DisableHTTP2 bool `yaml:"disable_http2"`
} }
type PushConfig struct {
FCMGateway string `yaml:"fcm_gateway"`
}
type WebConfig struct { type WebConfig struct {
ListenAddress string `yaml:"listen_address"` ListenAddress string `yaml:"listen_address"`
Username string `yaml:"username"` Username string `yaml:"username"`
PasswordHash string `yaml:"password_hash"` PasswordHash string `yaml:"password_hash"`
TokenKey string `yaml:"token_key"` TokenKey string `yaml:"token_key"`
DebugEndpoints bool `yaml:"debug_endpoints"` DebugEndpoints bool `yaml:"debug_endpoints"`
EventBufferSize int `yaml:"event_buffer_size"` EventBufferSize int `yaml:"event_buffer_size"`
OriginPatterns []string `yaml:"origin_patterns"`
} }
var defaultFileWriter = zeroconfig.WriterConfig{ var defaultFileWriter = zeroconfig.WriterConfig{
@ -120,6 +126,14 @@ func (gmx *Gomuks) LoadConfig() error {
gmx.Config.Web.EventBufferSize = 512 gmx.Config.Web.EventBufferSize = 512
changed = true changed = true
} }
if gmx.Config.Push.FCMGateway == "" {
gmx.Config.Push.FCMGateway = "https://push.gomuks.app"
changed = true
}
if len(gmx.Config.Web.OriginPatterns) == 0 {
gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"}
changed = true
}
if changed { if changed {
err = gmx.SaveConfig() err = gmx.SaveConfig()
if err != nil { if err != nil {

View file

@ -35,6 +35,7 @@ import (
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog" "go.mau.fi/util/exzerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/zeroconfig" "go.mau.fi/zeroconfig"
"golang.org/x/net/http2" "golang.org/x/net/http2"
@ -184,7 +185,7 @@ func (gmx *Gomuks) StartClient() {
nil, nil,
gmx.Log.With().Str("component", "hicli").Logger(), gmx.Log.With().Str("component", "hicli").Logger(),
[]byte("meow"), []byte("meow"),
gmx.EventBuffer.HicliEventHandler, gmx.HandleEvent,
) )
gmx.Client.LogoutFunc = gmx.Logout gmx.Client.LogoutFunc = gmx.Logout
httpClient := gmx.Client.Client.Client httpClient := gmx.Client.Client.Client
@ -210,6 +211,14 @@ func (gmx *Gomuks) StartClient() {
gmx.Log.Info().Stringer("user_id", userID).Msg("Client started") gmx.Log.Info().Stringer("user_id", userID).Msg("Client started")
} }
func (gmx *Gomuks) HandleEvent(evt any) {
gmx.EventBuffer.Push(evt)
syncComplete, ok := evt.(*hicli.SyncComplete)
if ok && ptr.Val(syncComplete.Since) != "" {
go gmx.SendPushNotifications(syncComplete)
}
}
func (gmx *Gomuks) Stop() { func (gmx *Gomuks) Stop() {
gmx.stopOnce.Do(func() { gmx.stopOnce.Do(func() {
close(gmx.stopChan) close(gmx.stopChan)
@ -230,9 +239,11 @@ func (gmx *Gomuks) DirectStop() {
closer(websocket.StatusServiceRestart, "Server shutting down") closer(websocket.StatusServiceRestart, "Server shutting down")
} }
gmx.Client.Stop() gmx.Client.Stop()
err := gmx.Server.Close() if gmx.Server != nil {
if err != nil { err := gmx.Server.Close()
gmx.Log.Error().Err(err).Msg("Failed to close server") if err != nil {
gmx.Log.Error().Err(err).Msg("Failed to close server")
}
} }
} }

View file

@ -109,7 +109,7 @@ func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media) {
w.Header().Set("Content-Type", entry.MimeType) w.Header().Set("Content-Type", entry.MimeType)
w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10)) w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10))
w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName})) w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName}))
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none';") w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; media-src 'self';")
w.Header().Set("Cache-Control", "max-age=2592000, immutable") w.Header().Set("Cache-Control", "max-age=2592000, immutable")
w.Header().Set("ETag", entry.ETag()) w.Header().Set("ETag", entry.ETag())
} }
@ -125,7 +125,7 @@ func (new *noErrorWriter) Write(p []byte) (n int, err error) {
// note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts // note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts
const fallbackAvatarTemplate = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"> const fallbackAvatarTemplate = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<circle cx="500" cy="500" r="500" fill="%s"/> <rect x="0" y="0" width="1000" height="1000" fill="%s"/>
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666" <text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
>%s</text> >%s</text>

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

@ -0,0 +1,252 @@
// 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) {
rawPayload, err := json.Marshal(notif)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to marshal push notification")
return
} else if base64.StdEncoding.EncodedLen(len(rawPayload)) >= 4000 {
zerolog.Ctx(ctx).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 {
zerolog.Ctx(ctx).Err(err).Str("device_id", reg.DeviceID).Msg("Failed to encrypt push payload")
continue
}
encrypted = true
}
switch reg.Type {
case database.PushTypeFCM:
if !encrypted {
zerolog.Ctx(ctx).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 {
zerolog.Ctx(ctx).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()
}
}

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

@ -0,0 +1,169 @@
// 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
}
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

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

View file

@ -86,7 +86,7 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
defer recoverPanic("read loop") defer recoverPanic("read loop")
conn, acceptErr := websocket.Accept(w, r, &websocket.AcceptOptions{ conn, acceptErr := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"localhost:*"}, OriginPatterns: gmx.Config.Web.OriginPatterns,
}) })
if acceptErr != nil { if acceptErr != nil {
log.Warn().Err(acceptErr).Msg("Failed to accept websocket connection") log.Warn().Err(acceptErr).Msg("Failed to accept websocket connection")
@ -148,7 +148,7 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
sendImageAuthToken := func() { sendImageAuthToken := func() {
err := writeCmd(ctx, conn, &hicli.JSONCommand{ err := writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "image_auth_token", Command: "image_auth_token",
Data: exerrors.Must(json.Marshal(gmx.generateImageToken())), Data: exerrors.Must(json.Marshal(gmx.generateImageToken(1 * time.Hour))),
}) })
if err != nil { if err != nil {
log.Err(err).Msg("Failed to write image auth token message") log.Err(err).Msg("Failed to write image auth token message")

View file

@ -17,15 +17,18 @@ import (
type Database struct { type Database struct {
*dbutil.Database *dbutil.Database
Account AccountQuery Account *AccountQuery
AccountData AccountDataQuery AccountData *AccountDataQuery
Room RoomQuery Room *RoomQuery
Event EventQuery InvitedRoom *InvitedRoomQuery
CurrentState CurrentStateQuery Event *EventQuery
Timeline TimelineQuery CurrentState *CurrentStateQuery
SessionRequest SessionRequestQuery Timeline *TimelineQuery
Receipt ReceiptQuery SessionRequest *SessionRequestQuery
Media MediaQuery Receipt *ReceiptQuery
Media *MediaQuery
SpaceEdge *SpaceEdgeQuery
PushRegistration *PushRegistrationQuery
} }
func New(rawDB *dbutil.Database) *Database { func New(rawDB *dbutil.Database) *Database {
@ -34,15 +37,18 @@ func New(rawDB *dbutil.Database) *Database {
return &Database{ return &Database{
Database: rawDB, Database: rawDB,
Account: AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)}, Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
AccountData: AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)}, AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
Room: RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)}, Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
Event: EventQuery{QueryHelper: eventQH}, InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
CurrentState: CurrentStateQuery{QueryHelper: eventQH}, Event: &EventQuery{QueryHelper: eventQH},
Timeline: TimelineQuery{QueryHelper: eventQH}, CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
SessionRequest: SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)}, Timeline: &TimelineQuery{QueryHelper: eventQH},
Receipt: ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)}, SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
Media: MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)}, 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)},
} }
} }
@ -58,6 +64,10 @@ func newRoom(_ *dbutil.QueryHelper[*Room]) *Room {
return &Room{} return &Room{}
} }
func newInvitedRoom(_ *dbutil.QueryHelper[*InvitedRoom]) *InvitedRoom {
return &InvitedRoom{}
}
func newReceipt(_ *dbutil.QueryHelper[*Receipt]) *Receipt { func newReceipt(_ *dbutil.QueryHelper[*Receipt]) *Receipt {
return &Receipt{} return &Receipt{}
} }
@ -73,3 +83,11 @@ func newAccountData(_ *dbutil.QueryHelper[*AccountData]) *AccountData {
func newAccount(_ *dbutil.QueryHelper[*Account]) *Account { func newAccount(_ *dbutil.QueryHelper[*Account]) *Account {
return &Account{} return &Account{}
} }
func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
return &SpaceEdge{}
}
func newPushRegistration(_ *dbutil.QueryHelper[*PushRegistration]) *PushRegistration {
return &PushRegistration{}
}

View file

@ -327,12 +327,17 @@ func (m EventRowID) GetMassInsertValues() [1]any {
} }
type LocalContent struct { type LocalContent struct {
SanitizedHTML string `json:"sanitized_html,omitempty"` SanitizedHTML string `json:"sanitized_html,omitempty"`
HTMLVersion int `json:"html_version,omitempty"` HTMLVersion int `json:"html_version,omitempty"`
WasPlaintext bool `json:"was_plaintext,omitempty"` WasPlaintext bool `json:"was_plaintext,omitempty"`
BigEmoji bool `json:"big_emoji,omitempty"` BigEmoji bool `json:"big_emoji,omitempty"`
HasMath bool `json:"has_math,omitempty"` HasMath bool `json:"has_math,omitempty"`
EditSource string `json:"edit_source,omitempty"` EditSource string `json:"edit_source,omitempty"`
ReplyFallbackRemoved bool `json:"reply_fallback_removed,omitempty"`
}
func (c *LocalContent) GetReplyFallbackRemoved() bool {
return c != nil && c.ReplyFallbackRemoved
} }
type Event struct { type Event struct {
@ -461,6 +466,7 @@ func (e *Event) Scan(row dbutil.Scannable) (*Event, error) {
var relatesToPath = exgjson.Path("m.relates_to", "event_id") var relatesToPath = exgjson.Path("m.relates_to", "event_id")
var relationTypePath = exgjson.Path("m.relates_to", "rel_type") var relationTypePath = exgjson.Path("m.relates_to", "rel_type")
var replyToPath = exgjson.Path("m.relates_to", "m.in_reply_to", "event_id")
func getRelatesToFromEvent(evt *event.Event) (id.EventID, event.RelationType) { func getRelatesToFromEvent(evt *event.Event) (id.EventID, event.RelationType) {
if evt.StateKey != nil { if evt.StateKey != nil {
@ -488,6 +494,18 @@ func getMegolmSessionID(evt *event.Event) id.SessionID {
return "" return ""
} }
func (e *Event) GetReplyTo() id.EventID {
content := e.Content
if e.Decrypted != nil {
content = e.Decrypted
}
result := gjson.GetBytes(content, replyToPath)
if result.Type == gjson.String {
return id.EventID(result.Str)
}
return ""
}
func (e *Event) sqlVariables() []any { func (e *Event) sqlVariables() []any {
var reactions any var reactions any
if e.Reactions != nil { if e.Reactions != nil {
@ -545,3 +563,10 @@ func (e *Event) BumpsSortingTimestamp() bool {
return (e.Type == event.EventMessage.Type || e.Type == event.EventSticker.Type || e.Type == event.EventEncrypted.Type) && return (e.Type == event.EventMessage.Type || e.Type == event.EventSticker.Type || e.Type == event.EventEncrypted.Type) &&
e.RelationType != event.RelReplace e.RelationType != event.RelReplace
} }
func (e *Event) MarkReplyFallbackRemoved() {
if e.LocalContent == nil {
e.LocalContent = &LocalContent{}
}
e.LocalContent.ReplyFallbackRemoved = true
}

View file

@ -0,0 +1,73 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
const (
getInvitedRoomsQuery = `
SELECT room_id, received_at, invite_state
FROM invited_room
ORDER BY received_at DESC
`
deleteInvitedRoomQuery = `
DELETE FROM invited_room WHERE room_id = $1
`
upsertInvitedRoomQuery = `
INSERT INTO invited_room (room_id, received_at, invite_state)
VALUES ($1, $2, $3)
ON CONFLICT (room_id) DO UPDATE
SET received_at = $2, invite_state = $3
`
)
type InvitedRoomQuery struct {
*dbutil.QueryHelper[*InvitedRoom]
}
func (irq *InvitedRoomQuery) GetAll(ctx context.Context) ([]*InvitedRoom, error) {
return irq.QueryMany(ctx, getInvitedRoomsQuery)
}
func (irq *InvitedRoomQuery) Upsert(ctx context.Context, room *InvitedRoom) error {
return irq.Exec(ctx, upsertInvitedRoomQuery, room.sqlVariables()...)
}
func (irq *InvitedRoomQuery) Delete(ctx context.Context, roomID id.RoomID) error {
return irq.Exec(ctx, deleteInvitedRoomQuery, roomID)
}
type InvitedRoom struct {
ID id.RoomID `json:"room_id"`
CreatedAt jsontime.UnixMilli `json:"created_at"`
InviteState []*event.Event `json:"invite_state"`
}
func (r *InvitedRoom) sqlVariables() []any {
return []any{
r.ID,
dbutil.UnixMilliPtr(r.CreatedAt.Time),
dbutil.JSON{Data: &r.InviteState},
}
}
func (r *InvitedRoom) Scan(row dbutil.Scannable) (*InvitedRoom, error) {
var createdAt int64
err := row.Scan(&r.ID, &createdAt, dbutil.JSON{Data: &r.InviteState})
if err != nil {
return nil, err
}
r.CreatedAt = jsontime.UMInt(createdAt)
return r, 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

@ -8,7 +8,9 @@ package database
import ( import (
"context" "context"
"fmt"
"slices" "slices"
"strings"
"time" "time"
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
@ -25,6 +27,7 @@ const (
SET event_id = excluded.event_id, SET event_id = excluded.event_id,
timestamp = excluded.timestamp timestamp = excluded.timestamp
` `
getReadReceiptsQuery = `SELECT room_id, user_id, receipt_type, thread_id, event_id, timestamp FROM receipt WHERE room_id = $1 AND receipt_type='m.read' AND event_id IN ($2)`
) )
var receiptMassInserter = dbutil.NewMassInsertBuilder[*Receipt, [1]any](upsertReceiptQuery, "($1, $%d, $%d, $%d, $%d, $%d)") var receiptMassInserter = dbutil.NewMassInsertBuilder[*Receipt, [1]any](upsertReceiptQuery, "($1, $%d, $%d, $%d, $%d, $%d)")
@ -53,11 +56,29 @@ func (rq *ReceiptQuery) PutMany(ctx context.Context, roomID id.RoomID, receipts
return rq.Exec(ctx, query, params...) return rq.Exec(ctx, query, params...)
} }
func (rq *ReceiptQuery) GetManyRead(ctx context.Context, roomID id.RoomID, eventIDs []id.EventID) (map[id.EventID][]*Receipt, error) {
args := make([]any, len(eventIDs)+1)
placeholders := make([]string, len(eventIDs)+1)
args[0] = roomID
placeholders[0] = "?1"
for i, evtID := range eventIDs {
args[i+1] = evtID
placeholders[i+1] = fmt.Sprintf("?%d", i+2)
}
query := strings.Replace(getReadReceiptsQuery, "$2", strings.Join(placeholders, ", "), 1)
output := make(map[id.EventID][]*Receipt)
err := rq.QueryManyIter(ctx, query, args...).Iter(func(receipt *Receipt) (bool, error) {
output[receipt.EventID] = append(output[receipt.EventID], receipt)
return true, nil
})
return output, err
}
type Receipt struct { type Receipt struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id,omitempty"`
UserID id.UserID `json:"user_id"` UserID id.UserID `json:"user_id"`
ReceiptType event.ReceiptType `json:"receipt_type"` ReceiptType event.ReceiptType `json:"receipt_type"`
ThreadID event.ThreadID `json:"thread_id"` ThreadID event.ThreadID `json:"thread_id,omitempty"`
EventID id.EventID `json:"event_id"` EventID id.EventID `json:"event_id"`
Timestamp jsontime.UnixMilli `json:"timestamp"` Timestamp jsontime.UnixMilli `json:"timestamp"`
} }

View file

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

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

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

View file

@ -1,4 +1,4 @@
-- v0 -> v7 (compatible with v5+): Latest revision -- v0 -> v12 (compatible with v10+): Latest revision
CREATE TABLE account ( CREATE TABLE account (
user_id TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
@ -10,6 +10,7 @@ CREATE TABLE account (
CREATE TABLE room ( CREATE TABLE room (
room_id TEXT NOT NULL PRIMARY KEY, room_id TEXT NOT NULL PRIMARY KEY,
room_type TEXT,
creation_content TEXT, creation_content TEXT,
tombstone_content TEXT, tombstone_content TEXT,
@ -17,6 +18,7 @@ CREATE TABLE room (
name_quality INTEGER NOT NULL DEFAULT 0, name_quality INTEGER NOT NULL DEFAULT 0,
avatar TEXT, avatar TEXT,
explicit_avatar INTEGER NOT NULL DEFAULT 0, explicit_avatar INTEGER NOT NULL DEFAULT 0,
dm_user_id TEXT,
topic TEXT, topic TEXT,
canonical_alias TEXT, canonical_alias TEXT,
lazy_load_summary TEXT, lazy_load_summary TEXT,
@ -35,11 +37,25 @@ CREATE TABLE room (
CONSTRAINT room_preview_event_fkey FOREIGN KEY (preview_event_rowid) REFERENCES event (rowid) ON DELETE SET NULL CONSTRAINT room_preview_event_fkey FOREIGN KEY (preview_event_rowid) REFERENCES event (rowid) ON DELETE SET NULL
) STRICT; ) STRICT;
CREATE INDEX room_type_idx ON room (creation_content ->> 'type'); CREATE INDEX room_type_idx ON room (room_type);
CREATE INDEX room_sorting_timestamp_idx ON room (sorting_timestamp DESC); CREATE INDEX room_sorting_timestamp_idx ON room (sorting_timestamp DESC);
CREATE INDEX room_preview_idx ON room (preview_event_rowid);
-- CREATE INDEX room_sorting_timestamp_idx ON room (unread_notifications > 0); -- CREATE INDEX room_sorting_timestamp_idx ON room (unread_notifications > 0);
-- CREATE INDEX room_sorting_timestamp_idx ON room (unread_messages > 0); -- CREATE INDEX room_sorting_timestamp_idx ON room (unread_messages > 0);
CREATE TABLE invited_room (
room_id TEXT NOT NULL PRIMARY KEY,
received_at INTEGER NOT NULL,
invite_state TEXT NOT NULL
) STRICT;
CREATE TRIGGER invited_room_delete_on_room_insert
AFTER INSERT
ON room
BEGIN
DELETE FROM invited_room WHERE room_id = NEW.room_id;
END;
CREATE TABLE account_data ( CREATE TABLE account_data (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
@ -248,7 +264,8 @@ CREATE TABLE current_state (
PRIMARY KEY (room_id, event_type, state_key), PRIMARY KEY (room_id, event_type, state_key),
CONSTRAINT current_state_room_fkey FOREIGN KEY (room_id) REFERENCES room (room_id) ON DELETE CASCADE, CONSTRAINT current_state_room_fkey FOREIGN KEY (room_id) REFERENCES room (room_id) ON DELETE CASCADE,
CONSTRAINT current_state_event_fkey FOREIGN KEY (event_rowid) REFERENCES event (rowid) CONSTRAINT current_state_event_fkey FOREIGN KEY (event_rowid) REFERENCES event (rowid),
CONSTRAINT current_state_rowid_unique UNIQUE (event_rowid)
) STRICT, WITHOUT ROWID; ) STRICT, WITHOUT ROWID;
CREATE TABLE receipt ( CREATE TABLE receipt (
@ -263,3 +280,34 @@ CREATE TABLE receipt (
CONSTRAINT receipt_room_fkey FOREIGN KEY (room_id) REFERENCES room (room_id) ON DELETE CASCADE CONSTRAINT receipt_room_fkey FOREIGN KEY (room_id) REFERENCES room (room_id) ON DELETE CASCADE
-- note: there's no foreign key on event ID because receipts could point at events that are too far in history. -- note: there's no foreign key on event ID because receipts could point at events that are too far in history.
) STRICT; ) STRICT;
CREATE TABLE space_edge (
space_id TEXT NOT NULL,
child_id TEXT NOT NULL,
-- m.space.child fields
child_event_rowid INTEGER,
"order" TEXT NOT NULL DEFAULT '',
suggested INTEGER NOT NULL DEFAULT false CHECK ( suggested IN (false, true) ),
-- m.space.parent fields
parent_event_rowid INTEGER,
canonical INTEGER NOT NULL DEFAULT false CHECK ( canonical IN (false, true) ),
parent_validated INTEGER NOT NULL DEFAULT false CHECK ( parent_validated IN (false, true) ),
PRIMARY KEY (space_id, child_id),
CONSTRAINT space_edge_child_event_fkey FOREIGN KEY (child_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
CONSTRAINT space_edge_parent_event_fkey FOREIGN KEY (parent_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
CONSTRAINT space_edge_child_event_unique UNIQUE (child_event_rowid),
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
) STRICT;
CREATE INDEX space_edge_child_idx ON space_edge (child_id);
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,3 @@
-- v8 (compatible with v5+): Add indexes necessary for fast room deletion
CREATE INDEX room_preview_idx ON room (preview_event_rowid);
CREATE UNIQUE INDEX current_state_rowid_unique ON current_state (event_rowid);

View file

@ -0,0 +1,13 @@
-- v9 (compatible with v5+): Add table for invited rooms
CREATE TABLE invited_room (
room_id TEXT NOT NULL PRIMARY KEY,
received_at INTEGER NOT NULL,
invite_state TEXT NOT NULL
) STRICT;
CREATE TRIGGER invited_room_delete_on_room_insert
AFTER INSERT
ON room
BEGIN
DELETE FROM invited_room WHERE room_id = NEW.room_id;
END;

View file

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

View file

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

View file

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

@ -59,7 +59,7 @@ func (h *HiClient) handleReceivedMegolmSession(ctx context.Context, roomID id.Ro
} }
var mautrixEvt *event.Event var mautrixEvt *event.Event
mautrixEvt, evt.Decrypted, evt.DecryptedType, err = h.decryptEvent(ctx, evt.AsRawMautrix()) mautrixEvt, err = h.decryptEventInto(ctx, evt.AsRawMautrix(), evt)
if err != nil { if err != nil {
log.Warn().Err(err).Stringer("event_id", evt.ID).Msg("Failed to decrypt event even after receiving megolm session") log.Warn().Err(err).Stringer("event_id", evt.ID).Msg("Failed to decrypt event even after receiving megolm session")
} else { } else {

View file

@ -15,38 +15,58 @@ import (
) )
type SyncRoom struct { type SyncRoom struct {
Meta *database.Room `json:"meta"` Meta *database.Room `json:"meta"`
Timeline []database.TimelineRowTuple `json:"timeline"` Timeline []database.TimelineRowTuple `json:"timeline"`
State map[event.Type]map[string]database.EventRowID `json:"state"` State map[event.Type]map[string]database.EventRowID `json:"state"`
AccountData map[event.Type]*database.AccountData `json:"account_data"` AccountData map[event.Type]*database.AccountData `json:"account_data"`
Events []*database.Event `json:"events"` Events []*database.Event `json:"events"`
Reset bool `json:"reset"` Reset bool `json:"reset"`
Notifications []SyncNotification `json:"notifications"` Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
DismissNotifications bool `json:"dismiss_notifications"`
Notifications []SyncNotification `json:"notifications"`
} }
type SyncNotification struct { type SyncNotification struct {
RowID database.EventRowID `json:"event_rowid"` RowID database.EventRowID `json:"event_rowid"`
Sound bool `json:"sound"` Sound bool `json:"sound"`
Highlight bool `json:"highlight"`
Event *database.Event `json:"-"`
Room *database.Room `json:"-"`
} }
type SyncComplete struct { type SyncComplete struct {
Since *string `json:"since,omitempty"` Since *string `json:"since,omitempty"`
ClearState bool `json:"clear_state,omitempty"` ClearState bool `json:"clear_state,omitempty"`
Rooms map[id.RoomID]*SyncRoom `json:"rooms"` AccountData map[event.Type]*database.AccountData `json:"account_data"`
AccountData map[event.Type]*database.AccountData `json:"account_data"` Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
LeftRooms []id.RoomID `json:"left_rooms"` LeftRooms []id.RoomID `json:"left_rooms"`
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
}
func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) {
for _, room := range c.Rooms {
for _, notif := range room.Notifications {
if !yield(notif) {
return
}
}
}
} }
func (c *SyncComplete) IsEmpty() bool { func (c *SyncComplete) IsEmpty() bool {
return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.AccountData) == 0 return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.InvitedRooms) == 0 && len(c.AccountData) == 0
} }
type SyncStatusType string type SyncStatusType string
const ( const (
SyncStatusOK SyncStatusType = "ok" SyncStatusOK SyncStatusType = "ok"
SyncStatusWaiting SyncStatusType = "waiting" SyncStatusWaiting SyncStatusType = "waiting"
SyncStatusErrored SyncStatusType = "errored" SyncStatusErroring SyncStatusType = "erroring"
SyncStatusFailed SyncStatusType = "permanently-failed"
) )
type SyncStatus struct { type SyncStatus struct {

View file

@ -252,7 +252,7 @@ func (h *HiClient) Sync() {
log.Info().Msg("Starting syncing") log.Info().Msg("Starting syncing")
err := h.Client.SyncWithContext(ctx) err := h.Client.SyncWithContext(ctx)
if err != nil && ctx.Err() == nil { if err != nil && ctx.Err() == nil {
h.markSyncErrored(err) h.markSyncErrored(err, true)
log.Err(err).Msg("Fatal error in syncer") log.Err(err).Msg("Fatal error in syncer")
} else { } else {
h.SyncStatus.Store(syncWaiting) h.SyncStatus.Store(syncWaiting)

View file

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

View file

@ -86,10 +86,22 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) { return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) {
return h.Client.GetProfile(ctx, params.UserID) return h.Client.GetProfile(ctx, params.UserID)
}) })
case "set_profile_field":
return unmarshalAndCall(req.Data, func(params *setProfileFieldParams) (bool, error) {
return true, h.Client.UnstableSetProfileField(ctx, params.Field, params.Value)
})
case "get_mutual_rooms": case "get_mutual_rooms":
return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) { return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) {
return h.GetMutualRooms(ctx, params.UserID) return h.GetMutualRooms(ctx, params.UserID)
}) })
case "track_user_devices":
return unmarshalAndCall(req.Data, func(params *getProfileParams) (*ProfileEncryptionInfo, error) {
err := h.TrackUserDevices(ctx, params.UserID)
if err != nil {
return nil, err
}
return h.GetProfileEncryptionInfo(ctx, params.UserID)
})
case "get_profile_encryption_info": case "get_profile_encryption_info":
return unmarshalAndCall(req.Data, func(params *getProfileParams) (*ProfileEncryptionInfo, error) { return unmarshalAndCall(req.Data, func(params *getProfileParams) (*ProfileEncryptionInfo, error) {
return h.GetProfileEncryptionInfo(ctx, params.UserID) return h.GetProfileEncryptionInfo(ctx, params.UserID)
@ -98,10 +110,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) { return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
return h.GetEvent(ctx, params.RoomID, params.EventID) return h.GetEvent(ctx, params.RoomID, params.EventID)
}) })
case "get_events_by_rowids": //case "get_events_by_rowids":
return unmarshalAndCall(req.Data, func(params *getEventsByRowIDsParams) ([]*database.Event, error) { // return unmarshalAndCall(req.Data, func(params *getEventsByRowIDsParams) ([]*database.Event, error) {
return h.GetEventsByRowIDs(ctx, params.RowIDs) // return h.GetEventsByRowIDs(ctx, params.RowIDs)
}) // })
case "get_room_state": case "get_room_state":
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) { return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch) return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch)
@ -110,6 +122,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *getSpecificRoomStateParams) ([]*database.Event, error) { return unmarshalAndCall(req.Data, func(params *getSpecificRoomStateParams) ([]*database.Event, error) {
return h.DB.CurrentState.GetMany(ctx, params.Keys) return h.DB.CurrentState.GetMany(ctx, params.Keys)
}) })
case "get_receipts":
return unmarshalAndCall(req.Data, func(params *getReceiptsParams) (map[id.EventID][]*database.Receipt, error) {
return h.GetReceipts(ctx, params.RoomID, params.EventIDs)
})
case "paginate": case "paginate":
return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) { return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) {
return h.Paginate(ctx, params.RoomID, params.MaxTimelineID, params.Limit) return h.Paginate(ctx, params.RoomID, params.MaxTimelineID, params.Limit)
@ -118,6 +134,21 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) { return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) {
return h.PaginateServer(ctx, params.RoomID, params.Limit) return h.PaginateServer(ctx, params.RoomID, params.Limit)
}) })
case "get_room_summary":
return unmarshalAndCall(req.Data, func(params *joinRoomParams) (*mautrix.RespRoomSummary, error) {
return h.Client.GetRoomSummary(ctx, params.RoomIDOrAlias, params.Via...)
})
case "join_room":
return unmarshalAndCall(req.Data, func(params *joinRoomParams) (*mautrix.RespJoinRoom, error) {
return h.Client.JoinRoom(ctx, params.RoomIDOrAlias, &mautrix.ReqJoinRoom{
Via: params.Via,
Reason: params.Reason,
})
})
case "leave_room":
return unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) {
return h.Client.LeaveRoom(ctx, params.RoomID, &mautrix.ReqLeave{Reason: params.Reason})
})
case "ensure_group_session_shared": case "ensure_group_session_shared":
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) { return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
return true, h.EnsureGroupSessionShared(ctx, params.RoomID) return true, h.EnsureGroupSessionShared(ctx, params.RoomID)
@ -126,6 +157,8 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) { return unmarshalAndCall(req.Data, func(params *resolveAliasParams) (*mautrix.RespAliasResolve, error) {
return h.Client.ResolveAlias(ctx, params.Alias) return h.Client.ResolveAlias(ctx, params.Alias)
}) })
case "request_openid_token":
return h.Client.RequestOpenIDToken(ctx)
case "logout": case "logout":
if h.LogoutFunc == nil { if h.LogoutFunc == nil {
return nil, errors.New("logout not supported") return nil, errors.New("logout not supported")
@ -168,6 +201,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
} }
return cli.GetLoginFlows(ctx) return cli.GetLoginFlows(ctx)
}) })
case "register_push":
return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
return true, h.DB.PushRegistration.Put(ctx, params)
})
default: default:
return nil, fmt.Errorf("unknown command %q", req.Command) return nil, fmt.Errorf("unknown command %q", req.Command)
} }
@ -246,14 +283,19 @@ type getProfileParams struct {
UserID id.UserID `json:"user_id"` UserID id.UserID `json:"user_id"`
} }
type setProfileFieldParams struct {
Field string `json:"field"`
Value any `json:"value"`
}
type getEventParams struct { type getEventParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
EventID id.EventID `json:"event_id"` EventID id.EventID `json:"event_id"`
} }
type getEventsByRowIDsParams struct { //type getEventsByRowIDsParams struct {
RowIDs []database.EventRowID `json:"row_ids"` // RowIDs []database.EventRowID `json:"row_ids"`
} //}
type getRoomStateParams struct { type getRoomStateParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
@ -302,3 +344,19 @@ type paginateParams struct {
MaxTimelineID database.TimelineRowID `json:"max_timeline_id"` MaxTimelineID database.TimelineRowID `json:"max_timeline_id"`
Limit int `json:"limit"` Limit int `json:"limit"`
} }
type joinRoomParams struct {
RoomIDOrAlias string `json:"room_id_or_alias"`
Via []string `json:"via"`
Reason string `json:"reason"`
}
type leaveRoomParams struct {
RoomID id.RoomID `json:"room_id"`
Reason string `json:"reason"`
}
type getReceiptsParams struct {
RoomID id.RoomID `json:"room_id"`
EventIDs []id.EventID `json:"event_ids"`
}

View file

@ -22,7 +22,7 @@ import (
var ErrPaginationAlreadyInProgress = errors.New("pagination is already in progress") var ErrPaginationAlreadyInProgress = errors.New("pagination is already in progress")
func (h *HiClient) GetEventsByRowIDs(ctx context.Context, rowIDs []database.EventRowID) ([]*database.Event, error) { /*func (h *HiClient) GetEventsByRowIDs(ctx context.Context, rowIDs []database.EventRowID) ([]*database.Event, error) {
events, err := h.DB.Event.GetByRowIDs(ctx, rowIDs...) events, err := h.DB.Event.GetByRowIDs(ctx, rowIDs...)
if err != nil { if err != nil {
return nil, err return nil, err
@ -51,7 +51,7 @@ func (h *HiClient) GetEventsByRowIDs(ctx context.Context, rowIDs []database.Even
// TODO slow path where events are collected and filling is done one room at a time? // TODO slow path where events are collected and filling is done one room at a time?
} }
return events, nil return events, nil
} }*/
func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*database.Event, error) { func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*database.Event, error) {
if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil { if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
@ -121,13 +121,14 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
if err != nil { if err != nil {
return fmt.Errorf("failed to save events: %w", err) return fmt.Errorf("failed to save events: %w", err)
} }
sdc := &spaceDataCollector{}
for i := range currentStateEntries { for i := range currentStateEntries {
currentStateEntries[i].EventRowID = dbEvts[i].RowID currentStateEntries[i].EventRowID = dbEvts[i].RowID
if mediaReferenceEntries[i] != nil { if mediaReferenceEntries[i] != nil {
mediaReferenceEntries[i].EventRowID = dbEvts[i].RowID mediaReferenceEntries[i].EventRowID = dbEvts[i].RowID
} }
if evts[i].Type != event.StateMember { if evts[i].Type != event.StateMember {
processImportantEvent(ctx, evts[i], room, updatedRoom) processImportantEvent(ctx, evts[i], room, updatedRoom, dbEvts[i].RowID, sdc)
} }
} }
err = h.DB.Media.AddMany(ctx, mediaCacheEntries) err = h.DB.Media.AddMany(ctx, mediaCacheEntries)
@ -146,6 +147,11 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
return fmt.Errorf("failed to save current state entries: %w", err) return fmt.Errorf("failed to save current state entries: %w", err)
} }
roomChanged := updatedRoom.CheckChangesAndCopyInto(room) roomChanged := updatedRoom.CheckChangesAndCopyInto(room)
// TODO dispatch space edge changes if something changed? (fairly unlikely though)
err = sdc.Apply(ctx, room, h.DB.SpaceEdge)
if err != nil {
return err
}
if roomChanged { if roomChanged {
err = h.DB.Room.Upsert(ctx, updatedRoom) err = h.DB.Room.Upsert(ctx, updatedRoom)
if err != nil { if err != nil {
@ -155,17 +161,9 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
h.EventHandler(&SyncComplete{ h.EventHandler(&SyncComplete{
Rooms: map[id.RoomID]*SyncRoom{ Rooms: map[id.RoomID]*SyncRoom{
roomID: { roomID: {
Meta: room, Meta: room,
Timeline: make([]database.TimelineRowTuple, 0),
State: make(map[event.Type]map[string]database.EventRowID),
AccountData: make(map[event.Type]*database.AccountData),
Events: make([]*database.Event, 0),
Reset: false,
Notifications: make([]SyncNotification, 0),
}, },
}, },
AccountData: make(map[event.Type]*database.AccountData),
LeftRooms: make([]id.RoomID, 0),
}) })
} }
} }
@ -196,22 +194,87 @@ func (h *HiClient) GetRoomState(ctx context.Context, roomID id.RoomID, includeMe
} }
type PaginationResponse struct { type PaginationResponse struct {
Events []*database.Event `json:"events"` Events []*database.Event `json:"events"`
HasMore bool `json:"has_more"` Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
RelatedEvents []*database.Event `json:"related_events"`
HasMore bool `json:"has_more"`
} }
func (h *HiClient) Paginate(ctx context.Context, roomID id.RoomID, maxTimelineID database.TimelineRowID, limit int) (*PaginationResponse, error) { func (h *HiClient) Paginate(ctx context.Context, roomID id.RoomID, maxTimelineID database.TimelineRowID, limit int) (*PaginationResponse, error) {
evts, err := h.DB.Timeline.Get(ctx, roomID, limit, maxTimelineID) evts, err := h.DB.Timeline.Get(ctx, roomID, limit, maxTimelineID)
if err != nil { if err != nil {
return nil, err return nil, err
} else if len(evts) > 0 { }
var resp *PaginationResponse
if len(evts) > 0 {
for _, evt := range evts { for _, evt := range evts {
h.ReprocessExistingEvent(ctx, evt) h.ReprocessExistingEvent(ctx, evt)
} }
return &PaginationResponse{Events: evts, HasMore: true}, nil resp = &PaginationResponse{Events: evts, HasMore: true}
} else { } else {
return h.PaginateServer(ctx, roomID, limit) resp, err = h.PaginateServer(ctx, roomID, limit)
if err != nil {
return nil, err
}
} }
resp.RelatedEvents = make([]*database.Event, 0)
eventIDs := make([]id.EventID, len(resp.Events))
eventMap := make(map[id.EventID]struct{})
for i := len(resp.Events) - 1; i >= 0; i-- {
evt := resp.Events[i]
eventIDs[i] = evt.ID
eventMap[evt.ID] = struct{}{}
replyTo := evt.GetReplyTo()
if replyTo != "" {
_, replyToAdded := eventMap[replyTo]
if !replyToAdded {
dbEvt, err := h.DB.Event.GetByID(ctx, replyTo)
if err != nil {
return nil, fmt.Errorf("failed to get reply-to event: %w", err)
} else if dbEvt != nil {
resp.RelatedEvents = append(resp.RelatedEvents, dbEvt)
eventMap[replyTo] = struct{}{}
}
}
}
}
resp.Receipts, err = h.GetReceipts(ctx, roomID, eventIDs)
if err != nil {
return nil, fmt.Errorf("failed to get receipts: %w", err)
}
return resp, nil
}
func (h *HiClient) GetReceipts(ctx context.Context, roomID id.RoomID, eventIDs []id.EventID) (map[id.EventID][]*database.Receipt, error) {
receipts, err := h.DB.Receipt.GetManyRead(ctx, roomID, eventIDs)
if err != nil {
return nil, err
}
encounteredUsers := map[id.UserID]struct{}{
// Never include own receipts
h.Account.UserID: {},
}
// If there are multiple receipts (e.g. due to threads), only keep the one for the latest event (first in the array)
// The input event IDs are already sorted in reverse chronological order
for _, evtID := range eventIDs {
receiptArr := receipts[evtID]
i := 0
for _, receipt := range receiptArr {
_, alreadyEncountered := encounteredUsers[receipt.UserID]
if alreadyEncountered {
continue
}
// Clear room ID for efficiency
receipt.RoomID = ""
encounteredUsers[receipt.UserID] = struct{}{}
receiptArr[i] = receipt
i++
}
if len(receiptArr) > 0 && i < len(receiptArr) {
receipts[evtID] = receiptArr[:i]
}
}
return receipts, nil
} }
func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit int) (*PaginationResponse, error) { func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit int) (*PaginationResponse, error) {

View file

@ -91,3 +91,8 @@ func (h *HiClient) GetProfileEncryptionInfo(ctx context.Context, userID id.UserI
} }
return &resp, nil return &resp, nil
} }
func (h *HiClient) TrackUserDevices(ctx context.Context, userID id.UserID) error {
_, err := h.Crypto.FetchKeys(ctx, []id.UserID{userID}, true)
return err
}

View file

@ -71,6 +71,11 @@ func (h *HiClient) SendMessage(
relatesTo *event.RelatesTo, relatesTo *event.RelatesTo,
mentions *event.Mentions, mentions *event.Mentions,
) (*database.Event, error) { ) (*database.Event, error) {
var unencrypted bool
if strings.HasPrefix(text, "/unencrypted ") {
text = strings.TrimPrefix(text, "/unencrypted ")
unencrypted = true
}
if strings.HasPrefix(text, "/raw ") { if strings.HasPrefix(text, "/raw ") {
parts := strings.SplitN(text, " ", 3) parts := strings.SplitN(text, " ", 3)
if len(parts) < 2 || len(parts[1]) == 0 { if len(parts) < 2 || len(parts[1]) == 0 {
@ -85,7 +90,18 @@ func (h *HiClient) SendMessage(
if !json.Valid(content) { if !json.Valid(content) {
return nil, fmt.Errorf("invalid JSON in /raw command") return nil, fmt.Errorf("invalid JSON in /raw command")
} }
return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "") return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "", unencrypted)
} else if strings.HasPrefix(text, "/rawstate ") {
parts := strings.SplitN(text, " ", 4)
if len(parts) < 4 || len(parts[1]) == 0 {
return nil, fmt.Errorf("invalid /rawstate command")
}
content := json.RawMessage(parts[3])
if !json.Valid(content) {
return nil, fmt.Errorf("invalid JSON in /rawstate command")
}
_, err := h.SetState(ctx, roomID, event.Type{Type: parts[1], Class: event.StateEventType}, parts[2], content)
return nil, err
} }
var content event.MessageEventContent var content event.MessageEventContent
msgType := event.MsgText msgType := event.MsgText
@ -148,7 +164,12 @@ func (h *HiClient) SendMessage(
content.RelatesTo = relatesTo content.RelatesTo = relatesTo
} }
} }
return h.send(ctx, roomID, event.EventMessage, &event.Content{Parsed: content, Raw: extra}, origText) evtType := event.EventMessage
if content.MsgType == "m.sticker" {
content.MsgType = ""
evtType = event.EventSticker
}
return h.send(ctx, roomID, evtType, &event.Content{Parsed: content, Raw: extra}, origText, unencrypted)
} }
func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error { func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error {
@ -212,7 +233,7 @@ func (h *HiClient) Send(
evtType event.Type, evtType event.Type,
content any, content any,
) (*database.Event, error) { ) (*database.Event, error) {
return h.send(ctx, roomID, evtType, content, "") return h.send(ctx, roomID, evtType, content, "", false)
} }
func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, error) { func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, error) {
@ -241,6 +262,7 @@ func (h *HiClient) send(
evtType event.Type, evtType event.Type,
content any, content any,
overrideEditSource string, overrideEditSource string,
disableEncryption bool,
) (*database.Event, error) { ) (*database.Event, error) {
room, err := h.DB.Room.Get(ctx, roomID) room, err := h.DB.Room.Get(ctx, roomID)
if err != nil { if err != nil {
@ -261,7 +283,7 @@ func (h *HiClient) send(
Reactions: map[string]int{}, Reactions: map[string]int{},
LastEditRowID: ptr.Ptr(database.EventRowID(0)), LastEditRowID: ptr.Ptr(database.EventRowID(0)),
} }
if room.EncryptionEvent != nil && evtType != event.EventReaction { if room.EncryptionEvent != nil && evtType != event.EventReaction && !disableEncryption {
dbEvt.Type = event.EventEncrypted.Type dbEvt.Type = event.EventEncrypted.Type
dbEvt.DecryptedType = evtType.Type dbEvt.DecryptedType = evtType.Type
dbEvt.Decrypted, err = json.Marshal(content) dbEvt.Decrypted, err = json.Marshal(content)
@ -281,7 +303,7 @@ func (h *HiClient) send(
var inlineImages []id.ContentURI var inlineImages []id.ContentURI
mautrixEvt := dbEvt.AsRawMautrix() mautrixEvt := dbEvt.AsRawMautrix()
dbEvt.LocalContent, inlineImages = h.calculateLocalContent(ctx, dbEvt, mautrixEvt) dbEvt.LocalContent, inlineImages = h.calculateLocalContent(ctx, dbEvt, mautrixEvt)
if overrideEditSource != "" { if overrideEditSource != "" && dbEvt.LocalContent != nil {
dbEvt.LocalContent.EditSource = overrideEditSource dbEvt.LocalContent.EditSource = overrideEditSource
} }
_, err = h.DB.Event.Insert(ctx, dbEvt) _, err = h.DB.Event.Insert(ctx, dbEvt)

View file

@ -39,13 +39,16 @@ type syncContext struct {
evt *SyncComplete evt *SyncComplete
} }
func (h *HiClient) markSyncErrored(err error) { func (h *HiClient) markSyncErrored(err error, permanent bool) {
stat := &SyncStatus{ stat := &SyncStatus{
Type: SyncStatusErrored, Type: SyncStatusErroring,
Error: err.Error(), Error: err.Error(),
ErrorCount: h.syncErrors, ErrorCount: h.syncErrors,
LastSync: jsontime.UM(h.lastSync), LastSync: jsontime.UM(h.lastSync),
} }
if permanent {
stat.Type = SyncStatusFailed
}
h.SyncStatus.Store(stat) h.SyncStatus.Store(stat)
h.EventHandler(stat) h.EventHandler(stat)
} }
@ -85,6 +88,7 @@ func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.Res
} }
} }
resp.ToDevice.Events = postponedToDevices resp.ToDevice.Events = postponedToDevices
h.Crypto.MarkOlmHashSavePoint(ctx)
return nil return nil
} }
@ -148,14 +152,20 @@ func (h *HiClient) processSyncResponse(ctx context.Context, resp *mautrix.RespSy
} }
} }
ctx.Value(syncContextKey).(*syncContext).evt.AccountData = accountData ctx.Value(syncContextKey).(*syncContext).evt.AccountData = accountData
for roomID, room := range resp.Rooms.Invite {
err = h.processSyncInvitedRoom(ctx, roomID, room)
if err != nil {
return fmt.Errorf("failed to process invited room %s: %w", roomID, err)
}
}
for roomID, room := range resp.Rooms.Join { for roomID, room := range resp.Rooms.Join {
err := h.processSyncJoinedRoom(ctx, roomID, room) err = h.processSyncJoinedRoom(ctx, roomID, room)
if err != nil { if err != nil {
return fmt.Errorf("failed to process joined room %s: %w", roomID, err) return fmt.Errorf("failed to process joined room %s: %w", roomID, err)
} }
} }
for roomID, room := range resp.Rooms.Leave { for roomID, room := range resp.Rooms.Leave {
err := h.processSyncLeftRoom(ctx, roomID, room) err = h.processSyncLeftRoom(ctx, roomID, room)
if err != nil { if err != nil {
return fmt.Errorf("failed to process left room %s: %w", roomID, err) return fmt.Errorf("failed to process left room %s: %w", roomID, err)
} }
@ -177,6 +187,9 @@ func (h *HiClient) receiptsToList(content *event.ReceiptEventContent) ([]*databa
if userID == h.Account.UserID { if userID == h.Account.UserID {
newOwnReceipts = append(newOwnReceipts, eventID) newOwnReceipts = append(newOwnReceipts, eventID)
} }
if receiptInfo.ThreadID == event.ReadReceiptThreadMain {
receiptInfo.ThreadID = ""
}
receiptList = append(receiptList, &database.Receipt{ receiptList = append(receiptList, &database.Receipt{
UserID: userID, UserID: userID,
ReceiptType: receiptType, ReceiptType: receiptType,
@ -190,6 +203,27 @@ func (h *HiClient) receiptsToList(content *event.ReceiptEventContent) ([]*databa
return receiptList, newOwnReceipts return receiptList, newOwnReceipts
} }
func (h *HiClient) processSyncInvitedRoom(ctx context.Context, roomID id.RoomID, room *mautrix.SyncInvitedRoom) error {
ir := &database.InvitedRoom{
ID: roomID,
CreatedAt: jsontime.UnixMilliNow(),
InviteState: room.State.Events,
}
for _, evt := range room.State.Events {
if evt.Type == event.StateMember && evt.GetStateKey() == h.Account.UserID.String() && evt.Timestamp != 0 {
ir.CreatedAt = jsontime.UM(time.UnixMilli(evt.Timestamp))
break
}
}
err := h.DB.InvitedRoom.Upsert(ctx, ir)
if err != nil {
return fmt.Errorf("failed to save invited room: %w", err)
}
syncEvt := ctx.Value(syncContextKey).(*syncContext).evt
syncEvt.InvitedRooms = append(syncEvt.InvitedRooms, ir)
return nil
}
func (h *HiClient) processSyncJoinedRoom(ctx context.Context, roomID id.RoomID, room *mautrix.SyncJoinedRoom) error { func (h *HiClient) processSyncJoinedRoom(ctx context.Context, roomID id.RoomID, room *mautrix.SyncJoinedRoom) error {
existingRoomData, err := h.DB.Room.Get(ctx, roomID) existingRoomData, err := h.DB.Room.Get(ctx, roomID)
if err != nil { if err != nil {
@ -259,6 +293,10 @@ func (h *HiClient) processSyncLeftRoom(ctx context.Context, roomID id.RoomID, ro
if err != nil { if err != nil {
return fmt.Errorf("failed to delete room: %w", err) return fmt.Errorf("failed to delete room: %w", err)
} }
err = h.DB.InvitedRoom.Delete(ctx, roomID)
if err != nil {
return fmt.Errorf("failed to delete invited room: %w", err)
}
payload := ctx.Value(syncContextKey).(*syncContext).evt payload := ctx.Value(syncContextKey).(*syncContext).evt
payload.LeftRooms = append(payload.LeftRooms, roomID) payload.LeftRooms = append(payload.LeftRooms, roomID)
return nil return nil
@ -288,20 +326,34 @@ func removeReplyFallback(evt *event.Event) []byte {
return nil return nil
} }
func (h *HiClient) decryptEvent(ctx context.Context, evt *event.Event) (*event.Event, []byte, string, error) { func (h *HiClient) decryptEvent(ctx context.Context, evt *event.Event) (*event.Event, []byte, bool, string, error) {
err := evt.Content.ParseRaw(evt.Type) err := evt.Content.ParseRaw(evt.Type)
if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) {
return nil, nil, "", err return nil, nil, false, "", err
} }
decrypted, err := h.Crypto.DecryptMegolmEvent(ctx, evt) decrypted, err := h.Crypto.DecryptMegolmEvent(ctx, evt)
if err != nil { if err != nil {
return nil, nil, "", err return nil, nil, false, "", err
} }
withoutFallback := removeReplyFallback(decrypted) withoutFallback := removeReplyFallback(decrypted)
if withoutFallback != nil { if withoutFallback != nil {
return decrypted, withoutFallback, decrypted.Type.Type, nil return decrypted, withoutFallback, true, decrypted.Type.Type, nil
} }
return decrypted, decrypted.Content.VeryRaw, decrypted.Type.Type, nil return decrypted, decrypted.Content.VeryRaw, false, decrypted.Type.Type, nil
}
func (h *HiClient) decryptEventInto(ctx context.Context, evt *event.Event, dbEvt *database.Event) (*event.Event, error) {
decryptedEvt, rawContent, fallbackRemoved, decryptedType, err := h.decryptEvent(ctx, evt)
if err != nil {
dbEvt.DecryptionError = err.Error()
return nil, err
}
dbEvt.Decrypted = rawContent
if fallbackRemoved {
dbEvt.MarkReplyFallbackRemoved()
}
dbEvt.DecryptedType = decryptedType
return decryptedEvt, nil
} }
func (h *HiClient) addMediaCache( func (h *HiClient) addMediaCache(
@ -344,12 +396,7 @@ func (h *HiClient) addMediaCache(
} }
func (h *HiClient) cacheMedia(ctx context.Context, evt *event.Event, rowID database.EventRowID) { func (h *HiClient) cacheMedia(ctx context.Context, evt *event.Event, rowID database.EventRowID) {
switch evt.Type { cacheMessageEventContent := func(content *event.MessageEventContent) {
case event.EventMessage, event.EventSticker:
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return
}
if content.File != nil { if content.File != nil {
h.addMediaCache(ctx, rowID, content.File.URL, content.File, content.Info, content.GetFileName()) h.addMediaCache(ctx, rowID, content.File.URL, content.File, content.Info, content.GetFileName())
} else if content.URL != "" { } else if content.URL != "" {
@ -360,6 +407,35 @@ func (h *HiClient) cacheMedia(ctx context.Context, evt *event.Event, rowID datab
} else if content.GetInfo().ThumbnailURL != "" { } else if content.GetInfo().ThumbnailURL != "" {
h.addMediaCache(ctx, rowID, content.Info.ThumbnailURL, nil, content.Info.ThumbnailInfo, "") h.addMediaCache(ctx, rowID, content.Info.ThumbnailURL, nil, content.Info.ThumbnailInfo, "")
} }
for _, image := range content.BeeperGalleryImages {
h.cacheMedia(ctx, &event.Event{
Type: event.EventMessage,
Content: event.Content{Parsed: image},
}, rowID)
}
for _, preview := range content.BeeperLinkPreviews {
info := &event.FileInfo{MimeType: preview.ImageType}
if preview.ImageEncryption != nil {
h.addMediaCache(ctx, rowID, preview.ImageEncryption.URL, preview.ImageEncryption, info, "")
} else if preview.ImageURL != "" {
h.addMediaCache(ctx, rowID, preview.ImageURL, nil, info, "")
}
}
}
switch evt.Type {
case event.EventMessage, event.EventSticker:
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return
}
cacheMessageEventContent(content)
if content.NewContent != nil {
cacheMessageEventContent(content.NewContent)
}
case event.StateRoomAvatar: case event.StateRoomAvatar:
_ = evt.Content.ParseRaw(evt.Type) _ = evt.Content.ParseRaw(evt.Type)
content, ok := evt.Content.Parsed.(*event.RoomAvatarEventContent) content, ok := evt.Content.Parsed.(*event.RoomAvatarEventContent)
@ -442,12 +518,13 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
wasPlaintext = true wasPlaintext = true
} }
return &database.LocalContent{ return &database.LocalContent{
SanitizedHTML: sanitizedHTML, SanitizedHTML: sanitizedHTML,
HTMLVersion: CurrentHTMLSanitizerVersion, HTMLVersion: CurrentHTMLSanitizerVersion,
WasPlaintext: wasPlaintext, WasPlaintext: wasPlaintext,
BigEmoji: bigEmoji, BigEmoji: bigEmoji,
HasMath: hasMath, HasMath: hasMath,
EditSource: editSource, EditSource: editSource,
ReplyFallbackRemoved: dbEvt.LocalContent.GetReplyFallbackRemoved(),
}, inlineImages }, inlineImages
} }
return nil, nil return nil, nil
@ -499,14 +576,12 @@ func (h *HiClient) processEvent(
contentWithoutFallback := removeReplyFallback(evt) contentWithoutFallback := removeReplyFallback(evt)
if contentWithoutFallback != nil { if contentWithoutFallback != nil {
dbEvt.Content = contentWithoutFallback dbEvt.Content = contentWithoutFallback
dbEvt.MarkReplyFallbackRemoved()
} }
var decryptionErr error var decryptionErr error
var decryptedMautrixEvt *event.Event var decryptedMautrixEvt *event.Event
if evt.Type == event.EventEncrypted && dbEvt.RedactedBy == "" { if evt.Type == event.EventEncrypted && dbEvt.RedactedBy == "" {
decryptedMautrixEvt, dbEvt.Decrypted, dbEvt.DecryptedType, decryptionErr = h.decryptEvent(ctx, evt) decryptedMautrixEvt, decryptionErr = h.decryptEventInto(ctx, evt, dbEvt)
if decryptionErr != nil {
dbEvt.DecryptionError = decryptionErr.Error()
}
} else if evt.Type == event.EventRedaction { } else if evt.Type == event.EventRedaction {
if evt.Redacts != "" && gjson.GetBytes(evt.Content.VeryRaw, "redacts").Str != evt.Redacts.String() { if evt.Redacts != "" && gjson.GetBytes(evt.Content.VeryRaw, "redacts").Str != evt.Redacts.String() {
var err error var err error
@ -592,8 +667,10 @@ func (h *HiClient) processStateAndTimeline(
updatedRoom.LazyLoadSummary = summary updatedRoom.LazyLoadSummary = summary
heroesChanged = true heroesChanged = true
} }
sdc := &spaceDataCollector{}
decryptionQueue := make(map[id.SessionID]*database.SessionRequest) decryptionQueue := make(map[id.SessionID]*database.SessionRequest)
allNewEvents := make([]*database.Event, 0, len(state.Events)+len(timeline.Events)) allNewEvents := make([]*database.Event, 0, len(state.Events)+len(timeline.Events))
addedEvents := make(map[database.EventRowID]struct{})
newNotifications := make([]SyncNotification, 0) newNotifications := make([]SyncNotification, 0)
var recalculatePreviewEvent, unreadMessagesWereMaybeRedacted bool var recalculatePreviewEvent, unreadMessagesWereMaybeRedacted bool
var newUnreadCounts database.UnreadCounts var newUnreadCounts database.UnreadCounts
@ -608,7 +685,11 @@ func (h *HiClient) processStateAndTimeline(
} else if dbEvt == nil { } else if dbEvt == nil {
return nil, nil return nil, nil
} }
allNewEvents = append(allNewEvents, dbEvt) _, alreadyAdded := addedEvents[dbEvt.RowID]
if !alreadyAdded {
addedEvents[dbEvt.RowID] = struct{}{}
allNewEvents = append(allNewEvents, dbEvt)
}
return dbEvt, nil return dbEvt, nil
} }
processRedaction := func(evt *event.Event) error { processRedaction := func(evt *event.Event) error {
@ -643,8 +724,11 @@ func (h *HiClient) processStateAndTimeline(
if isUnread { if isUnread {
if dbEvt.UnreadType.Is(database.UnreadTypeNotify) && h.firstSyncReceived { if dbEvt.UnreadType.Is(database.UnreadTypeNotify) && h.firstSyncReceived {
newNotifications = append(newNotifications, SyncNotification{ newNotifications = append(newNotifications, SyncNotification{
RowID: dbEvt.RowID, RowID: dbEvt.RowID,
Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound), Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound),
Highlight: dbEvt.UnreadType.Is(database.UnreadTypeHighlight),
Event: dbEvt,
Room: room,
}) })
} }
newUnreadCounts.AddOne(dbEvt.UnreadType) newUnreadCounts.AddOne(dbEvt.UnreadType)
@ -670,9 +754,10 @@ func (h *HiClient) processStateAndTimeline(
if err != nil { if err != nil {
return -1, fmt.Errorf("failed to save current state event ID %s for %s/%s: %w", evt.ID, evt.Type.Type, *evt.StateKey, err) return -1, fmt.Errorf("failed to save current state event ID %s for %s/%s: %w", evt.ID, evt.Type.Type, *evt.StateKey, err)
} }
processImportantEvent(ctx, evt, room, updatedRoom) processImportantEvent(ctx, evt, room, updatedRoom, dbEvt.RowID, sdc)
} }
allNewEvents = append(allNewEvents, dbEvt) allNewEvents = append(allNewEvents, dbEvt)
addedEvents[dbEvt.RowID] = struct{}{}
if evt.Type == event.EventRedaction && evt.Redacts != "" { if evt.Type == event.EventRedaction && evt.Redacts != "" {
err = processRedaction(evt) err = processRedaction(evt)
if err != nil { if err != nil {
@ -683,6 +768,11 @@ func (h *HiClient) processStateAndTimeline(
if err != nil { if err != nil {
return -1, fmt.Errorf("failed to get relation target of event: %w", err) return -1, fmt.Errorf("failed to get relation target of event: %w", err)
} }
} else if replyTo := dbEvt.GetReplyTo(); replyTo != "" {
_, err = addOldEvent(0, replyTo)
if err != nil {
return -1, fmt.Errorf("failed to get reply target of event: %w", err)
}
} }
return dbEvt.RowID, nil return dbEvt.RowID, nil
} }
@ -702,15 +792,38 @@ func (h *HiClient) processStateAndTimeline(
setNewState(evt.Type, *evt.StateKey, rowID) setNewState(evt.Type, *evt.StateKey, rowID)
} }
var timelineRowTuples []database.TimelineRowTuple var timelineRowTuples []database.TimelineRowTuple
receiptMap := make(map[id.EventID][]*database.Receipt)
for _, receipt := range receipts {
if receipt.UserID != h.Account.UserID {
receiptMap[receipt.EventID] = append(receiptMap[receipt.EventID], receipt)
}
}
var err error var err error
if len(timeline.Events) > 0 { if len(timeline.Events) > 0 {
timelineIDs := make([]database.EventRowID, len(timeline.Events)) timelineIDs := make([]database.EventRowID, len(timeline.Events))
encounteredReceiptUsers := make(map[id.UserID]struct{})
readUpToIndex := -1 readUpToIndex := -1
for i := len(timeline.Events) - 1; i >= 0; i-- { for i := len(timeline.Events) - 1; i >= 0; i-- {
evt := timeline.Events[i] evt := timeline.Events[i]
for _, receipt := range receiptMap[evt.ID] {
encounteredReceiptUsers[receipt.UserID] = struct{}{}
}
isRead := slices.Contains(newOwnReceipts, evt.ID) isRead := slices.Contains(newOwnReceipts, evt.ID)
isOwnEvent := evt.Sender == h.Account.UserID isOwnEvent := evt.Sender == h.Account.UserID
if isRead || isOwnEvent { _, alreadyEncountered := encounteredReceiptUsers[evt.Sender]
if !isOwnEvent && !alreadyEncountered {
encounteredReceiptUsers[evt.Sender] = struct{}{}
injectedReceipt := &database.Receipt{
RoomID: room.ID,
UserID: evt.Sender,
ReceiptType: event.ReceiptTypeRead,
EventID: evt.ID,
Timestamp: jsontime.UM(time.UnixMilli(evt.Timestamp)),
}
receipts = append(receipts, injectedReceipt)
receiptMap[evt.ID] = append(receiptMap[evt.ID], injectedReceipt)
}
if readUpToIndex == -1 && (isRead || isOwnEvent) {
readUpToIndex = i readUpToIndex = i
// Reset unread counts if we see our own read receipt in the timeline. // Reset unread counts if we see our own read receipt in the timeline.
// It'll be updated with new unreads (if any) at the end. // It'll be updated with new unreads (if any) at the end.
@ -725,7 +838,6 @@ func (h *HiClient) processStateAndTimeline(
}) })
newOwnReceipts = append(newOwnReceipts, evt.ID) newOwnReceipts = append(newOwnReceipts, evt.ID)
} }
break
} }
} }
for i, evt := range timeline.Events { for i, evt := range timeline.Events {
@ -785,10 +897,11 @@ func (h *HiClient) processStateAndTimeline(
} }
// Calculate name from participants if participants changed and current name was generated from participants, or if the room name was unset // Calculate name from participants if participants changed and current name was generated from participants, or if the room name was unset
if (heroesChanged && updatedRoom.NameQuality <= database.NameQualityParticipants) || updatedRoom.NameQuality == database.NameQualityNil { if (heroesChanged && updatedRoom.NameQuality <= database.NameQualityParticipants) || updatedRoom.NameQuality == database.NameQualityNil {
name, dmAvatarURL, err := h.calculateRoomParticipantName(ctx, room.ID, summary) name, dmAvatarURL, dmUserID, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
if err != nil { if err != nil {
return fmt.Errorf("failed to calculate room name: %w", err) return fmt.Errorf("failed to calculate room name: %w", err)
} }
updatedRoom.DMUserID = &dmUserID
updatedRoom.Name = &name updatedRoom.Name = &name
updatedRoom.NameQuality = database.NameQualityParticipants updatedRoom.NameQuality = database.NameQualityParticipants
if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar { if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar {
@ -814,6 +927,7 @@ func (h *HiClient) processStateAndTimeline(
} else { } else {
updatedRoom.UnreadCounts.Add(newUnreadCounts) updatedRoom.UnreadCounts.Add(newUnreadCounts)
} }
dismissNotifications := room.UnreadNotifications > 0 && updatedRoom.UnreadNotifications == 0 && len(newNotifications) == 0
if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) { if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) {
updatedRoom.PrevBatch = timeline.PrevBatch updatedRoom.PrevBatch = timeline.PrevBatch
} }
@ -824,16 +938,26 @@ func (h *HiClient) processStateAndTimeline(
return fmt.Errorf("failed to save room data: %w", err) return fmt.Errorf("failed to save room data: %w", err)
} }
} }
err = sdc.Apply(ctx, room, h.DB.SpaceEdge)
if err != nil {
return err
}
// TODO why is *old* unread count sometimes zero when processing the read receipt that is making it zero? // TODO why is *old* unread count sometimes zero when processing the read receipt that is making it zero?
if roomChanged || len(accountData) > 0 || len(newOwnReceipts) > 0 || len(timelineRowTuples) > 0 || len(allNewEvents) > 0 { if roomChanged || len(accountData) > 0 || len(newOwnReceipts) > 0 || len(receipts) > 0 || len(timelineRowTuples) > 0 || len(allNewEvents) > 0 {
for _, receipt := range receipts {
receipt.RoomID = ""
}
ctx.Value(syncContextKey).(*syncContext).evt.Rooms[room.ID] = &SyncRoom{ ctx.Value(syncContextKey).(*syncContext).evt.Rooms[room.ID] = &SyncRoom{
Meta: room, Meta: room,
Timeline: timelineRowTuples, Timeline: timelineRowTuples,
AccountData: accountData, AccountData: accountData,
State: changedState, State: changedState,
Reset: timeline.Limited, Reset: timeline.Limited,
Events: allNewEvents, Events: allNewEvents,
Notifications: newNotifications, Receipts: receiptMap,
Notifications: newNotifications,
DismissNotifications: dismissNotifications,
} }
} }
return nil return nil
@ -849,15 +973,15 @@ func joinMemberNames(names []string, totalCount int) string {
} }
} }
func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, error) { func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.RoomID, summary *mautrix.LazyLoadSummary) (string, id.ContentURI, id.UserID, error) {
var primaryAvatarURL id.ContentURI var primaryAvatarURL id.ContentURI
if summary == nil || len(summary.Heroes) == 0 { if summary == nil || len(summary.Heroes) == 0 {
return "Empty room", primaryAvatarURL, nil return "Empty room", primaryAvatarURL, "", nil
} }
var functionalMembers []id.UserID var functionalMembers []id.UserID
functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "") functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "")
if err != nil { if err != nil {
return "", primaryAvatarURL, fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err) return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err)
} else if functionalMembersEvt != nil { } else if functionalMembersEvt != nil {
mautrixEvt := functionalMembersEvt.AsRawMautrix() mautrixEvt := functionalMembersEvt.AsRawMautrix()
_ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type) _ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)
@ -873,16 +997,21 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
} else if summary.InvitedMemberCount != nil { } else if summary.InvitedMemberCount != nil {
memberCount = *summary.InvitedMemberCount memberCount = *summary.InvitedMemberCount
} }
var dmUserID id.UserID
for _, hero := range summary.Heroes { for _, hero := range summary.Heroes {
if slices.Contains(functionalMembers, hero) { if slices.Contains(functionalMembers, hero) {
// TODO save member count so push rule evaluation would use the subtracted one?
memberCount-- memberCount--
continue continue
} else if len(members) >= 5 { } else if len(members) >= 5 {
break break
} }
if dmUserID == "" {
dmUserID = hero
}
heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String()) heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String())
if err != nil { if err != nil {
return "", primaryAvatarURL, fmt.Errorf("failed to get %s's member event: %w", hero, err) return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s's member event: %w", hero, err)
} else if heroEvt == nil { } else if heroEvt == nil {
leftMembers = append(leftMembers, hero.String()) leftMembers = append(leftMembers, hero.String())
continue continue
@ -898,19 +1027,28 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
} }
if membership == "join" || membership == "invite" { if membership == "join" || membership == "invite" {
members = append(members, name) members = append(members, name)
dmUserID = hero
} else { } else {
leftMembers = append(leftMembers, name) leftMembers = append(leftMembers, name)
} }
} }
if len(members)+len(leftMembers) > 1 || !primaryAvatarURL.IsValid() { if !primaryAvatarURL.IsValid() {
primaryAvatarURL = id.ContentURI{} primaryAvatarURL = id.ContentURI{}
} }
if len(members) > 0 { if len(members) > 0 {
return joinMemberNames(members, memberCount), primaryAvatarURL, nil if len(members) > 1 {
primaryAvatarURL = id.ContentURI{}
dmUserID = ""
}
return joinMemberNames(members, memberCount), primaryAvatarURL, dmUserID, nil
} else if len(leftMembers) > 0 { } else if len(leftMembers) > 0 {
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, nil if len(leftMembers) > 1 {
primaryAvatarURL = id.ContentURI{}
dmUserID = ""
}
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, "", nil
} else { } else {
return "Empty room", primaryAvatarURL, nil return "Empty room", primaryAvatarURL, "", nil
} }
} }
@ -921,20 +1059,112 @@ func intPtrEqual(a, b *int) bool {
return *a == *b return *a == *b
} }
func processImportantEvent(ctx context.Context, evt *event.Event, existingRoomData, updatedRoom *database.Room) (roomDataChanged bool) { type spaceDataCollector struct {
Children []database.SpaceChildEntry
Parents []database.SpaceParentEntry
RemovedChildren []id.RoomID
RemovedParents []id.RoomID
PowerLevelChanged bool
IsFullState bool
}
func (sdc *spaceDataCollector) Collect(evt *event.Event, rowID database.EventRowID) {
switch evt.Type {
case event.StatePowerLevels:
sdc.PowerLevelChanged = true
case event.StateCreate:
sdc.IsFullState = true
case event.StateSpaceChild:
content := evt.Content.AsSpaceChild()
if len(content.Via) == 0 {
sdc.RemovedChildren = append(sdc.RemovedChildren, id.RoomID(*evt.StateKey))
} else {
sdc.Children = append(sdc.Children, database.SpaceChildEntry{
ChildID: id.RoomID(*evt.StateKey),
EventRowID: rowID,
Order: content.Order,
Suggested: content.Suggested,
})
}
case event.StateSpaceParent:
content := evt.Content.AsSpaceParent()
if len(content.Via) == 0 {
sdc.RemovedParents = append(sdc.RemovedParents, id.RoomID(*evt.StateKey))
} else {
sdc.Parents = append(sdc.Parents, database.SpaceParentEntry{
ParentID: id.RoomID(*evt.StateKey),
EventRowID: rowID,
Canonical: content.Canonical,
})
}
}
}
func (sdc *spaceDataCollector) Apply(ctx context.Context, room *database.Room, seq *database.SpaceEdgeQuery) error {
if room.CreationContent == nil || room.CreationContent.Type != event.RoomTypeSpace {
sdc.Children = nil
sdc.RemovedChildren = nil
sdc.PowerLevelChanged = false
}
if len(sdc.Children) == 0 && len(sdc.RemovedChildren) == 0 &&
len(sdc.Parents) == 0 && len(sdc.RemovedParents) == 0 &&
!sdc.PowerLevelChanged {
return nil
}
return seq.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
if len(sdc.Children) > 0 || len(sdc.RemovedChildren) > 0 {
err := seq.SetChildren(ctx, room.ID, sdc.Children, sdc.RemovedChildren, sdc.IsFullState)
if err != nil {
return fmt.Errorf("failed to set space children: %w", err)
}
}
if len(sdc.Parents) > 0 || len(sdc.RemovedParents) > 0 {
err := seq.SetParents(ctx, room.ID, sdc.Parents, sdc.RemovedParents, sdc.IsFullState)
if err != nil {
return fmt.Errorf("failed to set space parents: %w", err)
}
if len(sdc.Parents) > 0 {
err = seq.RevalidateAllParentsOfRoomValidity(ctx, room.ID)
if err != nil {
return fmt.Errorf("failed to revalidate own parent references: %w", err)
}
}
}
if sdc.PowerLevelChanged {
err := seq.RevalidateAllChildrenOfParentValidity(ctx, room.ID)
if err != nil {
return fmt.Errorf("failed to revalidate child parent references to self: %w", err)
}
}
return nil
})
}
func processImportantEvent(
ctx context.Context,
evt *event.Event,
existingRoomData, updatedRoom *database.Room,
rowID database.EventRowID,
sdc *spaceDataCollector,
) (roomDataChanged bool) {
if evt.StateKey == nil { if evt.StateKey == nil {
return return
} }
switch evt.Type { switch evt.Type {
case event.StateCreate, event.StateTombstone, event.StateRoomName, event.StateCanonicalAlias, case event.StateCreate, event.StateTombstone, event.StateRoomName, event.StateCanonicalAlias,
event.StateRoomAvatar, event.StateTopic, event.StateEncryption: event.StateRoomAvatar, event.StateTopic, event.StateEncryption, event.StatePowerLevels:
if *evt.StateKey != "" { if *evt.StateKey != "" {
return return
} }
case event.StateSpaceChild, event.StateSpaceParent:
if !strings.HasPrefix(*evt.StateKey, "!") {
return
}
default: default:
return return
} }
err := evt.Content.ParseRaw(evt.Type) err := evt.Content.ParseRaw(evt.Type)
sdc.Collect(evt, rowID)
if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) {
zerolog.Ctx(ctx).Warn().Err(err). zerolog.Ctx(ctx).Warn().Err(err).
Stringer("event_type", &evt.Type). Stringer("event_type", &evt.Type).

View file

@ -8,11 +8,16 @@ package hicli
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
"github.com/mattn/go-sqlite3"
"github.com/rs/zerolog"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli/database"
) )
type hiSyncer HiClient type hiSyncer HiClient
@ -29,19 +34,29 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync,
c := (*HiClient)(h) c := (*HiClient)(h)
c.lastSync = time.Now() c.lastSync = time.Now()
ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{ ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{
Since: &since, Since: &since,
Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)), Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)),
LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)), InvitedRooms: make([]*database.InvitedRoom, 0, len(resp.Rooms.Invite)),
LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)),
}}) }})
err := c.preProcessSyncResponse(ctx, resp, since) err := c.preProcessSyncResponse(ctx, resp, since)
if err != nil { if err != nil {
return err return err
} }
err = c.DB.DoTxn(ctx, nil, func(ctx context.Context) error { for i := 0; ; i++ {
return c.processSyncResponse(ctx, resp, since) err = c.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
}) return c.processSyncResponse(ctx, resp, since)
if err != nil { })
return err var sqliteErr sqlite3.Error
if errors.As(err, &sqliteErr) && sqliteErr.Code == sqlite3.ErrBusy && i < 24 {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Database is busy, retrying")
c.markSyncErrored(err, false)
continue
} else if err != nil {
return err
} else {
break
}
} }
c.postProcessSyncResponse(ctx, resp, since) c.postProcessSyncResponse(ctx, resp, since)
c.syncErrors = 0 c.syncErrors = 0
@ -56,7 +71,7 @@ func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration,
if c.syncErrors > 5 { if c.syncErrors > 5 {
delay = max(time.Duration(c.syncErrors)*time.Second, 30*time.Second) delay = max(time.Duration(c.syncErrors)*time.Second, 30*time.Second)
} }
c.markSyncErrored(err) c.markSyncErrored(err, false)
c.Log.Err(err).Dur("retry_in", delay).Msg("Sync failed") c.Log.Err(err).Dur("retry_in", delay).Msg("Sync failed")
return delay, nil return delay, nil
} }
@ -64,23 +79,23 @@ func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration,
func (h *hiSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter { func (h *hiSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
if !h.Verified { if !h.Verified {
return &mautrix.Filter{ return &mautrix.Filter{
Presence: mautrix.FilterPart{ Presence: &mautrix.FilterPart{
NotRooms: []id.RoomID{"*"}, NotRooms: []id.RoomID{"*"},
}, },
Room: mautrix.RoomFilter{ Room: &mautrix.RoomFilter{
NotRooms: []id.RoomID{"*"}, NotRooms: []id.RoomID{"*"},
}, },
} }
} }
return &mautrix.Filter{ return &mautrix.Filter{
Presence: mautrix.FilterPart{ Presence: &mautrix.FilterPart{
NotRooms: []id.RoomID{"*"}, NotRooms: []id.RoomID{"*"},
}, },
Room: mautrix.RoomFilter{ Room: &mautrix.RoomFilter{
State: mautrix.FilterPart{ State: &mautrix.FilterPart{
LazyLoadMembers: true, LazyLoadMembers: true,
}, },
Timeline: mautrix.FilterPart{ Timeline: &mautrix.FilterPart{
Limit: 100, Limit: 100,
LazyLoadMembers: true, LazyLoadMembers: true,
}, },

View file

@ -72,5 +72,5 @@ func init() {
builtWith = fmt.Sprintf("built at %s with %s", BuildTime, runtime.Version()) builtWith = fmt.Sprintf("built at %s with %s", BuildTime, runtime.Version())
} }
mautrix.DefaultUserAgent = fmt.Sprintf("gomuks/%s %s", Version, mautrix.DefaultUserAgent) mautrix.DefaultUserAgent = fmt.Sprintf("gomuks/%s %s", Version, mautrix.DefaultUserAgent)
Description = fmt.Sprintf("gomuks %s (%s)", Version, builtWith) Description = fmt.Sprintf("gomuks %s on %s/%s (%s)", Version, runtime.GOOS, runtime.GOARCH, builtWith)
} }

View file

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

View file

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

1358
web/package-lock.json generated

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 34 KiB

32
web/public/manifest.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "gomuks web",
"description": "A Matrix client written in Go",
"categories": ["social", "productivity"],
"icons": [
{
"src": "gomuks-transparent.png",
"sizes": "880x880",
"type": "image/png",
"purpose": "monochrome"
},
{
"src": "gomuks-maskable.png",
"sizes": "1200x1200",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "gomuks.png",
"sizes": "880x880",
"type": "image/png",
"purpose": "any"
}
],
"protocol_handlers": [{
"protocol": "matrix",
"url": "#/uri/%s"
}],
"start_url": ".",
"display": "standalone",
"theme_color": "#00c853"
}

View file

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

View file

@ -22,6 +22,7 @@ import type {
ElementRecentEmoji, ElementRecentEmoji,
EventID, EventID,
EventType, EventType,
GomuksAndroidMessageToWeb,
ImagePackRooms, ImagePackRooms,
RPCEvent, RPCEvent,
RawDBEvent, RawDBEvent,
@ -37,7 +38,7 @@ export default class Client {
readonly initComplete = new NonNullCachedEventDispatcher<boolean>(false) readonly initComplete = new NonNullCachedEventDispatcher<boolean>(false)
readonly store = new StateStore() readonly store = new StateStore()
#stateRequests: RoomStateGUID[] = [] #stateRequests: RoomStateGUID[] = []
#stateRequestQueued = false #stateRequestPromise: Promise<void> | null = null
#gcInterval: number | undefined #gcInterval: number | undefined
constructor(readonly rpc: RPCClient) { constructor(readonly rpc: RPCClient) {
@ -71,6 +72,74 @@ export default class Client {
this.requestNotificationPermission() this.requestNotificationPermission()
} }
async #reallyStartAndroid(signal: AbortSignal) {
const androidListener = async (evt: CustomEventInit<string>) => {
const evtData = JSON.parse(evt.detail ?? "{}") as GomuksAndroidMessageToWeb
switch (evtData.type) {
case "register_push":
await this.rpc.registerPush({
type: "fcm",
device_id: evtData.device_id,
data: evtData.token,
encryption: evtData.encryption,
expiration: evtData.expiration,
})
return
case "auth":
try {
const resp = await fetch("_gomuks/auth?no_prompt=true", {
method: "POST",
headers: {
Authorization: evtData.authorization,
},
signal,
})
if (!resp.ok && !signal.aborted) {
console.error("Failed to authenticate:", resp.status, resp.statusText)
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
detail: {
event: "auth_fail",
error: `${resp.statusText || resp.status}`,
},
}))
return
}
} catch (err) {
console.error("Failed to authenticate:", err)
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
detail: {
event: "auth_fail",
error: `${err}`.replace(/^Error: /, ""),
},
}))
return
}
if (signal.aborted) {
return
}
console.log("Successfully authenticated, connecting to websocket")
this.rpc.start()
return
}
}
const unsubscribeConnect = this.rpc.connect.listen(evt => {
if (!evt.connected) {
return
}
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
detail: { event: "connected" },
}))
})
window.addEventListener("GomuksAndroidMessageToWeb", androidListener)
signal.addEventListener("abort", () => {
unsubscribeConnect()
window.removeEventListener("GomuksAndroidMessageToWeb", androidListener)
})
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
detail: { event: "ready" },
}))
}
requestNotificationPermission = (evt?: MouseEvent) => { requestNotificationPermission = (evt?: MouseEvent) => {
window.Notification?.requestPermission().then(permission => { window.Notification?.requestPermission().then(permission => {
console.log("Notification permission:", permission) console.log("Notification permission:", permission)
@ -86,7 +155,11 @@ export default class Client {
start(): () => void { start(): () => void {
const abort = new AbortController() const abort = new AbortController()
this.#reallyStart(abort.signal) if (window.gomuksAndroid) {
this.#reallyStartAndroid(abort.signal)
} else {
this.#reallyStart(abort.signal)
}
this.#gcInterval = setInterval(() => { this.#gcInterval = setInterval(() => {
console.log("Garbage collection completed:", this.store.doGarbageCollection()) console.log("Garbage collection completed:", this.store.doGarbageCollection())
}, window.gcSettings.interval) }, window.gcSettings.interval)
@ -104,6 +177,7 @@ export default class Client {
#handleEvent = (ev: RPCEvent) => { #handleEvent = (ev: RPCEvent) => {
if (ev.command === "client_state") { if (ev.command === "client_state") {
this.state.emit(ev.data) this.state.emit(ev.data)
this.store.userID = ev.data.is_logged_in ? ev.data.user_id : ""
} else if (ev.command === "sync_status") { } else if (ev.command === "sync_status") {
this.syncStatus.emit(ev.data) this.syncStatus.emit(ev.data)
} else if (ev.command === "init_complete") { } else if (ev.command === "init_complete") {
@ -116,6 +190,8 @@ export default class Client {
this.store.applySendComplete(ev.data) this.store.applySendComplete(ev.data)
} else if (ev.command === "image_auth_token") { } else if (ev.command === "image_auth_token") {
this.store.imageAuthToken = ev.data this.store.imageAuthToken = ev.data
} else if (ev.command === "typing") {
this.store.applyTyping(ev.data)
} }
} }
@ -124,21 +200,25 @@ export default class Client {
room = this.store.rooms.get(room) room = this.store.rooms.get(room)
} }
if (!room || room.state.get("m.room.member")?.has(userID) || room.requestedMembers.has(userID)) { if (!room || room.state.get("m.room.member")?.has(userID) || room.requestedMembers.has(userID)) {
return return null
} }
room.requestedMembers.add(userID) room.requestedMembers.add(userID)
this.#stateRequests.push({ room_id: room.roomID, type: "m.room.member", state_key: userID }) this.#stateRequests.push({ room_id: room.roomID, type: "m.room.member", state_key: userID })
if (!this.#stateRequestQueued) { if (this.#stateRequestPromise === null) {
this.#stateRequestQueued = true this.#stateRequestPromise = new Promise(this.#doStateRequestsPromise)
window.queueMicrotask(this.doStateRequests)
} }
return this.#stateRequestPromise
} }
doStateRequests = () => { #doStateRequestsPromise = (resolve: () => void) => {
const reqs = this.#stateRequests window.queueMicrotask(() => {
this.#stateRequestQueued = false const reqs = this.#stateRequests
this.#stateRequests = [] this.#stateRequestPromise = null
this.loadSpecificRoomState(reqs).catch(err => console.error("Failed to load room state", reqs, err)) this.#stateRequests = []
this.loadSpecificRoomState(reqs)
.catch(err => console.error("Failed to load room state", reqs, err))
.finally(resolve)
})
} }
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) { requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) {
@ -204,7 +284,9 @@ export default class Client {
throw new Error("Room not found") throw new Error("Room not found")
} }
const dbEvent = await this.rpc.sendMessage(params) const dbEvent = await this.rpc.sendMessage(params)
this.#handleOutgoingEvent(dbEvent, room) if (dbEvent) {
this.#handleOutgoingEvent(dbEvent, room)
}
} }
async subscribeToEmojiPack(pack: RoomStateGUID, subscribe: boolean = true) { async subscribeToEmojiPack(pack: RoomStateGUID, subscribe: boolean = true) {
@ -314,7 +396,7 @@ export default class Client {
throw new Error("Timeline changed while loading history") throw new Error("Timeline changed while loading history")
} }
room.hasMoreHistory = resp.has_more room.hasMoreHistory = resp.has_more
room.applyPagination(resp.events) room.applyPagination(resp.events, resp.related_events, resp.receipts)
} finally { } finally {
room.paginating = false room.paginating = false
} }

View file

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

View file

@ -17,9 +17,11 @@ import { CachedEventDispatcher, EventDispatcher } from "../util/eventdispatcher.
import { CancellablePromise } from "../util/promise.ts" import { CancellablePromise } from "../util/promise.ts"
import type { import type {
ClientWellKnown, ClientWellKnown,
DBPushRegistration,
EventID, EventID,
EventRowID, EventRowID,
EventType, EventType,
JSONValue,
LoginFlowsResponse, LoginFlowsResponse,
LoginRequest, LoginRequest,
Mentions, Mentions,
@ -32,9 +34,12 @@ import type {
ReceiptType, ReceiptType,
RelatesTo, RelatesTo,
ResolveAliasResponse, ResolveAliasResponse,
RespOpenIDToken,
RespRoomJoin,
RoomAlias, RoomAlias,
RoomID, RoomID,
RoomStateGUID, RoomStateGUID,
RoomSummary,
TimelineRowID, TimelineRowID,
UserID, UserID,
UserProfile, UserProfile,
@ -136,7 +141,7 @@ export default abstract class RPCClient {
return this.request("logout", {}) return this.request("logout", {})
} }
sendMessage(params: SendMessageParams): Promise<RawDBEvent> { sendMessage(params: SendMessageParams): Promise<RawDBEvent | null> {
return this.request("send_message", params) return this.request("send_message", params)
} }
@ -178,6 +183,10 @@ export default abstract class RPCClient {
return this.request("get_profile", { user_id }) return this.request("get_profile", { user_id })
} }
setProfileField(field: string, value: JSONValue): Promise<boolean> {
return this.request("set_profile_field", { field, value })
}
getMutualRooms(user_id: UserID): Promise<RoomID[]> { getMutualRooms(user_id: UserID): Promise<RoomID[]> {
return this.request("get_mutual_rooms", { user_id }) return this.request("get_mutual_rooms", { user_id })
} }
@ -186,6 +195,10 @@ export default abstract class RPCClient {
return this.request("get_profile_encryption_info", { user_id }) return this.request("get_profile_encryption_info", { user_id })
} }
trackUserDevices(user_id: UserID): Promise<ProfileEncryptionInfo> {
return this.request("track_user_devices", { user_id })
}
ensureGroupSessionShared(room_id: RoomID): Promise<boolean> { ensureGroupSessionShared(room_id: RoomID): Promise<boolean> {
return this.request("ensure_group_session_shared", { room_id }) return this.request("ensure_group_session_shared", { room_id })
} }
@ -216,6 +229,18 @@ export default abstract class RPCClient {
return this.request("paginate_server", { room_id, limit }) return this.request("paginate_server", { room_id, limit })
} }
getRoomSummary(room_id_or_alias: RoomID | RoomAlias, via?: string[]): Promise<RoomSummary> {
return this.request("get_room_summary", { room_id_or_alias, via })
}
joinRoom(room_id_or_alias: RoomID | RoomAlias, via?: string[], reason?: string): Promise<RespRoomJoin> {
return this.request("join_room", { room_id_or_alias, via, reason })
}
leaveRoom(room_id: RoomID, reason?: string): Promise<Record<string, never>> {
return this.request("leave_room", { room_id, reason })
}
resolveAlias(alias: RoomAlias): Promise<ResolveAliasResponse> { resolveAlias(alias: RoomAlias): Promise<ResolveAliasResponse> {
return this.request("resolve_alias", { alias }) return this.request("resolve_alias", { alias })
} }
@ -239,4 +264,12 @@ export default abstract class RPCClient {
verify(recovery_key: string): Promise<boolean> { verify(recovery_key: string): Promise<boolean> {
return this.request("verify", { recovery_key }) return this.request("verify", { recovery_key })
} }
requestOpenIDToken(): Promise<RespOpenIDToken> {
return this.request("request_openid_token", {})
}
registerPush(reg: DBPushRegistration): Promise<boolean> {
return this.request("register_push", reg)
}
} }

View file

@ -13,9 +13,18 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useEffect, useMemo, useState, useSyncExternalStore } from "react" import { useEffect, useMemo, useReducer, useState, useSyncExternalStore } from "react"
import Client from "@/api/client.ts"
import type { CustomEmojiPack } from "@/util/emoji" import type { CustomEmojiPack } from "@/util/emoji"
import type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types" import type {
EventID,
EventType,
MemDBEvent,
MemReceipt,
MemberEventContent,
UnknownEventContent,
UserID,
} from "../types"
import { Preferences, preferences } from "../types/preferences" import { Preferences, preferences } from "../types/preferences"
import type { StateStore } from "./main.ts" import type { StateStore } from "./main.ts"
import type { AutocompleteMemberEntry, RoomStateStore } from "./room.ts" import type { AutocompleteMemberEntry, RoomStateStore } from "./room.ts"
@ -27,6 +36,17 @@ export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] {
) )
} }
export function useRoomTyping(room: RoomStateStore): string[] {
return useSyncExternalStore(room.typingSub.subscribe, () => room.typing)
}
export function useReadReceipts(room: RoomStateStore, evtID: EventID): MemReceipt[] {
return useSyncExternalStore(
room.receiptSubs.getSubscriber(evtID),
() => room.receiptsByEventID.get(evtID) ?? emptyArray,
)
}
export function useRoomState( export function useRoomState(
room?: RoomStateStore, type?: EventType, stateKey: string | undefined = "", room?: RoomStateStore, type?: EventType, stateKey: string | undefined = "",
): MemDBEvent | null { ): MemDBEvent | null {
@ -37,6 +57,34 @@ export function useRoomState(
) )
} }
export function useRoomMember(
client: Client | undefined | null, room: RoomStateStore | undefined, userID: UserID,
): MemDBEvent | null {
const evt = useRoomState(room, "m.room.member", userID)
if (!evt && client && room) {
client.requestMemberEvent(room, userID)
}
return evt
}
export function useMultipleRoomMembers(
client: Client, room: RoomStateStore, userIDs: UserID[],
): [UserID, MemberEventContent | null][] {
const [, forceUpdate] = useReducer(x => x + 1, 0)
let promiseAwaited = false
return userIDs.map(userID => {
const evt = room.getStateEvent("m.room.member", userID)
if (!evt) {
const promise = client.requestMemberEvent(room, userID)
if (promise && !promiseAwaited) {
promiseAwaited = true
promise.then(forceUpdate)
}
}
const member = (evt?.content ?? null) as MemberEventContent | null
return [userID, member]
})
}
export function useRoomMembers(room?: RoomStateStore): AutocompleteMemberEntry[] { export function useRoomMembers(room?: RoomStateStore): AutocompleteMemberEntry[] {
return useSyncExternalStore( return useSyncExternalStore(
@ -97,7 +145,7 @@ export function usePreference<T extends keyof Preferences>(
} }
export function useCustomEmojis( export function useCustomEmojis(
ss: StateStore, room: RoomStateStore, ss: StateStore, room: RoomStateStore, usage: "stickers" | "emojis" = "emojis",
): CustomEmojiPack[] { ): CustomEmojiPack[] {
const personalPack = useSyncExternalStore( const personalPack = useSyncExternalStore(
ss.accountDataSubs.getSubscriber("im.ponies.user_emotes"), ss.accountDataSubs.getSubscriber("im.ponies.user_emotes"),
@ -116,6 +164,6 @@ export function useCustomEmojis(
if (personalPack) { if (personalPack) {
allPacksObject.personal = personalPack allPacksObject.personal = personalPack
} }
return Object.values(allPacksObject) return Object.values(allPacksObject).filter(pack => pack[usage].length > 0)
}, [personalPack, watchedRoomPacks, specialRoomPacks]) }, [personalPack, watchedRoomPacks, specialRoomPacks, usage])
} }

View file

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

View file

@ -0,0 +1,135 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import toSearchableString from "@/util/searchablestring.ts"
import { ensureString, getDisplayname } from "@/util/validation.ts"
import type {
ContentURI,
DBInvitedRoom, JoinRule,
MemberEventContent, Membership,
RoomAlias,
RoomID,
RoomSummary,
RoomType,
RoomVersion,
StrippedStateEvent,
UserID,
} from "../types"
import type { RoomListEntry, StateStore } from "./main.ts"
export class InvitedRoomStore implements RoomListEntry, RoomSummary {
readonly room_id: RoomID
readonly sorting_timestamp: number
readonly date: string
readonly name: string = ""
readonly search_name: string
readonly dm_user_id?: UserID
readonly canonical_alias?: RoomAlias
readonly topic?: string
readonly avatar?: ContentURI
readonly encryption?: "m.megolm.v1.aes-sha2"
readonly room_version?: RoomVersion
readonly join_rule?: JoinRule
readonly invited_by?: UserID
readonly inviter_profile?: MemberEventContent
readonly is_direct: boolean
constructor(public readonly meta: DBInvitedRoom, parent: StateStore) {
this.room_id = meta.room_id
this.sorting_timestamp = 1000000000000000 + meta.created_at
this.date = new Date(meta.created_at - new Date().getTimezoneOffset() * 60000)
.toISOString().replace("T", " ").replace("Z", "")
const members = new Map<UserID, StrippedStateEvent>()
for (const state of this.meta.invite_state) {
if (state.type === "m.room.name") {
this.name = ensureString(state.content.name)
} else if (state.type === "m.room.canonical_alias") {
this.canonical_alias = ensureString(state.content.alias)
} else if (state.type === "m.room.topic") {
this.topic = ensureString(state.content.topic)
} else if (state.type === "m.room.avatar") {
this.avatar = ensureString(state.content.url)
} else if (state.type === "m.room.encryption") {
this.encryption = state.content.algorithm as "m.megolm.v1.aes-sha2"
} else if (state.type === "m.room.create") {
this.room_version = ensureString(state.content.version) as RoomVersion
} else if (state.type === "m.room.member") {
members.set(state.state_key, state)
} else if (state.type === "m.room.join_rules") {
this.join_rule = ensureString(state.content.join_rule) as JoinRule
}
}
this.search_name = toSearchableString(this.name ?? "")
const ownMemberEvt = members.get(parent.userID)
if (ownMemberEvt) {
this.invited_by = ownMemberEvt.sender
this.inviter_profile = members.get(ownMemberEvt.sender)?.content as MemberEventContent
}
this.is_direct = Boolean(ownMemberEvt?.content.is_direct)
if (
!this.name
&& !this.avatar
&& !this.topic
&& !this.canonical_alias
&& this.join_rule === "invite"
&& this.invited_by
&& this.is_direct
) {
this.dm_user_id = this.invited_by
this.name = getDisplayname(this.invited_by, this.inviter_profile)
this.avatar = this.inviter_profile?.avatar_url
}
}
get membership(): Membership {
return "invite"
}
get avatar_url(): ContentURI | undefined {
return this.avatar
}
get num_joined_members(): number {
return 0
}
get room_type(): RoomType {
return ""
}
get world_readable(): boolean {
return false
}
get guest_can_join(): boolean {
return false
}
get unread_messages(): number {
return 0
}
get unread_notifications(): number {
return 0
}
get unread_highlights(): number {
return 1
}
get marked_unread(): boolean {
return true
}
}

View file

@ -32,11 +32,14 @@ import {
SendCompleteData, SendCompleteData,
SyncCompleteData, SyncCompleteData,
SyncRoom, SyncRoom,
TypingEventData,
UnknownEventContent, UnknownEventContent,
UserID, UserID,
roomStateGUIDToString, roomStateGUIDToString,
} from "../types" } from "../types"
import { InvitedRoomStore } from "./invitedroom.ts"
import { RoomStateStore } from "./room.ts" import { RoomStateStore } from "./room.ts"
import { DirectChatSpace, RoomListFilter, Space, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts"
export interface RoomListEntry { export interface RoomListEntry {
room_id: RoomID room_id: RoomID
@ -66,13 +69,27 @@ window.gcSettings ??= {
} }
export class StateStore { export class StateStore {
userID: UserID = ""
readonly rooms: Map<RoomID, RoomStateStore> = new Map() readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([]) readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
currentRoomListFilter: string = "" readonly roomListEntries = new Map<RoomID, RoomListEntry>()
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
readonly spaceOrphans = new SpaceOrphansSpace(this)
readonly directChatsSpace = new DirectChatSpace()
readonly unreadsSpace = new UnreadsSpace(this)
readonly pseudoSpaces = [
this.spaceOrphans,
this.directChatsSpace,
this.unreadsSpace,
] as const
currentRoomListQuery: string = ""
currentRoomListFilter: RoomListFilter | null = null
readonly accountData: Map<string, UnknownEventContent> = new Map() readonly accountData: Map<string, UnknownEventContent> = new Map()
readonly accountDataSubs = new MultiSubscribable() readonly accountDataSubs = new MultiSubscribable()
readonly emojiRoomsSub = new Subscribable() readonly emojiRoomsSub = new Subscribable()
readonly preferences: Preferences = getPreferenceProxy(this) readonly preferences = getPreferenceProxy(this)
#frequentlyUsedEmoji: Map<string, number> | null = null #frequentlyUsedEmoji: Map<string, number> | null = null
#emojiPackKeys: RoomStateGUID[] | null = null #emojiPackKeys: RoomStateGUID[] | null = null
#watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | null = null #watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | null = null
@ -82,13 +99,61 @@ export class StateStore {
serverPreferenceCache: Preferences = {} serverPreferenceCache: Preferences = {}
switchRoom?: (roomID: RoomID | null) => void switchRoom?: (roomID: RoomID | null) => void
activeRoomID: RoomID | null = null activeRoomID: RoomID | null = null
activeRoomIsPreview: boolean = false
imageAuthToken?: string imageAuthToken?: string
getFilteredRoomList(): RoomListEntry[] { #roomListFilterFunc = (entry: RoomListEntry) => {
if (!this.currentRoomListFilter) { if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
return this.roomList.current return false
} else if (this.currentRoomListFilter && !this.currentRoomListFilter.include(entry)) {
return false
} }
return this.roomList.current.filter(entry => entry.search_name.includes(this.currentRoomListFilter)) return true
}
getSpaceByID(spaceID: string | undefined): RoomListFilter | null {
if (!spaceID) {
return null
}
const realSpace = this.spaceEdges.get(spaceID)
if (realSpace) {
return realSpace
}
for (const pseudoSpace of this.pseudoSpaces) {
if (pseudoSpace.id === spaceID) {
return pseudoSpace
}
}
console.warn("Failed to find space", spaceID)
return null
}
findMatchingSpace(room: RoomListEntry): Space | null {
if (this.spaceOrphans.include(room)) {
return this.spaceOrphans
}
for (const spaceID of this.topLevelSpaces.current) {
const space = this.spaceEdges.get(spaceID)
if (space?.include(room)) {
return space
}
}
if (this.directChatsSpace.include(room)) {
return this.directChatsSpace
}
return null
}
get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
return null
}
return this.#roomListFilterFunc
}
getFilteredRoomList(): RoomListEntry[] {
const fn = this.roomListFilterFunc
return fn ? this.roomList.current.filter(fn) : this.roomList.current
} }
#shouldHideRoom(entry: SyncRoom): boolean { #shouldHideRoom(entry: SyncRoom): boolean {
@ -117,7 +182,7 @@ export class StateStore {
entry.meta.unread_highlights !== oldEntry.meta.current.unread_highlights || entry.meta.unread_highlights !== oldEntry.meta.current.unread_highlights ||
entry.meta.marked_unread !== oldEntry.meta.current.marked_unread || entry.meta.marked_unread !== oldEntry.meta.current.marked_unread ||
entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid || entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid ||
entry.events.findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1 (entry.events ?? []).findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
} }
#makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null { #makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null {
@ -138,8 +203,7 @@ export class StateStore {
const name = entry.meta.name ?? "Unnamed room" const name = entry.meta.name ?? "Unnamed room"
return { return {
room_id: entry.meta.room_id, room_id: entry.meta.room_id,
dm_user_id: entry.meta.lazy_load_summary?.heroes?.length === 1 dm_user_id: entry.meta.dm_user_id,
? entry.meta.lazy_load_summary.heroes[0] : undefined,
sorting_timestamp: entry.meta.sorting_timestamp, sorting_timestamp: entry.meta.sorting_timestamp,
preview_event, preview_event,
preview_sender, preview_sender,
@ -153,6 +217,25 @@ export class StateStore {
} }
} }
#applyUnreadModification(meta: RoomListEntry | null, oldMeta: RoomListEntry | undefined | null) {
const someMeta = meta ?? oldMeta
if (!someMeta) {
return
}
if (this.spaceOrphans.include(someMeta)) {
this.spaceOrphans.applyUnreads(meta, oldMeta)
return
}
if (this.directChatsSpace.include(someMeta)) {
this.directChatsSpace.applyUnreads(meta, oldMeta)
}
for (const space of this.spaceEdges.values()) {
if (space.include(someMeta)) {
space.applyUnreads(meta, oldMeta)
}
}
}
applySync(sync: SyncCompleteData) { applySync(sync: SyncCompleteData) {
if (sync.clear_state && this.rooms.size > 0) { if (sync.clear_state && this.rooms.size > 0) {
console.info("Clearing state store as sync told to reset and there are rooms in the store") console.info("Clearing state store as sync told to reset and there are rooms in the store")
@ -160,18 +243,41 @@ export class StateStore {
} }
const resyncRoomList = this.roomList.current.length === 0 const resyncRoomList = this.roomList.current.length === 0
const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>() const changedRoomListEntries = new Map<RoomID, RoomListEntry | null>()
for (const [roomID, data] of Object.entries(sync.rooms)) { for (const data of sync.invited_rooms ?? []) {
const room = new InvitedRoomStore(data, this)
this.inviteRooms.set(room.room_id, room)
if (!resyncRoomList) {
changedRoomListEntries.set(room.room_id, room)
this.#applyUnreadModification(room, this.roomListEntries.get(room.room_id))
this.roomListEntries.set(room.room_id, room)
}
if (this.activeRoomID === room.room_id) {
this.switchRoom?.(room.room_id)
}
}
const hasInvites = this.inviteRooms.size > 0
for (const [roomID, data] of Object.entries(sync.rooms ?? {})) {
let isNewRoom = false let isNewRoom = false
let room = this.rooms.get(roomID) let room = this.rooms.get(roomID)
if (!room) { if (!room) {
room = new RoomStateStore(data.meta, this) room = new RoomStateStore(data.meta, this)
this.rooms.set(roomID, room) this.rooms.set(roomID, room)
if (hasInvites) {
this.inviteRooms.delete(roomID)
}
isNewRoom = true isNewRoom = true
} }
const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room)) const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room))
room.applySync(data) room.applySync(data)
if (roomListEntryChanged) { if (roomListEntryChanged) {
changedRoomListEntries.set(roomID, this.#makeRoomListEntry(data, room)) const entry = this.#makeRoomListEntry(data, room)
changedRoomListEntries.set(roomID, entry)
this.#applyUnreadModification(entry, this.roomListEntries.get(roomID))
if (entry) {
this.roomListEntries.set(roomID, entry)
} else {
this.roomListEntries.delete(roomID)
}
} }
if (!resyncRoomList) { if (!resyncRoomList) {
// When we join a valid replacement room, hide the tombstoned room. // When we join a valid replacement room, hide the tombstoned room.
@ -184,13 +290,16 @@ export class StateStore {
} }
} }
if (window.Notification?.permission === "granted" && !focused.current) { if (window.Notification?.permission === "granted" && !focused.current && data.notifications) {
for (const notification of data.notifications) { for (const notification of data.notifications) {
this.showNotification(room, notification.event_rowid, notification.sound) this.showNotification(room, notification.event_rowid, notification.sound)
} }
} }
if (this.activeRoomID === roomID && this.activeRoomIsPreview) {
this.switchRoom?.(roomID)
}
} }
for (const ad of Object.values(sync.account_data)) { for (const ad of Object.values(sync.account_data ?? {})) {
if (ad.type === "io.element.recent_emoji") { if (ad.type === "io.element.recent_emoji") {
this.#frequentlyUsedEmoji = null this.#frequentlyUsedEmoji = null
} else if (ad.type === "fi.mau.gomuks.preferences") { } else if (ad.type === "fi.mau.gomuks.preferences") {
@ -200,20 +309,26 @@ export class StateStore {
this.accountData.set(ad.type, ad.content) this.accountData.set(ad.type, ad.content)
this.accountDataSubs.notify(ad.type) this.accountDataSubs.notify(ad.type)
} }
for (const roomID of sync.left_rooms) { for (const roomID of sync.left_rooms ?? []) {
if (this.activeRoomID === roomID) { if (this.activeRoomID === roomID) {
this.switchRoom?.(null) this.switchRoom?.(null)
} }
this.rooms.delete(roomID) this.rooms.delete(roomID)
changedRoomListEntries.set(roomID, null) changedRoomListEntries.set(roomID, null)
this.#applyUnreadModification(null, this.roomListEntries.get(roomID))
} }
let updatedRoomList: RoomListEntry[] | undefined let updatedRoomList: RoomListEntry[] | undefined
if (resyncRoomList) { if (resyncRoomList) {
updatedRoomList = Object.values(sync.rooms) updatedRoomList = this.inviteRooms.values().toArray()
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms ?? {})
.map(entry => this.#makeRoomListEntry(entry)) .map(entry => this.#makeRoomListEntry(entry))
.filter(entry => entry !== null) .filter(entry => entry !== null))
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp) updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
for (const entry of updatedRoomList) {
this.#applyUnreadModification(entry, undefined)
this.roomListEntries.set(entry.room_id, entry)
}
} else if (changedRoomListEntries.size > 0) { } else if (changedRoomListEntries.size > 0) {
updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id)) updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id))
for (const entry of changedRoomListEntries.values()) { for (const entry of changedRoomListEntries.values()) {
@ -236,6 +351,19 @@ export class StateStore {
if (updatedRoomList) { if (updatedRoomList) {
this.roomList.emit(updatedRoomList) this.roomList.emit(updatedRoomList)
} }
if (sync.space_edges) {
// Ensure all space stores exist first
for (const spaceID of Object.keys(sync.space_edges)) {
this.getSpaceStore(spaceID, true)
}
for (const [spaceID, children] of Object.entries(sync.space_edges ?? {})) {
this.getSpaceStore(spaceID, true).children = children
}
}
if (sync.top_level_spaces) {
this.topLevelSpaces.emit(sync.top_level_spaces)
this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id }))
}
} }
invalidateEmojiPackKeyCache() { invalidateEmojiPackKeyCache() {
@ -301,6 +429,20 @@ export class StateStore {
return this.#watchedRoomEmojiPacks ?? {} return this.#watchedRoomEmojiPacks ?? {}
} }
getSpaceStore(spaceID: RoomID, force: true): SpaceEdgeStore
getSpaceStore(spaceID: RoomID): SpaceEdgeStore | null
getSpaceStore(spaceID: RoomID, force?: true): SpaceEdgeStore | null {
let store = this.spaceEdges.get(spaceID)
if (!store) {
if (!force && this.rooms.get(spaceID)?.meta.current.creation_content?.type !== "m.space") {
return null
}
store = new SpaceEdgeStore(spaceID, this)
this.spaceEdges.set(spaceID, store)
}
return store
}
get frequentlyUsedEmoji(): Map<string, number> { get frequentlyUsedEmoji(): Map<string, number> {
if (this.#frequentlyUsedEmoji === null) { if (this.#frequentlyUsedEmoji === null) {
const emojiData = this.accountData.get("io.element.recent_emoji") const emojiData = this.accountData.get("io.element.recent_emoji")
@ -337,9 +479,10 @@ export class StateStore {
const notif = new Notification(title, { const notif = new Notification(title, {
body, body,
icon, icon,
badge: "/gomuks.png", badge: "gomuks.png",
// timestamp: evt.timestamp, // timestamp: evt.timestamp,
// image: ..., // image: ...,
silent: !sound,
tag: rowid.toString(), tag: rowid.toString(),
}) })
room.openNotifications.set(rowid, notif) room.openNotifications.set(rowid, notif)
@ -382,6 +525,15 @@ export class StateStore {
} }
} }
applyTyping(typing: TypingEventData) {
const room = this.rooms.get(typing.room_id)
if (!room) {
// TODO log or something?
return
}
room.applyTyping(typing.user_ids)
}
doGarbageCollection() { doGarbageCollection() {
const maxLastOpened = Date.now() - window.gcSettings.lastOpenedCutoff const maxLastOpened = Date.now() - window.gcSettings.lastOpenedCutoff
let deletedEvents = 0 let deletedEvents = 0
@ -399,9 +551,14 @@ export class StateStore {
clear() { clear() {
this.rooms.clear() this.rooms.clear()
this.inviteRooms.clear()
this.spaceEdges.clear()
this.pseudoSpaces.forEach(space => space.clearUnreads())
this.roomList.emit([]) this.roomList.emit([])
this.topLevelSpaces.emit([])
this.accountData.clear() this.accountData.clear()
this.currentRoomListFilter = "" this.currentRoomListQuery = ""
this.currentRoomListFilter = null
this.#frequentlyUsedEmoji = null this.#frequentlyUsedEmoji = null
this.#emojiPackKeys = null this.#emojiPackKeys = null
this.#watchedRoomEmojiPacks = null this.#watchedRoomEmojiPacks = null

View file

@ -21,6 +21,7 @@ import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subs
import { getDisplayname } from "@/util/validation.ts" import { getDisplayname } from "@/util/validation.ts"
import { import {
ContentURI, ContentURI,
DBReceipt,
DBRoom, DBRoom,
EncryptedEventContent, EncryptedEventContent,
EventID, EventID,
@ -30,6 +31,7 @@ import {
ImagePack, ImagePack,
LazyLoadSummary, LazyLoadSummary,
MemDBEvent, MemDBEvent,
MemReceipt,
MemberEventContent, MemberEventContent,
PowerLevelEventContent, PowerLevelEventContent,
RawDBEvent, RawDBEvent,
@ -60,7 +62,7 @@ function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean { function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean {
return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] && return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] &&
ll1?.["m.invited_member_count"] === ll2?.["m.invited_member_count"] && ll1?.["m.invited_member_count"] === ll2?.["m.invited_member_count"] &&
arraysAreEqual(ll1?.heroes, ll2?.heroes) arraysAreEqual(ll1?.["m.heroes"], ll2?.["m.heroes"])
} }
function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean { function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
@ -68,6 +70,7 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
meta1.avatar === meta2.avatar && meta1.avatar === meta2.avatar &&
meta1.topic === meta2.topic && meta1.topic === meta2.topic &&
meta1.canonical_alias === meta2.canonical_alias && meta1.canonical_alias === meta2.canonical_alias &&
meta1.dm_user_id === meta2.dm_user_id &&
llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) && llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) &&
meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm && meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm &&
meta1.has_member_list === meta2.has_member_list meta1.has_member_list === meta2.has_member_list
@ -83,26 +86,34 @@ export interface AutocompleteMemberEntry {
const collator = new Intl.Collator() const collator = new Intl.Collator()
const UNSENT_TIMELINE_ROWID_BASE = 1000000000000000
export class RoomStateStore { export class RoomStateStore {
readonly roomID: RoomID readonly roomID: RoomID
readonly meta: NonNullCachedEventDispatcher<DBRoom> readonly meta: NonNullCachedEventDispatcher<DBRoom>
timeline: TimelineRowTuple[] = [] timeline: TimelineRowTuple[] = []
timelineCache: (MemDBEvent | null)[] = [] timelineCache: (MemDBEvent | null)[] = []
editTargets: EventRowID[] = []
state: Map<EventType, Map<string, EventRowID>> = new Map() state: Map<EventType, Map<string, EventRowID>> = new Map()
stateLoaded = false stateLoaded = false
typing: UserID[] = []
fullMembersLoaded = false fullMembersLoaded = false
readonly eventsByRowID: Map<EventRowID, MemDBEvent> = new Map() readonly eventsByRowID: Map<EventRowID, MemDBEvent> = new Map()
readonly eventsByID: Map<EventID, MemDBEvent> = new Map() readonly eventsByID: Map<EventID, MemDBEvent> = new Map()
readonly timelineSub = new Subscribable() readonly timelineSub = new Subscribable()
readonly typingSub = new Subscribable()
readonly stateSubs = new MultiSubscribable() readonly stateSubs = new MultiSubscribable()
readonly eventSubs = new MultiSubscribable() readonly eventSubs = new MultiSubscribable()
readonly receiptsByEventID: Map<EventID, MemReceipt[]> = new Map()
readonly receiptsByUserID: Map<UserID, MemReceipt> = new Map()
readonly receiptSubs = new MultiSubscribable()
readonly requestedEvents: Set<EventID> = new Set() readonly requestedEvents: Set<EventID> = new Set()
readonly requestedMembers: Set<UserID> = new Set() readonly requestedMembers: Set<UserID> = new Set()
readonly accountData: Map<string, UnknownEventContent> = new Map() readonly accountData: Map<string, UnknownEventContent> = new Map()
readonly accountDataSubs = new MultiSubscribable() readonly accountDataSubs = new MultiSubscribable()
readonly openNotifications: Map<EventRowID, Notification> = new Map() readonly openNotifications: Map<EventRowID, Notification> = new Map()
readonly #emojiPacksCache: Map<string, CustomEmojiPack | null> = new Map() readonly #emojiPacksCache: Map<string, CustomEmojiPack | null> = new Map()
readonly preferences: Preferences readonly preferences: Required<Preferences>
readonly localPreferenceCache: Preferences readonly localPreferenceCache: Preferences
readonly preferenceSub = new NoDataSubscribable() readonly preferenceSub = new NoDataSubscribable()
serverPreferenceCache: Preferences = {} serverPreferenceCache: Preferences = {}
@ -124,17 +135,30 @@ export class RoomStateStore {
this.preferences = getPreferenceProxy(parent, this) this.preferences = getPreferenceProxy(parent, this)
} }
notifyTimelineSubscribers() { #updateTimelineCache() {
const ownMessages: EventRowID[] = []
this.timelineCache = this.timeline.map(rt => { this.timelineCache = this.timeline.map(rt => {
const evt = this.eventsByRowID.get(rt.event_rowid) const evt = this.eventsByRowID.get(rt.event_rowid)
if (!evt) { if (!evt) {
return null return null
} }
evt.timeline_rowid = rt.timeline_rowid evt.timeline_rowid = rt.timeline_rowid
if (
evt.sender === this.parent.userID
&& evt.type === "m.room.message"
&& evt.relation_type !== "m.replace"
) {
ownMessages.push(evt.rowid)
}
return evt return evt
}).concat(this.pendingEvents }).concat(this.pendingEvents
.map(rowID => this.eventsByRowID.get(rowID)) .map(rowID => this.eventsByRowID.get(rowID))
.filter(evt => !!evt)) .filter(evt => !!evt))
this.editTargets = ownMessages
}
notifyTimelineSubscribers() {
this.#updateTimelineCache()
this.timelineSub.notify() this.timelineSub.notify()
} }
@ -230,15 +254,65 @@ export class RoomStateStore {
return [] return []
} }
applyPagination(history: RawDBEvent[]) { applyPagination(history: RawDBEvent[], related: RawDBEvent[], allReceipts: Record<EventID, DBReceipt[]>) {
// Pagination comes in newest to oldest, timeline is in the opposite order // Pagination comes in newest to oldest, timeline is in the opposite order
history.reverse() history.reverse()
const newTimeline = history.map(evt => { const newTimeline = history.map(evt => {
this.applyEvent(evt) this.applyEvent(evt)
return { timeline_rowid: evt.timeline_rowid, event_rowid: evt.rowid } return { timeline_rowid: evt.timeline_rowid, event_rowid: evt.rowid }
}) })
for (const evt of related) {
if (!this.eventsByRowID.has(evt.rowid)) {
this.applyEvent(evt)
}
}
this.timeline.splice(0, 0, ...newTimeline) this.timeline.splice(0, 0, ...newTimeline)
this.notifyTimelineSubscribers() this.notifyTimelineSubscribers()
for (const [evtID, receipts] of Object.entries(allReceipts)) {
this.applyReceipts(receipts, evtID, true)
}
}
applyReceipts(receipts: DBReceipt[], evtID: EventID, override: boolean) {
const evt = this.eventsByID.get(evtID)
if (!evt?.timeline_rowid) {
return
}
const filtered = receipts.filter(receipt => this.applyReceipt(receipt, evt))
filtered.sort((a, b) => a.timestamp - b.timestamp)
if (override) {
this.receiptsByEventID.set(evtID, filtered)
} else {
const existing = this.receiptsByEventID.get(evtID) ?? []
this.receiptsByEventID.set(evtID, existing.concat(filtered))
}
this.receiptSubs.notify(evtID)
}
applyReceipt(receipt: DBReceipt, evt: MemDBEvent): receipt is MemReceipt {
const existingReceipt = this.receiptsByUserID.get(receipt.user_id)
if (existingReceipt) {
if (existingReceipt.timeline_rowid >= evt.timeline_rowid) {
return false
}
const oldArr = this.receiptsByEventID.get(existingReceipt.event_id)
if (oldArr) {
const updated = oldArr.filter(r => r !== existingReceipt)
if (updated.length !== oldArr.length) {
if (updated.length === 0) {
this.receiptsByEventID.delete(existingReceipt.event_id)
} else {
this.receiptsByEventID.set(existingReceipt.event_id, updated)
}
this.receiptSubs.notify(existingReceipt.event_id)
}
}
}
const memReceipt = receipt as MemReceipt
memReceipt.timeline_rowid = evt.timeline_rowid > UNSENT_TIMELINE_ROWID_BASE ? 1 : evt.timeline_rowid
memReceipt.event_rowid = evt.rowid
this.receiptsByUserID.set(receipt.user_id, memReceipt)
return true
} }
applyEvent(evt: RawDBEvent, pending: boolean = false) { applyEvent(evt: RawDBEvent, pending: boolean = false) {
@ -246,7 +320,7 @@ export class RoomStateStore {
memEvt.mem = true memEvt.mem = true
memEvt.pending = pending memEvt.pending = pending
if (pending) { if (pending) {
memEvt.timeline_rowid = 1000000000000000 + memEvt.timestamp memEvt.timeline_rowid = UNSENT_TIMELINE_ROWID_BASE + memEvt.timestamp
} }
if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) { if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
memEvt.type = evt.decrypted_type memEvt.type = evt.decrypted_type
@ -285,6 +359,7 @@ export class RoomStateStore {
} }
} }
this.eventSubs.notify(memEvt.event_id) this.eventSubs.notify(memEvt.event_id)
return memEvt
} }
applySendComplete(evt: RawDBEvent) { applySendComplete(evt: RawDBEvent) {
@ -316,7 +391,7 @@ export class RoomStateStore {
} else { } else {
this.meta.emit(sync.meta) this.meta.emit(sync.meta)
} }
for (const ad of Object.values(sync.account_data)) { for (const ad of Object.values(sync.account_data ?? {})) {
if (ad.type === "fi.mau.gomuks.preferences") { if (ad.type === "fi.mau.gomuks.preferences") {
this.serverPreferenceCache = ad.content this.serverPreferenceCache = ad.content
this.preferenceSub.notify() this.preferenceSub.notify()
@ -324,10 +399,10 @@ export class RoomStateStore {
this.accountData.set(ad.type, ad.content) this.accountData.set(ad.type, ad.content)
this.accountDataSubs.notify(ad.type) this.accountDataSubs.notify(ad.type)
} }
for (const evt of sync.events) { for (const evt of sync.events ?? []) {
this.applyEvent(evt) this.applyEvent(evt)
} }
for (const [evtType, changedEvts] of Object.entries(sync.state)) { for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
let stateMap = this.state.get(evtType) let stateMap = this.state.get(evtType)
if (!stateMap) { if (!stateMap) {
stateMap = new Map() stateMap = new Map()
@ -340,9 +415,9 @@ export class RoomStateStore {
this.stateSubs.notify(evtType) this.stateSubs.notify(evtType)
} }
if (sync.reset) { if (sync.reset) {
this.timeline = sync.timeline this.timeline = sync.timeline ?? []
this.pendingEvents.splice(0, this.pendingEvents.length) this.pendingEvents.splice(0, this.pendingEvents.length)
} else { } else if (sync.timeline) {
this.timeline.push(...sync.timeline) this.timeline.push(...sync.timeline)
} }
if (sync.meta.unread_notifications === 0 && sync.meta.unread_highlights === 0) { if (sync.meta.unread_notifications === 0 && sync.meta.unread_highlights === 0) {
@ -352,6 +427,9 @@ export class RoomStateStore {
this.openNotifications.clear() this.openNotifications.clear()
} }
this.notifyTimelineSubscribers() this.notifyTimelineSubscribers()
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
this.applyReceipts(receipts, evtID, false)
}
} }
applyState(evt: RawDBEvent) { applyState(evt: RawDBEvent) {
@ -418,6 +496,11 @@ export class RoomStateStore {
} }
} }
applyTyping(users: string[]) {
this.typing = users
this.typingSub.notify()
}
doGarbageCollection() { doGarbageCollection() {
const memberEventsToKeep = new Set<UserID>() const memberEventsToKeep = new Set<UserID>()
const eventsToKeep = new Set<EventRowID>() const eventsToKeep = new Set<EventRowID>()
@ -466,6 +549,8 @@ export class RoomStateStore {
const deletedEvents = this.eventsByRowID.size - eventsToKeep.size const deletedEvents = this.eventsByRowID.size - eventsToKeep.size
this.eventsByRowID.clear() this.eventsByRowID.clear()
this.eventsByID.clear() this.eventsByID.clear()
this.receiptsByEventID.clear()
this.receiptsByUserID.clear()
for (const evt of eventsToKeepList) { for (const evt of eventsToKeepList) {
this.eventsByRowID.set(evt.rowid, evt) this.eventsByRowID.set(evt.rowid, evt)
this.eventsByID.set(evt.event_id, evt) this.eventsByID.set(evt.event_id, evt)

View file

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

View file

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

@ -15,14 +15,18 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { import {
DBAccountData, DBAccountData,
DBInvitedRoom,
DBReceipt,
DBRoom, DBRoom,
DBRoomAccountData, DBRoomAccountData,
DBSpaceEdge,
EventRowID, EventRowID,
RawDBEvent, RawDBEvent,
TimelineRowTuple, TimelineRowTuple,
} from "./hitypes.ts" } from "./hitypes.ts"
import { import {
DeviceID, DeviceID,
EventID,
EventType, EventType,
RoomID, RoomID,
UserID, UserID,
@ -68,12 +72,13 @@ export interface ImageAuthTokenEvent extends BaseRPCCommand<string> {
export interface SyncRoom { export interface SyncRoom {
meta: DBRoom meta: DBRoom
timeline: TimelineRowTuple[] timeline: TimelineRowTuple[] | null
events: RawDBEvent[] events: RawDBEvent[] | null
state: Record<EventType, Record<string, EventRowID>> state: Record<EventType, Record<string, EventRowID>> | null
reset: boolean reset: boolean
notifications: SyncNotification[] notifications: SyncNotification[] | null
account_data: Record<EventType, DBRoomAccountData> account_data: Record<EventType, DBRoomAccountData> | null
receipts: Record<EventID, DBReceipt[]> | null
} }
export interface SyncNotification { export interface SyncNotification {
@ -82,9 +87,12 @@ export interface SyncNotification {
} }
export interface SyncCompleteData { export interface SyncCompleteData {
rooms: Record<RoomID, SyncRoom> rooms: Record<RoomID, SyncRoom> | null
left_rooms: RoomID[] invited_rooms: DBInvitedRoom[] | null
account_data: Record<EventType, DBAccountData> left_rooms: RoomID[] | null
account_data: Record<EventType, DBAccountData> | null
space_edges: Record<RoomID, DBSpaceEdge[]> | null
top_level_spaces: RoomID[] | null
since?: string since?: string
clear_state?: boolean clear_state?: boolean
} }
@ -110,7 +118,7 @@ export interface ClientStateEvent extends BaseRPCCommand<ClientState> {
} }
export interface SyncStatus { export interface SyncStatus {
type: "ok" | "waiting" | "errored" type: "ok" | "waiting" | "erroring" | "permanently-failed"
error?: string error?: string
error_count: number error_count: number
last_sync?: number last_sync?: number

View file

@ -22,6 +22,7 @@ import {
EventID, EventID,
EventType, EventType,
LazyLoadSummary, LazyLoadSummary,
ReceiptType,
RelationType, RelationType,
RoomAlias, RoomAlias,
RoomID, RoomID,
@ -53,6 +54,7 @@ export interface DBRoom {
name_quality: RoomNameQuality name_quality: RoomNameQuality
avatar?: ContentURI avatar?: ContentURI
explicit_avatar: boolean explicit_avatar: boolean
dm_user_id?: UserID
topic?: string topic?: string
canonical_alias?: RoomAlias canonical_alias?: RoomAlias
lazy_load_summary?: LazyLoadSummary lazy_load_summary?: LazyLoadSummary
@ -70,9 +72,34 @@ export interface DBRoom {
prev_batch: string prev_batch: string
} }
export interface DBSpaceEdge {
// space_id: RoomID
child_id: RoomID
child_event_rowid?: EventRowID
order?: string
suggested?: true
parent_event_rowid?: EventRowID
canonical?: true
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any //eslint-disable-next-line @typescript-eslint/no-explicit-any
export type UnknownEventContent = Record<string, any> export type UnknownEventContent = Record<string, any>
export interface StrippedStateEvent {
type: EventType
sender: UserID
state_key: string
content: UnknownEventContent
}
export interface DBInvitedRoom {
room_id: RoomID
created_at: number
invite_state: StrippedStateEvent[]
}
export enum UnreadType { export enum UnreadType {
None = 0b0000, None = 0b0000,
Normal = 0b0001, Normal = 0b0001,
@ -145,8 +172,23 @@ export interface DBRoomAccountData {
content: UnknownEventContent content: UnknownEventContent
} }
export interface DBReceipt {
user_id: UserID
receipt_type: ReceiptType
thread_id?: EventID | "main"
event_id: EventID
timestamp: number
}
export interface MemReceipt extends DBReceipt {
event_rowid: EventRowID
timeline_rowid: TimelineRowID
}
export interface PaginationResponse { export interface PaginationResponse {
events: RawDBEvent[] events: RawDBEvent[]
receipts: Record<EventID, DBReceipt[]>
related_events: RawDBEvent[]
has_more: boolean has_more: boolean
} }
@ -242,3 +284,11 @@ export interface ProfileEncryptionInfo {
user_trusted: boolean user_trusted: boolean
errors: string[] errors: string[]
} }
export interface DBPushRegistration {
device_id: string
type: "fcm"
data: unknown
encryption: { key: string }
expiration?: number
}

View file

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

View file

@ -25,6 +25,14 @@ export type RoomVersion = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" |
export type RoomType = "" | "m.space" export type RoomType = "" | "m.space"
export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread" export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread"
export type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| {[key: string]: JSONValue}
export interface RoomPredecessor { export interface RoomPredecessor {
room_id: RoomID room_id: RoomID
event_id: EventID event_id: EventID
@ -43,7 +51,7 @@ export interface TombstoneEventContent {
} }
export interface LazyLoadSummary { export interface LazyLoadSummary {
heroes?: UserID[] "m.heroes"?: UserID[]
"m.joined_member_count"?: number "m.joined_member_count"?: number
"m.invited_member_count"?: number "m.invited_member_count"?: number
} }
@ -65,11 +73,24 @@ export interface EncryptedEventContent {
export interface UserProfile { export interface UserProfile {
displayname?: string displayname?: string
avatar_url?: ContentURI avatar_url?: ContentURI
avatar_file?: EncryptedFile
[custom: string]: unknown [custom: string]: unknown
} }
export interface PronounSet {
subject?: string
object?: string
possessive_determiner?: string
possessive_pronoun?: string
reflexive?: string
summary: string
language: string
}
export type Membership = "join" | "leave" | "ban" | "invite" | "knock"
export interface MemberEventContent extends UserProfile { export interface MemberEventContent extends UserProfile {
membership: "join" | "leave" | "ban" | "invite" | "knock" membership: Membership
reason?: string reason?: string
} }
@ -91,6 +112,12 @@ export interface ACLEventContent {
deny?: string[] deny?: string[]
} }
export interface PolicyRuleContent {
entity: string
reason: string
recommendation: string
}
export interface PowerLevelEventContent { export interface PowerLevelEventContent {
users?: Record<UserID, number> users?: Record<UserID, number>
users_default?: number users_default?: number
@ -138,6 +165,23 @@ export interface ContentWarning {
description?: string description?: string
} }
export interface URLPreview {
matched_url: string
"beeper:image:encryption"?: EncryptedFile
"matrix:image:size": number
"og:image"?: ContentURI
"og:url": string
"og:image:width"?: number
"og:image:height"?: number
"og:image:type"?: string
"og:title"?: string
"og:description"?: string
}
export interface BeeperPerMessageProfile extends UserProfile {
id: string
}
export interface BaseMessageEventContent { export interface BaseMessageEventContent {
msgtype: string msgtype: string
body: string body: string
@ -148,6 +192,9 @@ export interface BaseMessageEventContent {
"town.robin.msc3725.content_warning"?: ContentWarning "town.robin.msc3725.content_warning"?: ContentWarning
"page.codeberg.everypizza.msc4193.spoiler"?: boolean "page.codeberg.everypizza.msc4193.spoiler"?: boolean
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string "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 { export interface TextMessageEventContent extends BaseMessageEventContent {
@ -155,7 +202,7 @@ export interface TextMessageEventContent extends BaseMessageEventContent {
} }
export interface MediaMessageEventContent extends BaseMessageEventContent { export interface MediaMessageEventContent extends BaseMessageEventContent {
msgtype: "m.image" | "m.file" | "m.audio" | "m.video" msgtype: "m.sticker" | "m.image" | "m.file" | "m.audio" | "m.video"
filename?: string filename?: string
url?: ContentURI url?: ContentURI
file?: EncryptedFile file?: EncryptedFile
@ -235,3 +282,37 @@ export interface ImagePackRooms {
export interface ElementRecentEmoji { export interface ElementRecentEmoji {
recent_emoji: [string, number][] recent_emoji: [string, number][]
} }
export type JoinRule = "public" | "knock" | "restricted" | "knock_restricted" | "invite" | "private"
export interface RoomSummary {
room_id: RoomID
membership?: Membership
room_version?: RoomVersion
"im.nheko.summary.room_version"?: RoomVersion
"im.nheko.summary.version"?: RoomVersion
encryption?: "m.megolm.v1.aes-sha2"
"im.nheko.summary.encryption"?: "m.megolm.v1.aes-sha2"
avatar_url?: ContentURI
canonical_alias?: RoomAlias
guest_can_join: boolean
join_rule?: JoinRule
name?: string
num_joined_members: number
room_type: RoomType
topic?: string
world_readable: boolean
}
export interface RespRoomJoin {
room_id: RoomID
}
export interface RespOpenIDToken {
access_token: string
expires_in: number
matrix_server_name: string
token_type: "Bearer"
}

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import type { ContentURI } from "../../types" import type { ContentURI } from "../../types"
import { Preference, anyContext } from "./types.ts" import { Preference, anyContext, anyGlobalContext } from "./types.ts"
export const codeBlockStyles = [ export const codeBlockStyles = [
"auto", "abap", "algol_nu", "algol", "arduino", "autumn", "average", "base16-snazzy", "borland", "bw", "auto", "abap", "algol_nu", "algol", "arduino", "autumn", "average", "base16-snazzy", "borland", "bw",
@ -47,6 +47,12 @@ export const preferences = {
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: true, defaultValue: true,
}), }),
display_read_receipts: new Preference<boolean>({
displayName: "Display read receipts",
description: "Should read receipts be rendered in the timeline?",
allowedContexts: anyContext,
defaultValue: true,
}),
show_media_previews: new Preference<boolean>({ show_media_previews: new Preference<boolean>({
displayName: "Show image and video previews", displayName: "Show image and video previews",
description: "If disabled, images and videos will only be visible after clicking and will not be downloaded automatically.", description: "If disabled, images and videos will only be visible after clicking and will not be downloaded automatically.",
@ -96,6 +102,18 @@ export const preferences = {
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: true, defaultValue: true,
}), }),
render_url_previews: new Preference<boolean>({
displayName: "Render URL previews",
description: "Whether to render MSC4095 URL previews in the room timeline.",
allowedContexts: anyContext,
defaultValue: true,
}),
small_replies: new Preference<boolean>({
displayName: "Compact reply style",
description: "Whether to use a Discord-like compact style for replies instead of the traditional style.",
allowedContexts: anyContext,
defaultValue: false,
}),
show_date_separators: new Preference<boolean>({ show_date_separators: new Preference<boolean>({
displayName: "Show date separators", displayName: "Show date separators",
description: "Whether messages in different days should have a date separator between them in the room timeline.", description: "Whether messages in different days should have a date separator between them in the room timeline.",
@ -135,12 +153,42 @@ export const preferences = {
// allowedContexts: anyContext, // allowedContexts: anyContext,
// defaultValue: false, // defaultValue: false,
// }), // }),
message_context_menu: new Preference<boolean>({
displayName: "Right-click menu on messages",
description: "Show a context menu when right-clicking on messages.",
allowedContexts: anyContext,
defaultValue: true,
}),
ctrl_enter_send: new Preference<boolean>({
displayName: "Use Ctrl+Enter to send",
description: "Disable sending on enter and use Ctrl+Enter for sending instead",
allowedContexts: anyContext,
defaultValue: false,
}),
custom_notification_sound: new Preference<ContentURI>({ custom_notification_sound: new Preference<ContentURI>({
displayName: "Custom notification sound", displayName: "Custom notification sound",
description: "The mxc:// URI to a custom notification sound.", description: "The mxc:// URI to a custom notification sound.",
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: "", defaultValue: "",
}), }),
room_window_title: new Preference<string>({
displayName: "In-room window title",
description: "The title to use for the window when viewing a room. $room will be replaced with the room name",
allowedContexts: anyContext,
defaultValue: "$room - gomuks web",
}),
window_title: new Preference<string>({
displayName: "Default window title",
description: "The title to use for the window when not in a room.",
allowedContexts: anyGlobalContext,
defaultValue: "gomuks web",
}),
favicon: new Preference<string>({
displayName: "Favicon",
description: "The URL to use for the favicon.",
allowedContexts: anyContext,
defaultValue: "gomuks.png",
}),
} as const } as const
export const existingPreferenceKeys = new Set(Object.keys(preferences)) export const existingPreferenceKeys = new Set(Object.keys(preferences))

View file

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

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="m354-720-80-80h566v80H354Zm526 520-80-80v-280H640v126l-80-80v-86q0-17 11.5-28.5T600-640h240q17 0 28.5 11.5T880-600v400ZM792-56 688-160h-88q-17 0-28.5-11.5T560-200v-88L240-608v328h240v120H80v-120h80v-408L56-792l56-56 736 736-56 56Zm-72-301Z"/></svg>

After

Width:  |  Height:  |  Size: 365 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-160v-120h80v-440q0-33 23.5-56.5T240-800h600v80H240v440h240v120H80Zm520 0q-17 0-28.5-11.5T560-200v-400q0-17 11.5-28.5T600-640h240q17 0 28.5 11.5T880-600v400q0 17-11.5 28.5T840-160H600Zm40-120h160v-280H640v280Zm0 0h160-160Z"/></svg>

After

Width:  |  Height:  |  Size: 351 B

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

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

After

Width:  |  Height:  |  Size: 244 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M160-200v-80h80v-280q0-33 8.5-65t25.5-61l60 60q-7 16-10.5 32.5T320-560v280h248L56-792l56-56 736 736-56 56-146-144H160Zm560-154-80-80v-126q0-66-47-113t-113-47q-26 0-50 8t-44 24l-58-58q20-16 43-28t49-18v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v206Zm-276-50Zm36 324q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80Zm33-481Z"/></svg>

After

Width:  |  Height:  |  Size: 482 B

View file

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

After

Width:  |  Height:  |  Size: 480 B

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-200v-80h80v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v280h80v80H160Zm320-300Zm0 420q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80ZM320-280h320v-280q0-66-47-113t-113-47q-66 0-113 47t-47 113v280Z"/></svg>

After

Width:  |  Height:  |  Size: 388 B

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

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

After

Width:  |  Height:  |  Size: 549 B

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

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

After

Width:  |  Height:  |  Size: 322 B

1
web/src/icons/thread.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="M554-120q-54 0-91-37t-37-89q0-76 61.5-137.5T641-460q-3-36-18-54.5T582-533q-30 0-65 25t-83 82q-78 93-114.5 121T241-277q-51 0-86-38t-35-92q0-54 23.5-110.5T223-653q19-26 28-44t9-29q0-7-2.5-10.5T250-740q-10 0-25 12.5T190-689l-70-71q32-39 65-59.5t65-20.5q46 0 78 32t32 80q0 29-15 64t-50 84q-38 54-56.5 95T220-413q0 17 5.5 26.5T241-377q10 0 17.5-5.5T286-409q13-14 31-34.5t44-50.5q63-75 114-107t107-32q67 0 110 45t49 123h99v100h-99q-8 112-58.5 178.5T554-120Zm2-100q32 0 54-36.5T640-358q-46 11-80 43.5T526-250q0 14 8 22t22 8Z"/></svg>

After

Width:  |  Height:  |  Size: 643 B

View file

@ -11,6 +11,7 @@
--semisecondary-text-color: #555; --semisecondary-text-color: #555;
--link-text-color: #0467dd; --link-text-color: #0467dd;
--visited-link-text-color: var(--link-text-color); --visited-link-text-color: var(--link-text-color);
--small-font-size: .875rem;
--code-background-color: rgba(0, 0, 0, 0.15); --code-background-color: rgba(0, 0, 0, 0.15);
--media-placeholder-default-background: rgba(0, 0, 0, .1); --media-placeholder-default-background: rgba(0, 0, 0, .1);
@ -22,11 +23,14 @@
--border-color: #ccc; --border-color: #ccc;
--pill-background-color: #ccc; --pill-background-color: #ccc;
--url-preview-background-color: rgba(0, 0, 0, .05);
--highlight-pill-background-color: #c00; --highlight-pill-background-color: #c00;
--highlight-pill-text-color: #fff; --highlight-pill-text-color: #fff;
--button-hover-color: rgba(0, 0, 0, .2); --button-hover-color: rgba(0, 0, 0, .2);
--light-hover-color: rgba(0, 0, 0, .1); --light-hover-color: rgba(0, 0, 0, .1);
--composer-background-color: #f0f0f0;
--timeline-hover-bg-color: #eee; --timeline-hover-bg-color: #eee;
--timeline-highlight-bg-color: rgba(255, 255, 0, .1); --timeline-highlight-bg-color: rgba(255, 255, 0, .1);
--timeline-highlight-hover-bg-color: #eec; --timeline-highlight-hover-bg-color: #eec;
@ -42,7 +46,7 @@
--room-list-entry-selected-color: rgba(0, 0, 0, 0.125); --room-list-entry-selected-color: rgba(0, 0, 0, 0.125);
--dimmed-overlay-background-color: rgba(0, 0, 0, .75); --dimmed-overlay-background-color: rgba(0, 0, 0, .75);
--modal-box-shadow-color: rgba(0, 0, 0, 0.15); --modal-box-shadow-color: rgba(0, 0, 0, 0.1);
--emoji-selected-border-color: #cec; --emoji-selected-border-color: #cec;
@ -51,6 +55,9 @@
--unread-counter-notification-bg: rgba(50, 150, 0, 0.7); --unread-counter-notification-bg: rgba(50, 150, 0, 0.7);
--unread-counter-marked-unread-bg: var(--unread-counter-notification-bg); --unread-counter-marked-unread-bg: var(--unread-counter-notification-bg);
--unread-counter-highlight-bg: rgba(200, 0, 0, 0.7); --unread-counter-highlight-bg: rgba(200, 0, 0, 0.7);
--space-unread-counter-message-bg: rgb(100, 100, 100, 0.9);
--space-unread-counter-notification-bg: rgb(50, 150, 0);
--space-unread-counter-highlight-bg: rgb(200, 0, 0);
--sender-color-0: #a4041d; --sender-color-0: #a4041d;
--sender-color-1: #9b2200; --sender-color-1: #9b2200;
@ -79,6 +86,13 @@
--timeline-message-gap-small-event: 0; --timeline-message-gap-small-event: 0;
--timeline-sender-name-timestamp-gap: .25rem; --timeline-sender-name-timestamp-gap: .25rem;
--timeline-sender-name-content-gap: 0; --timeline-sender-name-content-gap: 0;
--timeline-horizontal-padding: 1.5rem;
--timeline-status-size: 4rem;
@media screen and (max-width: 45rem) {
--timeline-horizontal-padding: .5rem;
--timeline-status-size: 2.25rem;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
color-scheme: dark; color-scheme: dark;
@ -100,9 +114,12 @@
--border-color: #222; --border-color: #222;
--pill-background-color: #222; --pill-background-color: #222;
--url-preview-background-color: #222;
--button-hover-color: rgba(255, 255, 255, .2); --button-hover-color: rgba(255, 255, 255, .2);
--light-hover-color: rgba(255, 255, 255, .1); --light-hover-color: rgba(255, 255, 255, .1);
--composer-background-color: #0a0a0a;
--timeline-hover-bg-color: #111; --timeline-hover-bg-color: #111;
--timeline-highlight-bg-color: rgba(255, 255, 0, .1); --timeline-highlight-bg-color: rgba(255, 255, 0, .1);
--timeline-highlight-hover-bg-color: #331; --timeline-highlight-hover-bg-color: #331;
@ -115,13 +132,16 @@
--room-list-entry-hover-color: rgba(255, 255, 255, 0.075); --room-list-entry-hover-color: rgba(255, 255, 255, 0.075);
--room-list-entry-selected-color: rgba(255, 255, 255, 0.125); --room-list-entry-selected-color: rgba(255, 255, 255, 0.125);
--modal-box-shadow-color: rgba(255, 255, 255, 0.1); --modal-box-shadow-color: rgba(255, 255, 255, 0.04);
--emoji-selected-border-color: #131; --emoji-selected-border-color: #131;
--unread-counter-message-bg: rgba(255, 255, 255, 0.5); --unread-counter-message-bg: rgba(255, 255, 255, 0.5);
--unread-counter-notification-bg: rgba(150, 255, 0, 0.7); --unread-counter-notification-bg: rgba(150, 255, 0, 0.7);
--unread-counter-highlight-bg: rgba(255, 50, 50, 0.7); --unread-counter-highlight-bg: rgba(255, 50, 50, 0.7);
--space-unread-counter-message-bg: rgb(200, 200, 200, 0.8);
--space-unread-counter-notification-bg: rgb(150, 255, 0);
--space-unread-counter-highlight-bg: rgb(255, 50, 50);
--sender-color-0: #ff877c; --sender-color-0: #ff877c;
--sender-color-1: #f6913d; --sender-color-1: #f6913d;
@ -144,15 +164,17 @@ body {
font-family: var(--font-stack); font-family: var(--font-stack);
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: var(--login-background-color); background-color: var(--background-color);
line-height: 1.5; line-height: 1.5;
font-size: 16px; font-size: 16px;
touch-action: none; touch-action: none;
color: var(--text-color); color: var(--text-color);
min-height: 100vh;
} }
html { html {
touch-action: none; touch-action: none;
background-color: var(--background-color);
} }
#root { #root {
@ -232,9 +254,15 @@ div.connection-error-wrapper {
} }
} }
div.pre-connect { div.pre-main {
margin-top: 2rem; position: fixed;
text-align: center; inset: 0;
background-color: var(--login-background-color);
&.waiting-to-connect {
padding-top: 2rem;
text-align: center;
}
} }
a { a {

View file

@ -1,5 +1,5 @@
main.matrix-main { main.matrix-main {
--room-list-width: 300px; --room-list-width: 350px;
--right-panel-width: 300px; --right-panel-width: 300px;
position: fixed; position: fixed;
@ -16,35 +16,36 @@ main.matrix-main {
/ var(--room-list-width) 0 1fr 0 var(--right-panel-width); / var(--room-list-width) 0 1fr 0 var(--right-panel-width);
} }
@media screen and (max-width: 750px) { @media screen and (max-width: 45rem) {
&, &.right-panel-open {
grid-template:
"roomlist roomview rightpanel" 1fr
/ 100% 100% 100%;
}
/* Note: this timeout must match the one in MainScreen.tsx */
transition: .3s;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
&.room-selected {
translate: -100% 0;
}
&.right-panel-open { &.right-panel-open {
grid-template: "rightpanel" 1fr / 1fr; translate: -200% 0;
> div.room-list-wrapper {
display: none;
}
> div.room-view {
display: none;
}
}
&.room-selected:not(.right-panel-open) {
grid-template: "roomview" 1fr / 1fr;
> div.room-list-wrapper {
display: none;
}
}
&:not(.room-selected):not(.right-panel-open) {
grid-template: "roomlist" 1fr / 1fr;
} }
} }
> div.room-list-resizer { > div.room-list-resizer {
grid-area: rh1; grid-area: rh1;
z-index: 1;
} }
> div.right-panel-resizer { > div.right-panel-resizer {
grid-area: rh2; grid-area: rh2;
z-index: 1;
} }
} }

View file

@ -13,20 +13,21 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX, use, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useState } from "react" import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react"
import { SyncLoader } from "react-spinners" import { SyncLoader } from "react-spinners"
import Client from "@/api/client.ts" import Client from "@/api/client.ts"
import { RoomStateStore } from "@/api/statestore" import { RoomListFilter, RoomStateStore } from "@/api/statestore"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts" import { useEventAsState } from "@/util/eventdispatcher.ts"
import { parseMatrixURI } from "@/util/validation.ts" import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts"
import ClientContext from "./ClientContext.ts" import ClientContext from "./ClientContext.ts"
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts" import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
import StylePreferences from "./StylePreferences.tsx" import StylePreferences from "./StylePreferences.tsx"
import Keybindings from "./keybindings.ts" import Keybindings from "./keybindings.ts"
import { ModalWrapper } from "./modal/Modal.tsx" import { ModalWrapper } from "./modal"
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx" import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
import RoomList from "./roomlist/RoomList.tsx" import RoomList from "./roomlist/RoomList.tsx"
import RoomPreview, { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
import RoomView from "./roomview/RoomView.tsx" import RoomView from "./roomview/RoomView.tsx"
import { useResizeHandle } from "./util/useResizeHandle.tsx" import { useResizeHandle } from "./util/useResizeHandle.tsx"
import "./MainScreen.css" import "./MainScreen.css"
@ -50,7 +51,8 @@ class ContextFields implements MainScreenContextFields {
constructor( constructor(
private directSetRightPanel: (props: RightPanelProps | null) => void, private directSetRightPanel: (props: RightPanelProps | null) => void,
private directSetActiveRoom: (room: RoomStateStore | null) => void, private directSetActiveRoom: (room: RoomStateStore | RoomPreviewProps | null) => void,
private directSetSpace: (space: RoomListFilter | null) => void,
private client: Client, private client: Client,
) { ) {
this.keybindings = new Keybindings(client.store, this) this.keybindings = new Keybindings(client.store, this)
@ -94,33 +96,115 @@ class ContextFields implements MainScreenContextFields {
} }
} }
setActiveRoom = (roomID: RoomID | null, pushState = true) => { setActiveRoom = (
roomID: RoomID | null,
previewMeta?: Partial<RoomPreviewProps>,
toSpace?: RoomListFilter,
pushState = true,
) => {
console.log("Switching to room", roomID) console.log("Switching to room", roomID)
const room = (roomID && this.client.store.rooms.get(roomID)) || null if (roomID) {
const room = this.client.store.rooms.get(roomID)
if (room) {
this.#setActiveRoom(room, toSpace, pushState)
} else {
this.#setPreviewRoom(roomID, pushState, previewMeta)
}
} else {
this.#closeActiveRoom(pushState)
}
}
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)
this.directSetActiveRoom({ roomID, ...(meta ?? {}), invite })
this.client.store.activeRoomID = roomID
this.client.store.activeRoomIsPreview = true
if (pushState) {
history.pushState({
room_id: roomID,
source_via: meta?.via,
source_alias: meta?.alias,
space_id: history.state?.space_id,
}, "")
}
}
#getWindowTitle(room?: RoomStateStore, name?: string) {
if (!room) {
return this.client.store.preferences.window_title
}
return room.preferences.room_window_title.replace("$room", name!)
}
#setActiveRoom(room: RoomStateStore, space: RoomListFilter | undefined | null, pushState: boolean) {
window.activeRoom = room window.activeRoom = room
this.directSetActiveRoom(room) this.directSetActiveRoom(room)
this.directSetRightPanel(null) this.directSetRightPanel(null)
this.rightPanelStack = [] if (!space && this.client.store.currentRoomListFilter) {
this.client.store.activeRoomID = room?.roomID ?? null const roomListEntry = this.client.store.roomListEntries.get(room.roomID)
this.keybindings.activeRoom = room if (roomListEntry && !this.client.store.currentRoomListFilter.include(roomListEntry)) {
if (room) { space = this.client.store.findMatchingSpace(roomListEntry)
room.lastOpened = Date.now()
if (!room.stateLoaded) {
this.client.loadRoomState(room.roomID)
.catch(err => console.error("Failed to load room state", err))
} }
document
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
?.scrollIntoView({ block: "nearest" })
} }
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
this.keybindings.activeRoom = room
room.lastOpened = Date.now()
if (!room.stateLoaded) {
this.client.loadRoomState(room.roomID)
.catch(err => console.error("Failed to load room state", err))
}
document
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
?.scrollIntoView({ block: "nearest" })
if (pushState) { if (pushState) {
history.pushState({ room_id: roomID }, "") history.pushState({ room_id: room.roomID, space_id: space?.id ?? history.state?.space_id }, "")
} }
let roomNameForTitle = room?.meta.current.name let roomNameForTitle = room.meta.current.name
if (roomNameForTitle && roomNameForTitle.length > 48) { if (roomNameForTitle && roomNameForTitle.length > 48) {
roomNameForTitle = roomNameForTitle.slice(0, 45) + "…" roomNameForTitle = roomNameForTitle.slice(0, 45) + "…"
} }
document.title = roomNameForTitle ? `${roomNameForTitle} - gomuks web` : "gomuks web" document.title = this.#getWindowTitle(room, roomNameForTitle)
}
#closeActiveRoom(pushState: boolean) {
window.activeRoom = null
this.directSetActiveRoom(null)
this.directSetRightPanel(null)
this.rightPanelStack = []
this.client.store.activeRoomID = null
this.client.store.activeRoomIsPreview = false
this.keybindings.activeRoom = null
if (pushState) {
history.pushState({ space_id: history.state?.space_id }, "")
}
document.title = this.#getWindowTitle()
} }
clickRoom = (evt: React.MouseEvent) => { clickRoom = (evt: React.MouseEvent) => {
@ -133,6 +217,7 @@ class ContextFields implements MainScreenContextFields {
} }
clickRightPanelOpener = (evt: React.MouseEvent) => { clickRightPanelOpener = (evt: React.MouseEvent) => {
evt.preventDefault()
const type = evt.currentTarget.getAttribute("data-target-panel") const type = evt.currentTarget.getAttribute("data-target-panel")
if (type === "pinned-messages" || type === "members") { if (type === "pinned-messages" || type === "members") {
this.setRightPanel({ type }) this.setRightPanel({ type })
@ -149,8 +234,11 @@ class ContextFields implements MainScreenContextFields {
const SYNC_ERROR_HIDE_DELAY = 30 * 1000 const SYNC_ERROR_HIDE_DELAY = 30 * 1000
const handleURLHash = (client: Client) => { const handleURLHash = (client: Client, context: MainScreenContextFields, hashOnly = false) => {
if (!location.hash.startsWith("#/uri/")) { if (!location.hash.startsWith("#/uri/")) {
if (hashOnly) {
return null
}
if (location.search) { if (location.search) {
const currentETag = ( const currentETag = (
document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement
@ -164,7 +252,9 @@ const handleURLHash = (client: Client) => {
} }
const state = JSON.parse(newURL.searchParams.get("state") || "{}") const state = JSON.parse(newURL.searchParams.get("state") || "{}")
newURL.search = "" newURL.search = ""
history.replaceState(state, "", newURL.toString()) // Set an extra empty state to ensure back button goes to room list instead of reloading the page.
history.replaceState({}, "", newURL.toString())
history.pushState(state, "")
return state return state
} }
return history.state return history.state
@ -174,7 +264,7 @@ const handleURLHash = (client: Client) => {
const uri = parseMatrixURI(decodedURI) const uri = parseMatrixURI(decodedURI)
if (!uri) { if (!uri) {
console.error("Invalid matrix URI", decodedURI) console.error("Invalid matrix URI", decodedURI)
return history.state return hashOnly ? null : history.state
} }
console.log("Handling URI", uri) console.log("Handling URI", uri)
const newURL = new URL(location.href) const newURL = new URL(location.href)
@ -190,47 +280,82 @@ const handleURLHash = (client: Client) => {
history.replaceState(newState, "", newURL.toString()) history.replaceState(newState, "", newURL.toString())
return newState return newState
} else if (uri.identifier.startsWith("!")) { } else if (uri.identifier.startsWith("!")) {
const newState = { room_id: uri.identifier } const newState = { room_id: uri.identifier, source_via: uri.params.getAll("via") }
history.replaceState(newState, "", newURL.toString()) history.replaceState(newState, "", newURL.toString())
return newState return newState
} else if (uri.identifier.startsWith("#")) { } else if (uri.identifier.startsWith("#")) {
history.replaceState(history.state, "", newURL.toString())
// TODO loading indicator or something for this? // TODO loading indicator or something for this?
client.rpc.resolveAlias(uri.identifier).then( client.rpc.resolveAlias(uri.identifier).then(
res => { res => {
history.pushState({ room_id: res.room_id }, "", newURL.toString()) context.setActiveRoom(res.room_id, {
alias: uri.identifier,
via: res.servers.slice(0, 3),
})
}, },
err => window.alert(`Failed to resolve room alias ${uri.identifier}: ${err}`), err => window.alert(`Failed to resolve room alias ${uri.identifier}: ${err}`),
) )
return null return null
} else { } else {
console.error("Invalid matrix URI", uri) console.error("Invalid matrix URI", uri)
history.replaceState(history.state, "", newURL.toString())
}
return hashOnly ? null : history.state
}
type ActiveRoomType = [RoomStateStore | RoomPreviewProps | null, RoomStateStore | RoomPreviewProps | null]
const activeRoomReducer = (
prev: ActiveRoomType,
active: RoomStateStore | RoomPreviewProps | "clear-animation" | null,
): ActiveRoomType => {
if (active === "clear-animation") {
return prev[1] === null ? [null, null] : prev
} else if (window.innerWidth > 720 || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return [null, active]
} else {
return [prev[1], active]
} }
return history.state
} }
const MainScreen = () => { const MainScreen = () => {
const [activeRoom, directSetActiveRoom] = useState<RoomStateStore | null>(null) 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 [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(null)
const client = use(ClientContext)! const client = use(ClientContext)!
const syncStatus = useEventAsState(client.syncStatus) const syncStatus = useEventAsState(client.syncStatus)
const context = useMemo( const context = useMemo(
() => new ContextFields(directSetRightPanel, directSetActiveRoom, client), () => new ContextFields(directSetRightPanel, directSetActiveRoom, directSetSpace, client),
[client], [client],
) )
useLayoutEffect(() => {
window.mainScreenContext = context
}, [context])
useEffect(() => { useEffect(() => {
const listener = (evt: PopStateEvent) => { window.mainScreenContext = context
const listener = (evt: Pick<PopStateEvent, "state" | "hasUAVisualTransition">) => {
skipNextTransitionRef.current = evt.hasUAVisualTransition
const roomID = evt.state?.room_id ?? null const roomID = evt.state?.room_id ?? null
const spaceID = evt.state?.space_id ?? undefined
if (spaceID !== client.store.currentRoomListFilter?.id) {
context.setSpace(client.store.getSpaceByID(spaceID), false)
}
if (roomID !== client.store.activeRoomID) { if (roomID !== client.store.activeRoomID) {
context.setActiveRoom(roomID, false) context.setActiveRoom(roomID, {
alias: ensureString(evt.state?.source_alias) || undefined,
via: ensureStringArray(evt.state?.source_via),
}, undefined, false)
} }
context.setRightPanel(evt.state?.right_panel ?? null, false) context.setRightPanel(evt.state?.right_panel ?? null, false)
} }
const hashListener = () => {
const state = handleURLHash(client, context, true)
if (state !== null) {
listener({ state, hasUAVisualTransition: false })
}
}
window.addEventListener("hashchange", hashListener)
window.addEventListener("popstate", listener) window.addEventListener("popstate", listener)
const initHandle = () => { const initHandle = () => {
const state = handleURLHash(client) const state = handleURLHash(client, context)
listener({ state } as PopStateEvent) listener({ state } as PopStateEvent)
} }
let cancel = () => {} let cancel = () => {}
@ -241,33 +366,27 @@ const MainScreen = () => {
} }
return () => { return () => {
window.removeEventListener("popstate", listener) window.removeEventListener("popstate", listener)
window.removeEventListener("hashchange", hashListener)
cancel() cancel()
} }
}, [context, client]) }, [context, client])
useEffect(() => context.keybindings.listen(), [context]) useEffect(() => context.keybindings.listen(), [context])
useInsertionEffect(() => {
const styleTags = document.createElement("style")
styleTags.textContent = `
div.html-body > a.hicli-matrix-uri-user[href="matrix:u/${client.userID.slice(1).replaceAll(`"`, `\\"`)}"] {
background-color: var(--highlight-pill-background-color);
color: var(--highlight-pill-text-color);
}
`
document.head.appendChild(styleTags)
return () => {
document.head.removeChild(styleTags)
}
}, [client.userID])
const [roomListWidth, resizeHandle1] = useResizeHandle( const [roomListWidth, resizeHandle1] = useResizeHandle(
300, 48, 900, "roomListWidth", { className: "room-list-resizer" }, 350, 96, Math.min(900, window.innerWidth * 0.4),
"roomListWidth", { className: "room-list-resizer" },
) )
const [rightPanelWidth, resizeHandle2] = useResizeHandle( const [rightPanelWidth, resizeHandle2] = useResizeHandle(
300, 100, 900, "rightPanelWidth", { className: "right-panel-resizer", inverted: true }, 300, 100, Math.min(900, window.innerWidth * 0.4),
"rightPanelWidth", { className: "right-panel-resizer", inverted: true },
) )
const extraStyle = { const extraStyle = {
["--room-list-width" as string]: `${roomListWidth}px`, ["--room-list-width" as string]: `${roomListWidth}px`,
["--right-panel-width" as string]: `${rightPanelWidth}px`, ["--right-panel-width" as string]: `${rightPanelWidth}px`,
} }
if (skipNextTransitionRef.current) {
extraStyle["transition"] = "none"
skipNextTransitionRef.current = false
}
const classNames = ["matrix-main"] const classNames = ["matrix-main"]
if (activeRoom) { if (activeRoom) {
classNames.push("room-selected") classNames.push("room-selected")
@ -282,27 +401,42 @@ const MainScreen = () => {
Waiting for first sync... Waiting for first sync...
</div> </div>
} else if ( } else if (
syncStatus.type === "errored" syncStatus.type === "erroring"
&& (syncStatus.error_count > 2 || (syncStatus.last_sync ?? 0) + SYNC_ERROR_HIDE_DELAY < Date.now()) && (syncStatus.error_count > 2 || (syncStatus.last_sync ?? 0) + SYNC_ERROR_HIDE_DELAY < Date.now())
) { ) {
syncLoader = <div className="sync-status errored" title={syncStatus.error}> syncLoader = <div className="sync-status errored" title={syncStatus.error}>
<SyncLoader color="var(--error-color)"/> <SyncLoader color="var(--error-color)"/>
Sync is failing Sync is failing
</div> </div>
} else if (syncStatus.type === "permanently-failed") {
syncLoader = <div className="sync-status errored" title={syncStatus.error}>
Sync failed permanently
</div>
} }
const activeRealRoom = activeRoom instanceof RoomStateStore ? activeRoom : null
const renderedRoom = activeRoom ?? prevActiveRoom
useEffect(() => {
if (prevActiveRoom !== null && activeRoom === null) {
// Note: this timeout must match the one in MainScreen.css
const timeout = setTimeout(() => directSetActiveRoom("clear-animation"), 300)
return () => clearTimeout(timeout)
}
}, [activeRoom, prevActiveRoom])
return <MainScreenContext value={context}> return <MainScreenContext value={context}>
<ModalWrapper> <ModalWrapper>
<StylePreferences client={client} activeRoom={activeRoom}/> <StylePreferences client={client} activeRoom={activeRealRoom}/>
<main className={classNames.join(" ")} style={extraStyle}> <main className={classNames.join(" ")} style={extraStyle}>
<RoomList activeRoomID={activeRoom?.roomID ?? null}/> <RoomList activeRoomID={activeRoom?.roomID ?? null} space={space}/>
{resizeHandle1} {resizeHandle1}
{activeRoom {renderedRoom
? <RoomView ? renderedRoom instanceof RoomStateStore
key={activeRoom.roomID} ? <RoomView
room={activeRoom} key={renderedRoom.roomID}
rightPanel={rightPanel} room={renderedRoom}
rightPanelResizeHandle={resizeHandle2} rightPanel={rightPanel}
/> rightPanelResizeHandle={resizeHandle2}
/>
: <RoomPreview {...renderedRoom} />
: rightPanel && <> : rightPanel && <>
<div className="room-view placeholder"/> <div className="room-view placeholder"/>
{resizeHandle2} {resizeHandle2}

View file

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

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