diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 699fae9..6adf818 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 64f83b3..e1c09a7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e8281c..d8d8065 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/Dockerfile.ci b/Dockerfile.ci index 5585c0c..7513497 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -1,4 +1,4 @@ -FROM alpine:3.20 +FROM alpine:3.21 RUN apk add --no-cache ca-certificates jq curl diff --git a/desktop/.gitignore b/desktop/.gitignore index 678c4d5..fa24770 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -1,2 +1,3 @@ .task bin +build/appimage diff --git a/desktop/Taskfile.yml b/desktop/Taskfile.yml index b455132..bedee78 100644 --- a/desktop/Taskfile.yml +++ b/desktop/Taskfile.yml @@ -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 diff --git a/desktop/build/Info.dev.plist b/desktop/build/Info.dev.plist index 98c6ce3..995fe72 100644 --- a/desktop/build/Info.dev.plist +++ b/desktop/build/Info.dev.plist @@ -22,7 +22,7 @@ NSHighResolutionCapable true NSHumanReadableCopyright - © 2024, Tulir Asokan + © 2024, gomuks authors NSAppTransportSecurity NSAllowsLocalNetworking diff --git a/desktop/build/Info.plist b/desktop/build/Info.plist index 5ab9e57..cadeb2c 100644 --- a/desktop/build/Info.plist +++ b/desktop/build/Info.plist @@ -22,6 +22,6 @@ NSHighResolutionCapable true NSHumanReadableCopyright - © 2024, Tulir Asokan + © 2024, gomuks authors diff --git a/desktop/build/Taskfile.common.yml b/desktop/build/Taskfile.common.yml new file mode 100644 index 0000000..484efc8 --- /dev/null +++ b/desktop/build/Taskfile.common.yml @@ -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 . diff --git a/desktop/build/Taskfile.darwin.yml b/desktop/build/Taskfile.darwin.yml new file mode 100644 index 0000000..180748c --- /dev/null +++ b/desktop/build/Taskfile.darwin.yml @@ -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}}' diff --git a/desktop/build/Taskfile.linux.yml b/desktop/build/Taskfile.linux.yml new file mode 100644 index 0000000..1472c3a --- /dev/null +++ b/desktop/build/Taskfile.linux.yml @@ -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}}' diff --git a/desktop/build/Taskfile.windows.yml b/desktop/build/Taskfile.windows.yml new file mode 100644 index 0000000..505896a --- /dev/null +++ b/desktop/build/Taskfile.windows.yml @@ -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' diff --git a/desktop/build/config.yml b/desktop/build/config.yml new file mode 100644 index 0000000..0936770 --- /dev/null +++ b/desktop/build/config.yml @@ -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: [] diff --git a/desktop/build/devmode.config.yaml b/desktop/build/devmode.config.yaml deleted file mode 100644 index 1a441f2..0000000 --- a/desktop/build/devmode.config.yaml +++ /dev/null @@ -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 diff --git a/desktop/build/icon.ico b/desktop/build/icon.ico index 1980bab..7b3291f 100644 Binary files a/desktop/build/icon.ico and b/desktop/build/icon.ico differ diff --git a/desktop/build/icons.icns b/desktop/build/icons.icns index cfb6cf8..e76dfa0 100644 Binary files a/desktop/build/icons.icns and b/desktop/build/icons.icns differ diff --git a/desktop/build/info.json b/desktop/build/info.json index b60c867..862bfe7 100644 --- a/desktop/build/info.json +++ b/desktop/build/info.json @@ -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": "" } diff --git a/desktop/build/nfpm/nfpm.yaml b/desktop/build/nfpm/nfpm.yaml new file mode 100644 index 0000000..2b57384 --- /dev/null +++ b/desktop/build/nfpm/nfpm.yaml @@ -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 +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 diff --git a/desktop/build/nfpm/scripts/postinstall.sh b/desktop/build/nfpm/scripts/postinstall.sh new file mode 100644 index 0000000..a9bf588 --- /dev/null +++ b/desktop/build/nfpm/scripts/postinstall.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/desktop/build/nfpm/scripts/postremove.sh b/desktop/build/nfpm/scripts/postremove.sh new file mode 100644 index 0000000..a9bf588 --- /dev/null +++ b/desktop/build/nfpm/scripts/postremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/desktop/build/nfpm/scripts/preinstall.sh b/desktop/build/nfpm/scripts/preinstall.sh new file mode 100644 index 0000000..a9bf588 --- /dev/null +++ b/desktop/build/nfpm/scripts/preinstall.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/desktop/build/nfpm/scripts/preremove.sh b/desktop/build/nfpm/scripts/preremove.sh new file mode 100644 index 0000000..a9bf588 --- /dev/null +++ b/desktop/build/nfpm/scripts/preremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/desktop/build/nsis/project.nsi b/desktop/build/nsis/project.nsi index 6258611..f648290 100644 --- a/desktop/build/nsis/project.nsi +++ b/desktop/build/nsis/project.nsi @@ -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 diff --git a/desktop/build/nsis/wails_tools.nsh b/desktop/build/nsis/wails_tools.nsh index 9a3fce8..f67f2bd 100644 --- a/desktop/build/nsis/wails_tools.nsh +++ b/desktop/build/nsis/wails_tools.nsh @@ -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 diff --git a/desktop/build/wails.exe.manifest b/desktop/build/wails.exe.manifest index 144b94e..6749711 100644 --- a/desktop/build/wails.exe.manifest +++ b/desktop/build/wails.exe.manifest @@ -1,6 +1,6 @@ - + diff --git a/desktop/go.mod b/desktop/go.mod index 7268ece..2e38a85 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -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 => ../ diff --git a/desktop/go.sum b/desktop/go.sum index 63be3f6..c0fdeb3 100644 --- a/desktop/go.sum +++ b/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= diff --git a/desktop/main.go b/desktop/main.go index 2046017..cab771b 100644 --- a/desktop/main.go +++ b/desktop/main.go @@ -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()}, diff --git a/go.mod b/go.mod index 699a9ff..551a340 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 3601045..c9e72e4 100644 --- a/go.sum +++ b/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= diff --git a/pkg/gomuks/buffer.go b/pkg/gomuks/buffer.go index 5171fec..a6233b6 100644 --- a/pkg/gomuks/buffer.go +++ b/pkg/gomuks/buffer.go @@ -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)) diff --git a/pkg/gomuks/config.go b/pkg/gomuks/config.go index 0ed8f50..7aff6d8 100644 --- a/pkg/gomuks/config.go +++ b/pkg/gomuks/config.go @@ -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,13 +42,18 @@ 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"` - PasswordHash string `yaml:"password_hash"` - TokenKey string `yaml:"token_key"` - DebugEndpoints bool `yaml:"debug_endpoints"` - EventBufferSize int `yaml:"event_buffer_size"` + ListenAddress string `yaml:"listen_address"` + Username string `yaml:"username"` + PasswordHash string `yaml:"password_hash"` + 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 { diff --git a/pkg/gomuks/gomuks.go b/pkg/gomuks/gomuks.go index 4a2943d..0b59562 100644 --- a/pkg/gomuks/gomuks.go +++ b/pkg/gomuks/gomuks.go @@ -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,9 +239,11 @@ func (gmx *Gomuks) DirectStop() { closer(websocket.StatusServiceRestart, "Server shutting down") } gmx.Client.Stop() - err := gmx.Server.Close() - if err != nil { - gmx.Log.Error().Err(err).Msg("Failed to close server") + if gmx.Server != nil { + err := gmx.Server.Close() + if err != nil { + gmx.Log.Error().Err(err).Msg("Failed to close server") + } } } diff --git a/pkg/gomuks/media.go b/pkg/gomuks/media.go index 01e7637..fe03c96 100644 --- a/pkg/gomuks/media.go +++ b/pkg/gomuks/media.go @@ -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 = ` - + %s diff --git a/pkg/gomuks/push.go b/pkg/gomuks/push.go new file mode 100644 index 0000000..6e3347e --- /dev/null +++ b/pkg/gomuks/push.go @@ -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 . + +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() + } +} diff --git a/pkg/gomuks/pushmessage.go b/pkg/gomuks/pushmessage.go new file mode 100644 index 0000000..a4d3d1e --- /dev/null +++ b/pkg/gomuks/pushmessage.go @@ -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 . + +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, + } +} diff --git a/pkg/gomuks/server.go b/pkg/gomuks/server.go index 48464c8..fe6aade 100644 --- a/pkg/gomuks/server.go +++ b/pkg/gomuks/server.go @@ -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,16 +206,26 @@ 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() - http.SetCookie(w, &http.Cookie{ - Name: "gomuks_auth", - Value: token, - Expires: expiry, - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - }) + if !jsonOutput { + http.SetCookie(w, &http.Cookie{ + Name: "gomuks_auth", + Value: token, + Expires: expiry, + HttpOnly: true, + 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") - w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) + if allowPrompt { + w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) + } w.WriteHeader(http.StatusUnauthorized) } 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") - w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) + if allowPrompt { + w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) + } w.WriteHeader(http.StatusUnauthorized) } } diff --git a/pkg/gomuks/websocket.go b/pkg/gomuks/websocket.go index bec91dc..dda65d2 100644 --- a/pkg/gomuks/websocket.go +++ b/pkg/gomuks/websocket.go @@ -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") diff --git a/pkg/hicli/database/database.go b/pkg/hicli/database/database.go index 95e28a7..a3b1840 100644 --- a/pkg/hicli/database/database.go +++ b/pkg/hicli/database/database.go @@ -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{} +} diff --git a/pkg/hicli/database/event.go b/pkg/hicli/database/event.go index 3940614..cfef7e7 100644 --- a/pkg/hicli/database/event.go +++ b/pkg/hicli/database/event.go @@ -327,12 +327,17 @@ func (m EventRowID) GetMassInsertValues() [1]any { } type LocalContent struct { - SanitizedHTML string `json:"sanitized_html,omitempty"` - HTMLVersion int `json:"html_version,omitempty"` - WasPlaintext bool `json:"was_plaintext,omitempty"` - BigEmoji bool `json:"big_emoji,omitempty"` - HasMath bool `json:"has_math,omitempty"` - EditSource string `json:"edit_source,omitempty"` + SanitizedHTML string `json:"sanitized_html,omitempty"` + HTMLVersion int `json:"html_version,omitempty"` + WasPlaintext bool `json:"was_plaintext,omitempty"` + 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 +} diff --git a/pkg/hicli/database/invitedroom.go b/pkg/hicli/database/invitedroom.go new file mode 100644 index 0000000..5be4b27 --- /dev/null +++ b/pkg/hicli/database/invitedroom.go @@ -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 +} diff --git a/pkg/hicli/database/pushregistration.go b/pkg/hicli/database/pushregistration.go new file mode 100644 index 0000000..a89e7a1 --- /dev/null +++ b/pkg/hicli/database/pushregistration.go @@ -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} +} diff --git a/pkg/hicli/database/receipt.go b/pkg/hicli/database/receipt.go index 8b20816..0cf7047 100644 --- a/pkg/hicli/database/receipt.go +++ b/pkg/hicli/database/receipt.go @@ -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"` } diff --git a/pkg/hicli/database/room.go b/pkg/hicli/database/room.go index cb386b4..6e46001 100644 --- a/pkg/hicli/database/room.go +++ b/pkg/hicli/database/room.go @@ -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), diff --git a/pkg/hicli/database/space.go b/pkg/hicli/database/space.go new file mode 100644 index 0000000..3e801e1 --- /dev/null +++ b/pkg/hicli/database/space.go @@ -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 +} diff --git a/pkg/hicli/database/upgrades/00-latest-revision.sql b/pkg/hicli/database/upgrades/00-latest-revision.sql index 026c77b..847afae 100644 --- a/pkg/hicli/database/upgrades/00-latest-revision.sql +++ b/pkg/hicli/database/upgrades/00-latest-revision.sql @@ -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; diff --git a/pkg/hicli/database/upgrades/08-add-missing-indexes.sql b/pkg/hicli/database/upgrades/08-add-missing-indexes.sql new file mode 100644 index 0000000..980be44 --- /dev/null +++ b/pkg/hicli/database/upgrades/08-add-missing-indexes.sql @@ -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); diff --git a/pkg/hicli/database/upgrades/09-invited-rooms.sql b/pkg/hicli/database/upgrades/09-invited-rooms.sql new file mode 100644 index 0000000..49aa750 --- /dev/null +++ b/pkg/hicli/database/upgrades/09-invited-rooms.sql @@ -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; diff --git a/pkg/hicli/database/upgrades/10-spaces.sql b/pkg/hicli/database/upgrades/10-spaces.sql new file mode 100644 index 0000000..429973c --- /dev/null +++ b/pkg/hicli/database/upgrades/10-spaces.sql @@ -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; diff --git a/pkg/hicli/database/upgrades/11-dm-user-id.sql b/pkg/hicli/database/upgrades/11-dm-user-id.sql new file mode 100644 index 0000000..3377f0c --- /dev/null +++ b/pkg/hicli/database/upgrades/11-dm-user-id.sql @@ -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; diff --git a/pkg/hicli/database/upgrades/12-push-registrations.sql b/pkg/hicli/database/upgrades/12-push-registrations.sql new file mode 100644 index 0000000..07c03eb --- /dev/null +++ b/pkg/hicli/database/upgrades/12-push-registrations.sql @@ -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; diff --git a/pkg/hicli/decryptionqueue.go b/pkg/hicli/decryptionqueue.go index 772f2a7..d5f669a 100644 --- a/pkg/hicli/decryptionqueue.go +++ b/pkg/hicli/decryptionqueue.go @@ -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 { diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go index c3ee935..31cb9bb 100644 --- a/pkg/hicli/events.go +++ b/pkg/hicli/events.go @@ -15,38 +15,58 @@ import ( ) type SyncRoom struct { - Meta *database.Room `json:"meta"` - Timeline []database.TimelineRowTuple `json:"timeline"` - State map[event.Type]map[string]database.EventRowID `json:"state"` - AccountData map[event.Type]*database.AccountData `json:"account_data"` - Events []*database.Event `json:"events"` - Reset bool `json:"reset"` - Notifications []SyncNotification `json:"notifications"` + Meta *database.Room `json:"meta"` + Timeline []database.TimelineRowTuple `json:"timeline"` + State map[event.Type]map[string]database.EventRowID `json:"state"` + 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"` + 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"` - LeftRooms []id.RoomID `json:"left_rooms"` + Since *string `json:"since,omitempty"` + ClearState bool `json:"clear_state,omitempty"` + 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 const ( - SyncStatusOK SyncStatusType = "ok" - SyncStatusWaiting SyncStatusType = "waiting" - SyncStatusErrored SyncStatusType = "errored" + SyncStatusOK SyncStatusType = "ok" + SyncStatusWaiting SyncStatusType = "waiting" + SyncStatusErroring SyncStatusType = "erroring" + SyncStatusFailed SyncStatusType = "permanently-failed" ) type SyncStatus struct { diff --git a/pkg/hicli/hicli.go b/pkg/hicli/hicli.go index aa8e5e9..3b41577 100644 --- a/pkg/hicli/hicli.go +++ b/pkg/hicli/hicli.go @@ -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) diff --git a/pkg/hicli/init.go b/pkg/hicli/init.go index 7b44f07..5b08de5 100644 --- a/pkg/hicli/init.go +++ b/pkg/hicli/init.go @@ -14,11 +14,9 @@ import ( func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) *SyncRoom { 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), + Meta: room, + Events: make([]*database.Event, 0, 2), + State: map[event.Type]map[string]database.EventRowID{}, } 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 { diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index c09d4c0..dca1ea7 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -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"` +} diff --git a/pkg/hicli/paginate.go b/pkg/hicli/paginate.go index e492160..f41ceee 100644 --- a/pkg/hicli/paginate.go +++ b/pkg/hicli/paginate.go @@ -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 { @@ -155,17 +161,9 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe h.EventHandler(&SyncComplete{ 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), + Meta: room, }, }, - AccountData: make(map[event.Type]*database.AccountData), - LeftRooms: make([]id.RoomID, 0), }) } } @@ -196,22 +194,87 @@ func (h *HiClient) GetRoomState(ctx context.Context, roomID id.RoomID, includeMe } type PaginationResponse struct { - Events []*database.Event `json:"events"` - HasMore bool `json:"has_more"` + Events []*database.Event `json:"events"` + Receipts map[id.EventID][]*database.Receipt `json:"receipts"` + RelatedEvents []*database.Event `json:"related_events"` + HasMore bool `json:"has_more"` } func (h *HiClient) Paginate(ctx context.Context, roomID id.RoomID, maxTimelineID database.TimelineRowID, limit int) (*PaginationResponse, error) { 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) { diff --git a/pkg/hicli/profile.go b/pkg/hicli/profile.go index 754ec6f..2b5bf2a 100644 --- a/pkg/hicli/profile.go +++ b/pkg/hicli/profile.go @@ -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 +} diff --git a/pkg/hicli/send.go b/pkg/hicli/send.go index 67e9fdf..998382f 100644 --- a/pkg/hicli/send.go +++ b/pkg/hicli/send.go @@ -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) diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go index b4aa257..ad515de 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -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) @@ -442,12 +518,13 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev wasPlaintext = true } return &database.LocalContent{ - SanitizedHTML: sanitizedHTML, - HTMLVersion: CurrentHTMLSanitizerVersion, - WasPlaintext: wasPlaintext, - BigEmoji: bigEmoji, - HasMath: hasMath, - EditSource: editSource, + SanitizedHTML: sanitizedHTML, + HTMLVersion: CurrentHTMLSanitizerVersion, + WasPlaintext: wasPlaintext, + 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 } - allNewEvents = append(allNewEvents, dbEvt) + _, alreadyAdded := addedEvents[dbEvt.RowID] + if !alreadyAdded { + addedEvents[dbEvt.RowID] = struct{}{} + allNewEvents = append(allNewEvents, dbEvt) + } return dbEvt, nil } processRedaction := func(evt *event.Event) error { @@ -643,8 +724,11 @@ func (h *HiClient) processStateAndTimeline( if isUnread { if dbEvt.UnreadType.Is(database.UnreadTypeNotify) && h.firstSyncReceived { newNotifications = append(newNotifications, SyncNotification{ - RowID: dbEvt.RowID, - Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound), + 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,16 +938,26 @@ 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, - AccountData: accountData, - State: changedState, - Reset: timeline.Limited, - Events: allNewEvents, - Notifications: newNotifications, + Meta: room, + Timeline: timelineRowTuples, + AccountData: accountData, + 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). diff --git a/pkg/hicli/syncwrap.go b/pkg/hicli/syncwrap.go index 5c14e88..8492da2 100644 --- a/pkg/hicli/syncwrap.go +++ b/pkg/hicli/syncwrap.go @@ -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 @@ -29,19 +34,29 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, c := (*HiClient)(h) c.lastSync = time.Now() ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{ - Since: &since, - Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)), - LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)), + 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 } - err = c.DB.DoTxn(ctx, nil, func(ctx context.Context) error { - return c.processSyncResponse(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) + }) + 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, }, diff --git a/version/version.go b/version/version.go index feecfb8..9161ff7 100644 --- a/version/version.go +++ b/version/version.go @@ -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) } diff --git a/web/eslint.config.js b/web/eslint.config.js index 7654e8d..40156d5 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -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", diff --git a/web/index.html b/web/index.html index 739246e..6be7b75 100644 --- a/web/index.html +++ b/web/index.html @@ -1,9 +1,10 @@ - + - - + + + gomuks web @@ -11,5 +12,16 @@
+ + + + + + + diff --git a/web/package-lock.json b/web/package-lock.json index 4644b47..804656b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -805,9 +805,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.16.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.16.0.tgz", - "integrity": "sha512-tw2HxzQkrbeuvyj1tG2Yqq+0H9wGoI2IMk4EOsQeX+vmd75FtJAzf+gTA69WF+baUKRYQ3x2kbLE08js5OsTVg==", + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", + "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", "dev": true, "license": "MIT", "engines": { @@ -904,9 +904,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, "license": "MIT", "dependencies": { @@ -995,9 +995,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.3.tgz", - "integrity": "sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1031,9 +1031,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.0.tgz", - "integrity": "sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.30.0.tgz", + "integrity": "sha512-qFcFto9figFLz2g25DxJ1WWL9+c91fTxnGuwhToCl8BaqDsDYMl/kOnBXAyAqkkzAWimYMSWNPWEjt+ADAHuoQ==", "cpu": [ "arm" ], @@ -1045,9 +1045,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.0.tgz", - "integrity": "sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.30.0.tgz", + "integrity": "sha512-vqrQdusvVl7dthqNjWCL043qelBK+gv9v3ZiqdxgaJvmZyIAAXMjeGVSqZynKq69T7062T5VrVTuikKSAAVP6A==", "cpu": [ "arm64" ], @@ -1059,9 +1059,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.0.tgz", - "integrity": "sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.30.0.tgz", + "integrity": "sha512-617pd92LhdA9+wpixnzsyhVft3szYiN16aNUMzVkf2N+yAk8UXY226Bfp36LvxYTUt7MO/ycqGFjQgJ0wlMaWQ==", "cpu": [ "arm64" ], @@ -1073,9 +1073,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.0.tgz", - "integrity": "sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.30.0.tgz", + "integrity": "sha512-Y3b4oDoaEhCypg8ajPqigKDcpi5ZZovemQl9Edpem0uNv6UUjXv7iySBpGIUTSs2ovWOzYpfw9EbFJXF/fJHWw==", "cpu": [ "x64" ], @@ -1087,9 +1087,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.0.tgz", - "integrity": "sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.30.0.tgz", + "integrity": "sha512-3REQJ4f90sFIBfa0BUokiCdrV/E4uIjhkWe1bMgCkhFXbf4D8YN6C4zwJL881GM818qVYE9BO3dGwjKhpo2ABA==", "cpu": [ "arm64" ], @@ -1101,9 +1101,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.0.tgz", - "integrity": "sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.30.0.tgz", + "integrity": "sha512-ZtY3Y8icbe3Cc+uQicsXG5L+CRGUfLZjW6j2gn5ikpltt3Whqjfo5mkyZ86UiuHF9Q3ZsaQeW7YswlHnN+lAcg==", "cpu": [ "x64" ], @@ -1115,9 +1115,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.0.tgz", - "integrity": "sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.30.0.tgz", + "integrity": "sha512-bsPGGzfiHXMhQGuFGpmo2PyTwcrh2otL6ycSZAFTESviUoBOuxF7iBbAL5IJXc/69peXl5rAtbewBFeASZ9O0g==", "cpu": [ "arm" ], @@ -1129,9 +1129,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.0.tgz", - "integrity": "sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.30.0.tgz", + "integrity": "sha512-kvyIECEhs2DrrdfQf++maCWJIQ974EI4txlz1nNSBaCdtf7i5Xf1AQCEJWOC5rEBisdaMFFnOWNLYt7KpFqy5A==", "cpu": [ "arm" ], @@ -1143,9 +1143,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.0.tgz", - "integrity": "sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.30.0.tgz", + "integrity": "sha512-CFE7zDNrokaotXu+shwIrmWrFxllg79vciH4E/zeK7NitVuWEaXRzS0mFfFvyhZfn8WfVOG/1E9u8/DFEgK7WQ==", "cpu": [ "arm64" ], @@ -1157,9 +1157,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.0.tgz", - "integrity": "sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.30.0.tgz", + "integrity": "sha512-MctNTBlvMcIBP0t8lV/NXiUwFg9oK5F79CxLU+a3xgrdJjfBLVIEHSAjQ9+ipofN2GKaMLnFFXLltg1HEEPaGQ==", "cpu": [ "arm64" ], @@ -1170,10 +1170,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.30.0.tgz", + "integrity": "sha512-fBpoYwLEPivL3q368+gwn4qnYnr7GVwM6NnMo8rJ4wb0p/Y5lg88vQRRP077gf+tc25akuqd+1Sxbn9meODhwA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.0.tgz", - "integrity": "sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.30.0.tgz", + "integrity": "sha512-1hiHPV6dUaqIMXrIjN+vgJqtfkLpqHS1Xsg0oUfUVD98xGp1wX89PIXgDF2DWra1nxAd8dfE0Dk59MyeKaBVAw==", "cpu": [ "ppc64" ], @@ -1185,9 +1199,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.0.tgz", - "integrity": "sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.30.0.tgz", + "integrity": "sha512-U0xcC80SMpEbvvLw92emHrNjlS3OXjAM0aVzlWfar6PR0ODWCTQtKeeB+tlAPGfZQXicv1SpWwRz9Hyzq3Jx3g==", "cpu": [ "riscv64" ], @@ -1199,9 +1213,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.0.tgz", - "integrity": "sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.30.0.tgz", + "integrity": "sha512-VU/P/IODrNPasgZDLIFJmMiLGez+BN11DQWfTVlViJVabyF3JaeaJkP6teI8760f18BMGCQOW9gOmuzFaI1pUw==", "cpu": [ "s390x" ], @@ -1213,9 +1227,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.0.tgz", - "integrity": "sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.0.tgz", + "integrity": "sha512-laQVRvdbKmjXuFA3ZiZj7+U24FcmoPlXEi2OyLfbpY2MW1oxLt9Au8q9eHd0x6Pw/Kw4oe9gwVXWwIf2PVqblg==", "cpu": [ "x64" ], @@ -1227,9 +1241,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.0.tgz", - "integrity": "sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.0.tgz", + "integrity": "sha512-3wzKzduS7jzxqcOvy/ocU/gMR3/QrHEFLge5CD7Si9fyHuoXcidyYZ6jyx8OPYmCcGm3uKTUl+9jUSAY74Ln5A==", "cpu": [ "x64" ], @@ -1241,9 +1255,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.0.tgz", - "integrity": "sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.30.0.tgz", + "integrity": "sha512-jROwnI1+wPyuv696rAFHp5+6RFhXGGwgmgSfzE8e4xfit6oLRg7GyMArVUoM3ChS045OwWr9aTnU+2c1UdBMyw==", "cpu": [ "arm64" ], @@ -1255,9 +1269,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.0.tgz", - "integrity": "sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.30.0.tgz", + "integrity": "sha512-duzweyup5WELhcXx5H1jokpr13i3BV9b48FMiikYAwk/MT1LrMYYk2TzenBd0jj4ivQIt58JWSxc19y4SvLP4g==", "cpu": [ "ia32" ], @@ -1269,9 +1283,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.0.tgz", - "integrity": "sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.30.0.tgz", + "integrity": "sha512-DYvxS0M07PvgvavMIybCOBYheyrqlui6ZQBHJs6GqduVzHSZ06TPPvlfvnYstjODHQ8UUXFwt5YE+h0jFI8kwg==", "cpu": [ "x64" ], @@ -1515,9 +1529,9 @@ } }, "node_modules/@swc/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.0.tgz", - "integrity": "sha512-+CuuTCmQFfzaNGg1JmcZvdUVITQXJk9sMnl1C2TiDLzOSVOJRwVD4dNo5dljX/qxpMAN+2BIYlwjlSkoGi6grg==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.4.tgz", + "integrity": "sha512-ut3zfiTLORMxhr6y/GBxkHmzcGuVpwJYX4qyXWuBKkpw/0g0S5iO1/wW7RnLnZbAi8wS/n0atRZoaZlXWBkeJg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1533,16 +1547,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.10.0", - "@swc/core-darwin-x64": "1.10.0", - "@swc/core-linux-arm-gnueabihf": "1.10.0", - "@swc/core-linux-arm64-gnu": "1.10.0", - "@swc/core-linux-arm64-musl": "1.10.0", - "@swc/core-linux-x64-gnu": "1.10.0", - "@swc/core-linux-x64-musl": "1.10.0", - "@swc/core-win32-arm64-msvc": "1.10.0", - "@swc/core-win32-ia32-msvc": "1.10.0", - "@swc/core-win32-x64-msvc": "1.10.0" + "@swc/core-darwin-arm64": "1.10.4", + "@swc/core-darwin-x64": "1.10.4", + "@swc/core-linux-arm-gnueabihf": "1.10.4", + "@swc/core-linux-arm64-gnu": "1.10.4", + "@swc/core-linux-arm64-musl": "1.10.4", + "@swc/core-linux-x64-gnu": "1.10.4", + "@swc/core-linux-x64-musl": "1.10.4", + "@swc/core-win32-arm64-msvc": "1.10.4", + "@swc/core-win32-ia32-msvc": "1.10.4", + "@swc/core-win32-x64-msvc": "1.10.4" }, "peerDependencies": { "@swc/helpers": "*" @@ -1554,9 +1568,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.0.tgz", - "integrity": "sha512-wCeUpanqZyzvgqWRtXIyhcFK3CqukAlYyP+fJpY2gWc/+ekdrenNIfZMwY7tyTFDkXDYEKzvn3BN/zDYNJFowQ==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.4.tgz", + "integrity": "sha512-sV/eurLhkjn/197y48bxKP19oqcLydSel42Qsy2zepBltqUx+/zZ8+/IS0Bi7kaWVFxerbW1IPB09uq8Zuvm3g==", "cpu": [ "arm64" ], @@ -1571,9 +1585,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.0.tgz", - "integrity": "sha512-0CZPzqTynUBO+SHEl/qKsFSahp2Jv/P2ZRjFG0gwZY5qIcr1+B/v+o74/GyNMBGz9rft+F2WpU31gz2sJwyF4A==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.4.tgz", + "integrity": "sha512-gjYNU6vrAUO4+FuovEo9ofnVosTFXkF0VDuo1MKPItz6e2pxc2ale4FGzLw0Nf7JB1sX4a8h06CN16/pLJ8Q2w==", "cpu": [ "x64" ], @@ -1588,9 +1602,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.0.tgz", - "integrity": "sha512-oq+DdMu5uJOFPtRkeiITc4kxmd+QSmK+v+OBzlhdGkSgoH3yRWZP+H2ao0cBXo93ZgCr2LfjiER0CqSKhjGuNA==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.4.tgz", + "integrity": "sha512-zd7fXH5w8s+Sfvn2oO464KDWl+ZX1MJiVmE4Pdk46N3PEaNwE0koTfgx2vQRqRG4vBBobzVvzICC3618WcefOA==", "cpu": [ "arm" ], @@ -1605,9 +1619,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.0.tgz", - "integrity": "sha512-Y6+PC8knchEViRxiCUj3j8wsGXaIhuvU+WqrFqV834eiItEMEI9+Vh3FovqJMBE3L7d4E4ZQtgImHCXjrHfxbw==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.4.tgz", + "integrity": "sha512-+UGfoHDxsMZgFD3tABKLeEZHqLNOkxStu+qCG7atGBhS4Slri6h6zijVvf4yI5X3kbXdvc44XV/hrP/Klnui2A==", "cpu": [ "arm64" ], @@ -1622,9 +1636,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.0.tgz", - "integrity": "sha512-EbrX9A5U4cECCQQfky7945AW9GYnTXtCUXElWTkTYmmyQK87yCyFfY8hmZ9qMFIwxPOH6I3I2JwMhzdi8Qoz7g==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.4.tgz", + "integrity": "sha512-cDDj2/uYsOH0pgAnDkovLZvKJpFmBMyXkxEG6Q4yw99HbzO6QzZ5HDGWGWVq/6dLgYKlnnmpjZCPPQIu01mXEg==", "cpu": [ "arm64" ], @@ -1639,9 +1653,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.0.tgz", - "integrity": "sha512-TaxpO6snTjjfLXFYh5EjZ78se69j2gDcqEM8yB9gguPYwkCHi2Ylfmh7iVaNADnDJFtjoAQp0L41bTV/Pfq9Cg==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.4.tgz", + "integrity": "sha512-qJXh9D6Kf5xSdGWPINpLGixAbB5JX8JcbEJpRamhlDBoOcQC79dYfOMEIxWPhTS1DGLyFakAx2FX/b2VmQmj0g==", "cpu": [ "x64" ], @@ -1656,9 +1670,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.0.tgz", - "integrity": "sha512-IEGvDd6aEEKEyZFZ8oCKuik05G5BS7qwG5hO5PEMzdGeh8JyFZXxsfFXbfeAqjue4UaUUrhnoX+Ze3M2jBVMHw==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.4.tgz", + "integrity": "sha512-A76lIAeyQnHCVt0RL/pG+0er8Qk9+acGJqSZOZm67Ve3B0oqMd871kPtaHBM0BW3OZAhoILgfHW3Op9Q3mx3Cw==", "cpu": [ "x64" ], @@ -1673,9 +1687,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.0.tgz", - "integrity": "sha512-UkQ952GSpY+Z6XONj9GSW8xGSkF53jrCsuLj0nrcuw7Dvr1a816U/9WYZmmcYS8tnG2vHylhpm6csQkyS8lpCw==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.4.tgz", + "integrity": "sha512-e6j5kBu4fIY7fFxFxnZI0MlEovRvp50Lg59Fw+DVbtqHk3C85dckcy5xKP+UoXeuEmFceauQDczUcGs19SRGSQ==", "cpu": [ "arm64" ], @@ -1690,9 +1704,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.0.tgz", - "integrity": "sha512-a2QpIZmTiT885u/mUInpeN2W9ClCnqrV2LnMqJR1/Fgx1Afw/hAtiDZPtQ0SqS8yDJ2VR5gfNZo3gpxWMrqdVA==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.4.tgz", + "integrity": "sha512-RSYHfdKgNXV/amY5Tqk1EWVsyQnhlsM//jeqMLw5Fy9rfxP592W9UTumNikNRPdjI8wKKzNMXDb1U29tQjN0dg==", "cpu": [ "ia32" ], @@ -1707,9 +1721,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.0.tgz", - "integrity": "sha512-tZcCmMwf483nwsEBfUk5w9e046kMa1iSik4bP9Kwi2FGtOfHuDfIcwW4jek3hdcgF5SaBW1ktnK/lgQLDi5AtA==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.4.tgz", + "integrity": "sha512-1ujYpaqfqNPYdwKBlvJnOqcl+Syn3UrQ4XE0Txz6zMYgyh6cdU6a3pxqLqIUSJ12MtXRA9ZUhEz1ekU3LfLWXw==", "cpu": [ "x64" ], @@ -1748,9 +1762,9 @@ "license": "MIT" }, "node_modules/@types/geojson": { - "version": "7946.0.14", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", - "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", + "version": "7946.0.15", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.15.tgz", + "integrity": "sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==", "dev": true, "license": "MIT" }, @@ -1786,9 +1800,9 @@ } }, "node_modules/@types/react": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.0.tgz", - "integrity": "sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.3.tgz", + "integrity": "sha512-UavfHguIjnnuq9O67uXfgy/h3SRJbidAYvNjLceB+2RIKVRBzVsh0QO+Pw6BCSQqFS9xwzKfwstXx0m6AbAREA==", "dev": true, "license": "MIT", "dependencies": { @@ -1796,13 +1810,13 @@ } }, "node_modules/@types/react-dom": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.0.tgz", - "integrity": "sha512-1KfiQKsH1o00p9m5ag12axHQSb3FOU9H20UTrujVSkNhuCrRHiQWFqgEnTNK5ZNfnzZv8UWrnXVqCmCF9fgY3w==", + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz", + "integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==", "dev": true, "license": "MIT", - "dependencies": { - "@types/react": "*" + "peerDependencies": { + "@types/react": "^19.0.0" } }, "node_modules/@types/sanitize-html": { @@ -1816,17 +1830,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.17.0.tgz", - "integrity": "sha512-HU1KAdW3Tt8zQkdvNoIijfWDMvdSweFYm4hWh+KwhPstv+sCmWb89hCIP8msFm9N1R/ooh9honpSuvqKWlYy3w==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.0.tgz", + "integrity": "sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.17.0", - "@typescript-eslint/type-utils": "8.17.0", - "@typescript-eslint/utils": "8.17.0", - "@typescript-eslint/visitor-keys": "8.17.0", + "@typescript-eslint/scope-manager": "8.19.0", + "@typescript-eslint/type-utils": "8.19.0", + "@typescript-eslint/utils": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1841,25 +1855,21 @@ }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.17.0.tgz", - "integrity": "sha512-Drp39TXuUlD49F7ilHHCG7TTg8IkA+hxCuULdmzWYICxGXvDXmDmWEjJYZQYgf6l/TFfYNE167m7isnc3xlIEg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.0.tgz", + "integrity": "sha512-6M8taKyOETY1TKHp0x8ndycipTVgmp4xtg5QpEZzXxDhNvvHOJi5rLRkLr8SK3jTgD5l4fTlvBiRdfsuWydxBw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.17.0", - "@typescript-eslint/types": "8.17.0", - "@typescript-eslint/typescript-estree": "8.17.0", - "@typescript-eslint/visitor-keys": "8.17.0", + "@typescript-eslint/scope-manager": "8.19.0", + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/typescript-estree": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", "debug": "^4.3.4" }, "engines": { @@ -1870,23 +1880,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.17.0.tgz", - "integrity": "sha512-/ewp4XjvnxaREtqsZjF4Mfn078RD/9GmiEAtTeLQ7yFdKnqwTOgRMSvFz4et9U5RiJQ15WTGXPLj89zGusvxBg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.0.tgz", + "integrity": "sha512-hkoJiKQS3GQ13TSMEiuNmSCvhz7ujyqD1x3ShbaETATHrck+9RaDdUbt+osXaUuns9OFwrDTTrjtwsU8gJyyRA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.17.0", - "@typescript-eslint/visitor-keys": "8.17.0" + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1897,14 +1903,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.17.0.tgz", - "integrity": "sha512-q38llWJYPd63rRnJ6wY/ZQqIzPrBCkPdpIsaCfkR3Q4t3p6sb422zougfad4TFW9+ElIFLVDzWGiGAfbb/v2qw==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.0.tgz", + "integrity": "sha512-TZs0I0OSbd5Aza4qAMpp1cdCYVnER94IziudE3JU328YUHgWu9gwiwhag+fuLeJ2LkWLXI+F/182TbG+JaBdTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.17.0", - "@typescript-eslint/utils": "8.17.0", + "@typescript-eslint/typescript-estree": "8.19.0", + "@typescript-eslint/utils": "8.19.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1916,18 +1922,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.17.0.tgz", - "integrity": "sha512-gY2TVzeve3z6crqh2Ic7Cr+CAv6pfb0Egee7J5UAVWCpVvDI/F71wNfolIim4FE6hT15EbpZFVUj9j5i38jYXA==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.0.tgz", + "integrity": "sha512-8XQ4Ss7G9WX8oaYvD4OOLCjIQYgRQxO+qCiR2V2s2GxI9AUpo7riNwo6jDhKtTcaJjT8PY54j2Yb33kWtSJsmA==", "dev": true, "license": "MIT", "engines": { @@ -1939,14 +1941,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.17.0.tgz", - "integrity": "sha512-JqkOopc1nRKZpX+opvKqnM3XUlM7LpFMD0lYxTqOTKQfCWAmxw45e3qlOCsEqEB2yuacujivudOFpCnqkBDNMw==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.0.tgz", + "integrity": "sha512-WW9PpDaLIFW9LCbucMSdYUuGeFUz1OkWYS/5fwZwTA+l2RwlWFdJvReQqMUMBw4yJWJOfqd7An9uwut2Oj8sLw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.17.0", - "@typescript-eslint/visitor-keys": "8.17.0", + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/visitor-keys": "8.19.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1961,10 +1963,8 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { @@ -2007,16 +2007,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.17.0.tgz", - "integrity": "sha512-bQC8BnEkxqG8HBGKwG9wXlZqg37RKSMY7v/X8VEWD8JG2JuTHuNK0VFvMPMUKQcbk6B+tf05k+4AShAEtCtJ/w==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.0.tgz", + "integrity": "sha512-PTBG+0oEMPH9jCZlfg07LCB2nYI0I317yyvXGfxnvGvw4SHIOuRnQ3kadyyXY6tGdChusIHIbM5zfIbp4M6tCg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.17.0", - "@typescript-eslint/types": "8.17.0", - "@typescript-eslint/typescript-estree": "8.17.0" + "@typescript-eslint/scope-manager": "8.19.0", + "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/typescript-estree": "8.19.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2026,22 +2026,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.17.0.tgz", - "integrity": "sha512-1Hm7THLpO6ww5QU6H/Qp+AusUUl+z/CAm3cNZZ0jQvon9yicgO7Rwd+/WWRpMKLYV6p2UvdbR27c86rzCPpreg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.0.tgz", + "integrity": "sha512-mCFtBbFBJDCNCWUl5y6sZSCHXw1DEFEk3c/M3nRK2a4XUB8StGFtmcEMizdjKuBzB6e/smJAAWYug3VrdLMr1w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.17.0", + "@typescript-eslint/types": "8.19.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -2066,9 +2062,9 @@ } }, "node_modules/@wailsio/runtime": { - "version": "3.0.0-alpha.29", - "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.29.tgz", - "integrity": "sha512-gap5qxcw3fgDBYBN75X65XZoo3vEPyJ9L+cqRd8I133Bf0kPT6XVVchm8Gc693eDqH7djyhXmCB7zJfosVH0fA==", + "version": "3.0.0-alpha.36", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.36.tgz", + "integrity": "sha512-IPxzYLxgX8tOWcB1x2RHzx3VwRFTLAUrdeMQL2wZyaV7Xvtybt1h1WYaEp0iZiiNB/KCuCKIrnhnrN5sNDoDYg==", "license": "MIT", "dependencies": { "nanoid": "^5.0.7" @@ -2138,14 +2134,14 @@ "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" }, "engines": { "node": ">= 0.4" @@ -2197,16 +2193,16 @@ } }, "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -2216,16 +2212,16 @@ } }, "node_modules/array.prototype.flatmap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -2235,20 +2231,19 @@ } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" }, "engines": { "node": ">= 0.4" @@ -2311,9 +2306,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", - "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "version": "4.24.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", + "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", "dev": true, "funding": [ { @@ -2331,9 +2326,9 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001669", - "electron-to-chromium": "^1.5.41", - "node-releases": "^2.0.18", + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { @@ -2344,17 +2339,47 @@ } }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -2387,9 +2412,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001686", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001686.tgz", - "integrity": "sha512-Y7deg0Aergpa24M3qLC5xjNklnKnhsmSyR/V89dLZ1n0ucJIFNs7PgR2Yfa/Zf6W79SbBicgtGxZr2juHkEUIA==", + "version": "1.0.30001690", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", + "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", "dev": true, "funding": [ { @@ -2517,15 +2542,15 @@ "license": "MIT" }, "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -2535,31 +2560,31 @@ } }, "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/inspect-js" } }, "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" }, @@ -2571,9 +2596,9 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "license": "MIT", "dependencies": { @@ -2689,9 +2714,9 @@ } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.1.tgz", + "integrity": "sha512-xWXmuRnN9OMP6ptPd2+H0cCbcYBULa5YDTbMm/2lvkWvNA3O4wcW+GvzooqBuNM8yy6pl3VIAeJTUUWUbfI5Fw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2714,10 +2739,25 @@ "tslib": "^2.0.3" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.71", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.71.tgz", - "integrity": "sha512-dB68l59BI75W1BUGVTAEJy45CEVuEGy9qPVVQ8pnHyHMn36PLPPoE1mjLH+lo9rKulO3HC2OhbACI/8tCqJBcA==", + "version": "1.5.76", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz", + "integrity": "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==", "dev": true, "license": "ISC" }, @@ -2745,58 +2785,63 @@ } }, "node_modules/es-abstract": { - "version": "1.23.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.5.tgz", - "integrity": "sha512-vlmniQ0WNPwXqA0BnmwV3Ng7HxiGlh6r5U6JcTMNx8OilcAGqVJBHJcPjqOMaczU9fRuRK5Px2BdVyPRnKMMVQ==", + "version": "1.23.9", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", "dev": true, "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", - "gopd": "^1.0.1", + "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.3", "object-keys": "^1.1.1", - "object.assign": "^4.1.5", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.3", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" }, "engines": { "node": ">= 0.4" @@ -2806,14 +2851,11 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -2842,15 +2884,16 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -2947,9 +2990,9 @@ } }, "node_modules/eslint": { - "version": "9.16.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.16.0.tgz", - "integrity": "sha512-whp8mSQI4C8VXd+fLgSM0lh3UlmcFtVwUQjyKCFfsp+2ItAIYhlq/hqGahGqHE6cv9unM41VlqKk2VtKYR2TaA==", + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", + "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", "dev": true, "license": "MIT", "dependencies": { @@ -2958,7 +3001,7 @@ "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.9.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.16.0", + "@eslint/js": "9.17.0", "@eslint/plugin-kit": "^0.2.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -2967,7 +3010,7 @@ "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.5", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.2.0", @@ -3232,9 +3275,9 @@ "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -3242,7 +3285,7 @@ "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -3276,9 +3319,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", + "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", "dev": true, "license": "ISC", "dependencies": { @@ -3385,16 +3428,18 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" @@ -3424,17 +3469,22 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", "dev": true, "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3443,16 +3493,30 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -3475,9 +3539,9 @@ } }, "node_modules/globals": { - "version": "15.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.13.0.tgz", - "integrity": "sha512-49TewVEz0UxZjr1WYYsWpPrhyC/B/pA8Bq0fUmet2n+eR7yn0IvNzNaoBwnK6mdkzcN+se7Ez9zUgULTz2QH4g==", + "version": "15.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", + "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", "dev": true, "license": "MIT", "engines": { @@ -3525,11 +3589,14 @@ "license": "MIT" }, "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3558,13 +3625,13 @@ } }, "node_modules/has-proto": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.1.0.tgz", - "integrity": "sha512-QLdzI9IIO1Jg7f9GT1gXpPpXArAn6cS31R1eEZqz08Gc+uQ8/XiqHWt17Fiw+2p6oTTIq5GXEpQkAlA88YRl/Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7" + "dunder-proto": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -3673,29 +3740,30 @@ } }, "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -3712,13 +3780,16 @@ "license": "MIT" }, "node_modules/is-async-function": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", - "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.0.tgz", + "integrity": "sha512-GExz9MtyhlZyXYLxzlJRj5WUCE661zhDa1Yna52CN57AJsymh+DvXXjyveSioqSRdxvUrdKdvqB1b5cVKsNpWQ==", "dev": true, "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3744,13 +3815,13 @@ } }, "node_modules/is-boolean-object": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.0.tgz", - "integrity": "sha512-kR5g0+dXf/+kXnqI+lu0URKYPKgICtHGGNCDSB10AaUFj3o/HkB3u7WfpRBJGFopxxY0oH3ux7ZsDjLtK7xqvw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.1.tgz", + "integrity": "sha512-l9qO6eFlUETHtuihLcYOaLKByJ1f+N4kthcU9YjHy3N+B3hWv0y/2Nd0mu/7lTFnRQHTrSdXF50HQ3bl5fEnng==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" }, "engines": { @@ -3774,9 +3845,9 @@ } }, "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { @@ -3790,12 +3861,14 @@ } }, "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" }, "engines": { @@ -3806,13 +3879,14 @@ } }, "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -3832,13 +3906,13 @@ } }, "node_modules/is-finalizationregistry": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.0.tgz", - "integrity": "sha512-qfMdqbAQEwBw78ZyReKnlA8ezmPdb9BemzIIip/JkjaZUhitfXDkkr+3QTboW0JrSXT1QWyYShpvnNHGZ4c4yA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -3848,13 +3922,16 @@ } }, "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", "dev": true, "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3889,19 +3966,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3913,13 +3977,13 @@ } }, "node_modules/is-number-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.0.tgz", - "integrity": "sha512-KVSZV0Dunv9DTPkhXwcZ3Q+tUc9TsaE1ZwX5J2WMvsSGS6Md8TFPun5uwh0yRdrNerI6vf/tbJxqSx4c1ZI1Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" }, "engines": { @@ -3930,14 +3994,14 @@ } }, "node_modules/is-regex": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.0.tgz", - "integrity": "sha512-B6ohK4ZmoftlUe+uvenXSbPJFo6U37BH7oO1B3nQH8f/7h27N56s85MhUtbFJAziz5dcmuR3i8ovUl35zp8pFA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "gopd": "^1.1.0", + "call-bound": "^1.0.2", + "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" }, @@ -3962,13 +4026,13 @@ } }, "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -3978,13 +4042,13 @@ } }, "node_modules/is-string": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.0.tgz", - "integrity": "sha512-PlfzajuF9vSo5wErv3MJAKD/nqf9ngAs1NFQYm16nUYFO2IzxJ2hcm+IOCg+EEopdykNNUhVq5cz35cAUxU8+g==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" }, "engines": { @@ -3995,15 +4059,15 @@ } }, "node_modules/is-symbol": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.0.tgz", - "integrity": "sha512-qS8KkNNXUZ/I+nX6QT8ZS1/Yx0A444yhzdTKxCzKkNjQ9sHErBxJnJAgh+f5YhusYECEcjo4XcyH87hn6+ks0A==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "has-symbols": "^1.0.3", - "safe-regex-test": "^1.0.3" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4013,13 +4077,13 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.14" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -4042,27 +4106,30 @@ } }, "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.0.tgz", + "integrity": "sha512-SXM8Nwyys6nT5WP6pltOwKytLV7FqQ4UiibxVmW+EIosHcmCqkkjViTb5SNssDlkCiEYRP1/pdWUKVvZBmsR2Q==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2" + "call-bound": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-weakset": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", - "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -4106,9 +4173,9 @@ } }, "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", "bin": { @@ -4160,9 +4227,9 @@ } }, "node_modules/katex": { - "version": "0.16.11", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz", - "integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==", + "version": "0.16.19", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.19.tgz", + "integrity": "sha512-3IA6DYVhxhBabjSLTNO9S4+OliA3Qvb8pBQXMfC4WxXJgLwZgnfDl0BmB4z6nBMdznBsZ+CGM8DrGZ5hcguDZg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -4255,6 +4322,16 @@ "yallist": "^3.0.2" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4303,9 +4380,9 @@ } }, "node_modules/monaco-editor": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz", - "integrity": "sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw==", + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", "license": "MIT" }, "node_modules/ms": { @@ -4352,9 +4429,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true, "license": "MIT" }, @@ -4382,15 +4459,17 @@ } }, "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", "object-keys": "^1.1.1" }, "engines": { @@ -4435,13 +4514,14 @@ } }, "node_modules/object.values": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", - "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, @@ -4470,6 +4550,24 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4732,19 +4830,20 @@ } }, "node_modules/reflect.getprototypeof": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.7.tgz", - "integrity": "sha512-bMvFGIUKlc/eSfXNX+aZ+EL95/EgZzuwA0OBPTbZZDEJw/0AkentjMuM1oiRfwHrshqk4RzdgiTg5CcDalXN5g==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", + "es-abstract": "^1.23.9", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "which-builtin-type": "^1.1.4" + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -4754,15 +4853,17 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", - "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "set-function-name": "^2.0.2" }, "engines": { @@ -4773,19 +4874,22 @@ } }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4812,9 +4916,9 @@ } }, "node_modules/rollup": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.0.tgz", - "integrity": "sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.0.tgz", + "integrity": "sha512-sDnr1pcjTgUT69qBksNF1N1anwfbyYG6TBQ22b03bII8EdiUQ7J0TlozVaTMjT/eEJAO49e1ndV7t+UZfL1+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -4828,24 +4932,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.28.0", - "@rollup/rollup-android-arm64": "4.28.0", - "@rollup/rollup-darwin-arm64": "4.28.0", - "@rollup/rollup-darwin-x64": "4.28.0", - "@rollup/rollup-freebsd-arm64": "4.28.0", - "@rollup/rollup-freebsd-x64": "4.28.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.28.0", - "@rollup/rollup-linux-arm-musleabihf": "4.28.0", - "@rollup/rollup-linux-arm64-gnu": "4.28.0", - "@rollup/rollup-linux-arm64-musl": "4.28.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.28.0", - "@rollup/rollup-linux-riscv64-gnu": "4.28.0", - "@rollup/rollup-linux-s390x-gnu": "4.28.0", - "@rollup/rollup-linux-x64-gnu": "4.28.0", - "@rollup/rollup-linux-x64-musl": "4.28.0", - "@rollup/rollup-win32-arm64-msvc": "4.28.0", - "@rollup/rollup-win32-ia32-msvc": "4.28.0", - "@rollup/rollup-win32-x64-msvc": "4.28.0", + "@rollup/rollup-android-arm-eabi": "4.30.0", + "@rollup/rollup-android-arm64": "4.30.0", + "@rollup/rollup-darwin-arm64": "4.30.0", + "@rollup/rollup-darwin-x64": "4.30.0", + "@rollup/rollup-freebsd-arm64": "4.30.0", + "@rollup/rollup-freebsd-x64": "4.30.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.30.0", + "@rollup/rollup-linux-arm-musleabihf": "4.30.0", + "@rollup/rollup-linux-arm64-gnu": "4.30.0", + "@rollup/rollup-linux-arm64-musl": "4.30.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.30.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.30.0", + "@rollup/rollup-linux-riscv64-gnu": "4.30.0", + "@rollup/rollup-linux-s390x-gnu": "4.30.0", + "@rollup/rollup-linux-x64-gnu": "4.30.0", + "@rollup/rollup-linux-x64-musl": "4.30.0", + "@rollup/rollup-win32-arm64-msvc": "4.30.0", + "@rollup/rollup-win32-ia32-msvc": "4.30.0", + "@rollup/rollup-win32-x64-msvc": "4.30.0", "fsevents": "~2.3.2" } }, @@ -4874,15 +4979,16 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, "engines": { @@ -4892,16 +4998,33 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", "es-errors": "^1.3.0", - "is-regex": "^1.1.4" + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -4960,6 +5083,21 @@ "node": ">= 0.4" } }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4984,16 +5122,73 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -5024,16 +5219,19 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -5043,16 +5241,20 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -5191,32 +5393,32 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -5226,19 +5428,19 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.3.tgz", - "integrity": "sha512-GsvTyUHTriq6o/bHcTd0vM7OQ9JEdlvluu9YISaA7+KzDzPaIzEeDFNkTfhdE3MYcNhNi0vq/LlegYgIs5yPAw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13", - "reflect.getprototypeof": "^1.0.6" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" }, "engines": { "node": ">= 0.4" @@ -5283,15 +5485,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.17.0.tgz", - "integrity": "sha512-409VXvFd/f1br1DCbuKNFqQpXICoTB+V51afcwG1pn1a3Cp92MqAUges3YjwEdQ0cMUoCIodjVDAYzyD8h3SYA==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.19.0.tgz", + "integrity": "sha512-Ni8sUkVWYK4KAcTtPjQ/UTiRk6jcsuDhPpxULapUDi8A/l8TSBk+t1GtJA1RsCzIJg0q6+J7bf35AwQigENWRQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.17.0", - "@typescript-eslint/parser": "8.17.0", - "@typescript-eslint/utils": "8.17.0" + "@typescript-eslint/eslint-plugin": "8.19.0", + "@typescript-eslint/parser": "8.19.0", + "@typescript-eslint/utils": "8.19.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5301,25 +5503,24 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" } }, "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", + "call-bound": "^1.0.3", "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5464,17 +5665,17 @@ } }, "node_modules/which-boxed-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.0.tgz", - "integrity": "sha512-Ei7Miu/AXe2JJ4iNF5j/UphAgRoma4trE6PtisM09bPygb3egMH3YLW/befsWb1A1AxvNSFidOFTB18XtnIIng==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.0", - "is-number-object": "^1.1.0", - "is-string": "^1.1.0", - "is-symbol": "^1.1.0" + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -5484,25 +5685,25 @@ } }, "node_modules/which-builtin-type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.0.tgz", - "integrity": "sha512-I+qLGQ/vucCby4tf5HsLmGueEla4ZhwTBSqaooS+Y0BuxN4Cp+okmGuV+8mXZ84KDI9BA+oklo+RzKg0ONdSUA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", - "is-date-object": "^1.0.5", + "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", - "is-regex": "^1.1.4", + "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", - "which-boxed-primitive": "^1.0.2", + "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", - "which-typed-array": "^1.1.15" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -5531,16 +5732,17 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.16.tgz", - "integrity": "sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", + "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "for-each": "^0.3.3", - "gopd": "^1.0.1", + "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" }, "engines": { diff --git a/web/public/gomuks-maskable-transparent.png b/web/public/gomuks-maskable-transparent.png new file mode 100644 index 0000000..fc8790c Binary files /dev/null and b/web/public/gomuks-maskable-transparent.png differ diff --git a/web/public/gomuks-maskable.png b/web/public/gomuks-maskable.png new file mode 100644 index 0000000..6c9b407 Binary files /dev/null and b/web/public/gomuks-maskable.png differ diff --git a/web/public/gomuks-transparent.png b/web/public/gomuks-transparent.png new file mode 100644 index 0000000..1a639db Binary files /dev/null and b/web/public/gomuks-transparent.png differ diff --git a/web/public/gomuks.png b/web/public/gomuks.png index 645df4f..4e41dd1 100644 Binary files a/web/public/gomuks.png and b/web/public/gomuks.png differ diff --git a/web/public/manifest.json b/web/public/manifest.json new file mode 100644 index 0000000..1fbf843 --- /dev/null +++ b/web/public/manifest.json @@ -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" +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 748115c..816db3c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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() { : null if (connState?.error && !afterConnectError) { - return errorOverlay + return
{errorOverlay}
} else if ((!connState?.connected && !afterConnectError) || !clientState) { const msg = connState?.connected ? "Waiting for client state..." : "Connecting to backend..." - return
+ return
{msg}
} else if (!clientState.is_logged_in) { - return + return
} else if (!clientState.is_verified) { - return + return
} else { return diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f64973e..4f26ef4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(false) readonly store = new StateStore() #stateRequests: RoomStateGUID[] = [] - #stateRequestQueued = false + #stateRequestPromise: Promise | 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) => { + 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() - this.#reallyStart(abort.signal) + 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 = () => { - const reqs = this.#stateRequests - this.#stateRequestQueued = false - this.#stateRequests = [] - this.loadSpecificRoomState(reqs).catch(err => console.error("Failed to load room state", reqs, err)) + #doStateRequestsPromise = (resolve: () => void) => { + window.queueMicrotask(() => { + const reqs = this.#stateRequests + this.#stateRequestPromise = null + this.#stateRequests = [] + this.loadSpecificRoomState(reqs) + .catch(err => console.error("Failed to load room state", reqs, err)) + .finally(resolve) + }) } requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) { @@ -204,7 +284,9 @@ export default class Client { throw new Error("Room not found") } const dbEvent = await this.rpc.sendMessage(params) - this.#handleOutgoingEvent(dbEvent, room) + if (dbEvent) { + this.#handleOutgoingEvent(dbEvent, room) + } } async subscribeToEmojiPack(pack: RoomStateGUID, subscribe: boolean = true) { @@ -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 } diff --git a/web/src/api/media.ts b/web/src/api/media.ts index ca0a0ac..028ab6f 100644 --- a/web/src/api/media.ts +++ b/web/src/api/media.ts @@ -13,9 +13,8 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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(` - + ${escapeHTMLChar(fallbackCharacter)} @@ -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, + }) } diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 78c4c7b..95fb019 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -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 { + sendMessage(params: SendMessageParams): Promise { 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 { + return this.request("set_profile_field", { field, value }) + } + getMutualRooms(user_id: UserID): Promise { 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 { + return this.request("track_user_devices", { user_id }) + } + ensureGroupSessionShared(room_id: RoomID): Promise { 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 { + return this.request("get_room_summary", { room_id_or_alias, via }) + } + + joinRoom(room_id_or_alias: RoomID | RoomAlias, via?: string[], reason?: string): Promise { + return this.request("join_room", { room_id_or_alias, via, reason }) + } + + leaveRoom(room_id: RoomID, reason?: string): Promise> { + return this.request("leave_room", { room_id, reason }) + } + resolveAlias(alias: RoomAlias): Promise { return this.request("resolve_alias", { alias }) } @@ -239,4 +264,12 @@ export default abstract class RPCClient { verify(recovery_key: string): Promise { return this.request("verify", { recovery_key }) } + + requestOpenIDToken(): Promise { + return this.request("request_openid_token", {}) + } + + registerPush(reg: DBPushRegistration): Promise { + return this.request("register_push", reg) + } } diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index d2763b3..6979cc3 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -13,9 +13,18 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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( } 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]) } diff --git a/web/src/api/statestore/index.ts b/web/src/api/statestore/index.ts index 3bbe512..106a3f4 100644 --- a/web/src/api/statestore/index.ts +++ b/web/src/api/statestore/index.ts @@ -1,3 +1,4 @@ export * from "./main.ts" export * from "./room.ts" export * from "./hooks.ts" +export * from "./space.ts" diff --git a/web/src/api/statestore/invitedroom.ts b/web/src/api/statestore/invitedroom.ts new file mode 100644 index 0000000..a4b2a87 --- /dev/null +++ b/web/src/api/statestore/invitedroom.ts @@ -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 . +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() + 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 + } +} diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 08b63a9..c430fa7 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -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 = new Map() + readonly inviteRooms: Map = new Map() readonly roomList = new NonNullCachedEventDispatcher([]) - currentRoomListFilter: string = "" + readonly roomListEntries = new Map() + readonly topLevelSpaces = new NonNullCachedEventDispatcher([]) + readonly spaceEdges: Map = 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 = new Map() readonly accountDataSubs = new MultiSubscribable() readonly emojiRoomsSub = new Subscribable() - readonly preferences: Preferences = getPreferenceProxy(this) + readonly preferences = getPreferenceProxy(this) #frequentlyUsedEmoji: Map | null = null #emojiPackKeys: RoomStateGUID[] | null = null #watchedRoomEmojiPacks: Record | 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() - 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 { 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 diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 70d1f59..5252a3c 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -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(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 timeline: TimelineRowTuple[] = [] timelineCache: (MemDBEvent | null)[] = [] + editTargets: EventRowID[] = [] state: Map> = new Map() stateLoaded = false + typing: UserID[] = [] fullMembersLoaded = false readonly eventsByRowID: Map = new Map() readonly eventsByID: Map = new Map() readonly timelineSub = new Subscribable() + readonly typingSub = new Subscribable() readonly stateSubs = new MultiSubscribable() readonly eventSubs = new MultiSubscribable() + readonly receiptsByEventID: Map = new Map() + readonly receiptsByUserID: Map = new Map() + readonly receiptSubs = new MultiSubscribable() readonly requestedEvents: Set = new Set() readonly requestedMembers: Set = new Set() readonly accountData: Map = new Map() readonly accountDataSubs = new MultiSubscribable() readonly openNotifications: Map = new Map() readonly #emojiPacksCache: Map = new Map() - readonly preferences: Preferences + readonly preferences: Required 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) { // 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() const eventsToKeep = new Set() @@ -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) diff --git a/web/src/api/statestore/space.ts b/web/src/api/statestore/space.ts new file mode 100644 index 0000000..96b37b8 --- /dev/null +++ b/web/src/api/statestore/space.ts @@ -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 . +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 = new Set() + #flattenedRooms: Set = new Set() + #childSpaces: Set = new Set() + readonly #parentSpaces: Set = 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) { + 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, stack: WeakSet) { + 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() + const newChildSpaces = new Set() + 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 + } +} diff --git a/web/src/api/types/android.ts b/web/src/api/types/android.ts new file mode 100644 index 0000000..8f62f84 --- /dev/null +++ b/web/src/api/types/android.ts @@ -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 . + +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 diff --git a/web/src/api/types/hievents.ts b/web/src/api/types/hievents.ts index f4bc4ec..125eb57 100644 --- a/web/src/api/types/hievents.ts +++ b/web/src/api/types/hievents.ts @@ -15,14 +15,18 @@ // along with this program. If not, see . 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 { export interface SyncRoom { meta: DBRoom - timeline: TimelineRowTuple[] - events: RawDBEvent[] - state: Record> + timeline: TimelineRowTuple[] | null + events: RawDBEvent[] | null + state: Record> | null reset: boolean - notifications: SyncNotification[] - account_data: Record + notifications: SyncNotification[] | null + account_data: Record | null + receipts: Record | null } export interface SyncNotification { @@ -82,9 +87,12 @@ export interface SyncNotification { } export interface SyncCompleteData { - rooms: Record - left_rooms: RoomID[] - account_data: Record + rooms: Record | null + invited_rooms: DBInvitedRoom[] | null + left_rooms: RoomID[] | null + account_data: Record | null + space_edges: Record | null + top_level_spaces: RoomID[] | null since?: string clear_state?: boolean } @@ -110,7 +118,7 @@ export interface ClientStateEvent extends BaseRPCCommand { } export interface SyncStatus { - type: "ok" | "waiting" | "errored" + type: "ok" | "waiting" | "erroring" | "permanently-failed" error?: string error_count: number last_sync?: number diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index c4bdd49..c7489cd 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -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 +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 + 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 +} diff --git a/web/src/api/types/index.ts b/web/src/api/types/index.ts index 88930fa..693c67a 100644 --- a/web/src/api/types/index.ts +++ b/web/src/api/types/index.ts @@ -1,3 +1,4 @@ export * from "./mxtypes.ts" export * from "./hitypes.ts" export * from "./hievents.ts" +export * from "./android.ts" diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index cd3c659..51a22d4 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.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 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" +} diff --git a/web/src/api/types/preferences/preferences.ts b/web/src/api/types/preferences/preferences.ts index 6638057..94e8764 100644 --- a/web/src/api/types/preferences/preferences.ts +++ b/web/src/api/types/preferences/preferences.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . 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({ + displayName: "Display read receipts", + description: "Should read receipts be rendered in the timeline?", + allowedContexts: anyContext, + defaultValue: true, + }), show_media_previews: new Preference({ 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({ + displayName: "Render URL previews", + description: "Whether to render MSC4095 URL previews in the room timeline.", + allowedContexts: anyContext, + defaultValue: true, + }), + small_replies: new Preference({ + 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({ 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({ + 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({ + 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({ displayName: "Custom notification sound", description: "The mxc:// URI to a custom notification sound.", allowedContexts: anyContext, defaultValue: "", }), + room_window_title: new Preference({ + 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({ + 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({ + 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)) diff --git a/web/src/api/types/preferences/proxy.ts b/web/src/api/types/preferences/proxy.ts index d9cd11a..f7f0b68 100644 --- a/web/src/api/types/preferences/proxy.ts +++ b/web/src/api/types/preferences/proxy.ts @@ -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 { 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 } diff --git a/web/src/icons/devices-off.svg b/web/src/icons/devices-off.svg new file mode 100644 index 0000000..961aa6b --- /dev/null +++ b/web/src/icons/devices-off.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/devices.svg b/web/src/icons/devices.svg new file mode 100644 index 0000000..128d2d2 --- /dev/null +++ b/web/src/icons/devices.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/home.svg b/web/src/icons/home.svg new file mode 100644 index 0000000..cc29681 --- /dev/null +++ b/web/src/icons/home.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/notifications-off.svg b/web/src/icons/notifications-off.svg new file mode 100644 index 0000000..dcaba96 --- /dev/null +++ b/web/src/icons/notifications-off.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/notifications-unread.svg b/web/src/icons/notifications-unread.svg new file mode 100644 index 0000000..c96868b --- /dev/null +++ b/web/src/icons/notifications-unread.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/notifications.svg b/web/src/icons/notifications.svg new file mode 100644 index 0000000..5c49afe --- /dev/null +++ b/web/src/icons/notifications.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/person.svg b/web/src/icons/person.svg new file mode 100644 index 0000000..aa2b620 --- /dev/null +++ b/web/src/icons/person.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/tag.svg b/web/src/icons/tag.svg new file mode 100644 index 0000000..71dadef --- /dev/null +++ b/web/src/icons/tag.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/thread.svg b/web/src/icons/thread.svg new file mode 100644 index 0000000..aa5dec1 --- /dev/null +++ b/web/src/icons/thread.svg @@ -0,0 +1 @@ + diff --git a/web/src/index.css b/web/src/index.css index 98d8732..5d6f39d 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; - text-align: center; +div.pre-main { + position: fixed; + inset: 0; + background-color: var(--login-background-color); + + &.waiting-to-connect { + padding-top: 2rem; + text-align: center; + } } a { diff --git a/web/src/ui/MainScreen.css b/web/src/ui/MainScreen.css index 769c6f0..4e9bc21 100644 --- a/web/src/ui/MainScreen.css +++ b/web/src/ui/MainScreen.css @@ -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; } } diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 8bb10e8..c0a5b48 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -13,20 +13,21 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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,33 +96,115 @@ class ContextFields implements MainScreenContextFields { } } - setActiveRoom = (roomID: RoomID | null, pushState = true) => { + setActiveRoom = ( + roomID: RoomID | null, + previewMeta?: Partial, + 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) { + 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) - this.rightPanelStack = [] - this.client.store.activeRoomID = room?.roomID ?? null - this.keybindings.activeRoom = room - if (room) { - room.lastOpened = Date.now() - if (!room.stateLoaded) { - this.client.loadRoomState(room.roomID) - .catch(err => console.error("Failed to load room state", err)) + 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) } - document - .querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`) - ?.scrollIntoView({ block: "nearest" }) } + if (space && space !== this.client.store.currentRoomListFilter) { + console.log("Switching to space", space?.id) + this.directSetSpace(space) + this.client.store.currentRoomListFilter = space + } + this.rightPanelStack = [] + this.client.store.activeRoomID = room.roomID + this.client.store.activeRoomIsPreview = false + this.keybindings.activeRoom = room + room.lastOpened = Date.now() + if (!room.stateLoaded) { + this.client.loadRoomState(room.roomID) + .catch(err => console.error("Failed to load room state", err)) + } + document + .querySelector(`div.room-entry[data-room-id="${CSS.escape(room.roomID)}"]`) + ?.scrollIntoView({ block: "nearest" }) if (pushState) { - 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(null) + const [[prevActiveRoom, activeRoom], directSetActiveRoom] = useReducer(activeRoomReducer, [null, null]) + const [space, directSetSpace] = useState(null) + const skipNextTransitionRef = useRef(false) const [rightPanel, directSetRightPanel] = useState(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) => { + 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...
} else if ( - syncStatus.type === "errored" + syncStatus.type === "erroring" && (syncStatus.error_count > 2 || (syncStatus.last_sync ?? 0) + SYNC_ERROR_HIDE_DELAY < Date.now()) ) { syncLoader =
Sync is failing
+ } else if (syncStatus.type === "permanently-failed") { + syncLoader =
+ Sync failed permanently +
} + 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 - +
- + {resizeHandle1} - {activeRoom - ? + {renderedRoom + ? renderedRoom instanceof RoomStateStore + ? + : : rightPanel && <>
{resizeHandle2} diff --git a/web/src/ui/MainScreenContext.ts b/web/src/ui/MainScreenContext.ts index 8823f4b..de71425 100644 --- a/web/src/ui/MainScreenContext.ts +++ b/web/src/ui/MainScreenContext.ts @@ -13,12 +13,15 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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, 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") }, diff --git a/web/src/ui/StylePreferences.tsx b/web/src/ui/StylePreferences.tsx index c1c45a8..8c2da2b 100644 --- a/web/src/ui/StylePreferences.tsx +++ b/web/src/ui/StylePreferences.tsx @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { useInsertionEffect } from "react" +import React, { useEffect, useInsertionEffect } from "react" import type Client from "@/api/client.ts" import { RoomStateStore, usePreferences } from "@/api/statestore" @@ -32,29 +32,39 @@ function css(strings: TemplateStringsArray, ...values: unknown[]) { return newStyleSheet(String.raw(strings, ...values)) } +function pushSheet(sheet: CSSStyleSheet): () => void { + document.adoptedStyleSheets.push(sheet) + return () => { + const idx = document.adoptedStyleSheets.indexOf(sheet) + if (idx !== -1) { + document.adoptedStyleSheets.splice(idx, 1) + } + } +} + function useStyle(callback: () => CSSStyleSheet | false | undefined | null | "", dependencies: unknown[]) { useInsertionEffect(() => { const sheet = callback() if (!sheet) { return } - document.adoptedStyleSheets.push(sheet) - return () => { - const idx = document.adoptedStyleSheets.indexOf(sheet) - if (idx !== -1) { - document.adoptedStyleSheets.splice(idx, 1) - } - } + return pushSheet(sheet) }, dependencies) } -function useAsyncStyle(callback: () => string | false | undefined | null, dependencies: unknown[]) { +function useAsyncStyle(callback: () => string | false | undefined | null, dependencies: unknown[], id?: string) { useInsertionEffect(() => { const sheet = callback() if (!sheet) { return } + if (!sheet.includes("@import")) { + return pushSheet(newStyleSheet(sheet)) + } const styleTags = document.createElement("style") + if (id) { + styleTags.id = id + } styleTags.textContent = sheet document.head.appendChild(styleTags) return () => { @@ -102,6 +112,11 @@ const StylePreferences = ({ client, activeRoom }: StylePreferencesProps) => { display: none; } `, [preferences.show_date_separators]) + useStyle(() => !preferences.display_read_receipts && css` + :root { + --timeline-status-size: 2rem; + } + `, [preferences.display_read_receipts]) useAsyncStyle(() => preferences.code_block_theme === "auto" ? ` @import url("_gomuks/codeblock/github.css") (prefers-color-scheme: light); @import url("_gomuks/codeblock/github-dark.css") (prefers-color-scheme: dark); @@ -111,9 +126,14 @@ const StylePreferences = ({ client, activeRoom }: StylePreferencesProps) => { } ` : ` @import url("_gomuks/codeblock/${preferences.code_block_theme}.css"); - `, [preferences.code_block_theme]) - useStyle(() => preferences.custom_css && newStyleSheet(preferences.custom_css), [preferences.custom_css]) + `, [preferences.code_block_theme], "gomuks-pref-code-block-theme") + useAsyncStyle(() => preferences.custom_css, [preferences.custom_css], "gomuks-pref-custom-css") + useEffect(() => { + favicon.href = preferences.favicon + }, [preferences.favicon]) return null } +const favicon = document.getElementById("favicon") as HTMLLinkElement + export default React.memo(StylePreferences) diff --git a/web/src/ui/composer/Autocompleter.css b/web/src/ui/composer/Autocompleter.css index bd013b5..571532c 100644 --- a/web/src/ui/composer/Autocompleter.css +++ b/web/src/ui/composer/Autocompleter.css @@ -5,9 +5,11 @@ div.autocompletions-wrapper { div.autocompletions { position: absolute; - bottom: 0; - border-top: 1px solid var(--border-color); - border-right: 1px solid var(--border-color); + bottom: 1.25rem; + left: var(--timeline-horizontal-padding); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + box-shadow: 0 0 1rem var(--modal-box-shadow-color); background-color: var(--background-color); padding: .5rem; max-height: 20rem; @@ -33,6 +35,7 @@ div.autocompletions { > img { width: 1.5rem; height: 1.5rem; + object-fit: contain; } } } diff --git a/web/src/ui/composer/Autocompleter.tsx b/web/src/ui/composer/Autocompleter.tsx index 78e1a0d..432d327 100644 --- a/web/src/ui/composer/Autocompleter.tsx +++ b/web/src/ui/composer/Autocompleter.tsx @@ -80,13 +80,13 @@ function useAutocompleter({ }) document.querySelector(`div.autocompletion-item[data-index='${index}']`)?.scrollIntoView({ block: "nearest" }) }) - const onClick = useEvent((evt: React.MouseEvent) => { + const onClick = (evt: React.MouseEvent) => { const idx = evt.currentTarget.getAttribute("data-index") if (idx) { onSelect(+idx) setAutocomplete(null) } - }) + } useEffect(() => { if (params.selected !== undefined) { onSelect(params.selected) diff --git a/web/src/ui/composer/ComposerMedia.tsx b/web/src/ui/composer/ComposerMedia.tsx new file mode 100644 index 0000000..44fe952 --- /dev/null +++ b/web/src/ui/composer/ComposerMedia.tsx @@ -0,0 +1,63 @@ +// 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 . +import Client from "@/api/client.ts" +import { RoomStateStore, usePreference } from "@/api/statestore" +import type { MediaMessageEventContent } from "@/api/types" +import { LeafletPicker } from "../maps/async.tsx" +import { useMediaContent } from "../timeline/content/useMediaContent.tsx" +import CloseIcon from "@/icons/close.svg?react" +import "./MessageComposer.css" + +export interface ComposerMediaProps { + content: MediaMessageEventContent + clearMedia: false | (() => void) +} + +export const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => { + const [mediaContent, containerClass, containerStyle] = useMediaContent( + content, "m.room.message", { height: 120, width: 360 }, + ) + return
+
+ {mediaContent} +
+ {clearMedia && } +
+} + +export interface ComposerLocationValue { + lat: number + long: number + prec?: number +} + +export interface ComposerLocationProps { + room: RoomStateStore + client: Client + location: ComposerLocationValue + onChange: (location: ComposerLocationValue) => void + clearLocation: () => void +} + +export const ComposerLocation = ({ client, room, location, onChange, clearLocation }: ComposerLocationProps) => { + const tileTemplate = usePreference(client.store, room, "leaflet_tile_template") + return
+
+ +
+ +
+} diff --git a/web/src/ui/composer/MessageComposer.css b/web/src/ui/composer/MessageComposer.css index 6c02fd1..f6e83b9 100644 --- a/web/src/ui/composer/MessageComposer.css +++ b/web/src/ui/composer/MessageComposer.css @@ -1,9 +1,14 @@ div.message-composer { - border-top: 1px solid var(--border-color); + margin: -1rem var(--timeline-horizontal-padding) 0; + background-color: var(--composer-background-color); + border: 1px solid var(--border-color); + border-radius: 0.5rem; overflow: hidden; grid-area: input; /* WebKit/Safari requires this hack for some reason, works fine without in other browsers */ min-height: 2.25rem; + z-index: 1; + box-shadow: 0 0 1rem var(--modal-box-shadow-color); blockquote.reply-body > pre { text-wrap: auto !important; @@ -29,16 +34,29 @@ div.message-composer { height: 2rem; width: 2rem; padding: .25rem; + + > svg { + width: 1.5rem; + height: 1.5rem; + } } > input[type="file"] { display: none; } + + @media screen and (max-width: 45rem) { + margin-right: 0; + + > textarea:not(:empty) { + padding: .5rem 0; + } + } } > div.composer-media, > div.composer-location { display: flex; - padding: .5rem; + padding: .5rem .5rem 0; justify-content: space-between; > button { diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index f89337c..f9437b3 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -13,10 +13,9 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react" +import React, { CSSProperties, use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react" import { ScaleLoader } from "react-spinners" -import Client from "@/api/client.ts" -import { RoomStateStore, usePreference, useRoomEvent } from "@/api/statestore" +import { useRoomEvent } from "@/api/statestore" import type { EventID, MediaMessageEventContent, @@ -29,46 +28,55 @@ import type { import { PartialEmoji, emojiToMarkdown } from "@/util/emoji" import { isMobileDevice } from "@/util/ismobile.ts" import { escapeMarkdown } from "@/util/markdown.ts" -import useEvent from "@/util/useEvent.ts" import ClientContext from "../ClientContext.ts" import EmojiPicker from "../emojipicker/EmojiPicker.tsx" import GIFPicker from "../emojipicker/GIFPicker.tsx" +import StickerPicker from "../emojipicker/StickerPicker.tsx" import { keyToString } from "../keybindings.ts" -import { LeafletPicker } from "../maps/async.tsx" -import { ModalContext } from "../modal/Modal.tsx" +import { ModalContext } from "../modal" import { useRoomContext } from "../roomview/roomcontext.ts" import { ReplyBody } from "../timeline/ReplyBody.tsx" -import { useMediaContent } from "../timeline/content/useMediaContent.tsx" import type { AutocompleteQuery } from "./Autocompleter.tsx" +import { ComposerLocation, ComposerLocationValue, ComposerMedia } from "./ComposerMedia.tsx" import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./getAutocompleter.ts" import AttachIcon from "@/icons/attach.svg?react" -import CloseIcon from "@/icons/close.svg?react" import EmojiIcon from "@/icons/emoji-categories/smileys-emotion.svg?react" import GIFIcon from "@/icons/gif.svg?react" import LocationIcon from "@/icons/location.svg?react" +import MoreIcon from "@/icons/more.svg?react" import SendIcon from "@/icons/send.svg?react" +import StickerIcon from "@/icons/sticker.svg?react" import "./MessageComposer.css" -export interface ComposerLocationValue { - lat: number - long: number - prec?: number -} - export interface ComposerState { text: string media: MediaMessageEventContent | null location: ComposerLocationValue | null replyTo: EventID | null + silentReply: boolean + explicitReplyInThread: boolean uninited?: boolean } const MAX_TEXTAREA_ROWS = 10 -const emptyComposer: ComposerState = { text: "", media: null, replyTo: null, location: null } +const emptyComposer: ComposerState = { + text: "", + media: null, + replyTo: null, + location: null, + silentReply: false, + explicitReplyInThread: false, +} const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true } -const composerReducer = (state: ComposerState, action: Partial) => - ({ ...state, ...action, uninited: undefined }) +const composerReducer = ( + state: ComposerState, + action: Partial | ((current: ComposerState) => Partial), +) => ({ + ...state, + ...(typeof action === "function" ? action(state) : action), + uninited: undefined, +}) const draftStore = { get: (roomID: RoomID): ComposerState | null => { @@ -108,9 +116,25 @@ const MessageComposer = () => { document.execCommand("insertText", false, text) }, []) roomCtx.setReplyTo = useCallback((evt: EventID | null) => { - setState({ replyTo: evt }) + setState({ replyTo: evt, silentReply: false, explicitReplyInThread: false }) textInput.current?.focus() }, []) + const setSilentReply = useCallback((newVal: boolean | React.MouseEvent) => { + if (typeof newVal === "boolean") { + setState({ silentReply: newVal }) + } else { + newVal.stopPropagation() + setState(state => ({ silentReply: !state.silentReply })) + } + }, []) + const setExplicitReplyInThread = useCallback((newVal: boolean | React.MouseEvent) => { + if (typeof newVal === "boolean") { + setState({ explicitReplyInThread: newVal }) + } else { + newVal.stopPropagation() + setState(state => ({ explicitReplyInThread: !state.explicitReplyInThread })) + } + }, []) roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => { if (evt === null) { rawSetEditing(null) @@ -118,24 +142,36 @@ const MessageComposer = () => { return } const evtContent = evt.content as MessageEventContent - const mediaMsgTypes = ["m.image", "m.audio", "m.video", "m.file"] + const mediaMsgTypes = ["m.sticker", "m.image", "m.audio", "m.video", "m.file"] + if (evt.type === "m.sticker") { + evtContent.msgtype = "m.sticker" + } const isMedia = mediaMsgTypes.includes(evtContent.msgtype) && Boolean(evt.content?.url || evt.content?.file?.url) rawSetEditing(evt) + const textIsEditable = (evt.content.filename && evt.content.filename !== evt.content.body) + || evt.type === "m.sticker" + || !isMedia setState({ media: isMedia ? evtContent as MediaMessageEventContent : null, - text: (!evt.content.filename || evt.content.filename !== evt.content.body) + text: textIsEditable ? (evt.local_content?.edit_source ?? evtContent.body ?? "") : "", replyTo: null, + silentReply: false, + explicitReplyInThread: false, }) textInput.current?.focus() }, [room.roomID]) - const sendMessage = useEvent((evt: React.FormEvent) => { + const canSend = Boolean(state.text || state.media || state.location) + const onClickSend = (evt: React.FormEvent) => { evt.preventDefault() - if (state.text === "" && !state.media && !state.location) { + if (!canSend) { return } + doSendMessage(state) + } + const doSendMessage = (state: ComposerState) => { if (editing) { setState(draftStore.get(room.roomID) ?? emptyComposer) } else { @@ -154,18 +190,20 @@ const MessageComposer = () => { event_id: editing.event_id, } } else if (replyToEvt) { - mentions.user_ids.push(replyToEvt.sender) + const isThread = replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread" + && typeof replyToEvt.content?.["m.relates_to"]?.event_id === "string" + if (!state.silentReply && (!isThread || state.explicitReplyInThread)) { + mentions.user_ids.push(replyToEvt.sender) + } relates_to = { "m.in_reply_to": { event_id: replyToEvt.event_id, }, } - if (replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread" - && typeof replyToEvt.content?.["m.relates_to"]?.event_id === "string") { + if (isThread) { relates_to.rel_type = "m.thread" relates_to.event_id = replyToEvt.content?.["m.relates_to"].event_id - // TODO set this to true if replying to the last event in a thread? - relates_to.is_falling_back = false + relates_to.is_falling_back = !state.explicitReplyInThread } } let base_content: MessageEventContent | undefined @@ -196,8 +234,8 @@ const MessageComposer = () => { relates_to, mentions, }).catch(err => window.alert("Failed to send message: " + err)) - }) - const onComposerCaretChange = useEvent((evt: CaretEvent, newText?: string) => { + } + const onComposerCaretChange = (evt: CaretEvent, newText?: string) => { const area = evt.currentTarget if (area.selectionStart <= (autocomplete?.startPos ?? 0)) { if (autocomplete) { @@ -233,18 +271,21 @@ const MessageComposer = () => { }) } } - }) - const onComposerKeyDown = useEvent((evt: React.KeyboardEvent) => { + } + const onComposerKeyDown = (evt: React.KeyboardEvent) => { const inp = evt.currentTarget const fullKey = keyToString(evt) - if (fullKey === "Enter" && ( + const sendKey = fullKey === "Enter" || fullKey === "Ctrl+Enter" + ? (room.preferences.ctrl_enter_send ? "Ctrl+Enter" : "Enter") + : null + if (fullKey === sendKey && ( // If the autocomplete already has a selected item or has no results, send message even if it's open. // Otherwise, don't send message on enter, select the first autocomplete entry instead. !autocomplete || autocomplete.selected !== undefined || !document.getElementById("composer-autocompletions")?.classList.contains("has-items") )) { - sendMessage(evt) + onClickSend(evt) } else if (autocomplete) { let autocompleteUpdate: Partial | null | undefined if (fullKey === "Tab" || fullKey === "ArrowDown") { @@ -269,18 +310,18 @@ const MessageComposer = () => { } } else if (fullKey === "ArrowUp" && inp.selectionStart === 0 && inp.selectionEnd === 0) { const currentlyEditing = editing - ? roomCtx.ownMessages.indexOf(editing.rowid) - : roomCtx.ownMessages.length - const prevEventToEditID = roomCtx.ownMessages[currentlyEditing - 1] + ? room.editTargets.indexOf(editing.rowid) + : room.editTargets.length + const prevEventToEditID = room.editTargets[currentlyEditing - 1] const prevEventToEdit = prevEventToEditID ? room.eventsByRowID.get(prevEventToEditID) : undefined if (prevEventToEdit) { roomCtx.setEditing(prevEventToEdit) evt.preventDefault() } } else if (editing && fullKey === "ArrowDown" && inp.selectionStart === state.text.length) { - const currentlyEditingIdx = roomCtx.ownMessages.indexOf(editing.rowid) + const currentlyEditingIdx = room.editTargets.indexOf(editing.rowid) const nextEventToEdit = currentlyEditingIdx - ? room.eventsByRowID.get(roomCtx.ownMessages[currentlyEditingIdx + 1]) : undefined + ? room.eventsByRowID.get(room.editTargets[currentlyEditingIdx + 1]) : undefined roomCtx.setEditing(nextEventToEdit ?? null) // This timeout is very hacky and probably doesn't work in every case setTimeout(() => inp.setSelectionRange(0, 0), 0) @@ -289,8 +330,8 @@ const MessageComposer = () => { evt.stopPropagation() roomCtx.setEditing(null) } - }) - const onChange = useEvent((evt: React.ChangeEvent) => { + } + const onChange = (evt: React.ChangeEvent) => { setState({ text: evt.target.value }) const now = Date.now() if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) { @@ -307,7 +348,7 @@ const MessageComposer = () => { } } onComposerCaretChange(evt, evt.target.value) - }) + } const doUploadFile = useCallback((file: File | null | undefined) => { if (!file) { return @@ -329,10 +370,7 @@ const MessageComposer = () => { .catch(err => window.alert("Failed to upload file: " + err)) .finally(() => setLoadingMedia(false)) }, [room]) - const onAttachFile = useEvent( - (evt: React.ChangeEvent) => doUploadFile(evt.target.files?.[0]), - ) - const onPaste = useEvent((evt: React.ClipboardEvent) => { + const onPaste = (evt: React.ClipboardEvent) => { const file = evt.clipboardData?.files?.[0] const text = evt.clipboardData.getData("text/plain") const input = evt.currentTarget @@ -349,7 +387,7 @@ const MessageComposer = () => { return } evt.preventDefault() - }) + } // To ensure the cursor jumps to the end, do this in an effect rather than as the initial value of useState // To try to avoid the input bar flashing, use useLayoutEffect instead of useEffect useLayoutEffect(() => { @@ -374,10 +412,18 @@ const MessageComposer = () => { // checking scrollHeight seems to be the only reliable way to get the size of the text. textInput.current.rows = 1 const newTextRows = Math.min((textInput.current.scrollHeight - 16) / 20, MAX_TEXTAREA_ROWS) + if (newTextRows === MAX_TEXTAREA_ROWS) { + textInput.current.style.overflowY = "auto" + } else { + // There's a weird 1px scroll when using line-height, so set overflow to hidden when it's not needed + textInput.current.style.overflowY = "hidden" + } textInput.current.rows = newTextRows textRows.current = newTextRows // This has to be called unconditionally, because setting rows = 1 messes up the scroll state otherwise roomCtx.scrollToBottom() + // scrollToBottom needs to be called when replies/attachments/etc change, + // so listen to state instead of only state.text }, [state, roomCtx]) // Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect. useEffect(() => { @@ -391,7 +437,6 @@ const MessageComposer = () => { draftStore.set(room.roomID, state) } }, [roomCtx, room, state, editing]) - const openFilePicker = useCallback(() => fileInput.current!.click(), []) const clearMedia = useCallback(() => setState({ media: null, location: null }), []) const onChangeLocation = useCallback((location: ComposerLocationValue) => setState({ location }), []) const closeReply = useCallback((evt: React.MouseEvent) => { @@ -402,35 +447,9 @@ const MessageComposer = () => { evt.stopPropagation() roomCtx.setEditing(null) }, [roomCtx]) - const openEmojiPicker = useEvent(() => { - openModal({ - content: setState({ - text: state.text.slice(0, textInput.current?.selectionStart ?? 0) - + emojiToMarkdown(emoji) - + state.text.slice(textInput.current?.selectionEnd ?? 0), - })} - />, - onClose: () => textInput.current?.focus(), - }) - }) - const openGIFPicker = useEvent(() => { - openModal({ - content: setState({ media })} - />, - onClose: () => textInput.current?.focus(), - }) - }) - const openLocationPicker = useEvent(() => { - setState({ location: { lat: 0, long: 0, prec: 1 }, media: null }) - }) const Autocompleter = getAutocompleter(autocomplete, client, room) let mediaDisabledTitle: string | undefined + let stickerDisabledTitle: string | undefined let locationDisabledTitle: string | undefined if (state.media) { mediaDisabledTitle = "You can only attach one file at a time" @@ -442,6 +461,106 @@ const MessageComposer = () => { mediaDisabledTitle = "Uploading file..." locationDisabledTitle = "You can't attach a location to a message with a file" } + if (state.media?.msgtype !== "m.sticker") { + stickerDisabledTitle = mediaDisabledTitle + if (!stickerDisabledTitle && editing) { + stickerDisabledTitle = "You can't edit a message into a sticker" + } + } else if (state.text && !editing) { + stickerDisabledTitle = "You can't attach a sticker to a message with text" + } + const getEmojiPickerStyle = () => ({ + bottom: (composerRef.current?.clientHeight ?? 32) + 4 + 24, + right: "var(--timeline-horizontal-padding)", + }) + const makeAttachmentButtons = (includeText = false) => { + const openEmojiPicker = () => { + openModal({ + content: { + const mdEmoji = emojiToMarkdown(emoji) + setState({ + text: state.text.slice(0, textInput.current?.selectionStart ?? 0) + + mdEmoji + + state.text.slice(textInput.current?.selectionEnd ?? 0), + }) + if (textInput.current) { + textInput.current.setSelectionRange(textInput.current.selectionStart + mdEmoji.length, 0) + } + }} + // TODO allow keeping open on select on non-mobile devices + // (requires onSelect to be able to keep track of the state after updating it) + closeOnSelect={true} + />, + onClose: () => !isMobileDevice && textInput.current?.focus(), + }) + } + const openGIFPicker = () => { + openModal({ + content: setState({ media })} + />, + onClose: () => !isMobileDevice && textInput.current?.focus(), + }) + } + const openStickerPicker = () => { + openModal({ + content: doSendMessage({ ...state, media, text: "" })} + />, + onClose: () => !isMobileDevice && textInput.current?.focus(), + }) + } + const openLocationPicker = () => { + setState({ location: { lat: 0, long: 0, prec: 1 }, media: null }) + } + return <> + + + + + + + } + const openButtonsModal = () => { + const style: CSSProperties = getEmojiPickerStyle() + style.left = style.right + delete style.right + openModal({ + content:
+ {makeAttachmentButtons(true)} +
, + }) + } + const inlineButtons = state.text === "" || window.innerWidth > 720 + const showSendButton = canSend || window.innerWidth > 720 + const disableClearMedia = editing && state.media?.msgtype === "m.sticker" return <> {Autocompleter && autocomplete &&
{ event={replyToEvt} onClose={closeReply} isThread={replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread"} + isSilent={state.silentReply} + onSetSilent={setSilentReply} + isExplicitInThread={state.explicitReplyInThread} + onSetExplicitInThread={setExplicitReplyInThread} />} {editing && { isThread={false} onClose={stopEditing} />} - {loadingMedia &&
} - {state.media && } + {loadingMedia &&
} + {state.media && } {state.location && }
+ {!inlineButtons && }