Merge branch 'main' into terminal
1
.github/workflows/go.yml
vendored
|
@ -21,6 +21,7 @@ jobs:
|
|||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
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 honnef.co/go/tools/cmd/staticcheck@latest
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
stages:
|
||||
- frontend
|
||||
- build
|
||||
- build desktop
|
||||
- docker
|
||||
|
||||
default:
|
||||
|
@ -39,7 +40,7 @@ frontend:
|
|||
- 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'"
|
||||
script:
|
||||
- go build -ldflags "$GO_LDFLAGS" -o gomuks ./cmd/gomuks
|
||||
- go build -ldflags "$GO_LDFLAGS" ./cmd/gomuks
|
||||
artifacts:
|
||||
paths:
|
||||
- gomuks
|
||||
|
@ -81,6 +82,16 @@ linux/arm64:
|
|||
- linux
|
||||
- arm64
|
||||
|
||||
windows/amd64:
|
||||
<<: *build-linux
|
||||
image: dock.mau.dev/tulir/gomuks-build-docker:windows-amd64
|
||||
artifacts:
|
||||
paths:
|
||||
- gomuks.exe
|
||||
tags:
|
||||
- linux
|
||||
- amd64
|
||||
|
||||
macos/arm64:
|
||||
stage: build
|
||||
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 push $MANIFEST_NAME
|
||||
- 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
|
||||
|
|
|
@ -21,7 +21,7 @@ repos:
|
|||
- id: go-staticcheck-repo-mod
|
||||
|
||||
- repo: https://github.com/beeper/pre-commit-go
|
||||
rev: v0.3.1
|
||||
rev: v0.4.2
|
||||
hooks:
|
||||
- id: prevent-literal-http-methods
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM alpine:3.20
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates jq curl
|
||||
|
||||
|
|
1
desktop/.gitignore
vendored
|
@ -1,2 +1,3 @@
|
|||
.task
|
||||
bin
|
||||
build/appimage
|
||||
|
|
|
@ -1,448 +1,54 @@
|
|||
version: '3'
|
||||
|
||||
includes:
|
||||
common: ./build/Taskfile.common.yml
|
||||
windows: ./build/Taskfile.windows.yml
|
||||
darwin: ./build/Taskfile.darwin.yml
|
||||
linux: ./build/Taskfile.linux.yml
|
||||
|
||||
vars:
|
||||
APP_NAME: "gomuks-desktop"
|
||||
BIN_DIR: "bin"
|
||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||
|
||||
tasks:
|
||||
|
||||
## -------------------------- Build -------------------------- ##
|
||||
|
||||
build:
|
||||
summary: Builds the application
|
||||
cmds:
|
||||
# Build for current OS
|
||||
- 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 -------------------------- ##
|
||||
- task: "{{OS}}:build"
|
||||
|
||||
package:
|
||||
summary: Packages a production build of the application into a bundle
|
||||
summary: Packages a production build of the application
|
||||
cmds:
|
||||
|
||||
# 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 ----------------------- #
|
||||
|
||||
- task: "{{OS}}:package"
|
||||
|
||||
run:
|
||||
summary: Runs the application
|
||||
cmds:
|
||||
- task: run:{{OS}}
|
||||
|
||||
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
|
||||
- task: "{{OS}}:run"
|
||||
|
||||
dev:
|
||||
summary: Runs the application in development mode
|
||||
cmds:
|
||||
- wails3 dev -config ./build/devmode.config.yaml -port {{.VITE_PORT}}
|
||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||
|
||||
dev:reload:
|
||||
summary: Reloads the application
|
||||
darwin:build:universal:
|
||||
summary: Builds darwin universal binary (arm64 + amd64)
|
||||
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
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2024, Tulir Asokan</string>
|
||||
<string>© 2024, gomuks authors</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
|
|
|
@ -22,6 +22,6 @@
|
|||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2024, Tulir Asokan</string>
|
||||
<string>© 2024, gomuks authors</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
75
desktop/build/Taskfile.common.yml
Normal 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 .
|
47
desktop/build/Taskfile.darwin.yml
Normal 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}}'
|
117
desktop/build/Taskfile.linux.yml
Normal 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}}'
|
62
desktop/build/Taskfile.windows.yml
Normal 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
|
@ -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: []
|
|
@ -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
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
@ -6,8 +6,8 @@
|
|||
"0000": {
|
||||
"ProductVersion": "0.4.0",
|
||||
"CompanyName": "",
|
||||
"FileDescription": "",
|
||||
"LegalCopyright": "© 2024, Tulir Asokan",
|
||||
"FileDescription": "A Matrix client written in Go and React",
|
||||
"LegalCopyright": "© 2024, gomuks authors",
|
||||
"ProductName": "gomuks desktop",
|
||||
"Comments": ""
|
||||
}
|
||||
|
|
50
desktop/build/nfpm/nfpm.yaml
Normal 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
|
1
desktop/build/nfpm/scripts/postinstall.sh
Normal file
|
@ -0,0 +1 @@
|
|||
#!/bin/bash
|
1
desktop/build/nfpm/scripts/postremove.sh
Normal file
|
@ -0,0 +1 @@
|
|||
#!/bin/bash
|
1
desktop/build/nfpm/scripts/preinstall.sh
Normal file
|
@ -0,0 +1 @@
|
|||
#!/bin/bash
|
1
desktop/build/nfpm/scripts/preremove.sh
Normal file
|
@ -0,0 +1 @@
|
|||
#!/bin/bash
|
|
@ -20,10 +20,10 @@ Unicode true
|
|||
## 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_COMPANYNAME "My Company" # Default "My Company"
|
||||
## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product"
|
||||
## !define INFO_COMPANYNAME "My Company" # Default ""
|
||||
## !define INFO_PRODUCTNAME "My Product Name" # Default "gomuks desktop"
|
||||
## !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 UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
|
@ -91,6 +91,8 @@ Section
|
|||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
|
||||
!insertmacro wails.associateFiles
|
||||
|
||||
!insertmacro wails.writeUninstaller
|
||||
SectionEnd
|
||||
|
||||
|
@ -104,5 +106,7 @@ Section "uninstall"
|
|||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
!insertmacro wails.unassociateFiles
|
||||
|
||||
!insertmacro wails.deleteUninstaller
|
||||
SectionEnd
|
||||
|
|
|
@ -8,16 +8,16 @@
|
|||
!define INFO_PROJECTNAME "gomuks-desktop"
|
||||
!endif
|
||||
!ifndef INFO_COMPANYNAME
|
||||
!define INFO_COMPANYNAME "My Company"
|
||||
!define INFO_COMPANYNAME ""
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTNAME
|
||||
!define INFO_PRODUCTNAME "My Product"
|
||||
!define INFO_PRODUCTNAME "gomuks desktop"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "0.1.0"
|
||||
!define INFO_PRODUCTVERSION "0.4.0"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "© now, My Company"
|
||||
!define INFO_COPYRIGHT "© 2024, gomuks authors"
|
||||
!endif
|
||||
!ifndef PRODUCT_EXECUTABLE
|
||||
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||
|
@ -177,3 +177,36 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
|||
SetDetailsPrint both
|
||||
ok:
|
||||
!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
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?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">
|
||||
<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>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
|
|
|
@ -4,37 +4,38 @@ go 1.23.0
|
|||
|
||||
toolchain go1.23.3
|
||||
|
||||
require github.com/wailsapp/wails/v3 v3.0.0-alpha.7
|
||||
require github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3
|
||||
|
||||
require (
|
||||
go.mau.fi/gomuks v0.3.1
|
||||
go.mau.fi/util v0.8.3-0.20241207221539-07bba6a0c5eb
|
||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.0.0 // indirect
|
||||
github.com/adrg/xdg v0.5.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/buckket/go-blurhash v1.1.0 // indirect
|
||||
github.com/chzyer/readline v1.5.1 // indirect
|
||||
github.com/cloudflare/circl v1.3.7 // indirect
|
||||
github.com/cloudflare/circl v1.3.8 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.2.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/ebitengine/purego v0.4.0-alpha.4 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.5.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.11.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.12.0 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/uuid v1.4.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
|
@ -45,38 +46,38 @@ require (
|
|||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/rs/zerolog v1.33.0 // indirect
|
||||
github.com/samber/lo v1.38.1 // indirect
|
||||
github.com/sergi/go-diff v1.2.0 // indirect
|
||||
github.com/skeema/knownhosts v1.2.1 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.2.2 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.15 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.18 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
go.mau.fi/zeroconfig v0.1.3 // indirect
|
||||
golang.org/x/crypto v0.29.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect
|
||||
golang.org/x/image v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect
|
||||
golang.org/x/image v0.23.0 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/net v0.31.0 // indirect
|
||||
golang.org/x/sync v0.9.0 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
golang.org/x/tools v0.27.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/tools v0.28.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837 // indirect
|
||||
mvdan.cc/xurls/v2 v2.5.0 // indirect
|
||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f // indirect
|
||||
mvdan.cc/xurls/v2 v2.6.0 // indirect
|
||||
)
|
||||
|
||||
replace go.mau.fi/gomuks => ../
|
||||
|
|
138
desktop/go.sum
|
@ -1,5 +1,5 @@
|
|||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
|
@ -7,12 +7,14 @@ github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ
|
|||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
|
||||
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
|
||||
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
|
@ -31,39 +33,39 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk
|
|||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
|
||||
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
|
||||
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
|
||||
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
|
||||
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
|
||||
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
|
||||
github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
|
||||
github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
|
||||
github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
|
||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
|
||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
||||
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
|
||||
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
|
||||
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
|
||||
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
|
||||
github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
|
||||
github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
|
||||
github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
|
||||
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
|
@ -71,8 +73,8 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
|
|||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
|
@ -106,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-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
|
||||
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A=
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
|
||||
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||
|
@ -121,8 +123,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
|||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
|
||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
|
@ -130,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/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ=
|
||||
github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
|
||||
github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
|
||||
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
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.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
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/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/wailsapp/go-webview2 v1.0.15 h1:IeQFoWmsHp32y64I41J+Zod3SopjHs918KSO4jUqEnY=
|
||||
github.com/wailsapp/go-webview2 v1.0.15/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
|
||||
github.com/wailsapp/go-webview2 v1.0.18 h1:SSSCoLA+MYikSp1U0WmvELF/4c3x5kH8Vi31TKyZ4yk=
|
||||
github.com/wailsapp/go-webview2 v1.0.18/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.7 h1:LNX2EnbxTEYJYICJT8UkuzoGVNalRizTNGBY47endmk=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.7/go.mod h1:lBz4zedFxreJBoVpMe9u89oo4IE3IlyHJg5rOWnGNR0=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3 h1:9aCL0IXD60A5iscQ/ps6f3ti3IlaoG6LQe0RZ9JkueU=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.8.3/go.mod h1:9Ca1goy5oqxmy8Oetb8Tchkezcx4tK03DK+SqYByu5Y=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
go.mau.fi/util v0.8.3-0.20241207221539-07bba6a0c5eb h1:/iKi+4aRvd8LZJ3z1UQjxmFdDVfJuDWClc/4MToWnSY=
|
||||
go.mau.fi/util v0.8.3-0.20241207221539-07bba6a0c5eb/go.mod h1:BHHC9R2WLMJd1bwTZfTcFxUgRFmUgUmiWcT4RbzUgiA=
|
||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a h1:D9RCHBFjxah9F/YB7amvRJjT2IEOFWcz8jpcEY8dBV0=
|
||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
|
||||
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
||||
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
@ -169,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.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.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
|
||||
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g=
|
||||
golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
|
||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
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.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.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
|
||||
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
@ -208,20 +209,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
|
||||
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
|
@ -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.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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
|
||||
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=
|
||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
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=
|
||||
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=
|
||||
|
@ -247,10 +249,10 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
|
|||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837 h1:v3cRnMfhKxpnKjhikZ5HY72MKIsgYzldL2s3cqbkNbY=
|
||||
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM=
|
||||
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
|
||||
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
|
||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f h1:+nAznNCSCm+P4am9CmguSfNfcbpA7qtTzJQrwCP8aHM=
|
||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f/go.mod h1:FmwzK7RSzrd1OfGDgJzFWXl7nYmYm8/P0Y77sy/A1Uw=
|
||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||
|
|
|
@ -123,7 +123,7 @@ func main() {
|
|||
ch := &CommandHandler{Gomuks: gmx, Ctx: cmdCtx}
|
||||
app := application.New(application.Options{
|
||||
Name: "gomuks-desktop",
|
||||
Description: "A Matrix client written in Go",
|
||||
Description: "A Matrix client written in Go and React",
|
||||
Services: []application.Service{
|
||||
application.NewService(
|
||||
&PointableHandler{gmx.CreateAPIRouter()},
|
||||
|
|
30
go.mod
|
@ -2,14 +2,14 @@ module go.mau.fi/gomuks
|
|||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.3
|
||||
toolchain go1.23.4
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0
|
||||
github.com/alecthomas/chroma/v2 v2.15.0
|
||||
github.com/buckket/go-blurhash v1.1.0
|
||||
github.com/chzyer/readline v1.5.1
|
||||
github.com/coder/websocket v1.8.12
|
||||
github.com/gabriel-vasile/mimetype v1.4.7
|
||||
github.com/gabriel-vasile/mimetype v1.4.8
|
||||
github.com/gdamore/tcell/v2 v2.7.4
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
|
@ -19,33 +19,33 @@ require (
|
|||
github.com/tidwall/sjson v1.2.5
|
||||
github.com/yuin/goldmark v1.7.8
|
||||
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
|
||||
golang.org/x/crypto v0.29.0
|
||||
golang.org/x/image v0.22.0
|
||||
golang.org/x/net v0.31.0
|
||||
golang.org/x/text v0.20.0
|
||||
golang.org/x/crypto v0.32.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/text v0.21.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mauflag v1.0.0
|
||||
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837
|
||||
mvdan.cc/xurls/v2 v2.5.0
|
||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/gdamore/encoding v1.0.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // 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/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/zyedidia/clipboard v1.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
golang.org/x/term v0.26.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
)
|
||||
|
|
64
go.sum
|
@ -2,10 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
|
||||
|
@ -22,10 +22,10 @@ github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8
|
|||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
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-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A=
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
|
||||
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
|
@ -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/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
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.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
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.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
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=
|
||||
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/util v0.8.3-0.20241207221539-07bba6a0c5eb h1:/iKi+4aRvd8LZJ3z1UQjxmFdDVfJuDWClc/4MToWnSY=
|
||||
go.mau.fi/util v0.8.3-0.20241207221539-07bba6a0c5eb/go.mod h1:BHHC9R2WLMJd1bwTZfTcFxUgRFmUgUmiWcT4RbzUgiA=
|
||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a h1:D9RCHBFjxah9F/YB7amvRJjT2IEOFWcz8jpcEY8dBV0=
|
||||
go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
|
||||
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
||||
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
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.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
|
||||
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g=
|
||||
golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
|
||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
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-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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
|
||||
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
@ -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.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.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.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.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
|
||||
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
@ -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=
|
||||
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
||||
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
||||
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837 h1:v3cRnMfhKxpnKjhikZ5HY72MKIsgYzldL2s3cqbkNbY=
|
||||
maunium.net/go/mautrix v0.22.1-0.20241207130433-421bd5c4c837/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM=
|
||||
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
|
||||
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
|
||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f h1:+nAznNCSCm+P4am9CmguSfNfcbpA7qtTzJQrwCP8aHM=
|
||||
maunium.net/go/mautrix v0.22.2-0.20250106152426-68eaa9d1df1f/go.mod h1:FmwzK7RSzrd1OfGDgJzFWXl7nYmYm8/P0Y77sy/A1Uw=
|
||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||
|
|
|
@ -54,7 +54,7 @@ func NewEventBuffer(maxSize int) *EventBuffer {
|
|||
}
|
||||
}
|
||||
|
||||
func (eb *EventBuffer) HicliEventHandler(evt any) {
|
||||
func (eb *EventBuffer) Push(evt any) {
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to marshal event %T: %w", evt, err))
|
||||
|
|
|
@ -34,6 +34,7 @@ import (
|
|||
type Config struct {
|
||||
Web WebConfig `yaml:"web"`
|
||||
Matrix MatrixConfig `yaml:"matrix"`
|
||||
Push PushConfig `yaml:"push"`
|
||||
Logging zeroconfig.Config `yaml:"logging"`
|
||||
}
|
||||
|
||||
|
@ -41,6 +42,10 @@ type MatrixConfig struct {
|
|||
DisableHTTP2 bool `yaml:"disable_http2"`
|
||||
}
|
||||
|
||||
type PushConfig struct {
|
||||
FCMGateway string `yaml:"fcm_gateway"`
|
||||
}
|
||||
|
||||
type WebConfig struct {
|
||||
ListenAddress string `yaml:"listen_address"`
|
||||
Username string `yaml:"username"`
|
||||
|
@ -48,6 +53,7 @@ type WebConfig struct {
|
|||
TokenKey string `yaml:"token_key"`
|
||||
DebugEndpoints bool `yaml:"debug_endpoints"`
|
||||
EventBufferSize int `yaml:"event_buffer_size"`
|
||||
OriginPatterns []string `yaml:"origin_patterns"`
|
||||
}
|
||||
|
||||
var defaultFileWriter = zeroconfig.WriterConfig{
|
||||
|
@ -120,6 +126,14 @@ func (gmx *Gomuks) LoadConfig() error {
|
|||
gmx.Config.Web.EventBufferSize = 512
|
||||
changed = true
|
||||
}
|
||||
if gmx.Config.Push.FCMGateway == "" {
|
||||
gmx.Config.Push.FCMGateway = "https://push.gomuks.app"
|
||||
changed = true
|
||||
}
|
||||
if len(gmx.Config.Web.OriginPatterns) == 0 {
|
||||
gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
err = gmx.SaveConfig()
|
||||
if err != nil {
|
||||
|
|
|
@ -35,6 +35,7 @@ import (
|
|||
"go.mau.fi/util/dbutil"
|
||||
"go.mau.fi/util/exerrors"
|
||||
"go.mau.fi/util/exzerolog"
|
||||
"go.mau.fi/util/ptr"
|
||||
"go.mau.fi/zeroconfig"
|
||||
"golang.org/x/net/http2"
|
||||
|
||||
|
@ -184,7 +185,7 @@ func (gmx *Gomuks) StartClient() {
|
|||
nil,
|
||||
gmx.Log.With().Str("component", "hicli").Logger(),
|
||||
[]byte("meow"),
|
||||
gmx.EventBuffer.HicliEventHandler,
|
||||
gmx.HandleEvent,
|
||||
)
|
||||
gmx.Client.LogoutFunc = gmx.Logout
|
||||
httpClient := gmx.Client.Client.Client
|
||||
|
@ -210,6 +211,14 @@ func (gmx *Gomuks) StartClient() {
|
|||
gmx.Log.Info().Stringer("user_id", userID).Msg("Client started")
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) HandleEvent(evt any) {
|
||||
gmx.EventBuffer.Push(evt)
|
||||
syncComplete, ok := evt.(*hicli.SyncComplete)
|
||||
if ok && ptr.Val(syncComplete.Since) != "" {
|
||||
go gmx.SendPushNotifications(syncComplete)
|
||||
}
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) Stop() {
|
||||
gmx.stopOnce.Do(func() {
|
||||
close(gmx.stopChan)
|
||||
|
@ -230,10 +239,12 @@ func (gmx *Gomuks) DirectStop() {
|
|||
closer(websocket.StatusServiceRestart, "Server shutting down")
|
||||
}
|
||||
gmx.Client.Stop()
|
||||
if gmx.Server != nil {
|
||||
err := gmx.Server.Close()
|
||||
if err != nil {
|
||||
gmx.Log.Error().Err(err).Msg("Failed to close server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) Run() {
|
||||
|
|
|
@ -109,7 +109,7 @@ func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media) {
|
|||
w.Header().Set("Content-Type", entry.MimeType)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10))
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName}))
|
||||
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none';")
|
||||
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; media-src 'self';")
|
||||
w.Header().Set("Cache-Control", "max-age=2592000, immutable")
|
||||
w.Header().Set("ETag", entry.ETag())
|
||||
}
|
||||
|
@ -125,7 +125,7 @@ func (new *noErrorWriter) Write(p []byte) (n int, err error) {
|
|||
|
||||
// note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts
|
||||
const fallbackAvatarTemplate = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
|
||||
<circle cx="500" cy="500" r="500" fill="%s"/>
|
||||
<rect x="0" y="0" width="1000" height="1000" fill="%s"/>
|
||||
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
>%s</text>
|
||||
|
|
252
pkg/gomuks/push.go
Normal 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
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -190,10 +190,10 @@ func (gmx *Gomuks) generateToken() (string, time.Time) {
|
|||
}), expiry
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) generateImageToken() string {
|
||||
func (gmx *Gomuks) generateImageToken(expiry time.Duration) string {
|
||||
return gmx.signToken(tokenData{
|
||||
Username: gmx.Config.Web.Username,
|
||||
Expiry: jsontime.U(time.Now().Add(1 * time.Hour)),
|
||||
Expiry: jsontime.U(time.Now().Add(expiry)),
|
||||
ImageOnly: true,
|
||||
})
|
||||
}
|
||||
|
@ -206,8 +206,9 @@ func (gmx *Gomuks) signToken(td any) string {
|
|||
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum)
|
||||
}
|
||||
|
||||
func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) {
|
||||
func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter, created, jsonOutput bool) {
|
||||
token, expiry := gmx.generateToken()
|
||||
if !jsonOutput {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "gomuks_auth",
|
||||
Value: token,
|
||||
|
@ -216,6 +217,15 @@ func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) {
|
|||
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) {
|
||||
|
@ -226,14 +236,17 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
jsonOutput := r.URL.Query().Get("output") == "json"
|
||||
allowPrompt := r.URL.Query().Get("no_prompt") != "true"
|
||||
authCookie, err := r.Cookie("gomuks_auth")
|
||||
if err == nil && gmx.validateAuth(authCookie.Value, false) {
|
||||
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
|
||||
gmx.writeTokenCookie(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
gmx.writeTokenCookie(w, false, jsonOutput)
|
||||
} else if username, password, ok := r.BasicAuth(); !ok {
|
||||
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
|
||||
if allowPrompt {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
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
|
||||
if usernameCorrect && passwordCorrect {
|
||||
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
|
||||
gmx.writeTokenCookie(w)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
gmx.writeTokenCookie(w, true, jsonOutput)
|
||||
} else {
|
||||
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials")
|
||||
if allowPrompt {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -86,7 +86,7 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
|||
defer recoverPanic("read loop")
|
||||
|
||||
conn, acceptErr := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"localhost:*"},
|
||||
OriginPatterns: gmx.Config.Web.OriginPatterns,
|
||||
})
|
||||
if acceptErr != nil {
|
||||
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() {
|
||||
err := writeCmd(ctx, conn, &hicli.JSONCommand{
|
||||
Command: "image_auth_token",
|
||||
Data: exerrors.Must(json.Marshal(gmx.generateImageToken())),
|
||||
Data: exerrors.Must(json.Marshal(gmx.generateImageToken(1 * time.Hour))),
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to write image auth token message")
|
||||
|
|
|
@ -17,15 +17,18 @@ import (
|
|||
type Database struct {
|
||||
*dbutil.Database
|
||||
|
||||
Account AccountQuery
|
||||
AccountData AccountDataQuery
|
||||
Room RoomQuery
|
||||
Event EventQuery
|
||||
CurrentState CurrentStateQuery
|
||||
Timeline TimelineQuery
|
||||
SessionRequest SessionRequestQuery
|
||||
Receipt ReceiptQuery
|
||||
Media MediaQuery
|
||||
Account *AccountQuery
|
||||
AccountData *AccountDataQuery
|
||||
Room *RoomQuery
|
||||
InvitedRoom *InvitedRoomQuery
|
||||
Event *EventQuery
|
||||
CurrentState *CurrentStateQuery
|
||||
Timeline *TimelineQuery
|
||||
SessionRequest *SessionRequestQuery
|
||||
Receipt *ReceiptQuery
|
||||
Media *MediaQuery
|
||||
SpaceEdge *SpaceEdgeQuery
|
||||
PushRegistration *PushRegistrationQuery
|
||||
}
|
||||
|
||||
func New(rawDB *dbutil.Database) *Database {
|
||||
|
@ -34,15 +37,18 @@ func New(rawDB *dbutil.Database) *Database {
|
|||
return &Database{
|
||||
Database: rawDB,
|
||||
|
||||
Account: AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
|
||||
AccountData: AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
|
||||
Room: RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
|
||||
Event: EventQuery{QueryHelper: eventQH},
|
||||
CurrentState: CurrentStateQuery{QueryHelper: eventQH},
|
||||
Timeline: TimelineQuery{QueryHelper: eventQH},
|
||||
SessionRequest: SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
|
||||
Receipt: ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
|
||||
Media: MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
|
||||
Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
|
||||
AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
|
||||
Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
|
||||
InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
|
||||
Event: &EventQuery{QueryHelper: eventQH},
|
||||
CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
|
||||
Timeline: &TimelineQuery{QueryHelper: eventQH},
|
||||
SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
|
||||
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
|
||||
Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
|
||||
SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)},
|
||||
PushRegistration: &PushRegistrationQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newPushRegistration)},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,6 +64,10 @@ func newRoom(_ *dbutil.QueryHelper[*Room]) *Room {
|
|||
return &Room{}
|
||||
}
|
||||
|
||||
func newInvitedRoom(_ *dbutil.QueryHelper[*InvitedRoom]) *InvitedRoom {
|
||||
return &InvitedRoom{}
|
||||
}
|
||||
|
||||
func newReceipt(_ *dbutil.QueryHelper[*Receipt]) *Receipt {
|
||||
return &Receipt{}
|
||||
}
|
||||
|
@ -73,3 +83,11 @@ func newAccountData(_ *dbutil.QueryHelper[*AccountData]) *AccountData {
|
|||
func newAccount(_ *dbutil.QueryHelper[*Account]) *Account {
|
||||
return &Account{}
|
||||
}
|
||||
|
||||
func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
|
||||
return &SpaceEdge{}
|
||||
}
|
||||
|
||||
func newPushRegistration(_ *dbutil.QueryHelper[*PushRegistration]) *PushRegistration {
|
||||
return &PushRegistration{}
|
||||
}
|
||||
|
|
|
@ -333,6 +333,11 @@ type LocalContent struct {
|
|||
BigEmoji bool `json:"big_emoji,omitempty"`
|
||||
HasMath bool `json:"has_math,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 {
|
||||
|
@ -461,6 +466,7 @@ func (e *Event) Scan(row dbutil.Scannable) (*Event, error) {
|
|||
|
||||
var relatesToPath = exgjson.Path("m.relates_to", "event_id")
|
||||
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) {
|
||||
if evt.StateKey != nil {
|
||||
|
@ -488,6 +494,18 @@ func getMegolmSessionID(evt *event.Event) id.SessionID {
|
|||
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 {
|
||||
var reactions any
|
||||
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) &&
|
||||
e.RelationType != event.RelReplace
|
||||
}
|
||||
|
||||
func (e *Event) MarkReplyFallbackRemoved() {
|
||||
if e.LocalContent == nil {
|
||||
e.LocalContent = &LocalContent{}
|
||||
}
|
||||
e.LocalContent.ReplyFallbackRemoved = true
|
||||
}
|
||||
|
|
73
pkg/hicli/database/invitedroom.go
Normal 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
|
||||
}
|
78
pkg/hicli/database/pushregistration.go
Normal 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}
|
||||
}
|
|
@ -8,7 +8,9 @@ package database
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
|
@ -25,6 +27,7 @@ const (
|
|||
SET event_id = excluded.event_id,
|
||||
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)")
|
||||
|
@ -53,11 +56,29 @@ func (rq *ReceiptQuery) PutMany(ctx context.Context, roomID id.RoomID, receipts
|
|||
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 {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
RoomID id.RoomID `json:"room_id,omitempty"`
|
||||
UserID id.UserID `json:"user_id"`
|
||||
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"`
|
||||
Timestamp jsontime.UnixMilli `json:"timestamp"`
|
||||
}
|
||||
|
|
|
@ -21,12 +21,14 @@ import (
|
|||
|
||||
const (
|
||||
getRoomBaseQuery = `
|
||||
SELECT room_id, creation_content, tombstone_content, name, name_quality, avatar, explicit_avatar, topic, canonical_alias,
|
||||
SELECT room_id, creation_content, tombstone_content, name, name_quality,
|
||||
avatar, explicit_avatar, dm_user_id, topic, canonical_alias,
|
||||
lazy_load_summary, encryption_event, has_member_list, preview_event_rowid, sorting_timestamp,
|
||||
unread_highlights, unread_notifications, unread_messages, marked_unread, prev_batch
|
||||
FROM room
|
||||
`
|
||||
getRoomsBySortingTimestampQuery = getRoomBaseQuery + `WHERE sorting_timestamp < $1 AND sorting_timestamp > 0 ORDER BY sorting_timestamp DESC LIMIT $2`
|
||||
getRoomsByTypeQuery = getRoomBaseQuery + `WHERE room_type = $1`
|
||||
getRoomByIDQuery = getRoomBaseQuery + `WHERE room_id = $1`
|
||||
ensureRoomExistsQuery = `
|
||||
INSERT INTO room (room_id) VALUES ($1)
|
||||
|
@ -34,24 +36,26 @@ const (
|
|||
`
|
||||
upsertRoomFromSyncQuery = `
|
||||
UPDATE room
|
||||
SET creation_content = COALESCE(room.creation_content, $2),
|
||||
SET room_type = COALESCE(room.room_type, json($2)->>'$.type'),
|
||||
creation_content = COALESCE(room.creation_content, $2),
|
||||
tombstone_content = COALESCE(room.tombstone_content, $3),
|
||||
name = COALESCE($4, room.name),
|
||||
name_quality = CASE WHEN $4 IS NOT NULL THEN $5 ELSE room.name_quality END,
|
||||
avatar = COALESCE($6, room.avatar),
|
||||
explicit_avatar = CASE WHEN $6 IS NOT NULL THEN $7 ELSE room.explicit_avatar END,
|
||||
topic = COALESCE($8, room.topic),
|
||||
canonical_alias = COALESCE($9, room.canonical_alias),
|
||||
lazy_load_summary = COALESCE($10, room.lazy_load_summary),
|
||||
encryption_event = COALESCE($11, room.encryption_event),
|
||||
has_member_list = room.has_member_list OR $12,
|
||||
preview_event_rowid = COALESCE($13, room.preview_event_rowid),
|
||||
sorting_timestamp = COALESCE($14, room.sorting_timestamp),
|
||||
unread_highlights = COALESCE($15, room.unread_highlights),
|
||||
unread_notifications = COALESCE($16, room.unread_notifications),
|
||||
unread_messages = COALESCE($17, room.unread_messages),
|
||||
marked_unread = COALESCE($18, room.marked_unread),
|
||||
prev_batch = COALESCE($19, room.prev_batch)
|
||||
dm_user_id = COALESCE($8, room.dm_user_id),
|
||||
topic = COALESCE($9, room.topic),
|
||||
canonical_alias = COALESCE($10, room.canonical_alias),
|
||||
lazy_load_summary = COALESCE($11, room.lazy_load_summary),
|
||||
encryption_event = COALESCE($12, room.encryption_event),
|
||||
has_member_list = room.has_member_list OR $13,
|
||||
preview_event_rowid = COALESCE($14, room.preview_event_rowid),
|
||||
sorting_timestamp = COALESCE($15, room.sorting_timestamp),
|
||||
unread_highlights = COALESCE($16, room.unread_highlights),
|
||||
unread_notifications = COALESCE($17, room.unread_notifications),
|
||||
unread_messages = COALESCE($18, room.unread_messages),
|
||||
marked_unread = COALESCE($19, room.marked_unread),
|
||||
prev_batch = COALESCE($20, room.prev_batch)
|
||||
WHERE room_id = $1
|
||||
`
|
||||
setRoomPrevBatchQuery = `
|
||||
|
@ -95,6 +99,10 @@ func (rq *RoomQuery) GetBySortTS(ctx context.Context, maxTS time.Time, limit int
|
|||
return rq.QueryMany(ctx, getRoomsBySortingTimestampQuery, maxTS.UnixMilli(), limit)
|
||||
}
|
||||
|
||||
func (rq *RoomQuery) GetAllSpaces(ctx context.Context) ([]*Room, error) {
|
||||
return rq.QueryMany(ctx, getRoomsByTypeQuery, event.RoomTypeSpace)
|
||||
}
|
||||
|
||||
func (rq *RoomQuery) Upsert(ctx context.Context, room *Room) error {
|
||||
return rq.Exec(ctx, upsertRoomFromSyncQuery, room.sqlVariables()...)
|
||||
}
|
||||
|
@ -147,6 +155,7 @@ type Room struct {
|
|||
NameQuality NameQuality `json:"name_quality"`
|
||||
Avatar *id.ContentURI `json:"avatar,omitempty"`
|
||||
ExplicitAvatar bool `json:"explicit_avatar"`
|
||||
DMUserID *id.UserID `json:"dm_user_id,omitempty"`
|
||||
Topic *string `json:"topic,omitempty"`
|
||||
CanonicalAlias *id.RoomAlias `json:"canonical_alias,omitempty"`
|
||||
|
||||
|
@ -182,6 +191,10 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) {
|
|||
other.ExplicitAvatar = r.ExplicitAvatar
|
||||
hasChanges = true
|
||||
}
|
||||
if r.DMUserID != nil {
|
||||
other.DMUserID = r.DMUserID
|
||||
hasChanges = true
|
||||
}
|
||||
if r.Topic != nil {
|
||||
other.Topic = r.Topic
|
||||
hasChanges = true
|
||||
|
@ -244,6 +257,7 @@ func (r *Room) Scan(row dbutil.Scannable) (*Room, error) {
|
|||
&r.NameQuality,
|
||||
&r.Avatar,
|
||||
&r.ExplicitAvatar,
|
||||
&r.DMUserID,
|
||||
&r.Topic,
|
||||
&r.CanonicalAlias,
|
||||
dbutil.JSON{Data: &r.LazyLoadSummary},
|
||||
|
@ -262,7 +276,7 @@ func (r *Room) Scan(row dbutil.Scannable) (*Room, error) {
|
|||
}
|
||||
r.PrevBatch = prevBatch.String
|
||||
r.PreviewEventRowID = EventRowID(previewEventRowID.Int64)
|
||||
r.SortingTimestamp = jsontime.UM(time.UnixMilli(sortingTimestamp.Int64))
|
||||
r.SortingTimestamp = jsontime.UMInt(sortingTimestamp.Int64)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
|
@ -275,6 +289,7 @@ func (r *Room) sqlVariables() []any {
|
|||
r.NameQuality,
|
||||
r.Avatar,
|
||||
r.ExplicitAvatar,
|
||||
r.DMUserID,
|
||||
r.Topic,
|
||||
r.CanonicalAlias,
|
||||
dbutil.JSONPtr(r.LazyLoadSummary),
|
||||
|
|
250
pkg/hicli/database/space.go
Normal file
|
@ -0,0 +1,250 @@
|
|||
// Copyright (c) 2024 Tulir Asokan
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
const (
|
||||
getAllSpaceChildren = `
|
||||
SELECT space_id, child_id, child_event_rowid, "order", suggested, parent_event_rowid, canonical, parent_validated
|
||||
FROM space_edge
|
||||
-- This check should be redundant thanks to parent_validated and validation before insert for children
|
||||
--INNER JOIN room ON space_id = room.room_id AND room.room_type = 'm.space'
|
||||
WHERE (space_id = $1 OR $1 = '') AND (child_event_rowid IS NOT NULL OR parent_validated)
|
||||
ORDER BY space_id, "order", child_id
|
||||
`
|
||||
getTopLevelSpaces = `
|
||||
SELECT space_id
|
||||
FROM (SELECT DISTINCT(space_id) FROM space_edge) outeredge
|
||||
LEFT JOIN room_account_data ON
|
||||
room_account_data.user_id = $1
|
||||
AND room_account_data.room_id = outeredge.space_id
|
||||
AND room_account_data.type = 'org.matrix.msc3230.space_order'
|
||||
WHERE NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM space_edge inneredge
|
||||
INNER JOIN room ON inneredge.space_id = room.room_id
|
||||
WHERE inneredge.child_id = outeredge.space_id
|
||||
AND (inneredge.child_event_rowid IS NOT NULL OR inneredge.parent_validated)
|
||||
) AND EXISTS(SELECT 1 FROM room WHERE room_id = space_id AND room_type = 'm.space')
|
||||
ORDER BY room_account_data.content->>'$.order' NULLS LAST, space_id
|
||||
`
|
||||
revalidateAllParents = `
|
||||
UPDATE space_edge
|
||||
SET parent_validated=(SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM room
|
||||
INNER JOIN current_state cs ON cs.room_id = room.room_id AND cs.event_type = 'm.room.power_levels' AND cs.state_key = ''
|
||||
INNER JOIN event pls ON cs.event_rowid = pls.rowid
|
||||
INNER JOIN event edgeevt ON space_edge.parent_event_rowid = edgeevt.rowid
|
||||
WHERE room.room_id = space_edge.space_id
|
||||
AND room.room_type = 'm.space'
|
||||
AND COALESCE(
|
||||
(
|
||||
SELECT value
|
||||
FROM json_each(pls.content, '$.users')
|
||||
WHERE key=edgeevt.sender AND type='integer'
|
||||
),
|
||||
pls.content->>'$.users_default',
|
||||
0
|
||||
) >= COALESCE(
|
||||
pls.content->>'$.events."m.space.child"',
|
||||
pls.content->>'$.state_default',
|
||||
50
|
||||
)
|
||||
))
|
||||
WHERE parent_event_rowid IS NOT NULL
|
||||
`
|
||||
revalidateAllParentsPointingAtSpaceQuery = revalidateAllParents + ` AND space_id=$1`
|
||||
revalidateAllParentsOfRoomQuery = revalidateAllParents + ` AND child_id=$1`
|
||||
revalidateSpecificParentQuery = revalidateAllParents + ` AND space_id=$1 AND child_id=$2`
|
||||
clearSpaceChildrenQuery = `
|
||||
UPDATE space_edge SET child_event_rowid=NULL, "order"='', suggested=false
|
||||
WHERE space_id=$1
|
||||
`
|
||||
clearSpaceParentsQuery = `
|
||||
UPDATE space_edge SET parent_event_rowid=NULL, canonical=false, parent_validated=false
|
||||
WHERE child_id=$1
|
||||
`
|
||||
removeSpaceChildQuery = clearSpaceChildrenQuery + ` AND child_id=$2`
|
||||
removeSpaceParentQuery = clearSpaceParentsQuery + ` AND space_id=$2`
|
||||
deleteEmptySpaceEdgeRowsQuery = `
|
||||
DELETE FROM space_edge WHERE child_event_rowid IS NULL AND parent_event_rowid IS NULL
|
||||
`
|
||||
addSpaceChildQuery = `
|
||||
INSERT INTO space_edge (space_id, child_id, child_event_rowid, "order", suggested)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (space_id, child_id) DO UPDATE
|
||||
SET child_event_rowid=EXCLUDED.child_event_rowid,
|
||||
"order"=EXCLUDED."order",
|
||||
suggested=EXCLUDED.suggested
|
||||
`
|
||||
addSpaceParentQuery = `
|
||||
INSERT INTO space_edge (space_id, child_id, parent_event_rowid, canonical)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (space_id, child_id) DO UPDATE
|
||||
SET parent_event_rowid=EXCLUDED.parent_event_rowid,
|
||||
canonical=EXCLUDED.canonical,
|
||||
parent_validated=false
|
||||
`
|
||||
)
|
||||
|
||||
var massInsertSpaceParentBuilder = dbutil.NewMassInsertBuilder[SpaceParentEntry, [1]any](addSpaceParentQuery, "($%d, $1, $%d, $%d)")
|
||||
var massInsertSpaceChildBuilder = dbutil.NewMassInsertBuilder[SpaceChildEntry, [1]any](addSpaceChildQuery, "($1, $%d, $%d, $%d, $%d)")
|
||||
|
||||
type SpaceEdgeQuery struct {
|
||||
*dbutil.QueryHelper[*SpaceEdge]
|
||||
}
|
||||
|
||||
func (seq *SpaceEdgeQuery) AddChild(ctx context.Context, spaceID, childID id.RoomID, childEventRowID EventRowID, order string, suggested bool) error {
|
||||
return seq.Exec(ctx, addSpaceChildQuery, spaceID, childID, childEventRowID, order, suggested)
|
||||
}
|
||||
|
||||
func (seq *SpaceEdgeQuery) AddParent(ctx context.Context, spaceID, childID id.RoomID, parentEventRowID EventRowID, canonical bool) error {
|
||||
return seq.Exec(ctx, addSpaceParentQuery, spaceID, childID, parentEventRowID, canonical)
|
||||
}
|
||||
|
||||
type SpaceParentEntry struct {
|
||||
ParentID id.RoomID
|
||||
EventRowID EventRowID
|
||||
Canonical bool
|
||||
}
|
||||
|
||||
func (spe SpaceParentEntry) GetMassInsertValues() [3]any {
|
||||
return [...]any{spe.ParentID, spe.EventRowID, spe.Canonical}
|
||||
}
|
||||
|
||||
type SpaceChildEntry struct {
|
||||
ChildID id.RoomID
|
||||
EventRowID EventRowID
|
||||
Order string
|
||||
Suggested bool
|
||||
}
|
||||
|
||||
func (sce SpaceChildEntry) GetMassInsertValues() [4]any {
|
||||
return [...]any{sce.ChildID, sce.EventRowID, sce.Order, sce.Suggested}
|
||||
}
|
||||
|
||||
func (seq *SpaceEdgeQuery) SetChildren(ctx context.Context, spaceID id.RoomID, children []SpaceChildEntry, removedChildren []id.RoomID, clear bool) error {
|
||||
if clear {
|
||||
err := seq.Exec(ctx, clearSpaceChildrenQuery, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(removedChildren) > 0 {
|
||||
for _, child := range removedChildren {
|
||||
err := seq.Exec(ctx, removeSpaceChildQuery, spaceID, child)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(removedChildren) > 0 {
|
||||
err := seq.Exec(ctx, deleteEmptySpaceEdgeRowsQuery, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(children) == 0 {
|
||||
return nil
|
||||
}
|
||||
query, params := massInsertSpaceChildBuilder.Build([1]any{spaceID}, children)
|
||||
return seq.Exec(ctx, query, params...)
|
||||
}
|
||||
|
||||
func (seq *SpaceEdgeQuery) SetParents(ctx context.Context, childID id.RoomID, parents []SpaceParentEntry, removedParents []id.RoomID, clear bool) error {
|
||||
if clear {
|
||||
err := seq.Exec(ctx, clearSpaceParentsQuery, childID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(removedParents) > 0 {
|
||||
for _, parent := range removedParents {
|
||||
err := seq.Exec(ctx, removeSpaceParentQuery, childID, parent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(removedParents) > 0 {
|
||||
err := seq.Exec(ctx, deleteEmptySpaceEdgeRowsQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(parents) == 0 {
|
||||
return nil
|
||||
}
|
||||
query, params := massInsertSpaceParentBuilder.Build([1]any{childID}, parents)
|
||||
return seq.Exec(ctx, query, params...)
|
||||
}
|
||||
|
||||
func (seq *SpaceEdgeQuery) RevalidateAllChildrenOfParentValidity(ctx context.Context, spaceID id.RoomID) error {
|
||||
return seq.Exec(ctx, revalidateAllParentsPointingAtSpaceQuery, spaceID)
|
||||
}
|
||||
|
||||
func (seq *SpaceEdgeQuery) RevalidateAllParentsOfRoomValidity(ctx context.Context, childID id.RoomID) error {
|
||||
return seq.Exec(ctx, revalidateAllParentsOfRoomQuery, childID)
|
||||
}
|
||||
|
||||
func (seq *SpaceEdgeQuery) RevalidateSpecificParentValidity(ctx context.Context, spaceID, childID id.RoomID) error {
|
||||
return seq.Exec(ctx, revalidateSpecificParentQuery, spaceID, childID)
|
||||
}
|
||||
|
||||
func (seq *SpaceEdgeQuery) GetAll(ctx context.Context, spaceID id.RoomID) (map[id.RoomID][]*SpaceEdge, error) {
|
||||
edges := make(map[id.RoomID][]*SpaceEdge)
|
||||
err := seq.QueryManyIter(ctx, getAllSpaceChildren, spaceID).Iter(func(edge *SpaceEdge) (bool, error) {
|
||||
edges[edge.SpaceID] = append(edges[edge.SpaceID], edge)
|
||||
edge.SpaceID = ""
|
||||
if !edge.ParentValidated {
|
||||
edge.ParentEventRowID = 0
|
||||
edge.Canonical = false
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
return edges, err
|
||||
}
|
||||
|
||||
var roomIDScanner = dbutil.ConvertRowFn[id.RoomID](dbutil.ScanSingleColumn[id.RoomID])
|
||||
|
||||
func (seq *SpaceEdgeQuery) GetTopLevelIDs(ctx context.Context, userID id.UserID) ([]id.RoomID, error) {
|
||||
return roomIDScanner.NewRowIter(seq.GetDB().Query(ctx, getTopLevelSpaces, userID)).AsList()
|
||||
}
|
||||
|
||||
type SpaceEdge struct {
|
||||
SpaceID id.RoomID `json:"space_id,omitempty"`
|
||||
ChildID id.RoomID `json:"child_id"`
|
||||
|
||||
ChildEventRowID EventRowID `json:"child_event_rowid,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
Suggested bool `json:"suggested,omitempty"`
|
||||
|
||||
ParentEventRowID EventRowID `json:"parent_event_rowid,omitempty"`
|
||||
Canonical bool `json:"canonical,omitempty"`
|
||||
ParentValidated bool `json:"-"`
|
||||
}
|
||||
|
||||
func (se *SpaceEdge) Scan(row dbutil.Scannable) (*SpaceEdge, error) {
|
||||
var childRowID, parentRowID sql.NullInt64
|
||||
err := row.Scan(
|
||||
&se.SpaceID, &se.ChildID,
|
||||
&childRowID, &se.Order, &se.Suggested,
|
||||
&parentRowID, &se.Canonical, &se.ParentValidated,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
se.ChildEventRowID = EventRowID(childRowID.Int64)
|
||||
se.ParentEventRowID = EventRowID(parentRowID.Int64)
|
||||
return se, nil
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
-- v0 -> v7 (compatible with v5+): Latest revision
|
||||
-- v0 -> v12 (compatible with v10+): Latest revision
|
||||
CREATE TABLE account (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
|
@ -10,6 +10,7 @@ CREATE TABLE account (
|
|||
|
||||
CREATE TABLE room (
|
||||
room_id TEXT NOT NULL PRIMARY KEY,
|
||||
room_type TEXT,
|
||||
creation_content TEXT,
|
||||
tombstone_content TEXT,
|
||||
|
||||
|
@ -17,6 +18,7 @@ CREATE TABLE room (
|
|||
name_quality INTEGER NOT NULL DEFAULT 0,
|
||||
avatar TEXT,
|
||||
explicit_avatar INTEGER NOT NULL DEFAULT 0,
|
||||
dm_user_id TEXT,
|
||||
topic TEXT,
|
||||
canonical_alias TEXT,
|
||||
lazy_load_summary TEXT,
|
||||
|
@ -35,11 +37,25 @@ CREATE TABLE room (
|
|||
|
||||
CONSTRAINT room_preview_event_fkey FOREIGN KEY (preview_event_rowid) REFERENCES event (rowid) ON DELETE SET NULL
|
||||
) STRICT;
|
||||
CREATE INDEX room_type_idx ON room (creation_content ->> 'type');
|
||||
CREATE INDEX room_type_idx ON room (room_type);
|
||||
CREATE INDEX room_sorting_timestamp_idx ON room (sorting_timestamp DESC);
|
||||
CREATE INDEX room_preview_idx ON room (preview_event_rowid);
|
||||
-- CREATE INDEX room_sorting_timestamp_idx ON room (unread_notifications > 0);
|
||||
-- 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 (
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
|
@ -248,7 +264,8 @@ CREATE TABLE current_state (
|
|||
|
||||
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_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;
|
||||
|
||||
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
|
||||
-- note: there's no foreign key on event ID because receipts could point at events that are too far in history.
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE space_edge (
|
||||
space_id TEXT NOT NULL,
|
||||
child_id TEXT NOT NULL,
|
||||
|
||||
-- m.space.child fields
|
||||
child_event_rowid INTEGER,
|
||||
"order" TEXT NOT NULL DEFAULT '',
|
||||
suggested INTEGER NOT NULL DEFAULT false CHECK ( suggested IN (false, true) ),
|
||||
-- m.space.parent fields
|
||||
parent_event_rowid INTEGER,
|
||||
canonical INTEGER NOT NULL DEFAULT false CHECK ( canonical IN (false, true) ),
|
||||
parent_validated INTEGER NOT NULL DEFAULT false CHECK ( parent_validated IN (false, true) ),
|
||||
|
||||
PRIMARY KEY (space_id, child_id),
|
||||
CONSTRAINT space_edge_child_event_fkey FOREIGN KEY (child_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
|
||||
CONSTRAINT space_edge_parent_event_fkey FOREIGN KEY (parent_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
|
||||
CONSTRAINT space_edge_child_event_unique UNIQUE (child_event_rowid),
|
||||
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
|
||||
) STRICT;
|
||||
CREATE INDEX space_edge_child_idx ON space_edge (child_id);
|
||||
|
||||
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;
|
||||
|
|
3
pkg/hicli/database/upgrades/08-add-missing-indexes.sql
Normal 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);
|
13
pkg/hicli/database/upgrades/09-invited-rooms.sql
Normal 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;
|
83
pkg/hicli/database/upgrades/10-spaces.sql
Normal file
|
@ -0,0 +1,83 @@
|
|||
-- v10 (compatible with v10+): Add support for spaces
|
||||
ALTER TABLE room ADD COLUMN room_type TEXT;
|
||||
UPDATE room SET room_type=COALESCE(creation_content->>'$.type', '');
|
||||
DROP INDEX room_type_idx;
|
||||
CREATE INDEX room_type_idx ON room (room_type);
|
||||
|
||||
CREATE TABLE space_edge (
|
||||
space_id TEXT NOT NULL,
|
||||
child_id TEXT NOT NULL,
|
||||
|
||||
-- m.space.child fields
|
||||
child_event_rowid INTEGER,
|
||||
"order" TEXT NOT NULL DEFAULT '',
|
||||
suggested INTEGER NOT NULL DEFAULT false CHECK ( suggested IN (false, true) ),
|
||||
-- m.space.parent fields
|
||||
parent_event_rowid INTEGER,
|
||||
canonical INTEGER NOT NULL DEFAULT false CHECK ( canonical IN (false, true) ),
|
||||
parent_validated INTEGER NOT NULL DEFAULT false CHECK ( parent_validated IN (false, true) ),
|
||||
|
||||
PRIMARY KEY (space_id, child_id),
|
||||
CONSTRAINT space_edge_child_event_fkey FOREIGN KEY (child_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
|
||||
CONSTRAINT space_edge_parent_event_fkey FOREIGN KEY (parent_event_rowid) REFERENCES event (rowid) ON DELETE CASCADE,
|
||||
CONSTRAINT space_edge_child_event_unique UNIQUE (child_event_rowid),
|
||||
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
|
||||
) STRICT;
|
||||
CREATE INDEX space_edge_child_idx ON space_edge (child_id);
|
||||
|
||||
INSERT INTO space_edge (space_id, child_id, child_event_rowid, "order", suggested)
|
||||
SELECT
|
||||
event.room_id,
|
||||
event.state_key,
|
||||
event.rowid,
|
||||
CASE WHEN typeof(content->>'$.order')='TEXT' THEN content->>'$.order' ELSE '' END,
|
||||
CASE WHEN json_type(content, '$.suggested') IN ('true', 'false') THEN content->>'$.suggested' ELSE false END
|
||||
FROM current_state
|
||||
INNER JOIN event ON current_state.event_rowid = event.rowid
|
||||
LEFT JOIN room ON current_state.room_id = room.room_id
|
||||
WHERE type = 'm.space.child'
|
||||
AND json_array_length(event.content, '$.via') > 0
|
||||
AND event.state_key LIKE '!%'
|
||||
AND (room.room_id IS NULL OR room.room_type = 'm.space');
|
||||
|
||||
INSERT INTO space_edge (space_id, child_id, parent_event_rowid, canonical)
|
||||
SELECT
|
||||
event.state_key,
|
||||
event.room_id,
|
||||
event.rowid,
|
||||
CASE WHEN json_type(content, '$.canonical') IN ('true', 'false') THEN content->>'$.canonical' ELSE false END
|
||||
FROM current_state
|
||||
INNER JOIN event ON current_state.event_rowid = event.rowid
|
||||
LEFT JOIN room ON event.state_key = room.room_id
|
||||
WHERE type = 'm.space.parent'
|
||||
AND json_array_length(event.content, '$.via') > 0
|
||||
AND event.state_key LIKE '!%'
|
||||
AND (room.room_id IS NULL OR room.room_type = 'm.space')
|
||||
ON CONFLICT (space_id, child_id) DO UPDATE
|
||||
SET parent_event_rowid = excluded.parent_event_rowid,
|
||||
canonical = excluded.canonical;
|
||||
|
||||
UPDATE space_edge
|
||||
SET parent_validated=(SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM room
|
||||
INNER JOIN current_state cs ON cs.room_id = room.room_id AND cs.event_type = 'm.room.power_levels' AND cs.state_key = ''
|
||||
INNER JOIN event pls ON cs.event_rowid = pls.rowid
|
||||
INNER JOIN event edgeevt ON space_edge.parent_event_rowid = edgeevt.rowid
|
||||
WHERE room.room_id = space_edge.space_id
|
||||
AND room.room_type = 'm.space'
|
||||
AND COALESCE(
|
||||
(
|
||||
SELECT value
|
||||
FROM json_each(pls.content, '$.users')
|
||||
WHERE key=edgeevt.sender AND type='integer'
|
||||
),
|
||||
pls.content->>'$.users_default',
|
||||
0
|
||||
) >= COALESCE(
|
||||
pls.content->>'$.events."m.space.child"',
|
||||
pls.content->>'$.state_default',
|
||||
50
|
||||
)
|
||||
))
|
||||
WHERE parent_event_rowid IS NOT NULL;
|
19
pkg/hicli/database/upgrades/11-dm-user-id.sql
Normal file
|
@ -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;
|
10
pkg/hicli/database/upgrades/12-push-registrations.sql
Normal 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;
|
|
@ -59,7 +59,7 @@ func (h *HiClient) handleReceivedMegolmSession(ctx context.Context, roomID id.Ro
|
|||
}
|
||||
|
||||
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 {
|
||||
log.Warn().Err(err).Stringer("event_id", evt.ID).Msg("Failed to decrypt event even after receiving megolm session")
|
||||
} else {
|
||||
|
|
|
@ -21,24 +21,43 @@ type SyncRoom struct {
|
|||
AccountData map[event.Type]*database.AccountData `json:"account_data"`
|
||||
Events []*database.Event `json:"events"`
|
||||
Reset bool `json:"reset"`
|
||||
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
|
||||
|
||||
DismissNotifications bool `json:"dismiss_notifications"`
|
||||
Notifications []SyncNotification `json:"notifications"`
|
||||
}
|
||||
|
||||
type SyncNotification struct {
|
||||
RowID database.EventRowID `json:"event_rowid"`
|
||||
Sound bool `json:"sound"`
|
||||
Highlight bool `json:"highlight"`
|
||||
Event *database.Event `json:"-"`
|
||||
Room *database.Room `json:"-"`
|
||||
}
|
||||
|
||||
type SyncComplete struct {
|
||||
Since *string `json:"since,omitempty"`
|
||||
ClearState bool `json:"clear_state,omitempty"`
|
||||
Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
|
||||
AccountData map[event.Type]*database.AccountData `json:"account_data"`
|
||||
Rooms map[id.RoomID]*SyncRoom `json:"rooms"`
|
||||
LeftRooms []id.RoomID `json:"left_rooms"`
|
||||
InvitedRooms []*database.InvitedRoom `json:"invited_rooms"`
|
||||
SpaceEdges map[id.RoomID][]*database.SpaceEdge `json:"space_edges"`
|
||||
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
|
||||
}
|
||||
|
||||
func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) {
|
||||
for _, room := range c.Rooms {
|
||||
for _, notif := range room.Notifications {
|
||||
if !yield(notif) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -46,7 +65,8 @@ type SyncStatusType string
|
|||
const (
|
||||
SyncStatusOK SyncStatusType = "ok"
|
||||
SyncStatusWaiting SyncStatusType = "waiting"
|
||||
SyncStatusErrored SyncStatusType = "errored"
|
||||
SyncStatusErroring SyncStatusType = "erroring"
|
||||
SyncStatusFailed SyncStatusType = "permanently-failed"
|
||||
)
|
||||
|
||||
type SyncStatus struct {
|
||||
|
|
|
@ -252,7 +252,7 @@ func (h *HiClient) Sync() {
|
|||
log.Info().Msg("Starting syncing")
|
||||
err := h.Client.SyncWithContext(ctx)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
h.markSyncErrored(err)
|
||||
h.markSyncErrored(err, true)
|
||||
log.Err(err).Msg("Fatal error in syncer")
|
||||
} else {
|
||||
h.SyncStatus.Store(syncWaiting)
|
||||
|
|
|
@ -16,9 +16,7 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
|
|||
syncRoom := &SyncRoom{
|
||||
Meta: room,
|
||||
Events: make([]*database.Event, 0, 2),
|
||||
Timeline: make([]database.TimelineRowTuple, 0),
|
||||
State: map[event.Type]map[string]database.EventRowID{},
|
||||
Notifications: make([]SyncNotification, 0),
|
||||
}
|
||||
ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID)
|
||||
if err != nil {
|
||||
|
@ -26,7 +24,6 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
|
|||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
syncRoom.AccountData = make(map[event.Type]*database.AccountData)
|
||||
} else {
|
||||
syncRoom.AccountData = make(map[event.Type]*database.AccountData, len(ad))
|
||||
for _, data := range ad {
|
||||
|
@ -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] {
|
||||
return func(yield func(*SyncComplete) bool) {
|
||||
maxTS := time.Now().Add(1 * time.Hour)
|
||||
{
|
||||
spaces, err := h.DB.Room.GetAllSpaces(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get initial spaces to send to client")
|
||||
}
|
||||
return
|
||||
}
|
||||
payload := SyncComplete{
|
||||
Rooms: make(map[id.RoomID]*SyncRoom, len(spaces)),
|
||||
}
|
||||
for _, room := range spaces {
|
||||
payload.Rooms[room.ID] = h.getInitialSyncRoom(ctx, room)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
payload.TopLevelSpaces, err = h.DB.SpaceEdge.GetTopLevelIDs(ctx, h.Account.UserID)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get top-level space IDs to send to client")
|
||||
}
|
||||
return
|
||||
}
|
||||
payload.SpaceEdges, err = h.DB.SpaceEdge.GetAll(ctx, "")
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get space edges to send to client")
|
||||
}
|
||||
return
|
||||
}
|
||||
payload.InvitedRooms, err = h.DB.InvitedRoom.GetAll(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get invited rooms to send to client")
|
||||
}
|
||||
return
|
||||
}
|
||||
payload.ClearState = true
|
||||
if !yield(&payload) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for i := 0; ; i++ {
|
||||
rooms, err := h.DB.Room.GetBySortTS(ctx, maxTS, batchSize)
|
||||
if err != nil {
|
||||
|
@ -78,12 +118,7 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
|||
return
|
||||
}
|
||||
payload := SyncComplete{
|
||||
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)-1),
|
||||
LeftRooms: make([]id.RoomID, 0),
|
||||
AccountData: make(map[event.Type]*database.AccountData),
|
||||
}
|
||||
if i == 0 {
|
||||
payload.ClearState = true
|
||||
Rooms: make(map[id.RoomID]*SyncRoom, len(rooms)),
|
||||
}
|
||||
for _, room := range rooms {
|
||||
if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
|
||||
|
@ -95,7 +130,9 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
|||
return
|
||||
}
|
||||
}
|
||||
if !yield(&payload) || len(rooms) < batchSize {
|
||||
if !yield(&payload) {
|
||||
return
|
||||
} else if len(rooms) < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
@ -106,8 +143,6 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
|
|||
return
|
||||
}
|
||||
payload := SyncComplete{
|
||||
Rooms: make(map[id.RoomID]*SyncRoom, 0),
|
||||
LeftRooms: make([]id.RoomID, 0),
|
||||
AccountData: make(map[event.Type]*database.AccountData, len(ad)),
|
||||
}
|
||||
for _, data := range ad {
|
||||
|
|
|
@ -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 h.Client.GetProfile(ctx, params.UserID)
|
||||
})
|
||||
case "set_profile_field":
|
||||
return unmarshalAndCall(req.Data, func(params *setProfileFieldParams) (bool, error) {
|
||||
return true, h.Client.UnstableSetProfileField(ctx, params.Field, params.Value)
|
||||
})
|
||||
case "get_mutual_rooms":
|
||||
return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) {
|
||||
return h.GetMutualRooms(ctx, params.UserID)
|
||||
})
|
||||
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":
|
||||
return unmarshalAndCall(req.Data, func(params *getProfileParams) (*ProfileEncryptionInfo, error) {
|
||||
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 h.GetEvent(ctx, params.RoomID, params.EventID)
|
||||
})
|
||||
case "get_events_by_rowids":
|
||||
return unmarshalAndCall(req.Data, func(params *getEventsByRowIDsParams) ([]*database.Event, error) {
|
||||
return h.GetEventsByRowIDs(ctx, params.RowIDs)
|
||||
})
|
||||
//case "get_events_by_rowids":
|
||||
// return unmarshalAndCall(req.Data, func(params *getEventsByRowIDsParams) ([]*database.Event, error) {
|
||||
// return h.GetEventsByRowIDs(ctx, params.RowIDs)
|
||||
// })
|
||||
case "get_room_state":
|
||||
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
|
||||
return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch)
|
||||
|
@ -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 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":
|
||||
return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) {
|
||||
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 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":
|
||||
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
|
||||
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 h.Client.ResolveAlias(ctx, params.Alias)
|
||||
})
|
||||
case "request_openid_token":
|
||||
return h.Client.RequestOpenIDToken(ctx)
|
||||
case "logout":
|
||||
if h.LogoutFunc == nil {
|
||||
return nil, errors.New("logout not supported")
|
||||
|
@ -168,6 +201,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
|||
}
|
||||
return cli.GetLoginFlows(ctx)
|
||||
})
|
||||
case "register_push":
|
||||
return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
|
||||
return true, h.DB.PushRegistration.Put(ctx, params)
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown command %q", req.Command)
|
||||
}
|
||||
|
@ -246,14 +283,19 @@ type getProfileParams struct {
|
|||
UserID id.UserID `json:"user_id"`
|
||||
}
|
||||
|
||||
type setProfileFieldParams struct {
|
||||
Field string `json:"field"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type getEventParams struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
EventID id.EventID `json:"event_id"`
|
||||
}
|
||||
|
||||
type getEventsByRowIDsParams struct {
|
||||
RowIDs []database.EventRowID `json:"row_ids"`
|
||||
}
|
||||
//type getEventsByRowIDsParams struct {
|
||||
// RowIDs []database.EventRowID `json:"row_ids"`
|
||||
//}
|
||||
|
||||
type getRoomStateParams struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
|
@ -302,3 +344,19 @@ type paginateParams struct {
|
|||
MaxTimelineID database.TimelineRowID `json:"max_timeline_id"`
|
||||
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"`
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ import (
|
|||
|
||||
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...)
|
||||
if err != nil {
|
||||
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?
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
}*/
|
||||
|
||||
func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*database.Event, error) {
|
||||
if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
|
||||
|
@ -121,13 +121,14 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
|
|||
if err != nil {
|
||||
return fmt.Errorf("failed to save events: %w", err)
|
||||
}
|
||||
sdc := &spaceDataCollector{}
|
||||
for i := range currentStateEntries {
|
||||
currentStateEntries[i].EventRowID = dbEvts[i].RowID
|
||||
if mediaReferenceEntries[i] != nil {
|
||||
mediaReferenceEntries[i].EventRowID = dbEvts[i].RowID
|
||||
}
|
||||
if evts[i].Type != event.StateMember {
|
||||
processImportantEvent(ctx, evts[i], room, updatedRoom)
|
||||
processImportantEvent(ctx, evts[i], room, updatedRoom, dbEvts[i].RowID, sdc)
|
||||
}
|
||||
}
|
||||
err = h.DB.Media.AddMany(ctx, mediaCacheEntries)
|
||||
|
@ -146,6 +147,11 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
|
|||
return fmt.Errorf("failed to save current state entries: %w", err)
|
||||
}
|
||||
roomChanged := updatedRoom.CheckChangesAndCopyInto(room)
|
||||
// TODO dispatch space edge changes if something changed? (fairly unlikely though)
|
||||
err = sdc.Apply(ctx, room, h.DB.SpaceEdge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if roomChanged {
|
||||
err = h.DB.Room.Upsert(ctx, updatedRoom)
|
||||
if err != nil {
|
||||
|
@ -156,16 +162,8 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
|
|||
Rooms: map[id.RoomID]*SyncRoom{
|
||||
roomID: {
|
||||
Meta: room,
|
||||
Timeline: make([]database.TimelineRowTuple, 0),
|
||||
State: make(map[event.Type]map[string]database.EventRowID),
|
||||
AccountData: make(map[event.Type]*database.AccountData),
|
||||
Events: make([]*database.Event, 0),
|
||||
Reset: false,
|
||||
Notifications: make([]SyncNotification, 0),
|
||||
},
|
||||
},
|
||||
AccountData: make(map[event.Type]*database.AccountData),
|
||||
LeftRooms: make([]id.RoomID, 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -197,6 +195,8 @@ func (h *HiClient) GetRoomState(ctx context.Context, roomID id.RoomID, includeMe
|
|||
|
||||
type PaginationResponse struct {
|
||||
Events []*database.Event `json:"events"`
|
||||
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
|
||||
RelatedEvents []*database.Event `json:"related_events"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
|
@ -204,14 +204,77 @@ func (h *HiClient) Paginate(ctx context.Context, roomID id.RoomID, maxTimelineID
|
|||
evts, err := h.DB.Timeline.Get(ctx, roomID, limit, maxTimelineID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if len(evts) > 0 {
|
||||
}
|
||||
var resp *PaginationResponse
|
||||
if len(evts) > 0 {
|
||||
for _, evt := range evts {
|
||||
h.ReprocessExistingEvent(ctx, evt)
|
||||
}
|
||||
return &PaginationResponse{Events: evts, HasMore: true}, nil
|
||||
resp = &PaginationResponse{Events: evts, HasMore: true}
|
||||
} 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) {
|
||||
|
|
|
@ -91,3 +91,8 @@ func (h *HiClient) GetProfileEncryptionInfo(ctx context.Context, userID id.UserI
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
@ -71,6 +71,11 @@ func (h *HiClient) SendMessage(
|
|||
relatesTo *event.RelatesTo,
|
||||
mentions *event.Mentions,
|
||||
) (*database.Event, error) {
|
||||
var unencrypted bool
|
||||
if strings.HasPrefix(text, "/unencrypted ") {
|
||||
text = strings.TrimPrefix(text, "/unencrypted ")
|
||||
unencrypted = true
|
||||
}
|
||||
if strings.HasPrefix(text, "/raw ") {
|
||||
parts := strings.SplitN(text, " ", 3)
|
||||
if len(parts) < 2 || len(parts[1]) == 0 {
|
||||
|
@ -85,7 +90,18 @@ func (h *HiClient) SendMessage(
|
|||
if !json.Valid(content) {
|
||||
return nil, fmt.Errorf("invalid JSON in /raw command")
|
||||
}
|
||||
return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "")
|
||||
return h.send(ctx, roomID, event.Type{Type: parts[1]}, content, "", unencrypted)
|
||||
} else if strings.HasPrefix(text, "/rawstate ") {
|
||||
parts := strings.SplitN(text, " ", 4)
|
||||
if len(parts) < 4 || len(parts[1]) == 0 {
|
||||
return nil, fmt.Errorf("invalid /rawstate command")
|
||||
}
|
||||
content := json.RawMessage(parts[3])
|
||||
if !json.Valid(content) {
|
||||
return nil, fmt.Errorf("invalid JSON in /rawstate command")
|
||||
}
|
||||
_, err := h.SetState(ctx, roomID, event.Type{Type: parts[1], Class: event.StateEventType}, parts[2], content)
|
||||
return nil, err
|
||||
}
|
||||
var content event.MessageEventContent
|
||||
msgType := event.MsgText
|
||||
|
@ -148,7 +164,12 @@ func (h *HiClient) SendMessage(
|
|||
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 {
|
||||
|
@ -212,7 +233,7 @@ func (h *HiClient) Send(
|
|||
evtType event.Type,
|
||||
content any,
|
||||
) (*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) {
|
||||
|
@ -241,6 +262,7 @@ func (h *HiClient) send(
|
|||
evtType event.Type,
|
||||
content any,
|
||||
overrideEditSource string,
|
||||
disableEncryption bool,
|
||||
) (*database.Event, error) {
|
||||
room, err := h.DB.Room.Get(ctx, roomID)
|
||||
if err != nil {
|
||||
|
@ -261,7 +283,7 @@ func (h *HiClient) send(
|
|||
Reactions: map[string]int{},
|
||||
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.DecryptedType = evtType.Type
|
||||
dbEvt.Decrypted, err = json.Marshal(content)
|
||||
|
@ -281,7 +303,7 @@ func (h *HiClient) send(
|
|||
var inlineImages []id.ContentURI
|
||||
mautrixEvt := dbEvt.AsRawMautrix()
|
||||
dbEvt.LocalContent, inlineImages = h.calculateLocalContent(ctx, dbEvt, mautrixEvt)
|
||||
if overrideEditSource != "" {
|
||||
if overrideEditSource != "" && dbEvt.LocalContent != nil {
|
||||
dbEvt.LocalContent.EditSource = overrideEditSource
|
||||
}
|
||||
_, err = h.DB.Event.Insert(ctx, dbEvt)
|
||||
|
|
|
@ -39,13 +39,16 @@ type syncContext struct {
|
|||
evt *SyncComplete
|
||||
}
|
||||
|
||||
func (h *HiClient) markSyncErrored(err error) {
|
||||
func (h *HiClient) markSyncErrored(err error, permanent bool) {
|
||||
stat := &SyncStatus{
|
||||
Type: SyncStatusErrored,
|
||||
Type: SyncStatusErroring,
|
||||
Error: err.Error(),
|
||||
ErrorCount: h.syncErrors,
|
||||
LastSync: jsontime.UM(h.lastSync),
|
||||
}
|
||||
if permanent {
|
||||
stat.Type = SyncStatusFailed
|
||||
}
|
||||
h.SyncStatus.Store(stat)
|
||||
h.EventHandler(stat)
|
||||
}
|
||||
|
@ -85,6 +88,7 @@ func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.Res
|
|||
}
|
||||
}
|
||||
resp.ToDevice.Events = postponedToDevices
|
||||
h.Crypto.MarkOlmHashSavePoint(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -148,14 +152,20 @@ func (h *HiClient) processSyncResponse(ctx context.Context, resp *mautrix.RespSy
|
|||
}
|
||||
}
|
||||
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 {
|
||||
err := h.processSyncJoinedRoom(ctx, roomID, room)
|
||||
err = h.processSyncJoinedRoom(ctx, roomID, room)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to process joined room %s: %w", roomID, err)
|
||||
}
|
||||
}
|
||||
for roomID, room := range resp.Rooms.Leave {
|
||||
err := h.processSyncLeftRoom(ctx, roomID, room)
|
||||
err = h.processSyncLeftRoom(ctx, roomID, room)
|
||||
if err != nil {
|
||||
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 {
|
||||
newOwnReceipts = append(newOwnReceipts, eventID)
|
||||
}
|
||||
if receiptInfo.ThreadID == event.ReadReceiptThreadMain {
|
||||
receiptInfo.ThreadID = ""
|
||||
}
|
||||
receiptList = append(receiptList, &database.Receipt{
|
||||
UserID: userID,
|
||||
ReceiptType: receiptType,
|
||||
|
@ -190,6 +203,27 @@ func (h *HiClient) receiptsToList(content *event.ReceiptEventContent) ([]*databa
|
|||
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 {
|
||||
existingRoomData, err := h.DB.Room.Get(ctx, roomID)
|
||||
if err != nil {
|
||||
|
@ -259,6 +293,10 @@ func (h *HiClient) processSyncLeftRoom(ctx context.Context, roomID id.RoomID, ro
|
|||
if err != nil {
|
||||
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.LeftRooms = append(payload.LeftRooms, roomID)
|
||||
return nil
|
||||
|
@ -288,20 +326,34 @@ func removeReplyFallback(evt *event.Event) []byte {
|
|||
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)
|
||||
if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) {
|
||||
return nil, nil, "", err
|
||||
return nil, nil, false, "", err
|
||||
}
|
||||
decrypted, err := h.Crypto.DecryptMegolmEvent(ctx, evt)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
return nil, nil, false, "", err
|
||||
}
|
||||
withoutFallback := removeReplyFallback(decrypted)
|
||||
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(
|
||||
|
@ -344,12 +396,7 @@ func (h *HiClient) addMediaCache(
|
|||
}
|
||||
|
||||
func (h *HiClient) cacheMedia(ctx context.Context, evt *event.Event, rowID database.EventRowID) {
|
||||
switch evt.Type {
|
||||
case event.EventMessage, event.EventSticker:
|
||||
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cacheMessageEventContent := func(content *event.MessageEventContent) {
|
||||
if content.File != nil {
|
||||
h.addMediaCache(ctx, rowID, content.File.URL, content.File, content.Info, content.GetFileName())
|
||||
} else if content.URL != "" {
|
||||
|
@ -360,6 +407,35 @@ func (h *HiClient) cacheMedia(ctx context.Context, evt *event.Event, rowID datab
|
|||
} else if content.GetInfo().ThumbnailURL != "" {
|
||||
h.addMediaCache(ctx, rowID, content.Info.ThumbnailURL, nil, content.Info.ThumbnailInfo, "")
|
||||
}
|
||||
|
||||
for _, image := range content.BeeperGalleryImages {
|
||||
h.cacheMedia(ctx, &event.Event{
|
||||
Type: event.EventMessage,
|
||||
Content: event.Content{Parsed: image},
|
||||
}, rowID)
|
||||
}
|
||||
|
||||
for _, preview := range content.BeeperLinkPreviews {
|
||||
info := &event.FileInfo{MimeType: preview.ImageType}
|
||||
if preview.ImageEncryption != nil {
|
||||
h.addMediaCache(ctx, rowID, preview.ImageEncryption.URL, preview.ImageEncryption, info, "")
|
||||
} else if preview.ImageURL != "" {
|
||||
h.addMediaCache(ctx, rowID, preview.ImageURL, nil, info, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch evt.Type {
|
||||
case event.EventMessage, event.EventSticker:
|
||||
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
cacheMessageEventContent(content)
|
||||
if content.NewContent != nil {
|
||||
cacheMessageEventContent(content.NewContent)
|
||||
}
|
||||
case event.StateRoomAvatar:
|
||||
_ = evt.Content.ParseRaw(evt.Type)
|
||||
content, ok := evt.Content.Parsed.(*event.RoomAvatarEventContent)
|
||||
|
@ -448,6 +524,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
|
|||
BigEmoji: bigEmoji,
|
||||
HasMath: hasMath,
|
||||
EditSource: editSource,
|
||||
ReplyFallbackRemoved: dbEvt.LocalContent.GetReplyFallbackRemoved(),
|
||||
}, inlineImages
|
||||
}
|
||||
return nil, nil
|
||||
|
@ -499,14 +576,12 @@ func (h *HiClient) processEvent(
|
|||
contentWithoutFallback := removeReplyFallback(evt)
|
||||
if contentWithoutFallback != nil {
|
||||
dbEvt.Content = contentWithoutFallback
|
||||
dbEvt.MarkReplyFallbackRemoved()
|
||||
}
|
||||
var decryptionErr error
|
||||
var decryptedMautrixEvt *event.Event
|
||||
if evt.Type == event.EventEncrypted && dbEvt.RedactedBy == "" {
|
||||
decryptedMautrixEvt, dbEvt.Decrypted, dbEvt.DecryptedType, decryptionErr = h.decryptEvent(ctx, evt)
|
||||
if decryptionErr != nil {
|
||||
dbEvt.DecryptionError = decryptionErr.Error()
|
||||
}
|
||||
decryptedMautrixEvt, decryptionErr = h.decryptEventInto(ctx, evt, dbEvt)
|
||||
} else if evt.Type == event.EventRedaction {
|
||||
if evt.Redacts != "" && gjson.GetBytes(evt.Content.VeryRaw, "redacts").Str != evt.Redacts.String() {
|
||||
var err error
|
||||
|
@ -592,8 +667,10 @@ func (h *HiClient) processStateAndTimeline(
|
|||
updatedRoom.LazyLoadSummary = summary
|
||||
heroesChanged = true
|
||||
}
|
||||
sdc := &spaceDataCollector{}
|
||||
decryptionQueue := make(map[id.SessionID]*database.SessionRequest)
|
||||
allNewEvents := make([]*database.Event, 0, len(state.Events)+len(timeline.Events))
|
||||
addedEvents := make(map[database.EventRowID]struct{})
|
||||
newNotifications := make([]SyncNotification, 0)
|
||||
var recalculatePreviewEvent, unreadMessagesWereMaybeRedacted bool
|
||||
var newUnreadCounts database.UnreadCounts
|
||||
|
@ -608,7 +685,11 @@ func (h *HiClient) processStateAndTimeline(
|
|||
} else if dbEvt == nil {
|
||||
return nil, nil
|
||||
}
|
||||
_, alreadyAdded := addedEvents[dbEvt.RowID]
|
||||
if !alreadyAdded {
|
||||
addedEvents[dbEvt.RowID] = struct{}{}
|
||||
allNewEvents = append(allNewEvents, dbEvt)
|
||||
}
|
||||
return dbEvt, nil
|
||||
}
|
||||
processRedaction := func(evt *event.Event) error {
|
||||
|
@ -645,6 +726,9 @@ func (h *HiClient) processStateAndTimeline(
|
|||
newNotifications = append(newNotifications, SyncNotification{
|
||||
RowID: dbEvt.RowID,
|
||||
Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound),
|
||||
Highlight: dbEvt.UnreadType.Is(database.UnreadTypeHighlight),
|
||||
Event: dbEvt,
|
||||
Room: room,
|
||||
})
|
||||
}
|
||||
newUnreadCounts.AddOne(dbEvt.UnreadType)
|
||||
|
@ -670,9 +754,10 @@ func (h *HiClient) processStateAndTimeline(
|
|||
if err != nil {
|
||||
return -1, fmt.Errorf("failed to save current state event ID %s for %s/%s: %w", evt.ID, evt.Type.Type, *evt.StateKey, err)
|
||||
}
|
||||
processImportantEvent(ctx, evt, room, updatedRoom)
|
||||
processImportantEvent(ctx, evt, room, updatedRoom, dbEvt.RowID, sdc)
|
||||
}
|
||||
allNewEvents = append(allNewEvents, dbEvt)
|
||||
addedEvents[dbEvt.RowID] = struct{}{}
|
||||
if evt.Type == event.EventRedaction && evt.Redacts != "" {
|
||||
err = processRedaction(evt)
|
||||
if err != nil {
|
||||
|
@ -683,6 +768,11 @@ func (h *HiClient) processStateAndTimeline(
|
|||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
@ -702,15 +792,38 @@ func (h *HiClient) processStateAndTimeline(
|
|||
setNewState(evt.Type, *evt.StateKey, rowID)
|
||||
}
|
||||
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
|
||||
if len(timeline.Events) > 0 {
|
||||
timelineIDs := make([]database.EventRowID, len(timeline.Events))
|
||||
encounteredReceiptUsers := make(map[id.UserID]struct{})
|
||||
readUpToIndex := -1
|
||||
for i := len(timeline.Events) - 1; i >= 0; i-- {
|
||||
evt := timeline.Events[i]
|
||||
for _, receipt := range receiptMap[evt.ID] {
|
||||
encounteredReceiptUsers[receipt.UserID] = struct{}{}
|
||||
}
|
||||
isRead := slices.Contains(newOwnReceipts, evt.ID)
|
||||
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
|
||||
// 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.
|
||||
|
@ -725,7 +838,6 @@ func (h *HiClient) processStateAndTimeline(
|
|||
})
|
||||
newOwnReceipts = append(newOwnReceipts, evt.ID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
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
|
||||
if (heroesChanged && updatedRoom.NameQuality <= database.NameQualityParticipants) || updatedRoom.NameQuality == database.NameQualityNil {
|
||||
name, dmAvatarURL, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
|
||||
name, dmAvatarURL, dmUserID, err := h.calculateRoomParticipantName(ctx, room.ID, summary)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to calculate room name: %w", err)
|
||||
}
|
||||
updatedRoom.DMUserID = &dmUserID
|
||||
updatedRoom.Name = &name
|
||||
updatedRoom.NameQuality = database.NameQualityParticipants
|
||||
if !dmAvatarURL.IsEmpty() && !room.ExplicitAvatar {
|
||||
|
@ -814,6 +927,7 @@ func (h *HiClient) processStateAndTimeline(
|
|||
} else {
|
||||
updatedRoom.UnreadCounts.Add(newUnreadCounts)
|
||||
}
|
||||
dismissNotifications := room.UnreadNotifications > 0 && updatedRoom.UnreadNotifications == 0 && len(newNotifications) == 0
|
||||
if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) {
|
||||
updatedRoom.PrevBatch = timeline.PrevBatch
|
||||
}
|
||||
|
@ -824,8 +938,15 @@ func (h *HiClient) processStateAndTimeline(
|
|||
return fmt.Errorf("failed to save room data: %w", err)
|
||||
}
|
||||
}
|
||||
err = sdc.Apply(ctx, room, h.DB.SpaceEdge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO why is *old* unread count sometimes zero when processing the read receipt that is making it zero?
|
||||
if roomChanged || len(accountData) > 0 || len(newOwnReceipts) > 0 || len(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{
|
||||
Meta: room,
|
||||
Timeline: timelineRowTuples,
|
||||
|
@ -833,7 +954,10 @@ func (h *HiClient) processStateAndTimeline(
|
|||
State: changedState,
|
||||
Reset: timeline.Limited,
|
||||
Events: allNewEvents,
|
||||
Receipts: receiptMap,
|
||||
|
||||
Notifications: newNotifications,
|
||||
DismissNotifications: dismissNotifications,
|
||||
}
|
||||
}
|
||||
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
|
||||
if summary == nil || len(summary.Heroes) == 0 {
|
||||
return "Empty room", primaryAvatarURL, nil
|
||||
return "Empty room", primaryAvatarURL, "", nil
|
||||
}
|
||||
var functionalMembers []id.UserID
|
||||
functionalMembersEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateElementFunctionalMembers, "")
|
||||
if err != nil {
|
||||
return "", primaryAvatarURL, fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err)
|
||||
return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s event: %w", event.StateElementFunctionalMembers.Type, err)
|
||||
} else if functionalMembersEvt != nil {
|
||||
mautrixEvt := functionalMembersEvt.AsRawMautrix()
|
||||
_ = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)
|
||||
|
@ -873,16 +997,21 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
|
|||
} else if summary.InvitedMemberCount != nil {
|
||||
memberCount = *summary.InvitedMemberCount
|
||||
}
|
||||
var dmUserID id.UserID
|
||||
for _, hero := range summary.Heroes {
|
||||
if slices.Contains(functionalMembers, hero) {
|
||||
// TODO save member count so push rule evaluation would use the subtracted one?
|
||||
memberCount--
|
||||
continue
|
||||
} else if len(members) >= 5 {
|
||||
break
|
||||
}
|
||||
if dmUserID == "" {
|
||||
dmUserID = hero
|
||||
}
|
||||
heroEvt, err := h.DB.CurrentState.Get(ctx, roomID, event.StateMember, hero.String())
|
||||
if err != nil {
|
||||
return "", primaryAvatarURL, fmt.Errorf("failed to get %s's member event: %w", hero, err)
|
||||
return "", primaryAvatarURL, "", fmt.Errorf("failed to get %s's member event: %w", hero, err)
|
||||
} else if heroEvt == nil {
|
||||
leftMembers = append(leftMembers, hero.String())
|
||||
continue
|
||||
|
@ -898,19 +1027,28 @@ func (h *HiClient) calculateRoomParticipantName(ctx context.Context, roomID id.R
|
|||
}
|
||||
if membership == "join" || membership == "invite" {
|
||||
members = append(members, name)
|
||||
dmUserID = hero
|
||||
} else {
|
||||
leftMembers = append(leftMembers, name)
|
||||
}
|
||||
}
|
||||
if len(members)+len(leftMembers) > 1 || !primaryAvatarURL.IsValid() {
|
||||
if !primaryAvatarURL.IsValid() {
|
||||
primaryAvatarURL = id.ContentURI{}
|
||||
}
|
||||
if len(members) > 0 {
|
||||
return joinMemberNames(members, memberCount), primaryAvatarURL, nil
|
||||
if len(members) > 1 {
|
||||
primaryAvatarURL = id.ContentURI{}
|
||||
dmUserID = ""
|
||||
}
|
||||
return joinMemberNames(members, memberCount), primaryAvatarURL, dmUserID, nil
|
||||
} else if len(leftMembers) > 0 {
|
||||
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, nil
|
||||
if len(leftMembers) > 1 {
|
||||
primaryAvatarURL = id.ContentURI{}
|
||||
dmUserID = ""
|
||||
}
|
||||
return fmt.Sprintf("Empty room (was %s)", joinMemberNames(leftMembers, memberCount)), primaryAvatarURL, "", nil
|
||||
} else {
|
||||
return "Empty room", primaryAvatarURL, nil
|
||||
return "Empty room", primaryAvatarURL, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -921,20 +1059,112 @@ func intPtrEqual(a, b *int) bool {
|
|||
return *a == *b
|
||||
}
|
||||
|
||||
func processImportantEvent(ctx context.Context, evt *event.Event, existingRoomData, updatedRoom *database.Room) (roomDataChanged bool) {
|
||||
type spaceDataCollector struct {
|
||||
Children []database.SpaceChildEntry
|
||||
Parents []database.SpaceParentEntry
|
||||
RemovedChildren []id.RoomID
|
||||
RemovedParents []id.RoomID
|
||||
PowerLevelChanged bool
|
||||
IsFullState bool
|
||||
}
|
||||
|
||||
func (sdc *spaceDataCollector) Collect(evt *event.Event, rowID database.EventRowID) {
|
||||
switch evt.Type {
|
||||
case event.StatePowerLevels:
|
||||
sdc.PowerLevelChanged = true
|
||||
case event.StateCreate:
|
||||
sdc.IsFullState = true
|
||||
case event.StateSpaceChild:
|
||||
content := evt.Content.AsSpaceChild()
|
||||
if len(content.Via) == 0 {
|
||||
sdc.RemovedChildren = append(sdc.RemovedChildren, id.RoomID(*evt.StateKey))
|
||||
} else {
|
||||
sdc.Children = append(sdc.Children, database.SpaceChildEntry{
|
||||
ChildID: id.RoomID(*evt.StateKey),
|
||||
EventRowID: rowID,
|
||||
Order: content.Order,
|
||||
Suggested: content.Suggested,
|
||||
})
|
||||
}
|
||||
case event.StateSpaceParent:
|
||||
content := evt.Content.AsSpaceParent()
|
||||
if len(content.Via) == 0 {
|
||||
sdc.RemovedParents = append(sdc.RemovedParents, id.RoomID(*evt.StateKey))
|
||||
} else {
|
||||
sdc.Parents = append(sdc.Parents, database.SpaceParentEntry{
|
||||
ParentID: id.RoomID(*evt.StateKey),
|
||||
EventRowID: rowID,
|
||||
Canonical: content.Canonical,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sdc *spaceDataCollector) Apply(ctx context.Context, room *database.Room, seq *database.SpaceEdgeQuery) error {
|
||||
if room.CreationContent == nil || room.CreationContent.Type != event.RoomTypeSpace {
|
||||
sdc.Children = nil
|
||||
sdc.RemovedChildren = nil
|
||||
sdc.PowerLevelChanged = false
|
||||
}
|
||||
if len(sdc.Children) == 0 && len(sdc.RemovedChildren) == 0 &&
|
||||
len(sdc.Parents) == 0 && len(sdc.RemovedParents) == 0 &&
|
||||
!sdc.PowerLevelChanged {
|
||||
return nil
|
||||
}
|
||||
return seq.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
|
||||
if len(sdc.Children) > 0 || len(sdc.RemovedChildren) > 0 {
|
||||
err := seq.SetChildren(ctx, room.ID, sdc.Children, sdc.RemovedChildren, sdc.IsFullState)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set space children: %w", err)
|
||||
}
|
||||
}
|
||||
if len(sdc.Parents) > 0 || len(sdc.RemovedParents) > 0 {
|
||||
err := seq.SetParents(ctx, room.ID, sdc.Parents, sdc.RemovedParents, sdc.IsFullState)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set space parents: %w", err)
|
||||
}
|
||||
if len(sdc.Parents) > 0 {
|
||||
err = seq.RevalidateAllParentsOfRoomValidity(ctx, room.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to revalidate own parent references: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sdc.PowerLevelChanged {
|
||||
err := seq.RevalidateAllChildrenOfParentValidity(ctx, room.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to revalidate child parent references to self: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func processImportantEvent(
|
||||
ctx context.Context,
|
||||
evt *event.Event,
|
||||
existingRoomData, updatedRoom *database.Room,
|
||||
rowID database.EventRowID,
|
||||
sdc *spaceDataCollector,
|
||||
) (roomDataChanged bool) {
|
||||
if evt.StateKey == nil {
|
||||
return
|
||||
}
|
||||
switch evt.Type {
|
||||
case event.StateCreate, event.StateTombstone, event.StateRoomName, event.StateCanonicalAlias,
|
||||
event.StateRoomAvatar, event.StateTopic, event.StateEncryption:
|
||||
event.StateRoomAvatar, event.StateTopic, event.StateEncryption, event.StatePowerLevels:
|
||||
if *evt.StateKey != "" {
|
||||
return
|
||||
}
|
||||
case event.StateSpaceChild, event.StateSpaceParent:
|
||||
if !strings.HasPrefix(*evt.StateKey, "!") {
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
err := evt.Content.ParseRaw(evt.Type)
|
||||
sdc.Collect(evt, rowID)
|
||||
if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) {
|
||||
zerolog.Ctx(ctx).Warn().Err(err).
|
||||
Stringer("event_type", &evt.Type).
|
||||
|
|
|
@ -8,11 +8,16 @@ package hicli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/gomuks/pkg/hicli/database"
|
||||
)
|
||||
|
||||
type hiSyncer HiClient
|
||||
|
@ -31,17 +36,27 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync,
|
|||
ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{
|
||||
Since: &since,
|
||||
Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)),
|
||||
InvitedRooms: make([]*database.InvitedRoom, 0, len(resp.Rooms.Invite)),
|
||||
LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)),
|
||||
}})
|
||||
err := c.preProcessSyncResponse(ctx, resp, since)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; ; i++ {
|
||||
err = c.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
|
||||
return c.processSyncResponse(ctx, resp, since)
|
||||
})
|
||||
if err != nil {
|
||||
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.syncErrors = 0
|
||||
|
@ -56,7 +71,7 @@ func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration,
|
|||
if c.syncErrors > 5 {
|
||||
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")
|
||||
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 {
|
||||
if !h.Verified {
|
||||
return &mautrix.Filter{
|
||||
Presence: mautrix.FilterPart{
|
||||
Presence: &mautrix.FilterPart{
|
||||
NotRooms: []id.RoomID{"*"},
|
||||
},
|
||||
Room: mautrix.RoomFilter{
|
||||
Room: &mautrix.RoomFilter{
|
||||
NotRooms: []id.RoomID{"*"},
|
||||
},
|
||||
}
|
||||
}
|
||||
return &mautrix.Filter{
|
||||
Presence: mautrix.FilterPart{
|
||||
Presence: &mautrix.FilterPart{
|
||||
NotRooms: []id.RoomID{"*"},
|
||||
},
|
||||
Room: mautrix.RoomFilter{
|
||||
State: mautrix.FilterPart{
|
||||
Room: &mautrix.RoomFilter{
|
||||
State: &mautrix.FilterPart{
|
||||
LazyLoadMembers: true,
|
||||
},
|
||||
Timeline: mautrix.FilterPart{
|
||||
Timeline: &mautrix.FilterPart{
|
||||
Limit: 100,
|
||||
LazyLoadMembers: true,
|
||||
},
|
||||
|
|
|
@ -72,5 +72,5 @@ func init() {
|
|||
builtWith = fmt.Sprintf("built at %s with %s", BuildTime, runtime.Version())
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -73,8 +73,9 @@ export default tseslint.config(
|
|||
"one-var-declaration-per-line": ["error", "initializations"],
|
||||
"quotes": ["error", "double", {allowTemplateLiterals: true}],
|
||||
"semi": ["error", "never"],
|
||||
"curly": ["error", "all"],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"max-len": ["warn", 120],
|
||||
"max-len": ["error", 120],
|
||||
"space-before-function-paren": ["error", {
|
||||
"anonymous": "never",
|
||||
"named": "never",
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-gomuks="true">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/png" href="gomuks.png"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link id="favicon" rel="icon" type="image/png" href="gomuks.png"/>
|
||||
<link rel="manifest" href="manifest.json"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, interactive-widget=resizes-content"/>
|
||||
<title>gomuks web</title>
|
||||
<!-- etag placeholder -->
|
||||
</head>
|
||||
|
@ -11,5 +12,16 @@
|
|||
<div id="root"></div>
|
||||
<script type="module" src="src/main.tsx"></script>
|
||||
<audio id="default-notification-sound" preload="auto" src="sounds/bright.flac"></audio>
|
||||
<svg style="position: absolute; width: 0; height: 0;" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="squircle" clipPathUnits="objectBoundingBox">
|
||||
<path d="M 0,0.5
|
||||
C 0,0 0,0 0.5,0
|
||||
1,0 1,0 1,0.5
|
||||
1,1 1,1 0.5,1
|
||||
0,1 0,1 0,0.5"></path>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</body>
|
||||
</html>
|
||||
|
|
1358
web/package-lock.json
generated
BIN
web/public/gomuks-maskable-transparent.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
web/public/gomuks-maskable.png
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
web/public/gomuks-transparent.png
Normal file
After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 34 KiB |
32
web/public/manifest.json
Normal 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"
|
||||
}
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { useEffect, useLayoutEffect, useMemo } from "react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { ScaleLoader } from "react-spinners"
|
||||
import Client from "./api/client.ts"
|
||||
import RPCClient from "./api/rpc.ts"
|
||||
|
@ -22,7 +22,7 @@ import WSClient from "./api/wsclient.ts"
|
|||
import ClientContext from "./ui/ClientContext.ts"
|
||||
import MainScreen from "./ui/MainScreen.tsx"
|
||||
import { LoginScreen, VerificationScreen } from "./ui/login"
|
||||
import { LightboxWrapper } from "./ui/modal/Lightbox.tsx"
|
||||
import { LightboxWrapper } from "./ui/modal"
|
||||
import { useEventAsState } from "./util/eventdispatcher.ts"
|
||||
|
||||
function makeRPCClient(): RPCClient {
|
||||
|
@ -36,10 +36,10 @@ function App() {
|
|||
const client = useMemo(() => new Client(makeRPCClient()), [])
|
||||
const connState = useEventAsState(client.rpc.connect)
|
||||
const clientState = useEventAsState(client.state)
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
window.client = client
|
||||
return client.start()
|
||||
}, [client])
|
||||
useEffect(() => client.start(), [client])
|
||||
|
||||
const afterConnectError = Boolean(connState?.error && connState.reconnecting && clientState?.is_verified)
|
||||
useEffect(() => {
|
||||
|
@ -70,18 +70,18 @@ function App() {
|
|||
</div> : null
|
||||
|
||||
if (connState?.error && !afterConnectError) {
|
||||
return errorOverlay
|
||||
return <div className="pre-main">{errorOverlay}</div>
|
||||
} else if ((!connState?.connected && !afterConnectError) || !clientState) {
|
||||
const msg = connState?.connected ?
|
||||
"Waiting for client state..." : "Connecting to backend..."
|
||||
return <div className="pre-connect">
|
||||
return <div className="pre-main waiting-to-connect">
|
||||
<ScaleLoader width="2rem" height="2rem" color="var(--primary-color)"/>
|
||||
{msg}
|
||||
</div>
|
||||
} else if (!clientState.is_logged_in) {
|
||||
return <LoginScreen client={client} clientState={clientState}/>
|
||||
return <div className="pre-main"><LoginScreen client={client} clientState={clientState}/></div>
|
||||
} else if (!clientState.is_verified) {
|
||||
return <VerificationScreen client={client} clientState={clientState}/>
|
||||
return <div className="pre-main"><VerificationScreen client={client} clientState={clientState}/></div>
|
||||
} else {
|
||||
return <ClientContext value={client}>
|
||||
<LightboxWrapper>
|
||||
|
|
|
@ -22,6 +22,7 @@ import type {
|
|||
ElementRecentEmoji,
|
||||
EventID,
|
||||
EventType,
|
||||
GomuksAndroidMessageToWeb,
|
||||
ImagePackRooms,
|
||||
RPCEvent,
|
||||
RawDBEvent,
|
||||
|
@ -37,7 +38,7 @@ export default class Client {
|
|||
readonly initComplete = new NonNullCachedEventDispatcher<boolean>(false)
|
||||
readonly store = new StateStore()
|
||||
#stateRequests: RoomStateGUID[] = []
|
||||
#stateRequestQueued = false
|
||||
#stateRequestPromise: Promise<void> | null = null
|
||||
#gcInterval: number | undefined
|
||||
|
||||
constructor(readonly rpc: RPCClient) {
|
||||
|
@ -71,6 +72,74 @@ export default class Client {
|
|||
this.requestNotificationPermission()
|
||||
}
|
||||
|
||||
async #reallyStartAndroid(signal: AbortSignal) {
|
||||
const androidListener = async (evt: CustomEventInit<string>) => {
|
||||
const evtData = JSON.parse(evt.detail ?? "{}") as GomuksAndroidMessageToWeb
|
||||
switch (evtData.type) {
|
||||
case "register_push":
|
||||
await this.rpc.registerPush({
|
||||
type: "fcm",
|
||||
device_id: evtData.device_id,
|
||||
data: evtData.token,
|
||||
encryption: evtData.encryption,
|
||||
expiration: evtData.expiration,
|
||||
})
|
||||
return
|
||||
case "auth":
|
||||
try {
|
||||
const resp = await fetch("_gomuks/auth?no_prompt=true", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: evtData.authorization,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (!resp.ok && !signal.aborted) {
|
||||
console.error("Failed to authenticate:", resp.status, resp.statusText)
|
||||
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
|
||||
detail: {
|
||||
event: "auth_fail",
|
||||
error: `${resp.statusText || resp.status}`,
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to authenticate:", err)
|
||||
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
|
||||
detail: {
|
||||
event: "auth_fail",
|
||||
error: `${err}`.replace(/^Error: /, ""),
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
console.log("Successfully authenticated, connecting to websocket")
|
||||
this.rpc.start()
|
||||
return
|
||||
}
|
||||
}
|
||||
const unsubscribeConnect = this.rpc.connect.listen(evt => {
|
||||
if (!evt.connected) {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
|
||||
detail: { event: "connected" },
|
||||
}))
|
||||
})
|
||||
window.addEventListener("GomuksAndroidMessageToWeb", androidListener)
|
||||
signal.addEventListener("abort", () => {
|
||||
unsubscribeConnect()
|
||||
window.removeEventListener("GomuksAndroidMessageToWeb", androidListener)
|
||||
})
|
||||
window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
|
||||
detail: { event: "ready" },
|
||||
}))
|
||||
}
|
||||
|
||||
requestNotificationPermission = (evt?: MouseEvent) => {
|
||||
window.Notification?.requestPermission().then(permission => {
|
||||
console.log("Notification permission:", permission)
|
||||
|
@ -86,7 +155,11 @@ export default class Client {
|
|||
|
||||
start(): () => void {
|
||||
const abort = new AbortController()
|
||||
if (window.gomuksAndroid) {
|
||||
this.#reallyStartAndroid(abort.signal)
|
||||
} else {
|
||||
this.#reallyStart(abort.signal)
|
||||
}
|
||||
this.#gcInterval = setInterval(() => {
|
||||
console.log("Garbage collection completed:", this.store.doGarbageCollection())
|
||||
}, window.gcSettings.interval)
|
||||
|
@ -104,6 +177,7 @@ export default class Client {
|
|||
#handleEvent = (ev: RPCEvent) => {
|
||||
if (ev.command === "client_state") {
|
||||
this.state.emit(ev.data)
|
||||
this.store.userID = ev.data.is_logged_in ? ev.data.user_id : ""
|
||||
} else if (ev.command === "sync_status") {
|
||||
this.syncStatus.emit(ev.data)
|
||||
} else if (ev.command === "init_complete") {
|
||||
|
@ -116,6 +190,8 @@ export default class Client {
|
|||
this.store.applySendComplete(ev.data)
|
||||
} else if (ev.command === "image_auth_token") {
|
||||
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)
|
||||
}
|
||||
if (!room || room.state.get("m.room.member")?.has(userID) || room.requestedMembers.has(userID)) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
room.requestedMembers.add(userID)
|
||||
this.#stateRequests.push({ room_id: room.roomID, type: "m.room.member", state_key: userID })
|
||||
if (!this.#stateRequestQueued) {
|
||||
this.#stateRequestQueued = true
|
||||
window.queueMicrotask(this.doStateRequests)
|
||||
if (this.#stateRequestPromise === null) {
|
||||
this.#stateRequestPromise = new Promise(this.#doStateRequestsPromise)
|
||||
}
|
||||
return this.#stateRequestPromise
|
||||
}
|
||||
|
||||
doStateRequests = () => {
|
||||
#doStateRequestsPromise = (resolve: () => void) => {
|
||||
window.queueMicrotask(() => {
|
||||
const reqs = this.#stateRequests
|
||||
this.#stateRequestQueued = false
|
||||
this.#stateRequestPromise = null
|
||||
this.#stateRequests = []
|
||||
this.loadSpecificRoomState(reqs).catch(err => console.error("Failed to load room state", reqs, err))
|
||||
this.loadSpecificRoomState(reqs)
|
||||
.catch(err => console.error("Failed to load room state", reqs, err))
|
||||
.finally(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) {
|
||||
|
@ -204,8 +284,10 @@ export default class Client {
|
|||
throw new Error("Room not found")
|
||||
}
|
||||
const dbEvent = await this.rpc.sendMessage(params)
|
||||
if (dbEvent) {
|
||||
this.#handleOutgoingEvent(dbEvent, room)
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeToEmojiPack(pack: RoomStateGUID, subscribe: boolean = true) {
|
||||
const emoteRooms = (this.store.accountData.get("im.ponies.emote_rooms") ?? {}) as ImagePackRooms
|
||||
|
@ -314,7 +396,7 @@ export default class Client {
|
|||
throw new Error("Timeline changed while loading history")
|
||||
}
|
||||
room.hasMoreHistory = resp.has_more
|
||||
room.applyPagination(resp.events)
|
||||
room.applyPagination(resp.events, resp.related_events, resp.receipts)
|
||||
} finally {
|
||||
room.paginating = false
|
||||
}
|
||||
|
|
|
@ -13,9 +13,8 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import type { RoomListEntry } from "@/api/statestore"
|
||||
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 => {
|
||||
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
|
||||
function makeFallbackAvatar(backgroundColor: string, fallbackCharacter: string): string {
|
||||
return "data:image/svg+xml," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
|
||||
<circle cx="500" cy="500" r="500" fill="${backgroundColor}"/>
|
||||
<rect x="0" y="0" width="1000" height="1000" fill="${backgroundColor}"/>
|
||||
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
>${escapeHTMLChar(fallbackCharacter)}</text>
|
||||
|
@ -82,21 +81,26 @@ function getFallbackCharacter(from: unknown, idx: number): string {
|
|||
export const getAvatarURL = (userID: UserID, content?: UserProfile | null): string | undefined => {
|
||||
const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1)
|
||||
const backgroundColor = getUserColor(userID)
|
||||
const [server, mediaID] = parseMXC(content?.avatar_url)
|
||||
const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url)
|
||||
if (!mediaID) {
|
||||
return makeFallbackAvatar(backgroundColor, fallbackCharacter)
|
||||
}
|
||||
const encrypted = !!content?.avatar_file
|
||||
const fallback = `${backgroundColor}:${fallbackCharacter}`
|
||||
return `_gomuks/media/${server}/${mediaID}?encrypted=false&fallback=${encodeURIComponent(fallback)}`
|
||||
return `_gomuks/media/${server}/${mediaID}?encrypted=${encrypted}&fallback=${encodeURIComponent(fallback)}`
|
||||
}
|
||||
|
||||
export const getRoomAvatarURL = (room: DBRoom | RoomListEntry, avatarOverride?: ContentURI): string | undefined => {
|
||||
let dmUserID: UserID | undefined
|
||||
if ("dm_user_id" in room) {
|
||||
dmUserID = room.dm_user_id
|
||||
} else if ("lazy_load_summary" in room) {
|
||||
dmUserID = room.lazy_load_summary?.heroes?.length === 1
|
||||
? room.lazy_load_summary.heroes[0] : undefined
|
||||
}
|
||||
return getAvatarURL(dmUserID ?? room.room_id, { displayname: room.name, avatar_url: avatarOverride ?? room.avatar })
|
||||
interface RoomForAvatarURL {
|
||||
room_id: RoomID
|
||||
name?: string
|
||||
dm_user_id?: UserID
|
||||
avatar?: ContentURI
|
||||
avatar_url?: ContentURI
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -17,9 +17,11 @@ import { CachedEventDispatcher, EventDispatcher } from "../util/eventdispatcher.
|
|||
import { CancellablePromise } from "../util/promise.ts"
|
||||
import type {
|
||||
ClientWellKnown,
|
||||
DBPushRegistration,
|
||||
EventID,
|
||||
EventRowID,
|
||||
EventType,
|
||||
JSONValue,
|
||||
LoginFlowsResponse,
|
||||
LoginRequest,
|
||||
Mentions,
|
||||
|
@ -32,9 +34,12 @@ import type {
|
|||
ReceiptType,
|
||||
RelatesTo,
|
||||
ResolveAliasResponse,
|
||||
RespOpenIDToken,
|
||||
RespRoomJoin,
|
||||
RoomAlias,
|
||||
RoomID,
|
||||
RoomStateGUID,
|
||||
RoomSummary,
|
||||
TimelineRowID,
|
||||
UserID,
|
||||
UserProfile,
|
||||
|
@ -136,7 +141,7 @@ export default abstract class RPCClient {
|
|||
return this.request("logout", {})
|
||||
}
|
||||
|
||||
sendMessage(params: SendMessageParams): Promise<RawDBEvent> {
|
||||
sendMessage(params: SendMessageParams): Promise<RawDBEvent | null> {
|
||||
return this.request("send_message", params)
|
||||
}
|
||||
|
||||
|
@ -178,6 +183,10 @@ export default abstract class RPCClient {
|
|||
return this.request("get_profile", { user_id })
|
||||
}
|
||||
|
||||
setProfileField(field: string, value: JSONValue): Promise<boolean> {
|
||||
return this.request("set_profile_field", { field, value })
|
||||
}
|
||||
|
||||
getMutualRooms(user_id: UserID): Promise<RoomID[]> {
|
||||
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 })
|
||||
}
|
||||
|
||||
trackUserDevices(user_id: UserID): Promise<ProfileEncryptionInfo> {
|
||||
return this.request("track_user_devices", { user_id })
|
||||
}
|
||||
|
||||
ensureGroupSessionShared(room_id: RoomID): Promise<boolean> {
|
||||
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 })
|
||||
}
|
||||
|
||||
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> {
|
||||
return this.request("resolve_alias", { alias })
|
||||
}
|
||||
|
@ -239,4 +264,12 @@ export default abstract class RPCClient {
|
|||
verify(recovery_key: string): Promise<boolean> {
|
||||
return this.request("verify", { recovery_key })
|
||||
}
|
||||
|
||||
requestOpenIDToken(): Promise<RespOpenIDToken> {
|
||||
return this.request("request_openid_token", {})
|
||||
}
|
||||
|
||||
registerPush(reg: DBPushRegistration): Promise<boolean> {
|
||||
return this.request("register_push", reg)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,9 +13,18 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { useEffect, 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 { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types"
|
||||
import type {
|
||||
EventID,
|
||||
EventType,
|
||||
MemDBEvent,
|
||||
MemReceipt,
|
||||
MemberEventContent,
|
||||
UnknownEventContent,
|
||||
UserID,
|
||||
} from "../types"
|
||||
import { Preferences, preferences } from "../types/preferences"
|
||||
import type { StateStore } from "./main.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(
|
||||
room?: RoomStateStore, type?: EventType, stateKey: string | undefined = "",
|
||||
): 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[] {
|
||||
return useSyncExternalStore(
|
||||
|
@ -97,7 +145,7 @@ export function usePreference<T extends keyof Preferences>(
|
|||
}
|
||||
|
||||
export function useCustomEmojis(
|
||||
ss: StateStore, room: RoomStateStore,
|
||||
ss: StateStore, room: RoomStateStore, usage: "stickers" | "emojis" = "emojis",
|
||||
): CustomEmojiPack[] {
|
||||
const personalPack = useSyncExternalStore(
|
||||
ss.accountDataSubs.getSubscriber("im.ponies.user_emotes"),
|
||||
|
@ -116,6 +164,6 @@ export function useCustomEmojis(
|
|||
if (personalPack) {
|
||||
allPacksObject.personal = personalPack
|
||||
}
|
||||
return Object.values(allPacksObject)
|
||||
}, [personalPack, watchedRoomPacks, specialRoomPacks])
|
||||
return Object.values(allPacksObject).filter(pack => pack[usage].length > 0)
|
||||
}, [personalPack, watchedRoomPacks, specialRoomPacks, usage])
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./main.ts"
|
||||
export * from "./room.ts"
|
||||
export * from "./hooks.ts"
|
||||
export * from "./space.ts"
|
||||
|
|
135
web/src/api/statestore/invitedroom.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -32,11 +32,14 @@ import {
|
|||
SendCompleteData,
|
||||
SyncCompleteData,
|
||||
SyncRoom,
|
||||
TypingEventData,
|
||||
UnknownEventContent,
|
||||
UserID,
|
||||
roomStateGUIDToString,
|
||||
} from "../types"
|
||||
import { InvitedRoomStore } from "./invitedroom.ts"
|
||||
import { RoomStateStore } from "./room.ts"
|
||||
import { DirectChatSpace, RoomListFilter, Space, SpaceEdgeStore, SpaceOrphansSpace, UnreadsSpace } from "./space.ts"
|
||||
|
||||
export interface RoomListEntry {
|
||||
room_id: RoomID
|
||||
|
@ -66,13 +69,27 @@ window.gcSettings ??= {
|
|||
}
|
||||
|
||||
export class StateStore {
|
||||
userID: UserID = ""
|
||||
readonly rooms: Map<RoomID, RoomStateStore> = new Map()
|
||||
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
|
||||
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
|
||||
currentRoomListFilter: string = ""
|
||||
readonly roomListEntries = new Map<RoomID, RoomListEntry>()
|
||||
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
|
||||
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
|
||||
readonly spaceOrphans = new SpaceOrphansSpace(this)
|
||||
readonly directChatsSpace = new DirectChatSpace()
|
||||
readonly unreadsSpace = new UnreadsSpace(this)
|
||||
readonly pseudoSpaces = [
|
||||
this.spaceOrphans,
|
||||
this.directChatsSpace,
|
||||
this.unreadsSpace,
|
||||
] as const
|
||||
currentRoomListQuery: string = ""
|
||||
currentRoomListFilter: RoomListFilter | null = null
|
||||
readonly accountData: Map<string, UnknownEventContent> = new Map()
|
||||
readonly accountDataSubs = new MultiSubscribable()
|
||||
readonly emojiRoomsSub = new Subscribable()
|
||||
readonly preferences: Preferences = getPreferenceProxy(this)
|
||||
readonly preferences = getPreferenceProxy(this)
|
||||
#frequentlyUsedEmoji: Map<string, number> | null = null
|
||||
#emojiPackKeys: RoomStateGUID[] | null = null
|
||||
#watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | null = null
|
||||
|
@ -82,13 +99,61 @@ export class StateStore {
|
|||
serverPreferenceCache: Preferences = {}
|
||||
switchRoom?: (roomID: RoomID | null) => void
|
||||
activeRoomID: RoomID | null = null
|
||||
activeRoomIsPreview: boolean = false
|
||||
imageAuthToken?: string
|
||||
|
||||
getFilteredRoomList(): RoomListEntry[] {
|
||||
if (!this.currentRoomListFilter) {
|
||||
return this.roomList.current
|
||||
#roomListFilterFunc = (entry: RoomListEntry) => {
|
||||
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
|
||||
return false
|
||||
} else if (this.currentRoomListFilter && !this.currentRoomListFilter.include(entry)) {
|
||||
return false
|
||||
}
|
||||
return this.roomList.current.filter(entry => entry.search_name.includes(this.currentRoomListFilter))
|
||||
return true
|
||||
}
|
||||
|
||||
getSpaceByID(spaceID: string | undefined): RoomListFilter | null {
|
||||
if (!spaceID) {
|
||||
return null
|
||||
}
|
||||
const realSpace = this.spaceEdges.get(spaceID)
|
||||
if (realSpace) {
|
||||
return realSpace
|
||||
}
|
||||
for (const pseudoSpace of this.pseudoSpaces) {
|
||||
if (pseudoSpace.id === spaceID) {
|
||||
return pseudoSpace
|
||||
}
|
||||
}
|
||||
console.warn("Failed to find space", spaceID)
|
||||
return null
|
||||
}
|
||||
|
||||
findMatchingSpace(room: RoomListEntry): Space | null {
|
||||
if (this.spaceOrphans.include(room)) {
|
||||
return this.spaceOrphans
|
||||
}
|
||||
for (const spaceID of this.topLevelSpaces.current) {
|
||||
const space = this.spaceEdges.get(spaceID)
|
||||
if (space?.include(room)) {
|
||||
return space
|
||||
}
|
||||
}
|
||||
if (this.directChatsSpace.include(room)) {
|
||||
return this.directChatsSpace
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
|
||||
if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
|
||||
return null
|
||||
}
|
||||
return this.#roomListFilterFunc
|
||||
}
|
||||
|
||||
getFilteredRoomList(): RoomListEntry[] {
|
||||
const fn = this.roomListFilterFunc
|
||||
return fn ? this.roomList.current.filter(fn) : this.roomList.current
|
||||
}
|
||||
|
||||
#shouldHideRoom(entry: SyncRoom): boolean {
|
||||
|
@ -117,7 +182,7 @@ export class StateStore {
|
|||
entry.meta.unread_highlights !== oldEntry.meta.current.unread_highlights ||
|
||||
entry.meta.marked_unread !== oldEntry.meta.current.marked_unread ||
|
||||
entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid ||
|
||||
entry.events.findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
|
||||
(entry.events ?? []).findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
|
||||
}
|
||||
|
||||
#makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry | null {
|
||||
|
@ -138,8 +203,7 @@ export class StateStore {
|
|||
const name = entry.meta.name ?? "Unnamed room"
|
||||
return {
|
||||
room_id: entry.meta.room_id,
|
||||
dm_user_id: entry.meta.lazy_load_summary?.heroes?.length === 1
|
||||
? entry.meta.lazy_load_summary.heroes[0] : undefined,
|
||||
dm_user_id: entry.meta.dm_user_id,
|
||||
sorting_timestamp: entry.meta.sorting_timestamp,
|
||||
preview_event,
|
||||
preview_sender,
|
||||
|
@ -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) {
|
||||
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")
|
||||
|
@ -160,18 +243,41 @@ export class StateStore {
|
|||
}
|
||||
const resyncRoomList = this.roomList.current.length === 0
|
||||
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 room = this.rooms.get(roomID)
|
||||
if (!room) {
|
||||
room = new RoomStateStore(data.meta, this)
|
||||
this.rooms.set(roomID, room)
|
||||
if (hasInvites) {
|
||||
this.inviteRooms.delete(roomID)
|
||||
}
|
||||
isNewRoom = true
|
||||
}
|
||||
const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room))
|
||||
room.applySync(data)
|
||||
if (roomListEntryChanged) {
|
||||
changedRoomListEntries.set(roomID, this.#makeRoomListEntry(data, room))
|
||||
const entry = this.#makeRoomListEntry(data, room)
|
||||
changedRoomListEntries.set(roomID, entry)
|
||||
this.#applyUnreadModification(entry, this.roomListEntries.get(roomID))
|
||||
if (entry) {
|
||||
this.roomListEntries.set(roomID, entry)
|
||||
} else {
|
||||
this.roomListEntries.delete(roomID)
|
||||
}
|
||||
}
|
||||
if (!resyncRoomList) {
|
||||
// When we join a valid replacement room, hide the tombstoned room.
|
||||
|
@ -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) {
|
||||
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") {
|
||||
this.#frequentlyUsedEmoji = null
|
||||
} else if (ad.type === "fi.mau.gomuks.preferences") {
|
||||
|
@ -200,20 +309,26 @@ export class StateStore {
|
|||
this.accountData.set(ad.type, ad.content)
|
||||
this.accountDataSubs.notify(ad.type)
|
||||
}
|
||||
for (const roomID of sync.left_rooms) {
|
||||
for (const roomID of sync.left_rooms ?? []) {
|
||||
if (this.activeRoomID === roomID) {
|
||||
this.switchRoom?.(null)
|
||||
}
|
||||
this.rooms.delete(roomID)
|
||||
changedRoomListEntries.set(roomID, null)
|
||||
this.#applyUnreadModification(null, this.roomListEntries.get(roomID))
|
||||
}
|
||||
|
||||
let updatedRoomList: RoomListEntry[] | undefined
|
||||
if (resyncRoomList) {
|
||||
updatedRoomList = Object.values(sync.rooms)
|
||||
updatedRoomList = this.inviteRooms.values().toArray()
|
||||
updatedRoomList = updatedRoomList.concat(Object.values(sync.rooms ?? {})
|
||||
.map(entry => this.#makeRoomListEntry(entry))
|
||||
.filter(entry => entry !== null)
|
||||
.filter(entry => entry !== null))
|
||||
updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
|
||||
for (const entry of updatedRoomList) {
|
||||
this.#applyUnreadModification(entry, undefined)
|
||||
this.roomListEntries.set(entry.room_id, entry)
|
||||
}
|
||||
} else if (changedRoomListEntries.size > 0) {
|
||||
updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id))
|
||||
for (const entry of changedRoomListEntries.values()) {
|
||||
|
@ -236,6 +351,19 @@ export class StateStore {
|
|||
if (updatedRoomList) {
|
||||
this.roomList.emit(updatedRoomList)
|
||||
}
|
||||
if (sync.space_edges) {
|
||||
// Ensure all space stores exist first
|
||||
for (const spaceID of Object.keys(sync.space_edges)) {
|
||||
this.getSpaceStore(spaceID, true)
|
||||
}
|
||||
for (const [spaceID, children] of Object.entries(sync.space_edges ?? {})) {
|
||||
this.getSpaceStore(spaceID, true).children = children
|
||||
}
|
||||
}
|
||||
if (sync.top_level_spaces) {
|
||||
this.topLevelSpaces.emit(sync.top_level_spaces)
|
||||
this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id }))
|
||||
}
|
||||
}
|
||||
|
||||
invalidateEmojiPackKeyCache() {
|
||||
|
@ -301,6 +429,20 @@ export class StateStore {
|
|||
return this.#watchedRoomEmojiPacks ?? {}
|
||||
}
|
||||
|
||||
getSpaceStore(spaceID: RoomID, force: true): SpaceEdgeStore
|
||||
getSpaceStore(spaceID: RoomID): SpaceEdgeStore | null
|
||||
getSpaceStore(spaceID: RoomID, force?: true): SpaceEdgeStore | null {
|
||||
let store = this.spaceEdges.get(spaceID)
|
||||
if (!store) {
|
||||
if (!force && this.rooms.get(spaceID)?.meta.current.creation_content?.type !== "m.space") {
|
||||
return null
|
||||
}
|
||||
store = new SpaceEdgeStore(spaceID, this)
|
||||
this.spaceEdges.set(spaceID, store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
get frequentlyUsedEmoji(): Map<string, number> {
|
||||
if (this.#frequentlyUsedEmoji === null) {
|
||||
const emojiData = this.accountData.get("io.element.recent_emoji")
|
||||
|
@ -337,9 +479,10 @@ export class StateStore {
|
|||
const notif = new Notification(title, {
|
||||
body,
|
||||
icon,
|
||||
badge: "/gomuks.png",
|
||||
badge: "gomuks.png",
|
||||
// timestamp: evt.timestamp,
|
||||
// image: ...,
|
||||
silent: !sound,
|
||||
tag: rowid.toString(),
|
||||
})
|
||||
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() {
|
||||
const maxLastOpened = Date.now() - window.gcSettings.lastOpenedCutoff
|
||||
let deletedEvents = 0
|
||||
|
@ -399,9 +551,14 @@ export class StateStore {
|
|||
|
||||
clear() {
|
||||
this.rooms.clear()
|
||||
this.inviteRooms.clear()
|
||||
this.spaceEdges.clear()
|
||||
this.pseudoSpaces.forEach(space => space.clearUnreads())
|
||||
this.roomList.emit([])
|
||||
this.topLevelSpaces.emit([])
|
||||
this.accountData.clear()
|
||||
this.currentRoomListFilter = ""
|
||||
this.currentRoomListQuery = ""
|
||||
this.currentRoomListFilter = null
|
||||
this.#frequentlyUsedEmoji = null
|
||||
this.#emojiPackKeys = null
|
||||
this.#watchedRoomEmojiPacks = null
|
||||
|
|
|
@ -21,6 +21,7 @@ import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subs
|
|||
import { getDisplayname } from "@/util/validation.ts"
|
||||
import {
|
||||
ContentURI,
|
||||
DBReceipt,
|
||||
DBRoom,
|
||||
EncryptedEventContent,
|
||||
EventID,
|
||||
|
@ -30,6 +31,7 @@ import {
|
|||
ImagePack,
|
||||
LazyLoadSummary,
|
||||
MemDBEvent,
|
||||
MemReceipt,
|
||||
MemberEventContent,
|
||||
PowerLevelEventContent,
|
||||
RawDBEvent,
|
||||
|
@ -60,7 +62,7 @@ function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
|
|||
function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean {
|
||||
return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] &&
|
||||
ll1?.["m.invited_member_count"] === ll2?.["m.invited_member_count"] &&
|
||||
arraysAreEqual(ll1?.heroes, ll2?.heroes)
|
||||
arraysAreEqual(ll1?.["m.heroes"], ll2?.["m.heroes"])
|
||||
}
|
||||
|
||||
function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
|
||||
|
@ -68,6 +70,7 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
|
|||
meta1.avatar === meta2.avatar &&
|
||||
meta1.topic === meta2.topic &&
|
||||
meta1.canonical_alias === meta2.canonical_alias &&
|
||||
meta1.dm_user_id === meta2.dm_user_id &&
|
||||
llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) &&
|
||||
meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm &&
|
||||
meta1.has_member_list === meta2.has_member_list
|
||||
|
@ -83,26 +86,34 @@ export interface AutocompleteMemberEntry {
|
|||
|
||||
const collator = new Intl.Collator()
|
||||
|
||||
const UNSENT_TIMELINE_ROWID_BASE = 1000000000000000
|
||||
|
||||
export class RoomStateStore {
|
||||
readonly roomID: RoomID
|
||||
readonly meta: NonNullCachedEventDispatcher<DBRoom>
|
||||
timeline: TimelineRowTuple[] = []
|
||||
timelineCache: (MemDBEvent | null)[] = []
|
||||
editTargets: EventRowID[] = []
|
||||
state: Map<EventType, Map<string, EventRowID>> = new Map()
|
||||
stateLoaded = false
|
||||
typing: UserID[] = []
|
||||
fullMembersLoaded = false
|
||||
readonly eventsByRowID: Map<EventRowID, MemDBEvent> = new Map()
|
||||
readonly eventsByID: Map<EventID, MemDBEvent> = new Map()
|
||||
readonly timelineSub = new Subscribable()
|
||||
readonly typingSub = new Subscribable()
|
||||
readonly stateSubs = 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 requestedMembers: Set<UserID> = new Set()
|
||||
readonly accountData: Map<string, UnknownEventContent> = new Map()
|
||||
readonly accountDataSubs = new MultiSubscribable()
|
||||
readonly openNotifications: Map<EventRowID, Notification> = new Map()
|
||||
readonly #emojiPacksCache: Map<string, CustomEmojiPack | null> = new Map()
|
||||
readonly preferences: Preferences
|
||||
readonly preferences: Required<Preferences>
|
||||
readonly localPreferenceCache: Preferences
|
||||
readonly preferenceSub = new NoDataSubscribable()
|
||||
serverPreferenceCache: Preferences = {}
|
||||
|
@ -124,17 +135,30 @@ export class RoomStateStore {
|
|||
this.preferences = getPreferenceProxy(parent, this)
|
||||
}
|
||||
|
||||
notifyTimelineSubscribers() {
|
||||
#updateTimelineCache() {
|
||||
const ownMessages: EventRowID[] = []
|
||||
this.timelineCache = this.timeline.map(rt => {
|
||||
const evt = this.eventsByRowID.get(rt.event_rowid)
|
||||
if (!evt) {
|
||||
return null
|
||||
}
|
||||
evt.timeline_rowid = rt.timeline_rowid
|
||||
if (
|
||||
evt.sender === this.parent.userID
|
||||
&& evt.type === "m.room.message"
|
||||
&& evt.relation_type !== "m.replace"
|
||||
) {
|
||||
ownMessages.push(evt.rowid)
|
||||
}
|
||||
return evt
|
||||
}).concat(this.pendingEvents
|
||||
.map(rowID => this.eventsByRowID.get(rowID))
|
||||
.filter(evt => !!evt))
|
||||
this.editTargets = ownMessages
|
||||
}
|
||||
|
||||
notifyTimelineSubscribers() {
|
||||
this.#updateTimelineCache()
|
||||
this.timelineSub.notify()
|
||||
}
|
||||
|
||||
|
@ -230,15 +254,65 @@ export class RoomStateStore {
|
|||
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
|
||||
history.reverse()
|
||||
const newTimeline = history.map(evt => {
|
||||
this.applyEvent(evt)
|
||||
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.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) {
|
||||
|
@ -246,7 +320,7 @@ export class RoomStateStore {
|
|||
memEvt.mem = true
|
||||
memEvt.pending = 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) {
|
||||
memEvt.type = evt.decrypted_type
|
||||
|
@ -285,6 +359,7 @@ export class RoomStateStore {
|
|||
}
|
||||
}
|
||||
this.eventSubs.notify(memEvt.event_id)
|
||||
return memEvt
|
||||
}
|
||||
|
||||
applySendComplete(evt: RawDBEvent) {
|
||||
|
@ -316,7 +391,7 @@ export class RoomStateStore {
|
|||
} else {
|
||||
this.meta.emit(sync.meta)
|
||||
}
|
||||
for (const ad of Object.values(sync.account_data)) {
|
||||
for (const ad of Object.values(sync.account_data ?? {})) {
|
||||
if (ad.type === "fi.mau.gomuks.preferences") {
|
||||
this.serverPreferenceCache = ad.content
|
||||
this.preferenceSub.notify()
|
||||
|
@ -324,10 +399,10 @@ export class RoomStateStore {
|
|||
this.accountData.set(ad.type, ad.content)
|
||||
this.accountDataSubs.notify(ad.type)
|
||||
}
|
||||
for (const evt of sync.events) {
|
||||
for (const evt of sync.events ?? []) {
|
||||
this.applyEvent(evt)
|
||||
}
|
||||
for (const [evtType, changedEvts] of Object.entries(sync.state)) {
|
||||
for (const [evtType, changedEvts] of Object.entries(sync.state ?? {})) {
|
||||
let stateMap = this.state.get(evtType)
|
||||
if (!stateMap) {
|
||||
stateMap = new Map()
|
||||
|
@ -340,9 +415,9 @@ export class RoomStateStore {
|
|||
this.stateSubs.notify(evtType)
|
||||
}
|
||||
if (sync.reset) {
|
||||
this.timeline = sync.timeline
|
||||
this.timeline = sync.timeline ?? []
|
||||
this.pendingEvents.splice(0, this.pendingEvents.length)
|
||||
} else {
|
||||
} else if (sync.timeline) {
|
||||
this.timeline.push(...sync.timeline)
|
||||
}
|
||||
if (sync.meta.unread_notifications === 0 && sync.meta.unread_highlights === 0) {
|
||||
|
@ -352,6 +427,9 @@ export class RoomStateStore {
|
|||
this.openNotifications.clear()
|
||||
}
|
||||
this.notifyTimelineSubscribers()
|
||||
for (const [evtID, receipts] of Object.entries(sync.receipts ?? {})) {
|
||||
this.applyReceipts(receipts, evtID, false)
|
||||
}
|
||||
}
|
||||
|
||||
applyState(evt: RawDBEvent) {
|
||||
|
@ -418,6 +496,11 @@ export class RoomStateStore {
|
|||
}
|
||||
}
|
||||
|
||||
applyTyping(users: string[]) {
|
||||
this.typing = users
|
||||
this.typingSub.notify()
|
||||
}
|
||||
|
||||
doGarbageCollection() {
|
||||
const memberEventsToKeep = new Set<UserID>()
|
||||
const eventsToKeep = new Set<EventRowID>()
|
||||
|
@ -466,6 +549,8 @@ export class RoomStateStore {
|
|||
const deletedEvents = this.eventsByRowID.size - eventsToKeep.size
|
||||
this.eventsByRowID.clear()
|
||||
this.eventsByID.clear()
|
||||
this.receiptsByEventID.clear()
|
||||
this.receiptsByUserID.clear()
|
||||
for (const evt of eventsToKeepList) {
|
||||
this.eventsByRowID.set(evt.rowid, evt)
|
||||
this.eventsByID.set(evt.event_id, evt)
|
||||
|
|
199
web/src/api/statestore/space.ts
Normal file
|
@ -0,0 +1,199 @@
|
|||
// gomuks - A Matrix client written in Go.
|
||||
// Copyright (C) 2024 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { RoomListEntry, StateStore } from "@/api/statestore/main.ts"
|
||||
import { DBSpaceEdge, RoomID } from "@/api/types"
|
||||
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||
|
||||
export interface RoomListFilter {
|
||||
id: string
|
||||
include(room: RoomListEntry): boolean
|
||||
}
|
||||
|
||||
export interface SpaceUnreadCounts {
|
||||
unread_messages: number
|
||||
unread_notifications: number
|
||||
unread_highlights: number
|
||||
}
|
||||
|
||||
const emptyUnreadCounts: SpaceUnreadCounts = {
|
||||
unread_messages: 0,
|
||||
unread_notifications: 0,
|
||||
unread_highlights: 0,
|
||||
}
|
||||
|
||||
export abstract class Space implements RoomListFilter {
|
||||
counts = new NonNullCachedEventDispatcher(emptyUnreadCounts)
|
||||
|
||||
abstract id: string
|
||||
abstract include(room: RoomListEntry): boolean
|
||||
|
||||
clearUnreads() {
|
||||
this.counts.emit(emptyUnreadCounts)
|
||||
}
|
||||
|
||||
applyUnreads(newCounts?: SpaceUnreadCounts | null, oldCounts?: SpaceUnreadCounts | null) {
|
||||
const mergedCounts: SpaceUnreadCounts = {
|
||||
unread_messages: this.counts.current.unread_messages
|
||||
+ (newCounts?.unread_messages ?? 0) - (oldCounts?.unread_messages ?? 0),
|
||||
unread_notifications: this.counts.current.unread_notifications
|
||||
+ (newCounts?.unread_notifications ?? 0) - (oldCounts?.unread_notifications ?? 0),
|
||||
unread_highlights: this.counts.current.unread_highlights
|
||||
+ (newCounts?.unread_highlights ?? 0) - (oldCounts?.unread_highlights ?? 0),
|
||||
}
|
||||
if (mergedCounts.unread_messages < 0) {
|
||||
mergedCounts.unread_messages = 0
|
||||
}
|
||||
if (mergedCounts.unread_notifications < 0) {
|
||||
mergedCounts.unread_notifications = 0
|
||||
}
|
||||
if (mergedCounts.unread_highlights < 0) {
|
||||
mergedCounts.unread_highlights = 0
|
||||
}
|
||||
if (
|
||||
mergedCounts.unread_messages !== this.counts.current.unread_messages
|
||||
|| mergedCounts.unread_notifications !== this.counts.current.unread_notifications
|
||||
|| mergedCounts.unread_highlights !== this.counts.current.unread_highlights
|
||||
) {
|
||||
this.counts.emit(mergedCounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DirectChatSpace extends Space {
|
||||
id = "fi.mau.gomuks.direct_chats"
|
||||
|
||||
include(room: RoomListEntry): boolean {
|
||||
return Boolean(room.dm_user_id)
|
||||
}
|
||||
}
|
||||
|
||||
export class UnreadsSpace extends Space {
|
||||
id = "fi.mau.gomuks.unreads"
|
||||
|
||||
constructor(private parent: StateStore) {
|
||||
super()
|
||||
}
|
||||
|
||||
include(room: RoomListEntry): boolean {
|
||||
return Boolean(room.room_id === this.parent.activeRoomID
|
||||
|| room.unread_messages
|
||||
|| room.unread_notifications
|
||||
|| room.unread_highlights
|
||||
|| room.marked_unread)
|
||||
}
|
||||
}
|
||||
|
||||
export class SpaceEdgeStore extends Space {
|
||||
#children: DBSpaceEdge[] = []
|
||||
#childRooms: Set<RoomID> = new Set()
|
||||
#flattenedRooms: Set<RoomID> = new Set()
|
||||
#childSpaces: Set<SpaceEdgeStore> = new Set()
|
||||
readonly #parentSpaces: Set<SpaceEdgeStore> = new Set()
|
||||
|
||||
constructor(public id: RoomID, private parent: StateStore) {
|
||||
super()
|
||||
}
|
||||
|
||||
#addParent(parent: SpaceEdgeStore) {
|
||||
this.#parentSpaces.add(parent)
|
||||
}
|
||||
|
||||
#removeParent(parent: SpaceEdgeStore) {
|
||||
this.#parentSpaces.delete(parent)
|
||||
}
|
||||
|
||||
include(room: RoomListEntry) {
|
||||
return this.#flattenedRooms.has(room.room_id)
|
||||
}
|
||||
|
||||
get children() {
|
||||
return this.#children
|
||||
}
|
||||
|
||||
#updateFlattened(recalculate: boolean, added: Set<RoomID>) {
|
||||
if (recalculate) {
|
||||
let flattened = new Set(this.#childRooms)
|
||||
for (const space of this.#childSpaces) {
|
||||
flattened = flattened.union(space.#flattenedRooms)
|
||||
}
|
||||
this.#flattenedRooms = flattened
|
||||
} else if (added.size > 50) {
|
||||
this.#flattenedRooms = this.#flattenedRooms.union(added)
|
||||
} else if (added.size > 0) {
|
||||
for (const room of added) {
|
||||
this.#flattenedRooms.add(room)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#notifyParentsOfChange(recalculate: boolean, added: Set<RoomID>, stack: WeakSet<SpaceEdgeStore>) {
|
||||
if (stack.has(this)) {
|
||||
return
|
||||
}
|
||||
stack.add(this)
|
||||
for (const parent of this.#parentSpaces) {
|
||||
parent.#updateFlattened(recalculate, added)
|
||||
parent.#notifyParentsOfChange(recalculate, added, stack)
|
||||
}
|
||||
stack.delete(this)
|
||||
}
|
||||
|
||||
set children(newChildren: DBSpaceEdge[]) {
|
||||
const newChildRooms = new Set<RoomID>()
|
||||
const newChildSpaces = new Set<SpaceEdgeStore>()
|
||||
for (const child of newChildren) {
|
||||
const spaceStore = this.parent.getSpaceStore(child.child_id)
|
||||
if (spaceStore) {
|
||||
newChildSpaces.add(spaceStore)
|
||||
spaceStore.#addParent(this)
|
||||
} else {
|
||||
newChildRooms.add(child.child_id)
|
||||
}
|
||||
}
|
||||
for (const space of this.#childSpaces) {
|
||||
if (!newChildSpaces.has(space)) {
|
||||
space.#removeParent(this)
|
||||
}
|
||||
}
|
||||
const addedRooms = newChildRooms.difference(this.#childRooms)
|
||||
const removedRooms = this.#childRooms.difference(newChildRooms)
|
||||
const didAddChildren = newChildSpaces.difference(this.#childSpaces).size > 0
|
||||
const recalculateFlattened = removedRooms.size > 0 || didAddChildren
|
||||
this.#children = newChildren
|
||||
this.#childRooms = newChildRooms
|
||||
this.#childSpaces = newChildSpaces
|
||||
if (this.#childSpaces.size > 0) {
|
||||
this.#updateFlattened(recalculateFlattened, addedRooms)
|
||||
} else {
|
||||
this.#flattenedRooms = newChildRooms
|
||||
}
|
||||
if (this.#parentSpaces.size > 0) {
|
||||
this.#notifyParentsOfChange(recalculateFlattened, addedRooms, new WeakSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SpaceOrphansSpace extends SpaceEdgeStore {
|
||||
static id = "fi.mau.gomuks.space_orphans"
|
||||
|
||||
constructor(parent: StateStore) {
|
||||
super(SpaceOrphansSpace.id, parent)
|
||||
}
|
||||
|
||||
include(room: RoomListEntry): boolean {
|
||||
return !super.include(room) && !room.dm_user_id
|
||||
}
|
||||
}
|
30
web/src/api/types/android.ts
Normal 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
|
|
@ -15,14 +15,18 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import {
|
||||
DBAccountData,
|
||||
DBInvitedRoom,
|
||||
DBReceipt,
|
||||
DBRoom,
|
||||
DBRoomAccountData,
|
||||
DBSpaceEdge,
|
||||
EventRowID,
|
||||
RawDBEvent,
|
||||
TimelineRowTuple,
|
||||
} from "./hitypes.ts"
|
||||
import {
|
||||
DeviceID,
|
||||
EventID,
|
||||
EventType,
|
||||
RoomID,
|
||||
UserID,
|
||||
|
@ -68,12 +72,13 @@ export interface ImageAuthTokenEvent extends BaseRPCCommand<string> {
|
|||
|
||||
export interface SyncRoom {
|
||||
meta: DBRoom
|
||||
timeline: TimelineRowTuple[]
|
||||
events: RawDBEvent[]
|
||||
state: Record<EventType, Record<string, EventRowID>>
|
||||
timeline: TimelineRowTuple[] | null
|
||||
events: RawDBEvent[] | null
|
||||
state: Record<EventType, Record<string, EventRowID>> | null
|
||||
reset: boolean
|
||||
notifications: SyncNotification[]
|
||||
account_data: Record<EventType, DBRoomAccountData>
|
||||
notifications: SyncNotification[] | null
|
||||
account_data: Record<EventType, DBRoomAccountData> | null
|
||||
receipts: Record<EventID, DBReceipt[]> | null
|
||||
}
|
||||
|
||||
export interface SyncNotification {
|
||||
|
@ -82,9 +87,12 @@ export interface SyncNotification {
|
|||
}
|
||||
|
||||
export interface SyncCompleteData {
|
||||
rooms: Record<RoomID, SyncRoom>
|
||||
left_rooms: RoomID[]
|
||||
account_data: Record<EventType, DBAccountData>
|
||||
rooms: Record<RoomID, SyncRoom> | null
|
||||
invited_rooms: DBInvitedRoom[] | null
|
||||
left_rooms: RoomID[] | null
|
||||
account_data: Record<EventType, DBAccountData> | null
|
||||
space_edges: Record<RoomID, DBSpaceEdge[]> | null
|
||||
top_level_spaces: RoomID[] | null
|
||||
since?: string
|
||||
clear_state?: boolean
|
||||
}
|
||||
|
@ -110,7 +118,7 @@ export interface ClientStateEvent extends BaseRPCCommand<ClientState> {
|
|||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
type: "ok" | "waiting" | "errored"
|
||||
type: "ok" | "waiting" | "erroring" | "permanently-failed"
|
||||
error?: string
|
||||
error_count: number
|
||||
last_sync?: number
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
EventID,
|
||||
EventType,
|
||||
LazyLoadSummary,
|
||||
ReceiptType,
|
||||
RelationType,
|
||||
RoomAlias,
|
||||
RoomID,
|
||||
|
@ -53,6 +54,7 @@ export interface DBRoom {
|
|||
name_quality: RoomNameQuality
|
||||
avatar?: ContentURI
|
||||
explicit_avatar: boolean
|
||||
dm_user_id?: UserID
|
||||
topic?: string
|
||||
canonical_alias?: RoomAlias
|
||||
lazy_load_summary?: LazyLoadSummary
|
||||
|
@ -70,9 +72,34 @@ export interface DBRoom {
|
|||
prev_batch: string
|
||||
}
|
||||
|
||||
export interface DBSpaceEdge {
|
||||
// space_id: RoomID
|
||||
child_id: RoomID
|
||||
|
||||
child_event_rowid?: EventRowID
|
||||
order?: string
|
||||
suggested?: true
|
||||
|
||||
parent_event_rowid?: EventRowID
|
||||
canonical?: true
|
||||
}
|
||||
|
||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type UnknownEventContent = Record<string, any>
|
||||
|
||||
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 {
|
||||
None = 0b0000,
|
||||
Normal = 0b0001,
|
||||
|
@ -145,8 +172,23 @@ export interface DBRoomAccountData {
|
|||
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 {
|
||||
events: RawDBEvent[]
|
||||
receipts: Record<EventID, DBReceipt[]>
|
||||
related_events: RawDBEvent[]
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
|
@ -242,3 +284,11 @@ export interface ProfileEncryptionInfo {
|
|||
user_trusted: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface DBPushRegistration {
|
||||
device_id: string
|
||||
type: "fcm"
|
||||
data: unknown
|
||||
encryption: { key: string }
|
||||
expiration?: number
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./mxtypes.ts"
|
||||
export * from "./hitypes.ts"
|
||||
export * from "./hievents.ts"
|
||||
export * from "./android.ts"
|
||||
|
|
|
@ -25,6 +25,14 @@ export type RoomVersion = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" |
|
|||
export type RoomType = "" | "m.space"
|
||||
export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread"
|
||||
|
||||
export type JSONValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JSONValue[]
|
||||
| {[key: string]: JSONValue}
|
||||
|
||||
export interface RoomPredecessor {
|
||||
room_id: RoomID
|
||||
event_id: EventID
|
||||
|
@ -43,7 +51,7 @@ export interface TombstoneEventContent {
|
|||
}
|
||||
|
||||
export interface LazyLoadSummary {
|
||||
heroes?: UserID[]
|
||||
"m.heroes"?: UserID[]
|
||||
"m.joined_member_count"?: number
|
||||
"m.invited_member_count"?: number
|
||||
}
|
||||
|
@ -65,11 +73,24 @@ export interface EncryptedEventContent {
|
|||
export interface UserProfile {
|
||||
displayname?: string
|
||||
avatar_url?: ContentURI
|
||||
avatar_file?: EncryptedFile
|
||||
[custom: string]: unknown
|
||||
}
|
||||
|
||||
export interface PronounSet {
|
||||
subject?: string
|
||||
object?: string
|
||||
possessive_determiner?: string
|
||||
possessive_pronoun?: string
|
||||
reflexive?: string
|
||||
summary: string
|
||||
language: string
|
||||
}
|
||||
|
||||
export type Membership = "join" | "leave" | "ban" | "invite" | "knock"
|
||||
|
||||
export interface MemberEventContent extends UserProfile {
|
||||
membership: "join" | "leave" | "ban" | "invite" | "knock"
|
||||
membership: Membership
|
||||
reason?: string
|
||||
}
|
||||
|
||||
|
@ -91,6 +112,12 @@ export interface ACLEventContent {
|
|||
deny?: string[]
|
||||
}
|
||||
|
||||
export interface PolicyRuleContent {
|
||||
entity: string
|
||||
reason: string
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
export interface PowerLevelEventContent {
|
||||
users?: Record<UserID, number>
|
||||
users_default?: number
|
||||
|
@ -138,6 +165,23 @@ export interface ContentWarning {
|
|||
description?: string
|
||||
}
|
||||
|
||||
export interface URLPreview {
|
||||
matched_url: string
|
||||
"beeper:image:encryption"?: EncryptedFile
|
||||
"matrix:image:size": number
|
||||
"og:image"?: ContentURI
|
||||
"og:url": string
|
||||
"og:image:width"?: number
|
||||
"og:image:height"?: number
|
||||
"og:image:type"?: string
|
||||
"og:title"?: string
|
||||
"og:description"?: string
|
||||
}
|
||||
|
||||
export interface BeeperPerMessageProfile extends UserProfile {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface BaseMessageEventContent {
|
||||
msgtype: string
|
||||
body: string
|
||||
|
@ -148,6 +192,9 @@ export interface BaseMessageEventContent {
|
|||
"town.robin.msc3725.content_warning"?: ContentWarning
|
||||
"page.codeberg.everypizza.msc4193.spoiler"?: boolean
|
||||
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string
|
||||
"m.url_previews"?: URLPreview[]
|
||||
"com.beeper.linkpreviews"?: URLPreview[]
|
||||
"com.beeper.per_message_profile"?: BeeperPerMessageProfile
|
||||
}
|
||||
|
||||
export interface TextMessageEventContent extends BaseMessageEventContent {
|
||||
|
@ -155,7 +202,7 @@ export interface TextMessageEventContent 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
|
||||
url?: ContentURI
|
||||
file?: EncryptedFile
|
||||
|
@ -235,3 +282,37 @@ export interface ImagePackRooms {
|
|||
export interface ElementRecentEmoji {
|
||||
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"
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import type { ContentURI } from "../../types"
|
||||
import { Preference, anyContext } from "./types.ts"
|
||||
import { Preference, anyContext, anyGlobalContext } from "./types.ts"
|
||||
|
||||
export const codeBlockStyles = [
|
||||
"auto", "abap", "algol_nu", "algol", "arduino", "autumn", "average", "base16-snazzy", "borland", "bw",
|
||||
|
@ -47,6 +47,12 @@ export const preferences = {
|
|||
allowedContexts: anyContext,
|
||||
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>({
|
||||
displayName: "Show image and video previews",
|
||||
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,
|
||||
defaultValue: true,
|
||||
}),
|
||||
render_url_previews: new Preference<boolean>({
|
||||
displayName: "Render URL previews",
|
||||
description: "Whether to render MSC4095 URL previews in the room timeline.",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: true,
|
||||
}),
|
||||
small_replies: new Preference<boolean>({
|
||||
displayName: "Compact reply style",
|
||||
description: "Whether to use a Discord-like compact style for replies instead of the traditional style.",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: false,
|
||||
}),
|
||||
show_date_separators: new Preference<boolean>({
|
||||
displayName: "Show date separators",
|
||||
description: "Whether messages in different days should have a date separator between them in the room timeline.",
|
||||
|
@ -135,12 +153,42 @@ export const preferences = {
|
|||
// allowedContexts: anyContext,
|
||||
// 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>({
|
||||
displayName: "Custom notification sound",
|
||||
description: "The mxc:// URI to a custom notification sound.",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: "",
|
||||
}),
|
||||
room_window_title: new Preference<string>({
|
||||
displayName: "In-room window title",
|
||||
description: "The title to use for the window when viewing a room. $room will be replaced with the room name",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: "$room - gomuks web",
|
||||
}),
|
||||
window_title: new Preference<string>({
|
||||
displayName: "Default window title",
|
||||
description: "The title to use for the window when not in a room.",
|
||||
allowedContexts: anyGlobalContext,
|
||||
defaultValue: "gomuks web",
|
||||
}),
|
||||
favicon: new Preference<string>({
|
||||
displayName: "Favicon",
|
||||
description: "The URL to use for the favicon.",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: "gomuks.png",
|
||||
}),
|
||||
} as const
|
||||
|
||||
export const existingPreferenceKeys = new Set(Object.keys(preferences))
|
||||
|
|
|
@ -19,7 +19,7 @@ import { PreferenceContext, PreferenceValueType } from "./types.ts"
|
|||
|
||||
const prefKeys = Object.keys(preferences)
|
||||
|
||||
export function getPreferenceProxy(store: StateStore, room?: RoomStateStore): Preferences {
|
||||
export function getPreferenceProxy(store: StateStore, room?: RoomStateStore): Required<Preferences> {
|
||||
return new Proxy({}, {
|
||||
set(): boolean {
|
||||
throw new Error("The preference proxy is read-only")
|
||||
|
@ -61,5 +61,5 @@ export function getPreferenceProxy(store: StateStore, room?: RoomStateStore): Pr
|
|||
writable: false,
|
||||
} : undefined
|
||||
},
|
||||
})
|
||||
}) as Required<Preferences>
|
||||
}
|
||||
|
|
1
web/src/icons/devices-off.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="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 |
1
web/src/icons/devices.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M80-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
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M240-200h120v-240h240v240h120v-360L480-740 240-560v360Zm-80 80v-480l320-240 320 240v480H520v-240h-80v240H160Zm320-350Z"/></svg>
|
After Width: | Height: | Size: 244 B |
1
web/src/icons/notifications-off.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M160-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 |
1
web/src/icons/notifications-unread.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-80q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80Zm0-420ZM160-200v-80h80v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v13q-11 22-16 45t-4 47q-10-2-19.5-3.5T480-720q-66 0-113 47t-47 113v280h320v-257q18 8 38.5 12.5T720-520v240h80v80H160Zm560-400q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Z"/></svg>
|
After Width: | Height: | Size: 480 B |
1
web/src/icons/notifications.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M160-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
|
@ -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
|
@ -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
|
@ -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 |
|
@ -11,6 +11,7 @@
|
|||
--semisecondary-text-color: #555;
|
||||
--link-text-color: #0467dd;
|
||||
--visited-link-text-color: var(--link-text-color);
|
||||
--small-font-size: .875rem;
|
||||
|
||||
--code-background-color: rgba(0, 0, 0, 0.15);
|
||||
--media-placeholder-default-background: rgba(0, 0, 0, .1);
|
||||
|
@ -22,11 +23,14 @@
|
|||
|
||||
--border-color: #ccc;
|
||||
--pill-background-color: #ccc;
|
||||
--url-preview-background-color: rgba(0, 0, 0, .05);
|
||||
--highlight-pill-background-color: #c00;
|
||||
--highlight-pill-text-color: #fff;
|
||||
--button-hover-color: rgba(0, 0, 0, .2);
|
||||
--light-hover-color: rgba(0, 0, 0, .1);
|
||||
|
||||
--composer-background-color: #f0f0f0;
|
||||
|
||||
--timeline-hover-bg-color: #eee;
|
||||
--timeline-highlight-bg-color: rgba(255, 255, 0, .1);
|
||||
--timeline-highlight-hover-bg-color: #eec;
|
||||
|
@ -42,7 +46,7 @@
|
|||
--room-list-entry-selected-color: rgba(0, 0, 0, 0.125);
|
||||
|
||||
--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;
|
||||
|
||||
|
@ -51,6 +55,9 @@
|
|||
--unread-counter-notification-bg: rgba(50, 150, 0, 0.7);
|
||||
--unread-counter-marked-unread-bg: var(--unread-counter-notification-bg);
|
||||
--unread-counter-highlight-bg: rgba(200, 0, 0, 0.7);
|
||||
--space-unread-counter-message-bg: rgb(100, 100, 100, 0.9);
|
||||
--space-unread-counter-notification-bg: rgb(50, 150, 0);
|
||||
--space-unread-counter-highlight-bg: rgb(200, 0, 0);
|
||||
|
||||
--sender-color-0: #a4041d;
|
||||
--sender-color-1: #9b2200;
|
||||
|
@ -79,6 +86,13 @@
|
|||
--timeline-message-gap-small-event: 0;
|
||||
--timeline-sender-name-timestamp-gap: .25rem;
|
||||
--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) {
|
||||
color-scheme: dark;
|
||||
|
@ -100,9 +114,12 @@
|
|||
|
||||
--border-color: #222;
|
||||
--pill-background-color: #222;
|
||||
--url-preview-background-color: #222;
|
||||
--button-hover-color: rgba(255, 255, 255, .2);
|
||||
--light-hover-color: rgba(255, 255, 255, .1);
|
||||
|
||||
--composer-background-color: #0a0a0a;
|
||||
|
||||
--timeline-hover-bg-color: #111;
|
||||
--timeline-highlight-bg-color: rgba(255, 255, 0, .1);
|
||||
--timeline-highlight-hover-bg-color: #331;
|
||||
|
@ -115,13 +132,16 @@
|
|||
--room-list-entry-hover-color: rgba(255, 255, 255, 0.075);
|
||||
--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;
|
||||
|
||||
--unread-counter-message-bg: rgba(255, 255, 255, 0.5);
|
||||
--unread-counter-notification-bg: rgba(150, 255, 0, 0.7);
|
||||
--unread-counter-highlight-bg: rgba(255, 50, 50, 0.7);
|
||||
--space-unread-counter-message-bg: rgb(200, 200, 200, 0.8);
|
||||
--space-unread-counter-notification-bg: rgb(150, 255, 0);
|
||||
--space-unread-counter-highlight-bg: rgb(255, 50, 50);
|
||||
|
||||
--sender-color-0: #ff877c;
|
||||
--sender-color-1: #f6913d;
|
||||
|
@ -144,15 +164,17 @@ body {
|
|||
font-family: var(--font-stack);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--login-background-color);
|
||||
background-color: var(--background-color);
|
||||
line-height: 1.5;
|
||||
font-size: 16px;
|
||||
touch-action: none;
|
||||
color: var(--text-color);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
html {
|
||||
touch-action: none;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
#root {
|
||||
|
@ -232,9 +254,15 @@ div.connection-error-wrapper {
|
|||
}
|
||||
}
|
||||
|
||||
div.pre-connect {
|
||||
margin-top: 2rem;
|
||||
div.pre-main {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--login-background-color);
|
||||
|
||||
&.waiting-to-connect {
|
||||
padding-top: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
main.matrix-main {
|
||||
--room-list-width: 300px;
|
||||
--room-list-width: 350px;
|
||||
--right-panel-width: 300px;
|
||||
|
||||
position: fixed;
|
||||
|
@ -16,35 +16,36 @@ main.matrix-main {
|
|||
/ 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 {
|
||||
grid-template: "rightpanel" 1fr / 1fr;
|
||||
> 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;
|
||||
translate: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
> div.room-list-resizer {
|
||||
grid-area: rh1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
> div.right-panel-resizer {
|
||||
grid-area: rh2;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,20 +13,21 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { JSX, use, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useState } from "react"
|
||||
import { JSX, use, useEffect, useMemo, useReducer, useRef, useState } from "react"
|
||||
import { SyncLoader } from "react-spinners"
|
||||
import Client from "@/api/client.ts"
|
||||
import { RoomStateStore } from "@/api/statestore"
|
||||
import { RoomListFilter, RoomStateStore } from "@/api/statestore"
|
||||
import type { RoomID } from "@/api/types"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import { parseMatrixURI } from "@/util/validation.ts"
|
||||
import { ensureString, ensureStringArray, parseMatrixURI } from "@/util/validation.ts"
|
||||
import ClientContext from "./ClientContext.ts"
|
||||
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
|
||||
import StylePreferences from "./StylePreferences.tsx"
|
||||
import Keybindings from "./keybindings.ts"
|
||||
import { ModalWrapper } from "./modal/Modal.tsx"
|
||||
import { ModalWrapper } from "./modal"
|
||||
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
|
||||
import RoomList from "./roomlist/RoomList.tsx"
|
||||
import RoomPreview, { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
|
||||
import RoomView from "./roomview/RoomView.tsx"
|
||||
import { useResizeHandle } from "./util/useResizeHandle.tsx"
|
||||
import "./MainScreen.css"
|
||||
|
@ -50,7 +51,8 @@ class ContextFields implements MainScreenContextFields {
|
|||
|
||||
constructor(
|
||||
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,
|
||||
) {
|
||||
this.keybindings = new Keybindings(client.store, this)
|
||||
|
@ -94,16 +96,85 @@ 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)
|
||||
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
|
||||
this.directSetActiveRoom(room)
|
||||
this.directSetRightPanel(null)
|
||||
if (!space && this.client.store.currentRoomListFilter) {
|
||||
const roomListEntry = this.client.store.roomListEntries.get(room.roomID)
|
||||
if (roomListEntry && !this.client.store.currentRoomListFilter.include(roomListEntry)) {
|
||||
space = this.client.store.findMatchingSpace(roomListEntry)
|
||||
}
|
||||
}
|
||||
if (space && space !== this.client.store.currentRoomListFilter) {
|
||||
console.log("Switching to space", space?.id)
|
||||
this.directSetSpace(space)
|
||||
this.client.store.currentRoomListFilter = space
|
||||
}
|
||||
this.rightPanelStack = []
|
||||
this.client.store.activeRoomID = room?.roomID ?? null
|
||||
this.client.store.activeRoomID = room.roomID
|
||||
this.client.store.activeRoomIsPreview = false
|
||||
this.keybindings.activeRoom = room
|
||||
if (room) {
|
||||
room.lastOpened = Date.now()
|
||||
if (!room.stateLoaded) {
|
||||
this.client.loadRoomState(room.roomID)
|
||||
|
@ -112,15 +183,28 @@ class ContextFields implements MainScreenContextFields {
|
|||
document
|
||||
.querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`)
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
}
|
||||
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) {
|
||||
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) => {
|
||||
|
@ -133,6 +217,7 @@ class ContextFields implements MainScreenContextFields {
|
|||
}
|
||||
|
||||
clickRightPanelOpener = (evt: React.MouseEvent) => {
|
||||
evt.preventDefault()
|
||||
const type = evt.currentTarget.getAttribute("data-target-panel")
|
||||
if (type === "pinned-messages" || type === "members") {
|
||||
this.setRightPanel({ type })
|
||||
|
@ -149,8 +234,11 @@ class ContextFields implements MainScreenContextFields {
|
|||
|
||||
const SYNC_ERROR_HIDE_DELAY = 30 * 1000
|
||||
|
||||
const handleURLHash = (client: Client) => {
|
||||
const handleURLHash = (client: Client, context: MainScreenContextFields, hashOnly = false) => {
|
||||
if (!location.hash.startsWith("#/uri/")) {
|
||||
if (hashOnly) {
|
||||
return null
|
||||
}
|
||||
if (location.search) {
|
||||
const currentETag = (
|
||||
document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement
|
||||
|
@ -164,7 +252,9 @@ const handleURLHash = (client: Client) => {
|
|||
}
|
||||
const state = JSON.parse(newURL.searchParams.get("state") || "{}")
|
||||
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 history.state
|
||||
|
@ -174,7 +264,7 @@ const handleURLHash = (client: Client) => {
|
|||
const uri = parseMatrixURI(decodedURI)
|
||||
if (!uri) {
|
||||
console.error("Invalid matrix URI", decodedURI)
|
||||
return history.state
|
||||
return hashOnly ? null : history.state
|
||||
}
|
||||
console.log("Handling URI", uri)
|
||||
const newURL = new URL(location.href)
|
||||
|
@ -190,47 +280,82 @@ const handleURLHash = (client: Client) => {
|
|||
history.replaceState(newState, "", newURL.toString())
|
||||
return newState
|
||||
} 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())
|
||||
return newState
|
||||
} else if (uri.identifier.startsWith("#")) {
|
||||
history.replaceState(history.state, "", newURL.toString())
|
||||
// TODO loading indicator or something for this?
|
||||
client.rpc.resolveAlias(uri.identifier).then(
|
||||
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}`),
|
||||
)
|
||||
return null
|
||||
} else {
|
||||
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 [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 client = use(ClientContext)!
|
||||
const syncStatus = useEventAsState(client.syncStatus)
|
||||
const context = useMemo(
|
||||
() => new ContextFields(directSetRightPanel, directSetActiveRoom, client),
|
||||
() => new ContextFields(directSetRightPanel, directSetActiveRoom, directSetSpace, client),
|
||||
[client],
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
window.mainScreenContext = context
|
||||
}, [context])
|
||||
useEffect(() => {
|
||||
const listener = (evt: PopStateEvent) => {
|
||||
window.mainScreenContext = context
|
||||
const listener = (evt: Pick<PopStateEvent, "state" | "hasUAVisualTransition">) => {
|
||||
skipNextTransitionRef.current = evt.hasUAVisualTransition
|
||||
const roomID = evt.state?.room_id ?? null
|
||||
const spaceID = evt.state?.space_id ?? undefined
|
||||
if (spaceID !== client.store.currentRoomListFilter?.id) {
|
||||
context.setSpace(client.store.getSpaceByID(spaceID), false)
|
||||
}
|
||||
if (roomID !== client.store.activeRoomID) {
|
||||
context.setActiveRoom(roomID, 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)
|
||||
}
|
||||
const hashListener = () => {
|
||||
const state = handleURLHash(client, context, true)
|
||||
if (state !== null) {
|
||||
listener({ state, hasUAVisualTransition: false })
|
||||
}
|
||||
}
|
||||
window.addEventListener("hashchange", hashListener)
|
||||
window.addEventListener("popstate", listener)
|
||||
const initHandle = () => {
|
||||
const state = handleURLHash(client)
|
||||
const state = handleURLHash(client, context)
|
||||
listener({ state } as PopStateEvent)
|
||||
}
|
||||
let cancel = () => {}
|
||||
|
@ -241,33 +366,27 @@ const MainScreen = () => {
|
|||
}
|
||||
return () => {
|
||||
window.removeEventListener("popstate", listener)
|
||||
window.removeEventListener("hashchange", hashListener)
|
||||
cancel()
|
||||
}
|
||||
}, [context, client])
|
||||
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(
|
||||
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(
|
||||
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 = {
|
||||
["--room-list-width" as string]: `${roomListWidth}px`,
|
||||
["--right-panel-width" as string]: `${rightPanelWidth}px`,
|
||||
}
|
||||
if (skipNextTransitionRef.current) {
|
||||
extraStyle["transition"] = "none"
|
||||
skipNextTransitionRef.current = false
|
||||
}
|
||||
const classNames = ["matrix-main"]
|
||||
if (activeRoom) {
|
||||
classNames.push("room-selected")
|
||||
|
@ -282,27 +401,42 @@ const MainScreen = () => {
|
|||
Waiting for first sync...
|
||||
</div>
|
||||
} else if (
|
||||
syncStatus.type === "errored"
|
||||
syncStatus.type === "erroring"
|
||||
&& (syncStatus.error_count > 2 || (syncStatus.last_sync ?? 0) + SYNC_ERROR_HIDE_DELAY < Date.now())
|
||||
) {
|
||||
syncLoader = <div className="sync-status errored" title={syncStatus.error}>
|
||||
<SyncLoader color="var(--error-color)"/>
|
||||
Sync is failing
|
||||
</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}>
|
||||
<ModalWrapper>
|
||||
<StylePreferences client={client} activeRoom={activeRoom}/>
|
||||
<StylePreferences client={client} activeRoom={activeRealRoom}/>
|
||||
<main className={classNames.join(" ")} style={extraStyle}>
|
||||
<RoomList activeRoomID={activeRoom?.roomID ?? null}/>
|
||||
<RoomList activeRoomID={activeRoom?.roomID ?? null} space={space}/>
|
||||
{resizeHandle1}
|
||||
{activeRoom
|
||||
{renderedRoom
|
||||
? renderedRoom instanceof RoomStateStore
|
||||
? <RoomView
|
||||
key={activeRoom.roomID}
|
||||
room={activeRoom}
|
||||
key={renderedRoom.roomID}
|
||||
room={renderedRoom}
|
||||
rightPanel={rightPanel}
|
||||
rightPanelResizeHandle={resizeHandle2}
|
||||
/>
|
||||
: <RoomPreview {...renderedRoom} />
|
||||
: rightPanel && <>
|
||||
<div className="room-view placeholder"/>
|
||||
{resizeHandle2}
|
||||
|
|
|
@ -13,12 +13,15 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { createContext } from "react"
|
||||
import React, { createContext } from "react"
|
||||
import { RoomListFilter } from "@/api/statestore"
|
||||
import type { RoomID } from "@/api/types"
|
||||
import type { RightPanelProps } from "./rightpanel/RightPanel.tsx"
|
||||
import type { RoomPreviewProps } from "./roomview/RoomPreview.tsx"
|
||||
|
||||
export interface MainScreenContextFields {
|
||||
setActiveRoom: (roomID: RoomID | null) => void
|
||||
setActiveRoom: (roomID: RoomID | null, previewMeta?: Partial<RoomPreviewProps>, toSpace?: RoomListFilter) => void
|
||||
setSpace: (space: RoomListFilter | null, pushState?: boolean) => void
|
||||
clickRoom: (evt: React.MouseEvent) => void
|
||||
clearActiveRoom: () => void
|
||||
|
||||
|
@ -31,6 +34,9 @@ const stubContext = {
|
|||
get setActiveRoom(): never {
|
||||
throw new Error("MainScreenContext used outside main screen")
|
||||
},
|
||||
get setSpace(): never {
|
||||
throw new Error("MainScreenContext used outside main screen")
|
||||
},
|
||||
get clickRoom(): never {
|
||||
throw new Error("MainScreenContext used outside main screen")
|
||||
},
|
||||
|
|