Migrate to Tuari
This commit is contained in:
parent
331d0fb7fc
commit
54549f47e1
|
|
@ -0,0 +1,13 @@
|
|||
# connect to local Stoat instance
|
||||
VITE_API_URL=http://localhost:14702
|
||||
VITE_WS_URL=ws://localhost:14703
|
||||
VITE_MEDIA_URL=http://localhost:14704
|
||||
VITE_PROXY_URL=http://localhost:14705
|
||||
VITE_GIFBOX_URL=http://localhost:14706
|
||||
|
||||
# specify hCaptcha sitekey
|
||||
VITE_HCAPTCHA_SITEKEY=
|
||||
|
||||
# specify Sentry DSN
|
||||
VITE_SENTRY_DSN=
|
||||
VITE_SENTRY_TUNNEL=
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/electron",
|
||||
"plugin:import/typescript"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ on:
|
|||
jobs:
|
||||
build:
|
||||
name: Build App
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -16,13 +16,43 @@ jobs:
|
|||
- name: Checkout assets
|
||||
run: git -c submodule."assets".update=checkout submodule update --init assets
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm run package
|
||||
run: pnpm tauri build
|
||||
env:
|
||||
VITE_API_URL: ${{ secrets.VITE_API_URL }}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.rp.outputs.release_created }}
|
||||
tag_name: ${{ steps.rp.outputs.tag_name }}
|
||||
steps:
|
||||
- id: rp
|
||||
uses: googleapis/release-please-action@v4
|
||||
|
|
@ -31,14 +32,25 @@ jobs:
|
|||
name: Publish App
|
||||
needs: release-please
|
||||
if: needs.release-please.outputs.release_created == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.settings.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
settings:
|
||||
- os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-22.04
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -47,6 +59,39 @@ jobs:
|
|||
- name: Checkout assets
|
||||
run: git -c submodule."assets".update=checkout submodule update --init assets
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
if: matrix.settings.os == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Install aarch64 cross-compilation tools
|
||||
if: matrix.settings.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
sudo apt-get install -y \
|
||||
gcc-aarch64-linux-gnu \
|
||||
libwebkit2gtk-4.1-dev:arm64
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
key: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
|
|
@ -61,26 +106,15 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
pnpm run publish
|
||||
- name: Build and publish Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
PLATFORM: ${{ matrix.os }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VITE_API_URL: ${{ secrets.VITE_API_URL }}
|
||||
|
||||
- name: Publish macOS x64
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: pnpm run publish --arch=x64
|
||||
env:
|
||||
PLATFORM: ${{ matrix.os }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VITE_API_URL: ${{ secrets.VITE_API_URL }}
|
||||
|
||||
- name: Publish Linux arm64
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: pnpm run publish --arch=arm64
|
||||
env:
|
||||
PLATFORM: ${{ matrix.os }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VITE_API_URL: ${{ secrets.VITE_API_URL }}
|
||||
with:
|
||||
tagName: ${{ needs.release-please.outputs.tag_name }}
|
||||
releaseName: ${{ needs.release-please.outputs.tag_name }}
|
||||
releaseBody: ""
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.settings.target }}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
name: Release Webhook
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
release-webhook:
|
||||
name: Send Release Webhook
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Send release notification webhook
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
WEBHOOK_URL: ${{ secrets.STOAT_WEBHOOK_UPDATES_URL }}
|
||||
run: |
|
||||
RELEASE_URL="https://github.com/${REPOSITORY}/releases/tag/${TAG_NAME}"
|
||||
curl -X POST "$WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"content\": \"$RELEASE_URL\"}"
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
name: "Lint PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -1,92 +1,18 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
public/assets
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
.DS_Store
|
||||
## Panda
|
||||
styled-system
|
||||
styled-system-studio
|
||||
*storybook.log
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Webpack
|
||||
.webpack/
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
|
||||
# Electron-Forge
|
||||
out/
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
".": "1.4.2"
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/tsconfig.json": false
|
||||
},
|
||||
"editor.detectIndentation": false,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"nixEnvSelector.nixFile": "${workspaceFolder}/default.nix"
|
||||
}
|
||||
101
CHANGELOG.md
101
CHANGELOG.md
|
|
@ -1,101 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
## [1.4.2](https://github.com/Qinbeans/stoat/compare/v1.4.1...v1.4.2) (2026-02-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update application metadata and default build URL ([087df8a](https://github.com/Qinbeans/stoat/commit/087df8abfe5ea1ef03a2ce3a9e38b4e19e0ae651))
|
||||
|
||||
## [1.4.1](https://github.com/Qinbeans/stoat/compare/v1.4.0...v1.4.1) (2026-02-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update GitHub repository owner and name in configuration ([d88150b](https://github.com/Qinbeans/stoat/commit/d88150b092159a1c4d2a7cd452597eac82fb5276))
|
||||
|
||||
## [1.4.0](https://github.com/Qinbeans/stoat/compare/v1.3.0...v1.4.0) (2026-02-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* initial commit ([7ab6795](https://github.com/Qinbeans/stoat/commit/7ab6795a881e0dd41f21b9fbef1161bb1ff54be0))
|
||||
* minimise-to-tray-on-startup ([#126](https://github.com/Qinbeans/stoat/issues/126)) ([8284117](https://github.com/Qinbeans/stoat/commit/8284117e76c0fcff4091de3ef623014e4594a593))
|
||||
* new branding ([#87](https://github.com/Qinbeans/stoat/issues/87)) ([8910dcb](https://github.com/Qinbeans/stoat/commit/8910dcba923b55df789c0541b59a6a6321a28768))
|
||||
* persist and restore window size and position ([#74](https://github.com/Qinbeans/stoat/issues/74)) ([3bf697d](https://github.com/Qinbeans/stoat/commit/3bf697d1a9aba739b6954c8469223f51093497cc))
|
||||
* Reload/Refresh shortcut ([#119](https://github.com/Qinbeans/stoat/issues/119)) ([2e99b19](https://github.com/Qinbeans/stoat/commit/2e99b19353fbd45d9fdf1d148bae3a8a19c788ed))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add common zoom-reset shortcut. ([#112](https://github.com/Qinbeans/stoat/issues/112)) ([def29f9](https://github.com/Qinbeans/stoat/commit/def29f9b3c1205944aab58beb8000815d41633b5))
|
||||
* add NixOS compatibility for electron startup ([#23](https://github.com/Qinbeans/stoat/issues/23)) ([3eb9b8e](https://github.com/Qinbeans/stoat/commit/3eb9b8e84bf05debf9843b80c468911fd095f4a0))
|
||||
* allow CTRL+"+" to also zoom in. ([#108](https://github.com/Qinbeans/stoat/issues/108)) ([2b962c5](https://github.com/Qinbeans/stoat/commit/2b962c5d066787601223368ee7dcc1e46a345b8a))
|
||||
* App Autostart ([#68](https://github.com/Qinbeans/stoat/issues/68)) ([127d143](https://github.com/Qinbeans/stoat/commit/127d1430a9c630e0429c9cc50d57ee316a63ebe5))
|
||||
* App-maximized-2nd-monitor ([897d706](https://github.com/Qinbeans/stoat/commit/897d706983a347938a2fb42ba8e58e40794bba13))
|
||||
* correctly handle config updates ([2517f41](https://github.com/Qinbeans/stoat/commit/2517f412abe78bcf8e8f4aece19cfc5336dbc98f))
|
||||
* correctly load badge count; expose to renderer ([#25](https://github.com/Qinbeans/stoat/issues/25)) ([6817b55](https://github.com/Qinbeans/stoat/commit/6817b554e57c5a65b7b4aca7d1cc4e05cd6f01b7))
|
||||
* don't re-enable abutostart ([63b9ea8](https://github.com/Qinbeans/stoat/commit/63b9ea818a9f32ca8535948e18752726c0f50a12))
|
||||
* event listener accumulation from rpc client ([#26](https://github.com/Qinbeans/stoat/issues/26)) ([96fa8cc](https://github.com/Qinbeans/stoat/commit/96fa8cc647029cb53e5d619b94debc6cdfdf32f6))
|
||||
* firstLaunch = false after initial setup ([#131](https://github.com/Qinbeans/stoat/issues/131)) ([63b9ea8](https://github.com/Qinbeans/stoat/commit/63b9ea818a9f32ca8535948e18752726c0f50a12))
|
||||
* flatpak icons not building correctly and wayland support ([#132](https://github.com/Qinbeans/stoat/issues/132)) ([ffe17ec](https://github.com/Qinbeans/stoat/commit/ffe17ec2c54fca6967435b8a4ada7fa8d4da7b33))
|
||||
* hide menu when custom frame is off ([2517f41](https://github.com/Qinbeans/stoat/commit/2517f412abe78bcf8e8f4aece19cfc5336dbc98f))
|
||||
* include empty cert pass ([864571d](https://github.com/Qinbeans/stoat/commit/864571df56339919cfe1af79ff23ebd279d6def5))
|
||||
* **macos:** tray icon size ([5eecab5](https://github.com/Qinbeans/stoat/commit/5eecab59431cb4966eaa1fc907a8e5c16c813230))
|
||||
* remove unused GitHub App token creation step from release workflow ([cdc392f](https://github.com/Qinbeans/stoat/commit/cdc392f1dda919f4cbfa05a7a714c73badf8f552))
|
||||
* replace default dialog with notification ([#98](https://github.com/Qinbeans/stoat/issues/98)) ([7d2f296](https://github.com/Qinbeans/stoat/commit/7d2f296ca72bbd7ad694c66a917d47067f883fc5))
|
||||
* rpc should define largeImageText ([#21](https://github.com/Qinbeans/stoat/issues/21)) ([cb373b6](https://github.com/Qinbeans/stoat/commit/cb373b6dc62630147151039c3711aef74c8c2d88))
|
||||
* send config on web contents load ([2517f41](https://github.com/Qinbeans/stoat/commit/2517f412abe78bcf8e8f4aece19cfc5336dbc98f))
|
||||
* synchronise updates to config with preload ([2517f41](https://github.com/Qinbeans/stoat/commit/2517f412abe78bcf8e8f4aece19cfc5336dbc98f))
|
||||
* toggle window visibility on tray click instead of always showing ([#103](https://github.com/Qinbeans/stoat/issues/103)) ([742a95f](https://github.com/Qinbeans/stoat/commit/742a95f3cb820c5b5398c815b7b45017b6b06053))
|
||||
* try to restore maximised windows to correct display ([#92](https://github.com/Qinbeans/stoat/issues/92)) ([897d706](https://github.com/Qinbeans/stoat/commit/897d706983a347938a2fb42ba8e58e40794bba13))
|
||||
* update GitHub token usage in release workflow ([54c5957](https://github.com/Qinbeans/stoat/commit/54c595767691b05550e6fe18d4b2fd43e4136679))
|
||||
* use template icon for macOS tray, use higher res icons for other platforms ([#130](https://github.com/Qinbeans/stoat/issues/130)) ([58ccb63](https://github.com/Qinbeans/stoat/commit/58ccb63d23541a03e05a48a37a98f883a2ba0d3f))
|
||||
* use the correct argument for auto start ([#22](https://github.com/Qinbeans/stoat/issues/22)) ([532af4a](https://github.com/Qinbeans/stoat/commit/532af4a680069f72734148b0ccdacec6c435e640)), closes [#20](https://github.com/Qinbeans/stoat/issues/20)
|
||||
|
||||
## [1.3.0](https://github.com/stoatchat/for-desktop/compare/v1.2.0...v1.3.0) (2026-02-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* minimise-to-tray-on-startup ([#126](https://github.com/stoatchat/for-desktop/issues/126)) ([8284117](https://github.com/stoatchat/for-desktop/commit/8284117e76c0fcff4091de3ef623014e4594a593))
|
||||
* Reload/Refresh shortcut ([#119](https://github.com/stoatchat/for-desktop/issues/119)) ([2e99b19](https://github.com/stoatchat/for-desktop/commit/2e99b19353fbd45d9fdf1d148bae3a8a19c788ed))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add common zoom-reset shortcut. ([#112](https://github.com/stoatchat/for-desktop/issues/112)) ([def29f9](https://github.com/stoatchat/for-desktop/commit/def29f9b3c1205944aab58beb8000815d41633b5))
|
||||
* allow CTRL+"+" to also zoom in. ([#108](https://github.com/stoatchat/for-desktop/issues/108)) ([2b962c5](https://github.com/stoatchat/for-desktop/commit/2b962c5d066787601223368ee7dcc1e46a345b8a))
|
||||
* App-maximized-2nd-monitor ([897d706](https://github.com/stoatchat/for-desktop/commit/897d706983a347938a2fb42ba8e58e40794bba13))
|
||||
* don't re-enable abutostart ([63b9ea8](https://github.com/stoatchat/for-desktop/commit/63b9ea818a9f32ca8535948e18752726c0f50a12))
|
||||
* firstLaunch = false after initial setup ([#131](https://github.com/stoatchat/for-desktop/issues/131)) ([63b9ea8](https://github.com/stoatchat/for-desktop/commit/63b9ea818a9f32ca8535948e18752726c0f50a12))
|
||||
* flatpak icons not building correctly and wayland support ([#132](https://github.com/stoatchat/for-desktop/issues/132)) ([ffe17ec](https://github.com/stoatchat/for-desktop/commit/ffe17ec2c54fca6967435b8a4ada7fa8d4da7b33))
|
||||
* replace default dialog with notification ([#98](https://github.com/stoatchat/for-desktop/issues/98)) ([7d2f296](https://github.com/stoatchat/for-desktop/commit/7d2f296ca72bbd7ad694c66a917d47067f883fc5))
|
||||
* toggle window visibility on tray click instead of always showing ([#103](https://github.com/stoatchat/for-desktop/issues/103)) ([742a95f](https://github.com/stoatchat/for-desktop/commit/742a95f3cb820c5b5398c815b7b45017b6b06053))
|
||||
* try to restore maximised windows to correct display ([#92](https://github.com/stoatchat/for-desktop/issues/92)) ([897d706](https://github.com/stoatchat/for-desktop/commit/897d706983a347938a2fb42ba8e58e40794bba13))
|
||||
* use template icon for macOS tray, use higher res icons for other platforms ([#130](https://github.com/stoatchat/for-desktop/issues/130)) ([58ccb63](https://github.com/stoatchat/for-desktop/commit/58ccb63d23541a03e05a48a37a98f883a2ba0d3f))
|
||||
|
||||
## [1.2.0](https://github.com/stoatchat/for-desktop/compare/v1.1.12...v1.2.0) (2026-02-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* new branding ([#87](https://github.com/stoatchat/for-desktop/issues/87)) ([8910dcb](https://github.com/stoatchat/for-desktop/commit/8910dcba923b55df789c0541b59a6a6321a28768))
|
||||
* persist and restore window size and position ([#74](https://github.com/stoatchat/for-desktop/issues/74)) ([3bf697d](https://github.com/stoatchat/for-desktop/commit/3bf697d1a9aba739b6954c8469223f51093497cc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* App Autostart ([#68](https://github.com/stoatchat/for-desktop/issues/68)) ([127d143](https://github.com/stoatchat/for-desktop/commit/127d1430a9c630e0429c9cc50d57ee316a63ebe5))
|
||||
|
||||
## [1.1.12](https://github.com/stoatchat/for-desktop/compare/v1.1.11...v1.1.12) (2025-12-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add NixOS compatibility for electron startup ([#23](https://github.com/stoatchat/for-desktop/issues/23)) ([3eb9b8e](https://github.com/stoatchat/for-desktop/commit/3eb9b8e84bf05debf9843b80c468911fd095f4a0))
|
||||
* correctly load badge count; expose to renderer ([#25](https://github.com/stoatchat/for-desktop/issues/25)) ([6817b55](https://github.com/stoatchat/for-desktop/commit/6817b554e57c5a65b7b4aca7d1cc4e05cd6f01b7))
|
||||
* event listener accumulation from rpc client ([#26](https://github.com/stoatchat/for-desktop/issues/26)) ([96fa8cc](https://github.com/stoatchat/for-desktop/commit/96fa8cc647029cb53e5d619b94debc6cdfdf32f6))
|
||||
* **macos:** tray icon size ([5eecab5](https://github.com/stoatchat/for-desktop/commit/5eecab59431cb4966eaa1fc907a8e5c16c813230))
|
||||
* rpc should define largeImageText ([#21](https://github.com/stoatchat/for-desktop/issues/21)) ([cb373b6](https://github.com/stoatchat/for-desktop/commit/cb373b6dc62630147151039c3711aef74c8c2d88))
|
||||
* use the correct argument for auto start ([#22](https://github.com/stoatchat/for-desktop/issues/22)) ([532af4a](https://github.com/stoatchat/for-desktop/commit/532af4a680069f72734148b0ccdacec6c435e640)), closes [#20](https://github.com/stoatchat/for-desktop/issues/20)
|
||||
661
LICENSE
661
LICENSE
|
|
@ -1,661 +0,0 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
Stoat for Desktop
|
||||
Copyright (C) 2025 Pawel Makles
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
85
README.md
85
README.md
|
|
@ -1,84 +1,11 @@
|
|||
<div align="center">
|
||||
<h1>
|
||||
Stoat for Desktop
|
||||
# Revamped [Stoat Desktop App](https://github.com/stoatchat/for-desktop) for QStoat
|
||||
|
||||
[](https://github.com/stoatchat/for-desktop/stargazers)
|
||||
[](https://github.com/stoatchat/for-desktop/network/members)
|
||||
[](https://github.com/stoatchat/for-desktop/pulls)
|
||||
[](https://github.com/stoatchat/for-desktop/issues)
|
||||
[](https://github.com/stoatchat/for-desktop/graphs/contributors)
|
||||
[](https://github.com/stoatchat/for-desktop/blob/main/LICENSE)
|
||||
</h1>
|
||||
Application for Windows, macOS, and Linux.
|
||||
</div>
|
||||
<br/>
|
||||
QStoat is stoat for stoat.qinbeans.net. Specific to the friend group of the author, but can be used by anyone. It is a desktop application built with [Tauri](https://tauri.app/) with the modified frontend of the original [Stoat Web](https://github.com/stoatchat/for-web).
|
||||
|
||||
## Installation
|
||||
## Recommended IDE Setup
|
||||
|
||||
<a href="https://repology.org/project/stoat-desktop/versions">
|
||||
<img src="https://repology.org/badge/vertical-allrepos/stoat-desktop.svg" alt="Packaging status" align="right">
|
||||
</a>
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
|
||||
- All downloads and instructions for Stoat can be found on our [Website](https://stoat.chat/download).
|
||||
**Modified GIF backend to use Klipy**
|
||||
|
||||
## Development Guide
|
||||
|
||||
_Contribution guidelines for Desktop app TBA!_
|
||||
|
||||
<!-- Before contributing, make yourself familiar with [our contribution guidelines](https://developers.revolt.chat/contrib.html), the [code style guidelines](./GUIDELINES.md), and the [technical documentation for this project](https://revoltchat.github.io/frontend/). -->
|
||||
|
||||
Before getting started, you'll want to install:
|
||||
|
||||
- Git
|
||||
- Node.js
|
||||
- pnpm (run `corepack enable`)
|
||||
|
||||
Then proceed to setup:
|
||||
|
||||
```bash
|
||||
# clone the repository
|
||||
git clone --recursive https://github.com/stoatchat/for-desktop stoat-for-desktop
|
||||
cd stoat-for-desktop
|
||||
|
||||
# install all packages
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# start the application
|
||||
pnpm start
|
||||
# ... or build the bundle
|
||||
pnpm package
|
||||
# ... or build all distributables
|
||||
pnpm make
|
||||
```
|
||||
|
||||
Various useful commands for development testing:
|
||||
|
||||
```bash
|
||||
# connect to the development server
|
||||
pnpm start -- --force-server http://localhost:5173
|
||||
|
||||
# test the flatpak (after `make`)
|
||||
pnpm install:flatpak
|
||||
pnpm run:flatpak
|
||||
# ... also connect to dev server like so:
|
||||
pnpm run:flatpak --force-server http://localhost:5173
|
||||
|
||||
# Nix-specific instructions for testing
|
||||
pnpm package
|
||||
pnpm run:nix
|
||||
# ... as before:
|
||||
pnpm run:nix --force-server=http://localhost:5173
|
||||
# a better solution would be telling
|
||||
# Electron Forge where system Electron is
|
||||
```
|
||||
|
||||
### Pulling in Stoat's assets
|
||||
|
||||
If you want to pull in Stoat brand assets after pulling, run the following:
|
||||
|
||||
```bash
|
||||
# update the assets
|
||||
git -c submodule."assets".update=checkout submodule update --init assets
|
||||
```
|
||||
|
||||
Currently, this is required to build, any forks are expected to provide their own assets.
|
||||
This is not meant for other people to use, but if you want to use it, you will likely need to modify many pieces to get it to work with your own stoat instance.
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
[Desktop Entry]
|
||||
Name=Stoat
|
||||
Comment=Open source, user-first chat platform
|
||||
Exec=stoat-desktop
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=chat.stoat.StoatDesktop
|
||||
Categories=Network;InstantMessaging
|
||||
StartupWMClass=stoat-desktop
|
||||
X-Desktop-File-Install-Version=0.26
|
||||
X-Flatpak=chat.stoat.StoatDesktop
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop">
|
||||
<id>chat.stoat.StoatDesktop</id>
|
||||
<launchable type="desktop-id">chat.stoat.StoatDesktop.desktop</launchable>
|
||||
<name>Stoat</name>
|
||||
<developer id="chat.stoat">
|
||||
<name>Revolt Platforms Ltd</name>
|
||||
</developer>
|
||||
<summary>Open source, user-first chat platform</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>AGPL-3.0</project_license>
|
||||
<icon type="remote" height="256" width="256">
|
||||
https://raw.githubusercontent.com/stoatchat/assets/f106946659af67ad4f008588ac51570029b2fd47/desktop/icon.png</icon>
|
||||
<description>
|
||||
<p>Stoat is an open source, user-first chat platform. Send messages, share
|
||||
images, mention users, and join voice channels — all from a native desktop
|
||||
application.</p>
|
||||
</description>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Main window</caption>
|
||||
<image>screenshot.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release date="2026-02-14" version="1.2.0">
|
||||
<description>
|
||||
<p>Features:</p>
|
||||
<ul>
|
||||
<li>New Branding</li>
|
||||
<li>Persist and restore window size and position</li>
|
||||
</ul>
|
||||
<p>Bug Fixes:</p>
|
||||
<ul>
|
||||
<li>App Autostart</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release date="2025-12-29" version="1.1.12">
|
||||
<description>
|
||||
<p>Bug fixes:</p>
|
||||
<ul>
|
||||
<li>Add NixOS compatibility for electron startup</li>
|
||||
<li>Correctly load badge count; expose to renderer</li>
|
||||
<li>Fix event listener accumulation from rpc client</li>
|
||||
<li>Fix macOS tray icon size</li>
|
||||
<li>Fix RPC largeImageText</li>
|
||||
<li>Use the correct argument for auto start</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
<url type="bugtracker">https://github.com/stoatchat/for-desktop/issues</url>
|
||||
<url type="homepage">https://stoat.qinbeans.net</url>
|
||||
<url type="vcs-browser">https://github.com/stoatchat/for-desktop</url>
|
||||
<content_rating type="oars-1.1">
|
||||
<content_attribute id="social-chat">intense</content_attribute>
|
||||
<content_attribute id="social-info">intense</content_attribute>
|
||||
<content_attribute id="social-audio">intense</content_attribute>
|
||||
<content_attribute id="social-contacts">intense</content_attribute>
|
||||
</content_rating>
|
||||
<requires>
|
||||
<display_length compare="ge">940</display_length>
|
||||
<internet>always</internet>
|
||||
</requires>
|
||||
<supports>
|
||||
<control>keyboard</control>
|
||||
<control>pointing</control>
|
||||
</supports>
|
||||
</component>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { readdirSync } from "node:fs";
|
||||
|
||||
const fileRegex = /\.tsx$/;
|
||||
const codegenRegex = /\/\/ @codegen (.*)/g;
|
||||
|
||||
const DIRECTIVES = readdirSync("./components/ui/directives")
|
||||
.filter((x) => x !== "index.ts")
|
||||
.map((x) => x.substring(0, x.length - 3));
|
||||
|
||||
const directiveRegex = new RegExp("use:(" + DIRECTIVES.join("|") + ")");
|
||||
|
||||
export default function codegenPlugin() {
|
||||
return {
|
||||
name: "codegen",
|
||||
enforce: "pre" as const,
|
||||
transform(src: string, id: string) {
|
||||
if (fileRegex.test(id)) {
|
||||
src = src.replace(codegenRegex, (substring, group1) => {
|
||||
const rawArgs: string[] = group1.split(" ");
|
||||
const type = rawArgs.shift();
|
||||
|
||||
const args = rawArgs.reduce(
|
||||
(d, arg) => {
|
||||
const [key, value] = arg.split("=");
|
||||
return {
|
||||
...d,
|
||||
[key]: value,
|
||||
};
|
||||
},
|
||||
{ type },
|
||||
) as {
|
||||
type: "directives";
|
||||
props?: string;
|
||||
include?: string;
|
||||
};
|
||||
|
||||
switch (args.type) {
|
||||
case "directives": {
|
||||
// Generate directives forwarding
|
||||
const source = args.props ?? "props";
|
||||
const permitted: string[] =
|
||||
args.include?.split(",") ?? DIRECTIVES;
|
||||
return DIRECTIVES.filter((d) => permitted.includes(d))
|
||||
.map((d) => `use:${d}={${source}["use:${d}"]}`)
|
||||
.join("\n");
|
||||
}
|
||||
default:
|
||||
return substring;
|
||||
}
|
||||
});
|
||||
|
||||
if (directiveRegex.test(src)) {
|
||||
if (!id.endsWith("client/components/ui/index.tsx"))
|
||||
src =
|
||||
`import { ${DIRECTIVES.join(
|
||||
", ",
|
||||
)} } from "@revolt/ui/directives";\n` + src;
|
||||
}
|
||||
|
||||
return src;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export { DraftMessages } from "./interface/channels/text/DraftMessages";
|
||||
export { Message } from "./interface/channels/text/Message";
|
||||
export { Messages } from "./interface/channels/text/Messages";
|
||||
|
||||
export * from "./interface/settings";
|
||||
export * from "./menus";
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { For, Match, Switch } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import type { Channel } from "stoat.js";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { useClient, useUser } from "@revolt/client";
|
||||
import { Markdown } from "@revolt/markdown";
|
||||
import { userInformation } from "@revolt/markdown/users";
|
||||
import { useState } from "@revolt/state";
|
||||
import type { UnsentMessage } from "@revolt/state/stores/Draft";
|
||||
import {
|
||||
Avatar,
|
||||
MessageContainer,
|
||||
MessageReply,
|
||||
SizedContent,
|
||||
Text,
|
||||
Username,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import { DraftMessageContextMenu } from "../../../menus/DraftMessageContextMenu";
|
||||
|
||||
interface Props {
|
||||
draft: UnsentMessage;
|
||||
channel: Channel;
|
||||
tail?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsent message preview
|
||||
*/
|
||||
export function DraftMessage(props: Props) {
|
||||
const user = useUser();
|
||||
const state = useState();
|
||||
const client = useClient();
|
||||
const userInfo = () => userInformation(user(), props.channel.server?.member);
|
||||
|
||||
return (
|
||||
<MessageContainer
|
||||
tail={
|
||||
props.tail && (!props.draft.replies || props.draft.replies.length === 0)
|
||||
}
|
||||
avatar={<Avatar src={userInfo().avatar} size={36} />}
|
||||
timestamp={
|
||||
props.draft.status === "sending" ? (
|
||||
<Trans>Sending...</Trans>
|
||||
) : props.draft.status === "failed" ? (
|
||||
<Trans>Failed to send</Trans>
|
||||
) : (
|
||||
<Trans>Unsent message</Trans>
|
||||
)
|
||||
}
|
||||
sendStatus={props.draft.status === "sending" ? "sending" : "failed"}
|
||||
username={<Username username={userInfo().username} />}
|
||||
header={
|
||||
<For each={props.draft.replies}>
|
||||
{(reply) => (
|
||||
<MessageReply
|
||||
message={client().messages.get(reply.id)}
|
||||
mention={reply.mention}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
contextMenu={() => (
|
||||
<DraftMessageContextMenu draft={props.draft} channel={props.channel} />
|
||||
)}
|
||||
compact={state.settings.getValue("appearance:compact_mode")}
|
||||
>
|
||||
<BreakText>
|
||||
<Markdown content={props.draft.content!} />
|
||||
</BreakText>
|
||||
<For each={props.draft.files}>
|
||||
{(id) => {
|
||||
const file = state.draft.getFile(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text class="label">
|
||||
Uploading file `{file.file.name}`...{" "}
|
||||
{(file.uploadProgress[0]() * 100).toFixed()}%
|
||||
</Text>
|
||||
<Switch>
|
||||
<Match when={file.dimensions}>
|
||||
<SizedContent
|
||||
width={file.dimensions![0]}
|
||||
height={file.dimensions![1]}
|
||||
>
|
||||
<img src={file.dataUri} />
|
||||
</SizedContent>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</MessageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Break all text and prevent overflow from math blocks
|
||||
*/
|
||||
const BreakText = styled("div", {
|
||||
base: {
|
||||
wordBreak: "break-word",
|
||||
|
||||
"& .math": {
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
maxHeight: "100vh",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { For } from "solid-js";
|
||||
|
||||
import { Channel } from "stoat.js";
|
||||
|
||||
import { useState } from "@revolt/state";
|
||||
|
||||
import { DraftMessage } from "./DraftMessage";
|
||||
|
||||
interface Props {
|
||||
channel: Channel;
|
||||
tail: boolean;
|
||||
sentIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export function DraftMessages(props: Props) {
|
||||
const state = useState();
|
||||
|
||||
const unsent = () =>
|
||||
state.draft
|
||||
.getPendingMessages(props.channel.id)
|
||||
.filter((draft) => draft.status === "sending")
|
||||
.filter((draft) => !props.sentIds.includes(draft.idempotencyKey));
|
||||
|
||||
const failed = () =>
|
||||
state.draft
|
||||
.getPendingMessages(props.channel.id)
|
||||
.filter((draft) => draft.status !== "sending");
|
||||
|
||||
return (
|
||||
<>
|
||||
<For each={unsent()}>
|
||||
{(draft, index) => (
|
||||
<DraftMessage
|
||||
draft={draft}
|
||||
channel={props.channel}
|
||||
tail={index() !== 0 || props.tail}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<For each={failed()}>
|
||||
{(draft) => <DraftMessage draft={draft} channel={props.channel} />}
|
||||
</For>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { Match, Switch } from "solid-js";
|
||||
|
||||
import { useMutation } from "@tanstack/solid-query";
|
||||
import { Message } from "stoat.js";
|
||||
import { css } from "styled-system/css";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { KeybindAction, createKeybind } from "@revolt/keybinds";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { useState } from "@revolt/state";
|
||||
import { Text } from "@revolt/ui";
|
||||
import { TextEditor2 } from "@revolt/ui/components/features/texteditor/TextEditor2";
|
||||
import { useSearchSpace } from "@revolt/ui/components/utils/autoComplete";
|
||||
|
||||
export function EditMessage(props: { message: Message }) {
|
||||
const state = useState();
|
||||
const client = useClient();
|
||||
const { openModal, isOpen, pop } = useModals();
|
||||
|
||||
const initialValue = [state.draft.editingMessageContent || ""] as const;
|
||||
|
||||
const change = useMutation(() => ({
|
||||
mutationFn: (content: string) => props.message.edit({ content }),
|
||||
onSuccess() {
|
||||
state.draft.setEditingMessage(undefined);
|
||||
},
|
||||
onError(error) {
|
||||
openModal({ type: "error2", error });
|
||||
},
|
||||
}));
|
||||
|
||||
function saveMessage() {
|
||||
const content = state.draft.editingMessageContent;
|
||||
|
||||
if (content?.length) {
|
||||
state.draft._setNodeReplacement?.(["_focus"]); // focus message box
|
||||
change.mutate(content);
|
||||
} else if (isOpen("delete_message")) {
|
||||
void props.message.delete();
|
||||
pop();
|
||||
} else {
|
||||
openModal({
|
||||
type: "delete_message",
|
||||
message: props.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createKeybind(KeybindAction.CHAT_CANCEL_EDITING, () => {
|
||||
state.draft.setEditingMessage(undefined);
|
||||
state.draft._setNodeReplacement?.(["_focus"]); // focus message box
|
||||
});
|
||||
|
||||
const searchSpace = useSearchSpace(() => props.message, client);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorBox class={css({ flexGrow: 1 })}>
|
||||
<TextEditor2
|
||||
autoFocus
|
||||
onComplete={saveMessage}
|
||||
onChange={state.draft.setEditingMessageContent}
|
||||
initialValue={initialValue}
|
||||
autoCompleteSearchSpace={searchSpace}
|
||||
/>
|
||||
</EditorBox>
|
||||
|
||||
<Switch
|
||||
fallback={
|
||||
<Text size="small">
|
||||
escape to{" "}
|
||||
<Action onClick={() => state.draft.setEditingMessage(undefined)}>
|
||||
cancel
|
||||
</Action>{" "}
|
||||
· enter to <Action onClick={saveMessage}>save</Action>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Match when={change.isPending}>
|
||||
<Text size="small">Saving message...</Text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const EditorBox = styled("div", {
|
||||
base: {
|
||||
background: "var(--md-sys-color-surface-container-highest)",
|
||||
color: "var(--md-sys-color-on-surface-container)",
|
||||
borderRadius: "var(--borderRadius-sm)",
|
||||
padding: "var(--gap-md)",
|
||||
},
|
||||
});
|
||||
|
||||
const Action = styled("span", {
|
||||
base: {
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
color: "var(--md-sys-color-primary)",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
import { For, Match, Show, Switch, createSignal, onMount } from "solid-js";
|
||||
|
||||
import { useLingui } from "@lingui-solid/solid/macro";
|
||||
import { ImageEmbed, Message as MessageInterface, WebsiteEmbed } from "stoat.js";
|
||||
import { cva } from "styled-system/css";
|
||||
import { styled } from "styled-system/jsx";
|
||||
import { decodeTime } from "ulid";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { useTime } from "@revolt/i18n";
|
||||
import { Markdown } from "@revolt/markdown";
|
||||
import { useState } from "@revolt/state";
|
||||
import {
|
||||
Attachment,
|
||||
Avatar,
|
||||
Embed,
|
||||
MessageContainer,
|
||||
MessageReply,
|
||||
Reactions,
|
||||
SystemMessage,
|
||||
SystemMessageIcon,
|
||||
Tooltip,
|
||||
Username,
|
||||
} from "@revolt/ui";
|
||||
import { Symbol } from "@revolt/ui/components/utils/Symbol";
|
||||
|
||||
import { MessageContextMenu } from "../../../menus/MessageContextMenu";
|
||||
import {
|
||||
floatingUserMenus,
|
||||
floatingUserMenusFromMessage,
|
||||
} from "../../../menus/UserContextMenu";
|
||||
|
||||
import { EditMessage } from "./EditMessage";
|
||||
|
||||
/**
|
||||
* Regex for matching URLs
|
||||
*/
|
||||
const RE_URL =
|
||||
/[(http(s)?)://(www.)?a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/;
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Message
|
||||
*/
|
||||
message: MessageInterface;
|
||||
|
||||
/**
|
||||
* Whether this is the tail of another message
|
||||
*/
|
||||
tail?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to highlight this message
|
||||
*/
|
||||
highlight?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to replace content with editor
|
||||
*/
|
||||
editing?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this message is a link
|
||||
*/
|
||||
isLink?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Message with or without a tail
|
||||
*/
|
||||
export function Message(props: Props) {
|
||||
const dayjs = useTime();
|
||||
const state = useState();
|
||||
const { t } = useLingui();
|
||||
const client = useClient();
|
||||
|
||||
const [isHovering, setIsHovering] = createSignal(false);
|
||||
|
||||
/**
|
||||
* Determine whether this message only contains a GIF
|
||||
*/
|
||||
const isOnlyGIF = () => {
|
||||
if (
|
||||
!props.message.embeds ||
|
||||
props.message.embeds.length !== 1 ||
|
||||
!props.message.content ||
|
||||
props.message.content.replace(RE_URL, "").length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const embed = props.message.embeds[0];
|
||||
|
||||
if (embed.type === "Image") {
|
||||
return (embed as ImageEmbed).url?.startsWith("https://static.klipy.com");
|
||||
}
|
||||
|
||||
if (embed.type === "Website") {
|
||||
const websiteEmbed = embed as WebsiteEmbed;
|
||||
const gifProviders = ["https://tenor.com", "https://klipy.com"];
|
||||
return (
|
||||
websiteEmbed.specialContent?.type === "GIF" ||
|
||||
gifProviders.some((provider) =>
|
||||
websiteEmbed.originalUrl?.startsWith(provider),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* React with an emoji
|
||||
* @param emoji Emoji
|
||||
*/
|
||||
const react = (emoji: string) => props.message.react(emoji);
|
||||
|
||||
/**
|
||||
* Remove emoji reaction
|
||||
* @param emoji Emoji
|
||||
*/
|
||||
const unreact = (emoji: string) => props.message.unreact(emoji);
|
||||
|
||||
return (
|
||||
<MessageContainer
|
||||
message={props.message}
|
||||
onHover={setIsHovering}
|
||||
username={
|
||||
<div use:floating={floatingUserMenusFromMessage(props.message)}>
|
||||
<Username
|
||||
username={
|
||||
props.message.masquerade?.name ??
|
||||
props.message.member?.nickname ??
|
||||
props.message.author?.displayName ??
|
||||
props.message.author?.username ??
|
||||
props.message.username
|
||||
}
|
||||
colour={props.message.roleColour!}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
avatar={
|
||||
<div
|
||||
class={avatarContainer()}
|
||||
use:floating={floatingUserMenusFromMessage(props.message)}
|
||||
>
|
||||
<Avatar
|
||||
size={36}
|
||||
src={
|
||||
isHovering()
|
||||
? props.message.animatedAvatarURL
|
||||
: props.message.avatarURL
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
contextMenu={() => <MessageContextMenu message={props.message} />}
|
||||
timestamp={props.message.createdAt}
|
||||
edited={props.message.editedAt}
|
||||
mentioned={props.message.mentioned}
|
||||
highlight={props.highlight}
|
||||
editing={props.editing}
|
||||
isLink={props.isLink}
|
||||
tail={props.tail || state.settings.getValue("appearance:compact_mode")}
|
||||
header={
|
||||
<Show when={props.message.replyIds}>
|
||||
<For each={props.message.replyIds}>
|
||||
{(reply_id) => {
|
||||
/**
|
||||
* Signal the actual message
|
||||
*/
|
||||
const message = () => client().messages.get(reply_id);
|
||||
|
||||
onMount(() => {
|
||||
if (!message()) {
|
||||
props.message.channel!.fetchMessage(reply_id);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<MessageReply
|
||||
mention={props.message.mentionIds?.includes(
|
||||
message()!.authorId!,
|
||||
)}
|
||||
message={message()}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
}
|
||||
info={
|
||||
<Switch fallback={<div />}>
|
||||
<Match
|
||||
when={
|
||||
props.message.masquerade &&
|
||||
props.message.authorId === "01FHGJ3NPP7XANQQH8C2BE44ZY"
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
content={t`Message was sent on another platform`}
|
||||
placement="top"
|
||||
>
|
||||
<Symbol size={16}>link</Symbol>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
<Match when={props.message.author?.privileged}>
|
||||
<Tooltip content={t`Official Communication`} placement="top">
|
||||
<Symbol size={16}>brightness_alert</Symbol>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
<Match when={props.message.author?.bot}>
|
||||
<Tooltip content={t`Bot`} placement="top">
|
||||
<Symbol size={16} fill>
|
||||
smart_toy
|
||||
</Symbol>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
<Match when={props.message.webhook}>
|
||||
<Tooltip content={t`Webhook`} placement="top">
|
||||
<Symbol size={16} fill>
|
||||
cloud
|
||||
</Symbol>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
<Match when={props.message.isSuppressed}>
|
||||
<Tooltip content={t`Silent`} placement="top">
|
||||
<Symbol size={16} fill>
|
||||
notifications_off
|
||||
</Symbol>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
props.message.authorId &&
|
||||
dayjs().diff(decodeTime(props.message.authorId), "day") < 1
|
||||
}
|
||||
>
|
||||
<NewUser>
|
||||
<Tooltip content={t`New to Stoat`} placement="top">
|
||||
<Symbol size={16} fill>
|
||||
spa
|
||||
</Symbol>
|
||||
</Tooltip>
|
||||
</NewUser>
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
props.message.member &&
|
||||
dayjs().diff(props.message.member.joinedAt, "day") < 1
|
||||
}
|
||||
>
|
||||
<NewUser>
|
||||
<Tooltip content={t`New to the server`} placement="top">
|
||||
<Symbol size={16}>spa</Symbol>
|
||||
</Tooltip>
|
||||
</NewUser>
|
||||
</Match>
|
||||
{/* <Match when={props.message.authorId === "01EX2NCWQ0CHS3QJF0FEQS1GR4"}>
|
||||
<span />
|
||||
<span>placeholder · </span>
|
||||
</Match> */}
|
||||
</Switch>
|
||||
}
|
||||
compact={
|
||||
!!props.message.systemMessage ||
|
||||
state.settings.getValue("appearance:compact_mode")
|
||||
}
|
||||
infoMatch={
|
||||
<Match when={props.message.systemMessage}>
|
||||
<SystemMessageIcon
|
||||
systemMessage={props.message.systemMessage!}
|
||||
createdAt={props.message.createdAt}
|
||||
isServer={!!props.message.server}
|
||||
/>
|
||||
</Match>
|
||||
}
|
||||
>
|
||||
<Show when={props.message.systemMessage}>
|
||||
<SystemMessage
|
||||
systemMessage={props.message.systemMessage!}
|
||||
menuGenerator={(user) =>
|
||||
user
|
||||
? floatingUserMenus(
|
||||
user!,
|
||||
// TODO: try to fetch on demand member
|
||||
props.message.server?.getMember(user!.id),
|
||||
)
|
||||
: {}
|
||||
}
|
||||
isServer={!!props.message.server}
|
||||
/>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={props.editing}>
|
||||
<EditMessage message={props.message} />
|
||||
</Match>
|
||||
<Match when={props.message.content && !isOnlyGIF()}>
|
||||
<BreakText>
|
||||
<Markdown content={props.message.content!} />
|
||||
</BreakText>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={props.message.attachments}>
|
||||
<For each={props.message.attachments}>
|
||||
{(attachment) => (
|
||||
<Attachment message={props.message} file={attachment} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<Show when={props.message.embeds}>
|
||||
<For each={props.message.embeds}>
|
||||
{(embed) => <Embed embed={embed} />}
|
||||
</For>
|
||||
</Show>
|
||||
<Reactions
|
||||
reactions={props.message.reactions as never as Map<string, Set<string>>}
|
||||
interactions={props.message.interactions}
|
||||
userId={client().user!.id}
|
||||
addReaction={react}
|
||||
removeReaction={unreact}
|
||||
sendGIF={(content) =>
|
||||
props.message?.channel?.sendMessage({
|
||||
content,
|
||||
replies: [{ id: props.message.id, mention: true }],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</MessageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* New user indicator
|
||||
*/
|
||||
const NewUser = styled("div", {
|
||||
base: {
|
||||
fill: "var(--md-sys-color-primary)",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Avatar container
|
||||
*/
|
||||
const avatarContainer = cva({
|
||||
base: {
|
||||
height: "fit-content",
|
||||
borderRadius: "var(--borderRadius-circle)",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Break all text and prevent overflow from math blocks
|
||||
*/
|
||||
const BreakText = styled("div", {
|
||||
base: {
|
||||
wordBreak: "break-word",
|
||||
|
||||
"& .math": {
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
maxHeight: "100vh",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import {
|
||||
type JSX,
|
||||
createContext,
|
||||
createEffect,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
|
||||
import { Channel, Client, Message } from "stoat.js";
|
||||
|
||||
import { useClientLifecycle } from "@revolt/client";
|
||||
import { State } from "@revolt/client/Controller";
|
||||
|
||||
type ChannelState = {
|
||||
messages: Message[];
|
||||
atStart: boolean;
|
||||
atEnd: boolean;
|
||||
scrollTop?: number;
|
||||
};
|
||||
|
||||
const CacheContext = createContext<{
|
||||
manage(channel: Channel, state: ChannelState): void;
|
||||
unmanage(channel: Channel): ChannelState | void;
|
||||
// preload(channel: Channel): void; :: future optimisation feature
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Persistent message & channel state cache
|
||||
*/
|
||||
export function MessageCache(props: { client: Client; children: JSX.Element }) {
|
||||
const lifecycle = useClientLifecycle();
|
||||
const cache: Record<string, ChannelState> = {};
|
||||
|
||||
/**
|
||||
* Handle incoming messages
|
||||
* @param message Message object
|
||||
*/
|
||||
function onMessage(message: Message) {
|
||||
const entry = cache[message.channelId];
|
||||
if (entry?.atEnd) {
|
||||
entry.messages = [message, ...entry.messages].slice(0, 50);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle deleted messages
|
||||
*/
|
||||
function onMessageDelete(message: { id: string; channelId: string }) {
|
||||
const entry = cache[message.channelId];
|
||||
if (entry) {
|
||||
entry.messages = entry.messages.filter((msg) => msg.id !== message.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add listener for messages
|
||||
onMount(() => {
|
||||
props.client.addListener("messageCreate", onMessage);
|
||||
props.client.addListener("messageDelete", onMessageDelete);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
props.client.removeListener("messageCreate", onMessage);
|
||||
props.client.removeListener("messageDelete", onMessageDelete);
|
||||
});
|
||||
|
||||
// Clear cache when we reconnect
|
||||
createEffect(
|
||||
on(
|
||||
() => lifecycle.lifecycle.state(),
|
||||
(state) => {
|
||||
if (state === State.Connected) {
|
||||
for (const key of Object.keys(cache)) {
|
||||
delete cache[key];
|
||||
}
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<CacheContext.Provider
|
||||
value={{
|
||||
manage(channel, state) {
|
||||
cache[channel.id] = state;
|
||||
},
|
||||
unmanage(channel) {
|
||||
if (cache[channel.id]) {
|
||||
const state = cache[channel.id];
|
||||
delete cache[channel.id];
|
||||
return state;
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</CacheContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMessageCache() {
|
||||
return useContext(CacheContext);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,213 @@
|
|||
import { Match, Show, Switch, createSignal } from "solid-js";
|
||||
import { Motion, Presence } from "solid-motionone";
|
||||
|
||||
import { css } from "styled-system/css";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { useClientLifecycle } from "@revolt/client";
|
||||
import { State, TransitionType } from "@revolt/client/Controller";
|
||||
import { Button, Ripple, symbolSize, typography } from "@revolt/ui";
|
||||
|
||||
import MdBuild from "@material-symbols/svg-400/outlined/build.svg?component-solid";
|
||||
import MdClose from "@material-symbols/svg-400/outlined/close.svg?component-solid";
|
||||
import MdCollapseContent from "@material-symbols/svg-400/outlined/collapse_content.svg?component-solid";
|
||||
import MdExpandContent from "@material-symbols/svg-400/outlined/expand_content.svg?component-solid";
|
||||
import MdMinimize from "@material-symbols/svg-400/outlined/minimize.svg?component-solid";
|
||||
|
||||
import Wordmark from "../../../../public/assets/web/wordmark.svg?component-solid";
|
||||
import { pendingUpdate } from "../../../../src/serviceWorkerInterface";
|
||||
|
||||
export function Titlebar() {
|
||||
const [isMaximised, setIsMaximised] = createSignal(
|
||||
window.native ? window.desktopConfig.get().windowState.isMaximised : false,
|
||||
);
|
||||
const { lifecycle } = useClientLifecycle();
|
||||
|
||||
function isDisconnected() {
|
||||
return [
|
||||
State.Connecting,
|
||||
State.Disconnected,
|
||||
State.Reconnecting,
|
||||
State.Offline,
|
||||
].includes(lifecycle.state());
|
||||
}
|
||||
|
||||
function maximise() {
|
||||
window.native.maximise();
|
||||
setIsMaximised((t) => !t);
|
||||
}
|
||||
|
||||
return (
|
||||
<Presence>
|
||||
<Show
|
||||
when={
|
||||
(window.native && window.desktopConfig?.get().customFrame) ||
|
||||
isDisconnected()
|
||||
}
|
||||
>
|
||||
<Motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "29px" }}
|
||||
exit={{ height: 0 }}
|
||||
>
|
||||
<Base disconnected={isDisconnected()}>
|
||||
<Title
|
||||
style={{
|
||||
"-webkit-user-select": "none",
|
||||
"-webkit-app-region": "drag",
|
||||
}}
|
||||
>
|
||||
<Wordmark
|
||||
class={css({
|
||||
height: "18px",
|
||||
marginBlockStart: "1px",
|
||||
})}
|
||||
/>{" "}
|
||||
<Show when={import.meta.env.DEV}>
|
||||
<MdBuild {...symbolSize(16)} />
|
||||
</Show>
|
||||
</Title>
|
||||
<DragHandle
|
||||
style={{
|
||||
"-webkit-user-select": "none",
|
||||
"-webkit-app-region": "drag",
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={lifecycle.state() === State.Connecting}>
|
||||
Connecting
|
||||
</Match>
|
||||
{/* <Match when={lifecycle.state() === State.Connected}>Connected</Match> */}
|
||||
<Match when={lifecycle.state() === State.Disconnected}>
|
||||
Disconnected
|
||||
<a
|
||||
onClick={() =>
|
||||
lifecycle.transition({
|
||||
type: TransitionType.Retry,
|
||||
})
|
||||
}
|
||||
>
|
||||
<strong> (reconnect now)</strong>
|
||||
</a>
|
||||
</Match>
|
||||
<Match when={lifecycle.state() === State.Reconnecting}>
|
||||
Reconnecting
|
||||
</Match>
|
||||
<Match when={lifecycle.state() === State.Offline}>
|
||||
Device is offline
|
||||
<a
|
||||
onClick={() =>
|
||||
lifecycle.transition({
|
||||
type: TransitionType.Retry,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
"-webkit-app-region": "no-drag",
|
||||
}}
|
||||
>
|
||||
<strong> (reconnect now)</strong>
|
||||
</a>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={pendingUpdate()}>
|
||||
{" "}
|
||||
<div
|
||||
style={{
|
||||
"-webkit-app-region": "no-drag",
|
||||
}}
|
||||
>
|
||||
<Button size="sm" onPress={pendingUpdate()}>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</DragHandle>
|
||||
<Show when={window.native}>
|
||||
<Action onClick={window.native.minimise}>
|
||||
<Ripple />
|
||||
<MdMinimize {...symbolSize(20)} />
|
||||
</Action>
|
||||
<Action onClick={maximise}>
|
||||
<Ripple />
|
||||
<Show
|
||||
when={isMaximised()}
|
||||
fallback={<MdExpandContent {...symbolSize(20)} />}
|
||||
>
|
||||
<MdCollapseContent {...symbolSize(20)} />
|
||||
</Show>
|
||||
</Action>
|
||||
<Action onClick={window.native.close}>
|
||||
<Ripple />
|
||||
<MdClose {...symbolSize(20)} />
|
||||
</Action>
|
||||
</Show>
|
||||
</Base>
|
||||
</Motion.div>
|
||||
</Show>
|
||||
</Presence>
|
||||
);
|
||||
}
|
||||
|
||||
const Base = styled("div", {
|
||||
base: {
|
||||
flexShrink: 0,
|
||||
height: "29px",
|
||||
userSelect: "none",
|
||||
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
fill: "var(--md-sys-color-on-surface)",
|
||||
},
|
||||
variants: {
|
||||
disconnected: {
|
||||
true: {
|
||||
color: "var(--md-sys-color-on-primary-container)",
|
||||
background: "var(--md-sys-color-primary-container)",
|
||||
},
|
||||
false: {
|
||||
color: "var(--md-sys-color-outline)",
|
||||
background: "var(--md-sys-color-surface-container-high)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const Title = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
gap: "var(--gap-md)",
|
||||
alignItems: "center",
|
||||
paddingInlineStart: "var(--gap-md)",
|
||||
|
||||
color: "var(--md-sys-color-on-surface)",
|
||||
...typography.raw({ class: "title", size: "small" }),
|
||||
},
|
||||
});
|
||||
|
||||
const DragHandle = styled("div", {
|
||||
base: {
|
||||
flexGrow: 1,
|
||||
height: "100%",
|
||||
|
||||
display: "flex",
|
||||
gap: "var(--gap-md)",
|
||||
alignItems: "center",
|
||||
paddingInlineStart: "var(--gap-md)",
|
||||
|
||||
...typography.raw({ class: "label", size: "large" }),
|
||||
},
|
||||
});
|
||||
|
||||
const Action = styled("a", {
|
||||
base: {
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
|
||||
height: "100%",
|
||||
aspectRatio: "3/2",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import {
|
||||
BiRegularListUl,
|
||||
BiSolidCloud,
|
||||
BiSolidInfoCircle,
|
||||
BiSolidTrash,
|
||||
} from "solid-icons/bi";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
import { Channel } from "stoat.js";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { TextWithEmoji } from "@revolt/markdown";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { ColouredText } from "@revolt/ui";
|
||||
|
||||
import { SettingsConfiguration } from ".";
|
||||
import ChannelOverview from "./channel/Overview";
|
||||
import { ChannelPermissionsEditor } from "./channel/permissions/ChannelPermissionsEditor";
|
||||
import { ChannelPermissionsOverview } from "./channel/permissions/ChannelPermissionsOverview";
|
||||
import { ViewWebhook } from "./channel/webhooks/ViewWebhook";
|
||||
import { WebhooksList } from "./channel/webhooks/WebhooksList";
|
||||
|
||||
const Config: SettingsConfiguration<Channel> = {
|
||||
/**
|
||||
* Page titles
|
||||
*/
|
||||
title(ctx, key) {
|
||||
const client = useClient();
|
||||
const { t } = useLingui();
|
||||
|
||||
if (key.startsWith("webhooks/")) {
|
||||
const webhook = client().channelWebhooks.get(key.substring(9));
|
||||
if (webhook) return webhook.name;
|
||||
}
|
||||
|
||||
if (key.startsWith("permissions/")) {
|
||||
if (key === "permissions/default") return t`Default Permissions`;
|
||||
|
||||
return ctx.context.server?.roles.get(key.substring(12))?.name ?? "";
|
||||
}
|
||||
|
||||
return ctx.entries
|
||||
.flatMap((category) => category.entries)
|
||||
.find((entry) => entry.id === key)?.title as string;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the current channel settings page
|
||||
*/
|
||||
// we take care of the reactivity ourselves
|
||||
/* eslint-disable solid/components-return-once */
|
||||
render(props, channel) {
|
||||
const id = props.page();
|
||||
const client = useClient();
|
||||
|
||||
if (id?.startsWith("webhooks/")) {
|
||||
const webhook = client().channelWebhooks.get(id.substring(9));
|
||||
return <ViewWebhook webhook={webhook!} />;
|
||||
}
|
||||
|
||||
if (id?.startsWith("permissions/")) {
|
||||
if (id === "permissions/default") {
|
||||
return (
|
||||
<ChannelPermissionsEditor type="channel_default" context={channel} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChannelPermissionsEditor
|
||||
type="channel_role"
|
||||
context={channel}
|
||||
roleId={id.substring(12)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (id) {
|
||||
case "overview":
|
||||
return <ChannelOverview channel={channel} />;
|
||||
case "permissions":
|
||||
switch (channel.type) {
|
||||
case "Group":
|
||||
return <ChannelPermissionsEditor type="group" context={channel} />;
|
||||
case "TextChannel":
|
||||
return <ChannelPermissionsOverview context={channel} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
case "webhooks":
|
||||
return <WebhooksList channel={channel} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/* eslint-enable solid/components-return-once */
|
||||
|
||||
/**
|
||||
* Generate list of categories / entries for channel settings
|
||||
* @returns List
|
||||
*/
|
||||
list(channel) {
|
||||
const { openModal } = useModals();
|
||||
|
||||
return {
|
||||
context: channel,
|
||||
entries: [
|
||||
{
|
||||
title: <TextWithEmoji content={channel.name} />,
|
||||
entries: [
|
||||
{
|
||||
id: "overview",
|
||||
icon: <BiSolidInfoCircle size={20} />,
|
||||
title: <Trans>Overview</Trans>,
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
channel.type === "SavedMessages" ||
|
||||
!channel.havePermission("ManagePermissions"),
|
||||
id: "permissions",
|
||||
icon: <BiRegularListUl size={20} />,
|
||||
title: <Trans>Permissions</Trans>,
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
!channel.havePermission("ManageWebhooks") &&
|
||||
import.meta.env.DEV,
|
||||
id: "webhooks",
|
||||
icon: <BiSolidCloud size={20} />,
|
||||
title: <Trans>Webhooks</Trans>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
hidden: !(
|
||||
channel.type !== "Group" && channel.havePermission("ManageChannel")
|
||||
),
|
||||
entries: [
|
||||
{
|
||||
icon: (
|
||||
<BiSolidTrash size={20} color="var(--md-sys-color-error)" />
|
||||
),
|
||||
title: (
|
||||
<ColouredText colour="var(--md-sys-color-error)">
|
||||
<Trans>Delete Channel</Trans>
|
||||
</ColouredText>
|
||||
),
|
||||
onClick() {
|
||||
openModal({
|
||||
type: "delete_channel",
|
||||
channel,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default Config;
|
||||
|
||||
export type ChannelSettingsProps = {
|
||||
/**
|
||||
* Channel
|
||||
*/
|
||||
channel: Channel;
|
||||
};
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import {
|
||||
BiSolidEnvelope,
|
||||
BiSolidFlagAlt,
|
||||
BiSolidGroup,
|
||||
BiSolidHappyBeaming,
|
||||
BiSolidInfoCircle,
|
||||
BiSolidTrash,
|
||||
BiSolidUserX,
|
||||
} from "solid-icons/bi";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
import { Server } from "stoat.js";
|
||||
|
||||
import { useUser } from "@revolt/client";
|
||||
import { TextWithEmoji } from "@revolt/markdown";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { ColouredText } from "@revolt/ui";
|
||||
|
||||
import { SettingsConfiguration } from ".";
|
||||
import { ChannelPermissionsEditor } from "./channel/permissions/ChannelPermissionsEditor";
|
||||
import Overview from "./server/Overview";
|
||||
import { ListServerBans } from "./server/bans/ListBans";
|
||||
import { EmojiList } from "./server/emojis/EmojiList";
|
||||
import { ListServerInvites } from "./server/invites/ListServerInvites";
|
||||
import { ServerRoleEditor } from "./server/roles/ServerRoleEditor";
|
||||
import { ServerRoleOverview } from "./server/roles/ServerRoleOverview";
|
||||
|
||||
const Config: SettingsConfiguration<Server> = {
|
||||
/**
|
||||
* Page titles
|
||||
* @param key
|
||||
*/
|
||||
title(ctx, key) {
|
||||
const { t } = useLingui();
|
||||
|
||||
if (key.startsWith("roles/")) {
|
||||
if (key === "roles/default") return t`Default Permissions`;
|
||||
|
||||
return ctx.context.roles.get(key.substring(6))?.name ?? "";
|
||||
}
|
||||
|
||||
return ctx.entries
|
||||
.flatMap((category) => category.entries)
|
||||
.find((entry) => entry.id === key)?.title as string;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the current server settings page
|
||||
*/
|
||||
// we take care of the reactivity ourselves
|
||||
/* eslint-disable solid/components-return-once */
|
||||
render(props, server) {
|
||||
const id = props.page();
|
||||
|
||||
if (!server.$exists) {
|
||||
useModals().pop();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (id?.startsWith("roles/")) {
|
||||
if (id === "roles/default") {
|
||||
return (
|
||||
<ChannelPermissionsEditor type="server_default" context={server} />
|
||||
);
|
||||
}
|
||||
|
||||
return <ServerRoleEditor context={server} roleId={id.substring(6)} />;
|
||||
}
|
||||
|
||||
switch (id) {
|
||||
case "overview":
|
||||
return <Overview server={server} />;
|
||||
case "emojis":
|
||||
return <EmojiList server={server} />;
|
||||
case "roles":
|
||||
return <ServerRoleOverview context={server} />;
|
||||
case "invites":
|
||||
return <ListServerInvites server={server} />;
|
||||
case "bans":
|
||||
return <ListServerBans server={server} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/* eslint-enable solid/components-return-once */
|
||||
|
||||
/**
|
||||
* Generate list of categories / entries for server settings
|
||||
* @returns List
|
||||
*/
|
||||
list(server) {
|
||||
const user = useUser();
|
||||
const { openModal } = useModals();
|
||||
|
||||
return {
|
||||
context: server,
|
||||
entries: [
|
||||
{
|
||||
title: <TextWithEmoji content={server.name} />,
|
||||
entries: [
|
||||
{
|
||||
id: "overview",
|
||||
icon: <BiSolidInfoCircle size={20} />,
|
||||
title: <Trans>Overview</Trans>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
hidden: !server.havePermission("ManageCustomisation"),
|
||||
title: <Trans>Customisation</Trans>,
|
||||
entries: [
|
||||
{
|
||||
id: "emojis",
|
||||
icon: <BiSolidHappyBeaming size={20} />,
|
||||
title: <Trans>Emojis</Trans>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
!server.havePermission("ManageServer") &&
|
||||
!server.havePermission("BanMembers"),
|
||||
title: <Trans>User Management</Trans>,
|
||||
entries: [
|
||||
{
|
||||
hidden: true,
|
||||
id: "members",
|
||||
icon: <BiSolidGroup size={20} />,
|
||||
title: <Trans>Members</Trans>,
|
||||
},
|
||||
{
|
||||
hidden: !(
|
||||
server.havePermission("ManageRole") ||
|
||||
server.havePermission("ManagePermissions")
|
||||
),
|
||||
id: "roles",
|
||||
icon: <BiSolidFlagAlt size={20} />,
|
||||
title: <Trans>Roles</Trans>,
|
||||
},
|
||||
{
|
||||
hidden: !server.havePermission("ManageServer"),
|
||||
id: "invites",
|
||||
icon: <BiSolidEnvelope size={20} />,
|
||||
title: <Trans>Invites</Trans>,
|
||||
},
|
||||
{
|
||||
hidden: !server.havePermission("BanMembers"),
|
||||
id: "bans",
|
||||
icon: <BiSolidUserX size={20} />,
|
||||
title: <Trans>Bans</Trans>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
hidden: !(server.ownerId === user()?.id),
|
||||
entries: [
|
||||
{
|
||||
icon: (
|
||||
<BiSolidTrash size={20} color="var(--md-sys-color-error)" />
|
||||
),
|
||||
title: (
|
||||
<ColouredText colour="var(--md-sys-color-error)">
|
||||
<Trans>Delete Server</Trans>
|
||||
</ColouredText>
|
||||
),
|
||||
/**
|
||||
* Handle server deletion request
|
||||
*/
|
||||
onClick() {
|
||||
openModal({
|
||||
type: "delete_server",
|
||||
server,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default Config;
|
||||
|
||||
export type ServerSettingsProps = {
|
||||
/**
|
||||
* Server
|
||||
*/
|
||||
server: Server;
|
||||
};
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import {
|
||||
type JSX,
|
||||
Accessor,
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
untrack,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import { Motion, Presence } from "solid-motionone";
|
||||
|
||||
import { Rerun } from "@solid-primitives/keyed";
|
||||
|
||||
import { SettingsConfiguration, SettingsEntry, SettingsList } from ".";
|
||||
import { SettingsContent } from "./_layout/Content";
|
||||
import { SettingsSidebar } from "./_layout/Sidebar";
|
||||
|
||||
export interface SettingsProps {
|
||||
/**
|
||||
* Close settings
|
||||
*/
|
||||
onClose?: () => void;
|
||||
|
||||
/**
|
||||
* Settings context
|
||||
*/
|
||||
context: never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition animation
|
||||
*/
|
||||
export type SettingsTransition = "normal" | "to-child" | "to-parent";
|
||||
|
||||
/**
|
||||
* Provide navigation to child components
|
||||
*/
|
||||
const SettingsNavigationContext = createContext<{
|
||||
page: Accessor<string | undefined>;
|
||||
navigate: (path: string | SettingsEntry) => void;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Generic Settings component
|
||||
*/
|
||||
export function Settings(props: SettingsProps & SettingsConfiguration<never>) {
|
||||
const [page, setPage] = createSignal<undefined | string>(
|
||||
// eslint-disable-next-line
|
||||
(props.context as any)?.page,
|
||||
);
|
||||
const [transition, setTransition] =
|
||||
createSignal<SettingsTransition>("normal");
|
||||
|
||||
/**
|
||||
* Navigate to a certain page
|
||||
*/
|
||||
function navigate(entry: string | SettingsEntry) {
|
||||
let id;
|
||||
if (typeof entry === "object") {
|
||||
if (entry.onClick) {
|
||||
entry.onClick();
|
||||
} else if (entry.href) {
|
||||
window.open(entry.href, "_blank");
|
||||
} else if (entry.id) {
|
||||
id = entry.id;
|
||||
}
|
||||
} else {
|
||||
id = entry;
|
||||
}
|
||||
|
||||
if (!id) return;
|
||||
|
||||
const current = page();
|
||||
if (current?.startsWith(id)) {
|
||||
setTransition("to-parent");
|
||||
} else if (current && id.startsWith(current)) {
|
||||
setTransition("to-child");
|
||||
} else {
|
||||
setTransition("normal");
|
||||
}
|
||||
|
||||
setPage(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsNavigationContext.Provider
|
||||
value={{
|
||||
page,
|
||||
navigate,
|
||||
}}
|
||||
>
|
||||
<MemoisedList context={props.context} list={props.list}>
|
||||
{(list) => (
|
||||
<>
|
||||
<SettingsSidebar list={list} page={page} setPage={setPage} />
|
||||
<SettingsContent
|
||||
page={page}
|
||||
list={list}
|
||||
title={props.title}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<Presence exitBeforeEnter>
|
||||
<Rerun on={page}>
|
||||
<Motion.div
|
||||
style={
|
||||
untrack(transition) === "normal"
|
||||
? {}
|
||||
: { visibility: "hidden" }
|
||||
}
|
||||
ref={(el) =>
|
||||
untrack(transition) !== "normal" &&
|
||||
setTimeout(() => (el.style.visibility = "visible"), 250)
|
||||
}
|
||||
initial={
|
||||
transition() === "normal"
|
||||
? { opacity: 0, y: 50 }
|
||||
: transition() === "to-child"
|
||||
? {
|
||||
x: "100vw",
|
||||
}
|
||||
: { x: "-100vw" }
|
||||
}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}}
|
||||
exit={
|
||||
transition() === "normal"
|
||||
? undefined
|
||||
: transition() === "to-child"
|
||||
? {
|
||||
x: "-100vw",
|
||||
}
|
||||
: { x: "100vw" }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
easing: [0.17, 0.67, 0.58, 0.98],
|
||||
}}
|
||||
>
|
||||
{props.render({ page }, props.context)}
|
||||
</Motion.div>
|
||||
</Rerun>
|
||||
</Presence>
|
||||
</SettingsContent>
|
||||
</>
|
||||
)}
|
||||
</MemoisedList>
|
||||
</SettingsNavigationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoise the list but generate it within context
|
||||
*/
|
||||
function MemoisedList(props: {
|
||||
context: never;
|
||||
list: (context: never) => SettingsList<unknown>;
|
||||
children: (list: Accessor<SettingsList<unknown>>) => JSX.Element;
|
||||
}) {
|
||||
/**
|
||||
* Generate list of categories / links
|
||||
*/
|
||||
const list = createMemo(() => props.list(props.context));
|
||||
|
||||
return <>{props.children(list)}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use settings navigation context
|
||||
*/
|
||||
export const useSettingsNavigation = () =>
|
||||
useContext(SettingsNavigationContext)!;
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
import { Show } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { Server } from "stoat.js";
|
||||
import { css } from "styled-system/css";
|
||||
|
||||
import { useClient, useClientLifecycle } from "@revolt/client";
|
||||
import { useUser } from "@revolt/markdown/users";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { ColouredText, Column, Text, iconSize } from "@revolt/ui";
|
||||
import { Symbol } from "@revolt/ui/components/utils/Symbol";
|
||||
|
||||
import MdAccountCircle from "@material-design-icons/svg/outlined/account_circle.svg?component-solid";
|
||||
import MdCoffee from "@material-design-icons/svg/outlined/coffee.svg?component-solid";
|
||||
import MdLanguage from "@material-design-icons/svg/outlined/language.svg?component-solid";
|
||||
import MdLogout from "@material-design-icons/svg/outlined/logout.svg?component-solid";
|
||||
import MdMemory from "@material-design-icons/svg/outlined/memory.svg?component-solid";
|
||||
import MdMic from "@material-design-icons/svg/outlined/mic.svg?component-solid";
|
||||
import MdPalette from "@material-design-icons/svg/outlined/palette.svg?component-solid";
|
||||
import MdRateReview from "@material-design-icons/svg/outlined/rate_review.svg?component-solid";
|
||||
import MdScience from "@material-design-icons/svg/outlined/science.svg?component-solid";
|
||||
import MdSmartToy from "@material-design-icons/svg/outlined/smart_toy.svg?component-solid";
|
||||
import MdVerifiedUser from "@material-design-icons/svg/outlined/verified_user.svg?component-solid";
|
||||
import MdWorkspacePremium from "@material-design-icons/svg/outlined/workspace_premium.svg?component-solid";
|
||||
|
||||
import pkg from "../../../../package.json";
|
||||
|
||||
import { SettingsConfiguration } from ".";
|
||||
import { MyAccount } from "./user/Account";
|
||||
import AdvancedSettings from "./user/Advanced";
|
||||
import { Feedback } from "./user/Feedback";
|
||||
import { LanguageSettings } from "./user/Language";
|
||||
import Native from "./user/Native";
|
||||
import { Sessions } from "./user/Sessions";
|
||||
import { AccountCard } from "./user/_AccountCard";
|
||||
import { AppearanceMenu } from "./user/appearance";
|
||||
import { MyBots, ViewBot } from "./user/bots";
|
||||
import { EditProfile } from "./user/profile";
|
||||
import { EditSubscription } from "./user/subscriptions";
|
||||
import { VoiceSettings } from "./user/voice/VoiceSettings";
|
||||
|
||||
const Config: SettingsConfiguration<{ server: Server }> = {
|
||||
/**
|
||||
* Page titles
|
||||
* @param key
|
||||
*/
|
||||
title(ctx, key) {
|
||||
if (key.startsWith("bots/")) {
|
||||
const user = useUser(key.substring(5));
|
||||
return user()!.username;
|
||||
}
|
||||
|
||||
return ctx.entries
|
||||
.flatMap((category) => category.entries)
|
||||
.find((entry) => entry.id === key)?.title as string;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the current client settings page
|
||||
*/
|
||||
// we take care of the reactivity ourselves
|
||||
/* eslint-disable solid/reactivity */
|
||||
/* eslint-disable solid/components-return-once */
|
||||
render(props) {
|
||||
const id = props.page();
|
||||
const client = useClient();
|
||||
|
||||
if (id?.startsWith("bots/")) {
|
||||
const bot = client().bots.get(id.substring("bots/".length))!;
|
||||
return <ViewBot bot={bot!} />;
|
||||
}
|
||||
|
||||
switch (id) {
|
||||
case "account":
|
||||
return <MyAccount />;
|
||||
case "appearance":
|
||||
return <AppearanceMenu />;
|
||||
case "advanced":
|
||||
return <AdvancedSettings />;
|
||||
case "profile":
|
||||
return <EditProfile />;
|
||||
case "sessions":
|
||||
return <Sessions />;
|
||||
case "bots":
|
||||
return <MyBots />;
|
||||
case "language":
|
||||
return <LanguageSettings />;
|
||||
case "feedback":
|
||||
return <Feedback />;
|
||||
case "subscribe":
|
||||
return <EditSubscription />;
|
||||
case "native":
|
||||
return <Native />;
|
||||
case "voice":
|
||||
return <VoiceSettings />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/* eslint-enable solid/reactivity */
|
||||
/* eslint-enable solid/components-return-once */
|
||||
|
||||
/**
|
||||
* Generate list of categories / entries for client settings
|
||||
* @returns List
|
||||
*/
|
||||
list() {
|
||||
const { pop } = useModals();
|
||||
const { logout } = useClientLifecycle();
|
||||
|
||||
return {
|
||||
context: null!,
|
||||
prepend: (
|
||||
<Column gap="s">
|
||||
<AccountCard />
|
||||
<div />
|
||||
</Column>
|
||||
),
|
||||
append: (
|
||||
<Column gap="none">
|
||||
<Text class="label">
|
||||
<span class={css({ userSelect: "none", fontWeight: "bold" })}>
|
||||
<Trans>Version:</Trans>
|
||||
</span>{" "}
|
||||
<span class={css({ userSelect: "all" })}>
|
||||
{pkg.version} ({pkg["version-date"]})
|
||||
</span>
|
||||
</Text>
|
||||
<Show when={window.native}>
|
||||
<Text class="label">
|
||||
Stoat for Desktop {window.native.versions.desktop()}
|
||||
</Text>
|
||||
<Text class="label">
|
||||
<span
|
||||
class={css({
|
||||
fontSize: "0.8em",
|
||||
lineHeight: "0.8em",
|
||||
opacity: "0.5",
|
||||
})}
|
||||
>
|
||||
{window.native.versions.electron()},{" "}
|
||||
{window.native.versions.node()},{" "}
|
||||
{window.native.versions.chrome()}
|
||||
</span>
|
||||
</Text>
|
||||
</Show>
|
||||
</Column>
|
||||
),
|
||||
entries: [
|
||||
{
|
||||
title: <Trans>User Settings</Trans>,
|
||||
entries: [
|
||||
{
|
||||
id: "account",
|
||||
icon: <></>,
|
||||
title: <></>,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
icon: <MdAccountCircle {...iconSize(20)} />,
|
||||
title: <Trans>Profile</Trans>,
|
||||
},
|
||||
{
|
||||
id: "sessions",
|
||||
icon: <MdVerifiedUser {...iconSize(20)} />,
|
||||
title: <Trans>Sessions</Trans>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Stoat",
|
||||
entries: [
|
||||
{
|
||||
id: "bots",
|
||||
icon: <MdSmartToy {...iconSize(20)} />,
|
||||
title: <Trans>My Bots</Trans>,
|
||||
},
|
||||
{
|
||||
id: "feedback",
|
||||
icon: <MdRateReview {...iconSize(20)} />,
|
||||
title: <Trans>Feedback</Trans>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: <Trans>Subscriptions</Trans>,
|
||||
hidden: import.meta.env.PROD,
|
||||
entries: [
|
||||
{
|
||||
id: "subscribe",
|
||||
icon: <MdWorkspacePremium {...iconSize(20)} />,
|
||||
title: "[premium]",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: <Trans>Client Settings</Trans>,
|
||||
entries: [
|
||||
// {
|
||||
// id: "audio",
|
||||
// icon: <MdSpeaker {...iconSize(20)} />,
|
||||
// title: t("app.settings.pages.audio.title"),
|
||||
// hidden:
|
||||
// !getController("state").experiments.isEnabled("voice_chat"),
|
||||
// },
|
||||
{
|
||||
id: "voice",
|
||||
icon: <MdMic {...iconSize(20)} />,
|
||||
title: <Trans>Voice</Trans>,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
icon: <MdPalette {...iconSize(20)} />,
|
||||
title: <Trans>Appearance</Trans>,
|
||||
},
|
||||
// {
|
||||
// id: "accessibility",
|
||||
// icon: <MdAccessibility {...iconSize(20)} />,
|
||||
// title: t("app.settings.pages.accessibility.title"),
|
||||
// },
|
||||
// {
|
||||
// id: "plugins",
|
||||
// icon: <MdExtension {...iconSize(20)} />,
|
||||
// title: t("app.settings.pages.plugins.title"),
|
||||
// hidden: !getController("state").experiments.isEnabled("plugins"),
|
||||
// },
|
||||
// {
|
||||
// id: "notifications",
|
||||
// icon: <MdNotifications {...iconSize(20)} />,
|
||||
// title: t("app.settings.pages.notifications.title"),
|
||||
// },
|
||||
// {
|
||||
// id: "keybinds",
|
||||
// icon: <MdKeybinds {...iconSize(20)} />,
|
||||
// title: t("app.settings.pages.keybinds.title"),
|
||||
// },
|
||||
{
|
||||
id: "language",
|
||||
icon: <MdLanguage {...iconSize(20)} />,
|
||||
title: <Trans>Language</Trans>,
|
||||
},
|
||||
// {
|
||||
// id: "sync",
|
||||
// icon: <MdSync {...iconSize(20)} />,
|
||||
// title: t("app.settings.pages.sync.title"),
|
||||
// },
|
||||
{
|
||||
id: "native",
|
||||
hidden: !window.native,
|
||||
icon: <Symbol size={20}>desktop_windows</Symbol>,
|
||||
title: <Trans>Desktop</Trans>,
|
||||
},
|
||||
// {
|
||||
// id: "experiments",
|
||||
// icon: <MdScience {...iconSize(20)} />,
|
||||
// title: <Trans>Experiments</Trans>,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
entries: [
|
||||
// {
|
||||
// onClick: () =>
|
||||
// getController("modal").push({ type: "changelog", posts: [] }),
|
||||
// icon: <MdFormatListBulleted {...iconSize(20)} />,
|
||||
// title: t("app.special.modals.changelogs.title"),
|
||||
// },
|
||||
{
|
||||
href: "https://github.com/stoatchat",
|
||||
icon: <MdMemory {...iconSize(20)} />,
|
||||
title: <Trans>Source Code</Trans>,
|
||||
},
|
||||
{
|
||||
id: "advanced",
|
||||
icon: <MdScience {...iconSize(20)} />,
|
||||
title: <Trans>Advanced</Trans>,
|
||||
},
|
||||
{
|
||||
href: "https://ko-fi.com/stoatchat",
|
||||
icon: <MdCoffee {...iconSize(20)} />,
|
||||
title: <Trans>Donate</Trans>,
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
icon: (
|
||||
<MdLogout {...iconSize(20)} fill="var(--md-sys-color-error)" />
|
||||
),
|
||||
title: (
|
||||
<ColouredText colour="var(--md-sys-color-error)">
|
||||
<Trans>Log Out</Trans>
|
||||
</ColouredText>
|
||||
),
|
||||
onClick() {
|
||||
pop();
|
||||
logout();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default Config;
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { Accessor, JSX, Show } from "solid-js";
|
||||
|
||||
import { css, cva } from "styled-system/css";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { Breadcrumbs, IconButton, Text } from "@revolt/ui";
|
||||
|
||||
import MdClose from "@material-design-icons/svg/outlined/close.svg?component-solid";
|
||||
|
||||
import { SettingsList } from "..";
|
||||
import { useSettingsNavigation } from "../Settings";
|
||||
|
||||
/**
|
||||
* Content portion of the settings menu
|
||||
*/
|
||||
export function SettingsContent(props: {
|
||||
onClose?: () => void;
|
||||
children: JSX.Element;
|
||||
list: Accessor<SettingsList<unknown>>;
|
||||
title: (ctx: SettingsList<never>, key: string) => string;
|
||||
page: Accessor<string | undefined>;
|
||||
}) {
|
||||
const { navigate } = useSettingsNavigation();
|
||||
|
||||
return (
|
||||
<div
|
||||
use:scrollable={{
|
||||
class: base(),
|
||||
}}
|
||||
>
|
||||
<Show when={props.page()}>
|
||||
<InnerContent>
|
||||
<InnerColumn>
|
||||
<Text class="title" size="large">
|
||||
<Breadcrumbs
|
||||
elements={props.page()!.split("/")}
|
||||
renderElement={(key) =>
|
||||
props.title(props.list() as SettingsList<never>, key)
|
||||
}
|
||||
navigate={(keys) => navigate(keys.join("/"))}
|
||||
/>
|
||||
</Text>
|
||||
{props.children}
|
||||
<div class={css({ minHeight: "80px" })} />
|
||||
</InnerColumn>
|
||||
</InnerContent>
|
||||
</Show>
|
||||
<Show when={props.onClose}>
|
||||
<CloseAction>
|
||||
<IconButton variant="tonal" onPress={props.onClose}>
|
||||
<MdClose />
|
||||
</IconButton>
|
||||
</CloseAction>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base styles
|
||||
*/
|
||||
const base = cva({
|
||||
base: {
|
||||
minWidth: 0,
|
||||
flex: "1 1 800px",
|
||||
flexDirection: "row",
|
||||
display: "flex",
|
||||
background: "var(--md-sys-color-surface-container-low)",
|
||||
borderStartStartRadius: "30px",
|
||||
borderEndStartRadius: "30px",
|
||||
|
||||
"& > a": {
|
||||
textDecoration: "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Settings pane
|
||||
*/
|
||||
const InnerContent = styled("div", {
|
||||
base: {
|
||||
gap: "13px",
|
||||
minWidth: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
maxWidth: "740px",
|
||||
padding: "80px 32px",
|
||||
justifyContent: "stretch",
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pane content column
|
||||
*/
|
||||
const InnerColumn = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
gap: "var(--gap-md)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
marginBlockEnd: "80px",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Positioning for close button
|
||||
*/
|
||||
const CloseAction = styled("div", {
|
||||
base: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 0,
|
||||
padding: "80px 8px",
|
||||
visibility: "visible",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
|
||||
"&:after": {
|
||||
content: '"ESC"',
|
||||
marginTop: "4px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
width: "36px",
|
||||
fontWeight: 600,
|
||||
color: "var(--md-sys-color-on-surface)",
|
||||
fontSize: "0.75rem",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { Accessor, For, Setter, Show, onMount } from "solid-js";
|
||||
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { Column, OverflowingText, Ripple } from "@revolt/ui";
|
||||
|
||||
// import MdError from "@material-design-icons/svg/filled/error.svg?component-solid";
|
||||
// import MdOpenInNew from "@material-design-icons/svg/filled/open_in_new.svg?component-solid";
|
||||
import { SettingsList } from "..";
|
||||
import { useSettingsNavigation } from "../Settings";
|
||||
|
||||
import {
|
||||
SidebarButton,
|
||||
SidebarButtonContent,
|
||||
SidebarButtonTitle,
|
||||
} from "./SidebarButton";
|
||||
|
||||
/**
|
||||
* Settings Sidebar Layout
|
||||
*/
|
||||
export function SettingsSidebar(props: {
|
||||
list: Accessor<SettingsList<unknown>>;
|
||||
|
||||
setPage: Setter<string | undefined>;
|
||||
page: Accessor<string | undefined>;
|
||||
}) {
|
||||
const { navigate } = useSettingsNavigation();
|
||||
|
||||
/**
|
||||
* Select first page on load
|
||||
*/
|
||||
onMount(() => {
|
||||
if (!props.page()) {
|
||||
props.setPage(props.list().entries[0].entries[0].id);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Base>
|
||||
<div use:invisibleScrollable>
|
||||
<Content>
|
||||
<Column gap="lg">
|
||||
{props.list().prepend}
|
||||
<For each={props.list().entries}>
|
||||
{(category) => (
|
||||
<Show when={!category.hidden}>
|
||||
<Column>
|
||||
<Show when={category.title}>
|
||||
<CategoryTitle>{category.title}</CategoryTitle>
|
||||
</Show>
|
||||
<Column gap="s">
|
||||
<For each={category.entries}>
|
||||
{(entry) => (
|
||||
<Show when={!entry.hidden}>
|
||||
<SidebarButton
|
||||
onClick={() => navigate(entry)}
|
||||
aria-selected={
|
||||
props.page()?.split("/")[0] ===
|
||||
entry.id?.split("/")[0]
|
||||
}
|
||||
>
|
||||
<Ripple />
|
||||
<SidebarButtonTitle>
|
||||
{entry.icon}
|
||||
<SidebarButtonContent>
|
||||
<OverflowingText>
|
||||
{entry.title}
|
||||
</OverflowingText>
|
||||
</SidebarButtonContent>
|
||||
</SidebarButtonTitle>
|
||||
{/*<SidebarButtonIcon>
|
||||
<MdOpenInNew
|
||||
{...iconSize(20)}
|
||||
fill={theme!.colour("primary")}
|
||||
/>
|
||||
<MdError
|
||||
{...iconSize(20)}
|
||||
fill={theme!.colour("primary")}
|
||||
/>
|
||||
</SidebarButtonIcon>*/}
|
||||
</SidebarButton>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</Column>
|
||||
</Column>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
{props.list().append}
|
||||
</Column>
|
||||
</Content>
|
||||
</div>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base layout of the sidebar
|
||||
*/
|
||||
const Base = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
flex: "1 0 218px",
|
||||
paddingLeft: "8px",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Aligned content within the sidebar
|
||||
*/
|
||||
const Content = styled("div", {
|
||||
base: {
|
||||
minWidth: "230px",
|
||||
maxWidth: "300px",
|
||||
padding: "74px 0 8px",
|
||||
display: "flex",
|
||||
gap: "2px",
|
||||
|
||||
flexDirection: "column",
|
||||
|
||||
"& a > div": {
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Titles for each category
|
||||
*/
|
||||
const CategoryTitle = styled("span", {
|
||||
base: {
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
|
||||
textTransform: "uppercase",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 700,
|
||||
margin: "0 8px",
|
||||
marginInlineEnd: "20px",
|
||||
|
||||
color: "var(--md-sys-color-outline)",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { styled } from "styled-system/jsx";
|
||||
|
||||
/**
|
||||
* Sidebar button
|
||||
*/
|
||||
export const SidebarButton = styled("a", {
|
||||
base: {
|
||||
// for <Ripple />:
|
||||
position: "relative",
|
||||
|
||||
minWidth: 0,
|
||||
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "8px",
|
||||
fontWeight: 500,
|
||||
marginInlineEnd: "12px",
|
||||
fontSize: "15px",
|
||||
userSelect: "none",
|
||||
transition: "background-color 0.1s ease-in-out",
|
||||
color: "var(--md-sys-color-on-surface)",
|
||||
fill: "var(--md-sys-color-on-surface)",
|
||||
background: "unset",
|
||||
|
||||
"& svg": {
|
||||
flexShrink: 0,
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
"aria-selected": {
|
||||
true: {
|
||||
background: "var(--md-sys-color-primary-container)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const SidebarButtonTitle = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
paddingInlineEnd: "8px",
|
||||
},
|
||||
});
|
||||
|
||||
export const SidebarButtonContent = styled("div", {
|
||||
base: {
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
});
|
||||
|
||||
export const SidebarButtonIcon = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
gap: "2px",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { createFormControl, createFormGroup } from "solid-forms";
|
||||
import { Match, Show, Switch } from "solid-js";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
import type { API } from "stoat.js";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { CONFIGURATION } from "@revolt/common";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { Button, CircularProgress, Column, Form2, Row, Text } from "@revolt/ui";
|
||||
|
||||
import { ChannelSettingsProps } from "../ChannelSettings";
|
||||
|
||||
/**
|
||||
* Channel overview
|
||||
*/
|
||||
export default function ChannelOverview(props: ChannelSettingsProps) {
|
||||
const { t } = useLingui();
|
||||
const client = useClient();
|
||||
const { openModal } = useModals();
|
||||
|
||||
/* eslint-disable solid/reactivity */
|
||||
// we want to take the initial value only
|
||||
const editGroup = createFormGroup({
|
||||
name: createFormControl(props.channel.name),
|
||||
description: createFormControl(props.channel.description || ""),
|
||||
icon: createFormControl<string | File[] | null>(
|
||||
props.channel.animatedIconURL,
|
||||
),
|
||||
});
|
||||
/* eslint-enable solid/reactivity */
|
||||
|
||||
function onReset() {
|
||||
editGroup.controls.name.setValue(props.channel.name);
|
||||
editGroup.controls.description.setValue(props.channel.description || "");
|
||||
editGroup.controls.icon.setValue(props.channel.animatedIconURL ?? null);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const changes: API.DataEditChannel = {
|
||||
remove: [],
|
||||
};
|
||||
|
||||
if (editGroup.controls.name.isDirty) {
|
||||
changes.name = editGroup.controls.name.value.trim();
|
||||
}
|
||||
|
||||
if (editGroup.controls.description.isDirty) {
|
||||
const description = editGroup.controls.description.value.trim();
|
||||
|
||||
if (description) {
|
||||
changes.description = description;
|
||||
} else {
|
||||
changes.remove!.push("Description");
|
||||
}
|
||||
}
|
||||
|
||||
if (editGroup.controls.icon.isDirty) {
|
||||
if (!editGroup.controls.icon.value) {
|
||||
changes.remove!.push("Icon");
|
||||
} else if (Array.isArray(editGroup.controls.icon.value)) {
|
||||
const body = new FormData();
|
||||
body.append("file", editGroup.controls.icon.value[0]);
|
||||
|
||||
const [key, value] = client().authenticationHeader;
|
||||
const data: { id: string } = await fetch(
|
||||
`${CONFIGURATION.DEFAULT_MEDIA_URL}/icons`,
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
headers: {
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
).then((res) => res.json());
|
||||
|
||||
changes.icon = data.id;
|
||||
}
|
||||
}
|
||||
|
||||
await props.channel.edit(changes);
|
||||
}
|
||||
|
||||
const submit = Form2.useSubmitHandler(editGroup, onSubmit, onReset);
|
||||
|
||||
return (
|
||||
<Column gap="xl">
|
||||
<form onSubmit={submit}>
|
||||
<Column>
|
||||
<Text class="label">
|
||||
<Trans>Channel Info</Trans>
|
||||
</Text>
|
||||
<Form2.FileInput control={editGroup.controls.icon} accept="image/*" />
|
||||
<Form2.TextField
|
||||
name="name"
|
||||
control={editGroup.controls.name}
|
||||
label={t`Channel Name`}
|
||||
/>
|
||||
<Form2.TextField
|
||||
autosize
|
||||
min-rows={2}
|
||||
name="description"
|
||||
control={editGroup.controls.description}
|
||||
label={t`Channel Description`}
|
||||
placeholder={t`This channel is about...`}
|
||||
/>
|
||||
<Row>
|
||||
<Form2.Reset group={editGroup} onReset={onReset} />
|
||||
<Form2.Submit group={editGroup} requireDirty>
|
||||
<Trans>Save</Trans>
|
||||
</Form2.Submit>
|
||||
<Show when={editGroup.isPending}>
|
||||
<CircularProgress />
|
||||
</Show>
|
||||
</Row>
|
||||
</Column>
|
||||
</form>
|
||||
<Column>
|
||||
<Text class="label">
|
||||
<Trans>Mark as Mature</Trans>
|
||||
</Text>
|
||||
<Text>
|
||||
<Trans>
|
||||
Users will be asked to confirm their age before opening this
|
||||
channel.
|
||||
</Trans>
|
||||
</Text>
|
||||
<div>
|
||||
<Button
|
||||
onPress={() =>
|
||||
openModal({
|
||||
type: "channel_toggle_mature",
|
||||
channel: props.channel,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Switch fallback={<Trans>Mark as Mature</Trans>}>
|
||||
<Match when={props.channel.mature}>
|
||||
<Trans>Unmark as Mature</Trans>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Button>
|
||||
</div>
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,580 @@
|
|||
import { For, Match, Show, Switch, createSignal } from "solid-js";
|
||||
|
||||
import { useLingui } from "@lingui-solid/solid/macro";
|
||||
import {
|
||||
API,
|
||||
Channel,
|
||||
DEFAULT_PERMISSION_DIRECT_MESSAGE,
|
||||
Server,
|
||||
} from "stoat.js";
|
||||
import { css } from "styled-system/css";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { Button, Checkbox2, OverrideSwitch, Row, Text } from "@revolt/ui";
|
||||
|
||||
type Props =
|
||||
| { type: "server_default"; context: Server }
|
||||
| { type: "server_role"; context: Server; roleId: string }
|
||||
| { type: "channel_default"; context: Channel }
|
||||
| { type: "channel_role"; context: Channel; roleId: string }
|
||||
| { type: "group"; context: Channel };
|
||||
|
||||
type Context = API.Channel["channel_type"] | "Server";
|
||||
|
||||
/**
|
||||
* Generic editor for any channel permissions
|
||||
*/
|
||||
export function ChannelPermissionsEditor(props: Props) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const context: Context =
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
props.context instanceof Server ? "Server" : props.context.type;
|
||||
|
||||
/**
|
||||
* Current permission value, normalised to [allow, deny]
|
||||
* @returns [allow, deny] BigInts
|
||||
*/
|
||||
function currentValue() {
|
||||
switch (props.type) {
|
||||
case "server_default":
|
||||
return [BigInt(props.context.defaultPermissions), BigInt(0)];
|
||||
case "server_role":
|
||||
return [
|
||||
BigInt(props.context.roles?.get(props.roleId)?.permissions.a || 0),
|
||||
BigInt(props.context.roles?.get(props.roleId)?.permissions.d || 0),
|
||||
];
|
||||
case "channel_default":
|
||||
return [
|
||||
BigInt(props.context.defaultPermissions?.a || 0),
|
||||
BigInt(props.context.defaultPermissions?.d || 0),
|
||||
];
|
||||
case "channel_role":
|
||||
return [
|
||||
BigInt(props.context.rolePermissions?.[props.roleId]?.a || 0),
|
||||
BigInt(props.context.rolePermissions?.[props.roleId]?.d || 0),
|
||||
];
|
||||
case "group":
|
||||
return [
|
||||
BigInt(
|
||||
props.context.permissions ?? DEFAULT_PERMISSION_DIRECT_MESSAGE,
|
||||
),
|
||||
BigInt(0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current edited values
|
||||
*/
|
||||
const [value, setValue] = createSignal(currentValue());
|
||||
|
||||
/**
|
||||
* Whether there is a pending save
|
||||
*/
|
||||
function unsavedChanges() {
|
||||
const [a1, a2] = currentValue(),
|
||||
[b1, b2] = value();
|
||||
|
||||
return a1 !== b1 || a2 !== b2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to the current value
|
||||
*/
|
||||
function reset() {
|
||||
setValue(currentValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit changes
|
||||
* @todo mutator
|
||||
*/
|
||||
function save() {
|
||||
switch (props.type) {
|
||||
case "server_default":
|
||||
props.context.setPermissions(undefined, Number(value()[0]));
|
||||
break;
|
||||
case "server_role":
|
||||
props.context.setPermissions(props.roleId, {
|
||||
allow: Number(value()[0]),
|
||||
deny: Number(value()[1]),
|
||||
});
|
||||
break;
|
||||
case "channel_default":
|
||||
props.context.setPermissions(undefined, {
|
||||
allow: Number(value()[0]),
|
||||
deny: Number(value()[1]),
|
||||
});
|
||||
break;
|
||||
case "channel_role":
|
||||
props.context.setPermissions(props.roleId, {
|
||||
allow: Number(value()[0]),
|
||||
deny: Number(value()[1]),
|
||||
});
|
||||
break;
|
||||
case "group":
|
||||
props.context.setPermissions(undefined, Number(value()[0]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const Permissions: {
|
||||
heading?: string;
|
||||
key: string;
|
||||
value: bigint;
|
||||
title: string;
|
||||
description: Partial<Record<Context | "Any", string>>;
|
||||
}[] = [
|
||||
{
|
||||
heading: t`Admin`,
|
||||
key: "ManageChannel",
|
||||
value: 1n ** 0n,
|
||||
title: t`Manage Channel`,
|
||||
description: {
|
||||
Group: t`Edit group name and description`,
|
||||
Any: t`Edit and delete channel`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ManageServer",
|
||||
value: 2n ** 1n,
|
||||
title: t`Manage Server`,
|
||||
description: {
|
||||
Server: t`Edit the server's information and settings`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ManagePermissions",
|
||||
value: 2n ** 2n,
|
||||
title: t`Manage Permissions`,
|
||||
description: {
|
||||
Group: t`Whether other users can edit these settings`,
|
||||
TextChannel: t`Edit channel-specific role and default permissions`,
|
||||
Server: t`Edit any permissions on the server`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ManageRole",
|
||||
value: 2n ** 3n,
|
||||
title: t`Manage Roles`,
|
||||
description: {
|
||||
Server: t`Create and edit server roles`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ManageCustomisation",
|
||||
value: 2n ** 4n,
|
||||
title: t`Manage Customisation`,
|
||||
description: {
|
||||
Server: t`Create server emoji`,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: t`Members`,
|
||||
key: "KickMembers",
|
||||
value: 2n ** 6n,
|
||||
title: t`Kick Members`,
|
||||
description: {
|
||||
Server: t`Kick lower-ranking members from the server`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BanMembers",
|
||||
value: 2n ** 7n,
|
||||
title: t`Ban Members`,
|
||||
description: {
|
||||
Server: t`Ban lower-ranking members from the server`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "TimeoutMembers",
|
||||
value: 2n ** 8n,
|
||||
title: t`Timeout Members`,
|
||||
description: {
|
||||
Server: t`Temporarily prevent lower-ranking members from interacting`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "AssignRoles",
|
||||
value: 2n ** 9n,
|
||||
title: t`Assign Roles`,
|
||||
description: {
|
||||
Server: t`Assign lower-ranked roles to lower-ranking members`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ChangeNickname",
|
||||
value: 2n ** 10n,
|
||||
title: t`Change Nickname`,
|
||||
description: {
|
||||
Server: t`Change own nickname`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ManageNicknames",
|
||||
value: 2n ** 11n,
|
||||
title: t`Manage Nicknames`,
|
||||
description: {
|
||||
Server: t`Change other members' nicknames`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ChangeAvavar",
|
||||
value: 2n ** 12n,
|
||||
title: t`Change Avatar`,
|
||||
description: {
|
||||
Server: t`Change own avatar`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "RemoveAvatars",
|
||||
value: 2n ** 13n,
|
||||
title: t`Remove Avatars`,
|
||||
description: {
|
||||
Server: t`Remove other members' avatars`,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: t`Channels`,
|
||||
key: "ViewChannel",
|
||||
value: 2n ** 20n,
|
||||
title: t`View Channel`,
|
||||
description: {
|
||||
TextChannel: t`Able to access this channel`,
|
||||
Server: t`Able to access channels on this server`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ReadMessageHistory",
|
||||
value: 2n ** 21n,
|
||||
title: t`Read Message History`,
|
||||
description: {
|
||||
TextChannel: t`Read past messages sent in channel`,
|
||||
Server: t`Read past messages sent in channels`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "SendMessage",
|
||||
value: 2n ** 22n,
|
||||
title: t`Send Messages`,
|
||||
description: {
|
||||
Group: t`Send messages in channel`,
|
||||
TextChannel: t`Send messages in channel`,
|
||||
Server: t`Send messages in channels`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ManageMessages",
|
||||
value: 2n ** 23n,
|
||||
title: t`Manage Messages`,
|
||||
description: {
|
||||
Group: t`Delete and pin messages sent by other members`,
|
||||
TextChannel: t`Delete and pin messages sent by other members`,
|
||||
Server: t`Delete and pin messages sent by other members`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ManageWebhooks",
|
||||
value: 2n ** 24n,
|
||||
title: t`Manage Webhooks`,
|
||||
description: {
|
||||
Group: t`Create and edit webhooks`,
|
||||
TextChannel: t`Create and edit webhooks`,
|
||||
Server: t`Create and edit webhooks`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "InviteOthers",
|
||||
value: 2n ** 25n,
|
||||
title: t`Invite Others`,
|
||||
description: {
|
||||
Group: t`Add new members to the group`,
|
||||
Any: t`Create invites for others to use`,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: t`Messaging`,
|
||||
key: "SendEmbeds",
|
||||
value: 2n ** 26n,
|
||||
title: t`Send Embeds`,
|
||||
description: {
|
||||
Any: t`Send embedded content such as link embeds or custom embeds`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "UploadFiles",
|
||||
value: 2n ** 27n,
|
||||
title: t`Upload Files`,
|
||||
description: {
|
||||
Any: t`Send attachments to chat`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Masquerade",
|
||||
value: 2n ** 28n,
|
||||
title: t`Masquerade`,
|
||||
description: {
|
||||
Any: t`Allow members to change name and avatar per-message`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "React",
|
||||
value: 2n ** 29n,
|
||||
title: t`React`,
|
||||
description: {
|
||||
Any: t`React to messages with emoji`,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: t`Voice`,
|
||||
key: "Connect",
|
||||
value: 2n ** 30n,
|
||||
title: t`Connect`,
|
||||
description: {
|
||||
TextChannel: t`Connect to voice channel`,
|
||||
Server: t`Connect to voice channel`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Speak",
|
||||
value: 2n ** 31n,
|
||||
title: t`Speak`,
|
||||
description: {
|
||||
TextChannel: t`Able to speak in voice call`,
|
||||
Server: t`Able to speak in voice call`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Video",
|
||||
value: 2n ** 32n,
|
||||
title: t`Video`,
|
||||
description: {
|
||||
TextChannel: t`Share camera or screen in voice call`,
|
||||
Server: t`Share camera or screen in voice call`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "MuteMembers",
|
||||
value: 2n ** 33n,
|
||||
title: t`Mute Members`,
|
||||
description: {
|
||||
TextChannel: t`Mute lower-ranking members in voice call`,
|
||||
Server: t`Mute lower-ranking members in voice call`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "DeafenMembers",
|
||||
value: 2n ** 34n,
|
||||
title: t`Deafen Members`,
|
||||
description: {
|
||||
TextChannel: t`Deafen lower-ranking members in voice call`,
|
||||
Server: t`Deafen lower-ranking members in voice call`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "MoveMembers",
|
||||
value: 2n ** 35n,
|
||||
title: t`Move Members`,
|
||||
description: {
|
||||
TextChannel: t`Move members between voice channels`,
|
||||
Server: t`Move members between voice channels`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Listen",
|
||||
value: 2n ** 36n,
|
||||
title: t`Listen`,
|
||||
description: {
|
||||
TextChannel: t`Hear other people and see their video`,
|
||||
Server: t`Hear other people and see their video`,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: t`Mentions`,
|
||||
key: "MentionEveryone",
|
||||
value: 2n ** 37n,
|
||||
title: t`Mention Everyone`,
|
||||
description: {
|
||||
Any: t`Mention everyone and online members inside the server`,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "MentionRoles",
|
||||
value: 2n ** 38n,
|
||||
title: t`Mention Roles`,
|
||||
description: {
|
||||
Any: t`Mention specific roles`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Find description for this permission in context
|
||||
* If null, don't show this permission entry
|
||||
* @param entry Entry
|
||||
* @returns Description or null
|
||||
*/
|
||||
function description(entry: (typeof Permissions)[number]) {
|
||||
const desc = entry.description;
|
||||
return desc[context] ?? desc.Any;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={css({ display: "flex", flexDirection: "column" })}>
|
||||
<For each={Permissions}>
|
||||
{(entry) => (
|
||||
<Show when={description(entry)}>
|
||||
<Show when={entry.heading}>
|
||||
<span class={css({ marginTop: "var(--gap-md)" })}>
|
||||
<Text class="label">{entry.heading}</Text>
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<Switch
|
||||
fallback={
|
||||
<ChannelPermissionToggle
|
||||
key={entry.key}
|
||||
title={entry.title}
|
||||
description={description(entry) as string}
|
||||
value={(value()[0] & entry.value) == entry.value}
|
||||
onChange={() =>
|
||||
setValue((v) => [v[0] ^ BigInt(entry.value), v[1]])
|
||||
}
|
||||
havePermission={
|
||||
(props.context.permission & entry.value) === entry.value
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match when={props.type.startsWith("channel_")}>
|
||||
<ChannelPermissionOverride
|
||||
key={entry.key}
|
||||
title={entry.title}
|
||||
description={description(entry) as string}
|
||||
value={
|
||||
(value()[0] & entry.value) == entry.value
|
||||
? "allow"
|
||||
: (value()[1] & entry.value) == entry.value
|
||||
? "deny"
|
||||
: "neutral"
|
||||
}
|
||||
onChange={(target) => {
|
||||
let allow = value()[0] & ~entry.value;
|
||||
let deny = value()[1] & ~entry.value;
|
||||
|
||||
if (target === "allow") allow |= entry.value;
|
||||
if (target === "deny") deny |= entry.value;
|
||||
|
||||
setValue([allow, deny]);
|
||||
}}
|
||||
havePermission={
|
||||
(props.context.permission & entry.value) === entry.value
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<StickyPanel>
|
||||
<Row>
|
||||
<Button
|
||||
isDisabled={!unsavedChanges()}
|
||||
variant="text"
|
||||
size={unsavedChanges() ? "md" : "sm"}
|
||||
onPress={reset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={!unsavedChanges()}
|
||||
size={unsavedChanges() ? "md" : "sm"}
|
||||
onPress={save}
|
||||
>
|
||||
Save permissions
|
||||
</Button>
|
||||
</Row>
|
||||
</StickyPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StickyPanel = styled("div", {
|
||||
base: {
|
||||
position: "sticky",
|
||||
width: "fit-content",
|
||||
padding: "var(--gap-md)",
|
||||
bottom: "var(--gap-lg)",
|
||||
borderRadius: "var(--borderRadius-xl)",
|
||||
background: "var(--md-sys-color-surface)",
|
||||
},
|
||||
});
|
||||
|
||||
function ChannelPermissionToggle(props: {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
|
||||
havePermission: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Checkbox2
|
||||
name={props.key}
|
||||
checked={props.value}
|
||||
onChange={(event) => props.onChange(event.currentTarget.checked)}
|
||||
disabled={!props.havePermission}
|
||||
>
|
||||
<div
|
||||
class={css({
|
||||
marginStart: "var(--gap-md)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
})}
|
||||
>
|
||||
<Text size="large">{props.title}</Text>
|
||||
<Text>{props.description}</Text>
|
||||
</div>
|
||||
</Checkbox2>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelPermissionOverride(props: {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
value: "allow" | "deny" | "neutral";
|
||||
onChange: (value: "allow" | "deny" | "neutral") => void;
|
||||
|
||||
havePermission: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class={css({
|
||||
gap: "var(--gap-md)",
|
||||
display: "flex",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class={css({
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
})}
|
||||
>
|
||||
<Text size="large">{props.title}</Text>
|
||||
<Text>{props.description}</Text>
|
||||
</div>
|
||||
<OverrideSwitch
|
||||
disabled={!props.havePermission}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { For, createMemo } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { Channel } from "stoat.js";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { CategoryButton, Column, Text } from "@revolt/ui";
|
||||
|
||||
import { useSettingsNavigation } from "../../Settings";
|
||||
|
||||
/**
|
||||
* Count set bits
|
||||
* @param v Number
|
||||
* @returns Set bits
|
||||
*/
|
||||
function countBits(v: bigint) {
|
||||
let bits = 0;
|
||||
for (let i = 0n; i < 52n; i++) {
|
||||
if (((1n << i) & v) === 1n << i) {
|
||||
bits++;
|
||||
}
|
||||
}
|
||||
|
||||
return bits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu to select what permission set to change
|
||||
*/
|
||||
export function ChannelPermissionsOverview(props: { context: Channel }) {
|
||||
const { navigate } = useSettingsNavigation();
|
||||
|
||||
const roles = createMemo(() => {
|
||||
const ordered = props.context.server?.orderedRoles;
|
||||
|
||||
return {
|
||||
active: ordered?.filter(
|
||||
(role) =>
|
||||
countBits(props.context.rolePermissions?.[role.id]?.a || 0n) > 0 ||
|
||||
countBits(props.context.rolePermissions?.[role.id]?.d || 0n) > 0,
|
||||
),
|
||||
unused: ordered?.filter(
|
||||
(role) =>
|
||||
countBits(props.context.rolePermissions?.[role.id]?.a || 0n) === 0 &&
|
||||
countBits(props.context.rolePermissions?.[role.id]?.d || 0n) === 0,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
action="chevron"
|
||||
description={<Trans>Affects all roles and users</Trans>}
|
||||
onClick={() => navigate("permissions/default")}
|
||||
>
|
||||
<Trans>Default Permissions</Trans>
|
||||
</CategoryButton>
|
||||
|
||||
<Column gap="sm">
|
||||
<Text class="label">Role Permissions</Text>
|
||||
<For each={roles().active}>
|
||||
{(role) => (
|
||||
<CategoryButton
|
||||
icon={
|
||||
<RoleIcon
|
||||
style={{
|
||||
background:
|
||||
role.colour ?? "var(--md-sys-color-outline-variant)",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
action="chevron"
|
||||
onClick={() => navigate(`permissions/${role.id}`)}
|
||||
description={
|
||||
<Trans>
|
||||
Grants {countBits(props.context.rolePermissions![role.id].a)}{" "}
|
||||
permissions and denies{" "}
|
||||
{countBits(props.context.rolePermissions![role.id].d)}{" "}
|
||||
permissions
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
{role.name}
|
||||
</CategoryButton>
|
||||
)}
|
||||
</For>
|
||||
</Column>
|
||||
|
||||
<Column gap="sm">
|
||||
<Text class="label">Unused Roles</Text>
|
||||
<For each={roles().unused}>
|
||||
{(role) => (
|
||||
<CategoryButton
|
||||
icon={
|
||||
<RoleIcon
|
||||
style={{
|
||||
background:
|
||||
role.colour ?? "var(--md-sys-color-outline-variant)",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
action="chevron"
|
||||
onClick={() => navigate(`permissions/${role.id}`)}
|
||||
description={<Trans>No permissions set yet</Trans>}
|
||||
>
|
||||
{role.name}
|
||||
</CategoryButton>
|
||||
)}
|
||||
</For>
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const RoleIcon = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
aspectRatio: "1/1",
|
||||
borderRadius: "100%",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import { createFormControl, createFormGroup } from "solid-forms";
|
||||
import { Show } from "solid-js";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
import { useMutation } from "@tanstack/solid-query";
|
||||
import { API, ChannelWebhook } from "stoat.js";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { CONFIGURATION } from "@revolt/common";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import {
|
||||
CategoryButton,
|
||||
CircularProgress,
|
||||
Column,
|
||||
Form2,
|
||||
Row,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdContentCopy from "@material-design-icons/svg/outlined/content_copy.svg?component-solid";
|
||||
import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid";
|
||||
|
||||
import { useSettingsNavigation } from "../../Settings";
|
||||
|
||||
/**
|
||||
* Webhook
|
||||
*/
|
||||
export function ViewWebhook(props: { webhook: ChannelWebhook }) {
|
||||
const { t } = useLingui();
|
||||
const client = useClient();
|
||||
const { showError } = useModals();
|
||||
const { navigate } = useSettingsNavigation();
|
||||
|
||||
/* eslint-disable solid/reactivity */
|
||||
const editGroup = createFormGroup({
|
||||
name: createFormControl(props.webhook.name),
|
||||
avatar: createFormControl<string | File[] | null>(props.webhook.avatarURL),
|
||||
});
|
||||
/* eslint-enable solid/reactivity */
|
||||
|
||||
const deleteWebhook = useMutation(() => ({
|
||||
mutationFn: () => props.webhook.delete(),
|
||||
onSuccess() {
|
||||
navigate("webhooks");
|
||||
},
|
||||
onError: showError,
|
||||
}));
|
||||
|
||||
async function onSubmit() {
|
||||
const changes: API.DataEditWebhook = {
|
||||
remove: [],
|
||||
};
|
||||
|
||||
if (editGroup.controls.name.isDirty) {
|
||||
changes.name = editGroup.controls.name.value.trim();
|
||||
}
|
||||
|
||||
if (editGroup.controls.avatar.isDirty) {
|
||||
if (!editGroup.controls.avatar.value) {
|
||||
changes.remove!.push("Avatar");
|
||||
} else if (Array.isArray(editGroup.controls.avatar.value)) {
|
||||
const body = new FormData();
|
||||
body.append("file", editGroup.controls.avatar.value[0]);
|
||||
|
||||
const [key, value] = client().authenticationHeader;
|
||||
const data: { id: string } = await fetch(
|
||||
`${CONFIGURATION.DEFAULT_MEDIA_URL}/avatars`,
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
headers: {
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
).then((res) => res.json());
|
||||
|
||||
changes.avatar = data.id;
|
||||
}
|
||||
}
|
||||
|
||||
await props.webhook.edit(changes);
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
editGroup.controls.name.setValue(props.webhook.name);
|
||||
editGroup.controls.avatar.setValue(props.webhook.avatarURL ?? null);
|
||||
}
|
||||
|
||||
const submit = Form2.useSubmitHandler(editGroup, onSubmit, onReset);
|
||||
|
||||
return (
|
||||
<Column gap="xl">
|
||||
<form onSubmit={submit}>
|
||||
<Column>
|
||||
<Form2.FileInput
|
||||
control={editGroup.controls.avatar}
|
||||
accept="image/*"
|
||||
label={t`Webhook Icon`}
|
||||
imageJustify={false}
|
||||
/>
|
||||
<Form2.TextField
|
||||
name="name"
|
||||
control={editGroup.controls.name}
|
||||
label={t`Webhook Name`}
|
||||
/>
|
||||
<Row>
|
||||
<Form2.Reset group={editGroup} onReset={onReset} />
|
||||
<Form2.Submit group={editGroup}>
|
||||
<Trans>Save</Trans>
|
||||
</Form2.Submit>
|
||||
<Show when={editGroup.isPending}>
|
||||
<CircularProgress />
|
||||
</Show>
|
||||
</Row>
|
||||
</Column>
|
||||
</form>
|
||||
|
||||
<Column>
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
icon={<MdContentCopy />}
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
`${CONFIGURATION.DEFAULT_API_URL}/webhooks/${props.webhook.id}/${props.webhook.token}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Copy webhook URL</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
icon={<MdDelete />}
|
||||
disabled={deleteWebhook.isPending}
|
||||
onClick={() => deleteWebhook.mutate()}
|
||||
>
|
||||
Delete webhook
|
||||
</CategoryButton>
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { BiSolidCloud } from "solid-icons/bi";
|
||||
import { For, Match, Show, Switch, createMemo, onMount } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { Avatar, CategoryButton, CircularProgress, Column } from "@revolt/ui";
|
||||
|
||||
import { ChannelSettingsProps } from "../../ChannelSettings";
|
||||
import { useSettingsNavigation } from "../../Settings";
|
||||
|
||||
/**
|
||||
* Webhooks
|
||||
*/
|
||||
export function WebhooksList(props: ChannelSettingsProps) {
|
||||
const client = useClient();
|
||||
const { openModal } = useModals();
|
||||
const { navigate } = useSettingsNavigation();
|
||||
|
||||
const webhooks = createMemo(() =>
|
||||
client().channelWebhooks.filter(
|
||||
(webhook) => webhook.channelId === props.channel.id,
|
||||
),
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
if (!webhooks.length) {
|
||||
props.channel.fetchWebhooks();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
icon={<BiSolidCloud size={24} />}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
type: "create_webhook",
|
||||
channel: props.channel,
|
||||
callback(webhookId) {
|
||||
navigate(`webhooks/${webhookId}`);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Create Webhook</Trans>
|
||||
</CategoryButton>
|
||||
|
||||
<Show when={!webhooks() || webhooks()!.length !== 0}>
|
||||
<Column>
|
||||
<Switch fallback={<CircularProgress />}>
|
||||
<Match when={webhooks()?.length}>
|
||||
<For each={webhooks()}>
|
||||
{(webhook) => (
|
||||
<CategoryButton
|
||||
icon={
|
||||
<Avatar
|
||||
src={webhook.avatarURL}
|
||||
fallback={webhook.name}
|
||||
size={24}
|
||||
/>
|
||||
}
|
||||
description={webhook.id}
|
||||
onClick={() => navigate(`webhooks/${webhook.id}`)}
|
||||
action="chevron"
|
||||
>
|
||||
{webhook.name}
|
||||
</CategoryButton>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Column>
|
||||
</Show>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { Accessor, JSX } from "solid-js";
|
||||
|
||||
import channel from "./ChannelSettings";
|
||||
import server from "./ServerSettings";
|
||||
import user from "./UserSettings";
|
||||
|
||||
export { Settings } from "./Settings";
|
||||
|
||||
export type SettingsConfiguration<T> = {
|
||||
/**
|
||||
* Generate list of categories and entries
|
||||
* @returns List
|
||||
*/
|
||||
list: (context: T) => SettingsList<T>;
|
||||
|
||||
/**
|
||||
* Render the title of the current breadcrumb key
|
||||
* @param ctx Context from settings list
|
||||
* @param key Key
|
||||
*/
|
||||
title: (ctx: SettingsList<T>, key: string) => string;
|
||||
|
||||
/**
|
||||
* Render the current settings page
|
||||
* @param props State information
|
||||
*/
|
||||
render: (
|
||||
props: { page: Accessor<undefined | string> },
|
||||
context: T,
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
/**
|
||||
* List of categories and entries
|
||||
*/
|
||||
export type SettingsList<T> = {
|
||||
context: T;
|
||||
prepend?: JSX.Element;
|
||||
append?: JSX.Element;
|
||||
entries: {
|
||||
hidden?: boolean;
|
||||
title?: JSX.Element;
|
||||
entries: SettingsEntry[];
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual settings entry
|
||||
*/
|
||||
export type SettingsEntry = {
|
||||
id?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
|
||||
hidden?: boolean;
|
||||
|
||||
icon: JSX.Element;
|
||||
title: JSX.Element;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const SettingsConfigurations: Record<string, any> = {
|
||||
user,
|
||||
server,
|
||||
channel,
|
||||
};
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
import { createFormControl, createFormGroup } from "solid-forms";
|
||||
import { For, Show, createEffect, on } from "solid-js";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
import type { API } from "stoat.js";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { CONFIGURATION } from "@revolt/common";
|
||||
import {
|
||||
CircularProgress,
|
||||
Column,
|
||||
Form2,
|
||||
MenuItem,
|
||||
Row,
|
||||
Text,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import { ServerSettingsProps } from "../ServerSettings";
|
||||
|
||||
/**
|
||||
* Server overview
|
||||
*/
|
||||
export default function ServerOverview(props: ServerSettingsProps) {
|
||||
const { t } = useLingui();
|
||||
const client = useClient();
|
||||
|
||||
/* eslint-disable solid/reactivity */
|
||||
const editGroup = createFormGroup({
|
||||
name: createFormControl(props.server.name),
|
||||
description: createFormControl(props.server.description || ""),
|
||||
icon: createFormControl<string | File[] | null>(
|
||||
props.server.animatedIconURL,
|
||||
),
|
||||
banner: createFormControl<string | File[] | null>(props.server.bannerURL),
|
||||
sys_user_joined: createFormControl(
|
||||
props.server.systemMessages?.user_joined ?? "none",
|
||||
),
|
||||
sys_user_left: createFormControl(
|
||||
props.server.systemMessages?.user_left ?? "none",
|
||||
),
|
||||
sys_user_kicked: createFormControl(
|
||||
props.server.systemMessages?.user_kicked ?? "none",
|
||||
),
|
||||
sys_user_banned: createFormControl(
|
||||
props.server.systemMessages?.user_banned ?? "none",
|
||||
),
|
||||
});
|
||||
|
||||
const channels = () =>
|
||||
props.server.channels.map((channel) => ({
|
||||
item: channel,
|
||||
value: channel.id,
|
||||
}));
|
||||
/* eslint-enable solid/reactivity */
|
||||
|
||||
// update fields (if they are not dirty) ourselves:
|
||||
createEffect(
|
||||
on(
|
||||
() => props.server.name,
|
||||
(name) =>
|
||||
!editGroup.controls.name.isDirty &&
|
||||
editGroup.controls.name.setValue(name),
|
||||
{ defer: true },
|
||||
),
|
||||
);
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.server.description,
|
||||
(description) =>
|
||||
description &&
|
||||
!editGroup.controls.description.isDirty &&
|
||||
editGroup.controls.description.setValue(description),
|
||||
{ defer: true },
|
||||
),
|
||||
);
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.server.animatedIconURL,
|
||||
(icon) =>
|
||||
!editGroup.controls.icon.isDirty &&
|
||||
editGroup.controls.icon.setValue(icon ?? null),
|
||||
{ defer: true },
|
||||
),
|
||||
);
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.server.bannerURL,
|
||||
(banner) =>
|
||||
!editGroup.controls.banner.isDirty &&
|
||||
editGroup.controls.banner.setValue(banner ?? null),
|
||||
{ defer: true },
|
||||
),
|
||||
);
|
||||
|
||||
function onReset() {
|
||||
editGroup.controls.name.setValue(props.server.name);
|
||||
editGroup.controls.description.setValue(props.server.description || "");
|
||||
editGroup.controls.icon.setValue(props.server.animatedIconURL ?? null);
|
||||
editGroup.controls.banner.setValue(props.server.bannerURL ?? null);
|
||||
editGroup.controls.sys_user_joined.setValue(
|
||||
props.server.systemMessages?.user_joined ?? "none",
|
||||
);
|
||||
editGroup.controls.sys_user_left.setValue(
|
||||
props.server.systemMessages?.user_left ?? "none",
|
||||
);
|
||||
editGroup.controls.sys_user_kicked.setValue(
|
||||
props.server.systemMessages?.user_kicked ?? "none",
|
||||
);
|
||||
editGroup.controls.sys_user_banned.setValue(
|
||||
props.server.systemMessages?.user_banned ?? "none",
|
||||
);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const changes: API.DataEditServer = {
|
||||
remove: [],
|
||||
system_messages: {
|
||||
// empty object => remove every system_message channel
|
||||
...(props.server.systemMessages ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (editGroup.controls.name.isDirty) {
|
||||
changes.name = editGroup.controls.name.value.trim();
|
||||
}
|
||||
|
||||
if (editGroup.controls.description.isDirty) {
|
||||
const description = editGroup.controls.description.value.trim();
|
||||
|
||||
if (description) {
|
||||
changes.description = description;
|
||||
} else {
|
||||
changes.remove!.push("Description");
|
||||
}
|
||||
}
|
||||
|
||||
if (editGroup.controls.icon.isDirty) {
|
||||
if (!editGroup.controls.icon.value) {
|
||||
changes.remove!.push("Icon");
|
||||
} else if (Array.isArray(editGroup.controls.icon.value)) {
|
||||
changes.icon = await client().uploadFile(
|
||||
"icons",
|
||||
editGroup.controls.icon.value[0],
|
||||
CONFIGURATION.DEFAULT_MEDIA_URL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (editGroup.controls.banner.isDirty) {
|
||||
if (!editGroup.controls.banner.value) {
|
||||
changes.remove!.push("Banner");
|
||||
} else if (Array.isArray(editGroup.controls.banner.value)) {
|
||||
changes.banner = await client().uploadFile(
|
||||
"banners",
|
||||
editGroup.controls.banner.value[0],
|
||||
CONFIGURATION.DEFAULT_MEDIA_URL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (editGroup.controls.sys_user_joined.isDirty) {
|
||||
if (
|
||||
editGroup.controls.sys_user_joined.value == "none" &&
|
||||
changes.system_messages?.user_joined
|
||||
) {
|
||||
delete changes.system_messages.user_joined;
|
||||
} else {
|
||||
changes.system_messages!.user_joined =
|
||||
editGroup.controls.sys_user_joined.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (editGroup.controls.sys_user_left.isDirty) {
|
||||
if (
|
||||
editGroup.controls.sys_user_left.value == "none" &&
|
||||
changes.system_messages?.user_left
|
||||
) {
|
||||
delete changes.system_messages.user_left;
|
||||
} else {
|
||||
changes.system_messages!.user_left =
|
||||
editGroup.controls.sys_user_left.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (editGroup.controls.sys_user_kicked.isDirty) {
|
||||
if (
|
||||
editGroup.controls.sys_user_kicked.value == "none" &&
|
||||
changes.system_messages?.user_kicked
|
||||
) {
|
||||
delete changes.system_messages.user_kicked;
|
||||
} else {
|
||||
changes.system_messages!.user_kicked =
|
||||
editGroup.controls.sys_user_kicked.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (editGroup.controls.sys_user_banned.isDirty) {
|
||||
if (
|
||||
editGroup.controls.sys_user_banned.value == "none" &&
|
||||
changes.system_messages?.user_banned
|
||||
) {
|
||||
delete changes.system_messages.user_banned;
|
||||
} else {
|
||||
changes.system_messages!.user_banned =
|
||||
editGroup.controls.sys_user_banned.value;
|
||||
}
|
||||
}
|
||||
|
||||
await props.server.edit(changes);
|
||||
}
|
||||
|
||||
const submit = Form2.useSubmitHandler(editGroup, onSubmit, onReset);
|
||||
|
||||
return (
|
||||
<Column gap="xl">
|
||||
<form onSubmit={submit}>
|
||||
<Column>
|
||||
<Form2.FileInput
|
||||
control={editGroup.controls.icon}
|
||||
accept="image/*"
|
||||
label={t`Server Icon`}
|
||||
imageJustify={false}
|
||||
/>
|
||||
<Form2.FileInput
|
||||
control={editGroup.controls.banner}
|
||||
accept="image/*"
|
||||
label={t`Server Banner`}
|
||||
imageAspect="232/100"
|
||||
imageRounded={false}
|
||||
imageJustify={false}
|
||||
/>
|
||||
<Form2.TextField
|
||||
name="name"
|
||||
control={editGroup.controls.name}
|
||||
label={t`Server Name`}
|
||||
/>
|
||||
<Form2.TextField
|
||||
autosize
|
||||
min-rows={2}
|
||||
name="description"
|
||||
control={editGroup.controls.description}
|
||||
label={t`Server Description`}
|
||||
placeholder={t`This server is about...`}
|
||||
/>
|
||||
<Text class="title" size="small">
|
||||
<Trans>System message channels</Trans>
|
||||
</Text>
|
||||
<Column>
|
||||
<Text class="label">
|
||||
<Trans>User Joined</Trans>
|
||||
</Text>
|
||||
<Form2.TextField.Select
|
||||
control={editGroup.controls.sys_user_joined}
|
||||
>
|
||||
<MenuItem value="none">
|
||||
<Trans>Disabled</Trans>
|
||||
</MenuItem>
|
||||
<For each={channels()}>
|
||||
{(element) => (
|
||||
<MenuItem value={element.value}>{element.item.name}</MenuItem>
|
||||
)}
|
||||
</For>
|
||||
</Form2.TextField.Select>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text class="label">
|
||||
<Trans>User Left</Trans>
|
||||
</Text>
|
||||
<Form2.TextField.Select control={editGroup.controls.sys_user_left}>
|
||||
<MenuItem value="none">
|
||||
<Trans>Disabled</Trans>
|
||||
</MenuItem>
|
||||
<For each={channels()}>
|
||||
{(element) => (
|
||||
<MenuItem value={element.value}>{element.item.name}</MenuItem>
|
||||
)}
|
||||
</For>
|
||||
</Form2.TextField.Select>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text class="label">
|
||||
<Trans>User Kicked</Trans>
|
||||
</Text>
|
||||
<Form2.TextField.Select
|
||||
control={editGroup.controls.sys_user_kicked}
|
||||
>
|
||||
<MenuItem value="none">
|
||||
<Trans>Disabled</Trans>
|
||||
</MenuItem>
|
||||
<For each={channels()}>
|
||||
{(element) => (
|
||||
<MenuItem value={element.value}>{element.item.name}</MenuItem>
|
||||
)}
|
||||
</For>
|
||||
</Form2.TextField.Select>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text class="label">
|
||||
<Trans>User Banned</Trans>
|
||||
</Text>
|
||||
<Form2.TextField.Select
|
||||
control={editGroup.controls.sys_user_banned}
|
||||
>
|
||||
<MenuItem value="none">
|
||||
<Trans>Disabled</Trans>
|
||||
</MenuItem>
|
||||
<For each={channels()}>
|
||||
{(element) => (
|
||||
<MenuItem value={element.value}>{element.item.name}</MenuItem>
|
||||
)}
|
||||
</For>
|
||||
</Form2.TextField.Select>
|
||||
</Column>
|
||||
<Row>
|
||||
<Form2.Reset group={editGroup} onReset={onReset} />
|
||||
<Form2.Submit group={editGroup} requireDirty>
|
||||
<Trans>Save</Trans>
|
||||
</Form2.Submit>
|
||||
<Show when={editGroup.isPending}>
|
||||
<CircularProgress />
|
||||
</Show>
|
||||
</Row>
|
||||
</Column>
|
||||
</form>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { For, Match, Switch, createMemo, createSignal } from "solid-js";
|
||||
|
||||
import { useLingui } from "@lingui-solid/solid/macro";
|
||||
import { useQuery, useQueryClient } from "@tanstack/solid-query";
|
||||
import { Server, ServerBan } from "stoat.js";
|
||||
|
||||
import { useModals } from "@revolt/modal";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
CircularProgress,
|
||||
DataTable,
|
||||
Row,
|
||||
TextField,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid";
|
||||
|
||||
/**
|
||||
* List and invalidate server bans
|
||||
*/
|
||||
export function ListServerBans(props: { server: Server }) {
|
||||
const { t } = useLingui();
|
||||
const client = useQueryClient();
|
||||
const { showError } = useModals();
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ["bans", props.server.id],
|
||||
queryFn: () => props.server.fetchBans() as Promise<ServerBan[]>,
|
||||
}));
|
||||
|
||||
async function pardon(ban: ServerBan) {
|
||||
try {
|
||||
await ban.pardon();
|
||||
client.setQueryData(
|
||||
["bans", props.server.id],
|
||||
query.data!.filter((entry) => entry.id.user !== ban.id.user),
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const [filterName, setFilterName] = createSignal("");
|
||||
const [filterDesc, setFilterDesc] = createSignal("");
|
||||
|
||||
const data = createMemo(() => {
|
||||
if (query.data) {
|
||||
const name = filterName().toLowerCase(),
|
||||
desc = filterDesc().toLowerCase();
|
||||
|
||||
if (name || desc) {
|
||||
return query.data.filter(
|
||||
(entry) =>
|
||||
(entry.user?.username ?? "").toLowerCase().includes(name) &&
|
||||
(entry.reason ?? "").toLowerCase().includes(desc),
|
||||
);
|
||||
}
|
||||
|
||||
return query.data;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={[
|
||||
<TextField
|
||||
label={t`User`}
|
||||
value={filterName()}
|
||||
onChange={(e) => setFilterName(e.currentTarget.value)}
|
||||
/>,
|
||||
<TextField
|
||||
label={t`Reason`}
|
||||
value={filterDesc()}
|
||||
onChange={(e) => setFilterDesc(e.currentTarget.value)}
|
||||
/>,
|
||||
<></>,
|
||||
]}
|
||||
itemCount={query.data?.length}
|
||||
>
|
||||
{(page, itemsPerPage) => (
|
||||
<Switch>
|
||||
<Match when={query.isLoading}>
|
||||
<DataTable.Row>
|
||||
<DataTable.Cell colspan={3}>
|
||||
<CircularProgress />
|
||||
</DataTable.Cell>
|
||||
</DataTable.Row>
|
||||
</Match>
|
||||
<Match when={query.data}>
|
||||
<For
|
||||
each={data()!.slice(
|
||||
page * itemsPerPage,
|
||||
page * itemsPerPage + itemsPerPage,
|
||||
)}
|
||||
>
|
||||
{(item) => (
|
||||
<DataTable.Row>
|
||||
<DataTable.Cell>
|
||||
<Row align>
|
||||
<Avatar
|
||||
src={item.user?.avatar?.previewUrl}
|
||||
fallback={item.user?.username}
|
||||
size={32}
|
||||
/>
|
||||
<span>
|
||||
{item.user?.username}#{item.user?.discriminator}
|
||||
</span>
|
||||
</Row>
|
||||
</DataTable.Cell>
|
||||
<DataTable.Cell>{item.reason}</DataTable.Cell>
|
||||
<DataTable.Cell width="40px">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="filled"
|
||||
use:floating={{
|
||||
tooltip: {
|
||||
placement: "bottom",
|
||||
content: t`Pardon User`,
|
||||
},
|
||||
}}
|
||||
onPress={() => pardon(item)}
|
||||
>
|
||||
<MdDelete />
|
||||
</Button>
|
||||
</DataTable.Cell>
|
||||
</DataTable.Row>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import { createFormControl, createFormGroup } from "solid-forms";
|
||||
import { For, Match, Switch } from "solid-js";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
import { Server } from "stoat.js";
|
||||
import { css } from "styled-system/css";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { CONFIGURATION } from "@revolt/common";
|
||||
import { useError } from "@revolt/i18n";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import {
|
||||
Avatar,
|
||||
CategoryButton,
|
||||
CircularProgress,
|
||||
Column,
|
||||
Form2,
|
||||
Row,
|
||||
Text,
|
||||
} from "@revolt/ui";
|
||||
|
||||
/**
|
||||
* Emoji list
|
||||
*/
|
||||
export function EmojiList(props: { server: Server }) {
|
||||
const err = useError();
|
||||
const { t } = useLingui();
|
||||
const client = useClient();
|
||||
const { openModal } = useModals();
|
||||
|
||||
function isDisabled() {
|
||||
return props.server.emojis.length >= CONFIGURATION.MAX_EMOJI;
|
||||
}
|
||||
|
||||
const editGroup = createFormGroup(
|
||||
{
|
||||
name: createFormControl("", { required: true }),
|
||||
file: createFormControl<string | File[] | null>(null, {
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
disabled: isDisabled(),
|
||||
},
|
||||
);
|
||||
|
||||
async function onSubmit() {
|
||||
const body = new FormData();
|
||||
body.append("file", editGroup.controls.file.value![0]);
|
||||
|
||||
const [key, value] = client().authenticationHeader;
|
||||
const data: { id: string } = await fetch(
|
||||
`${CONFIGURATION.DEFAULT_MEDIA_URL}/emojis`,
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
headers: {
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
).then((res) => res.json());
|
||||
|
||||
await props.server.createEmoji(data.id, {
|
||||
name: editGroup.controls.name.value,
|
||||
});
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
editGroup.controls.name.setValue("");
|
||||
editGroup.controls.file.setValue(null);
|
||||
}
|
||||
|
||||
const submit = Form2.useSubmitHandler(editGroup, onSubmit, onReset);
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<form onSubmit={submit}>
|
||||
<Column>
|
||||
<Row align>
|
||||
<Column>
|
||||
<Form2.FileInput
|
||||
control={editGroup.controls.file}
|
||||
accept="image/*"
|
||||
imageJustify={false}
|
||||
allowRemoval={false}
|
||||
/>
|
||||
</Column>
|
||||
<Column grow>
|
||||
<Form2.TextField
|
||||
name="name"
|
||||
control={editGroup.controls.name}
|
||||
label={t`Emoji Name`}
|
||||
/>
|
||||
|
||||
<Row align>
|
||||
<Form2.Submit group={editGroup}>
|
||||
<Trans>Create</Trans>
|
||||
</Form2.Submit>
|
||||
<Switch
|
||||
fallback={
|
||||
<Trans>
|
||||
{CONFIGURATION.MAX_EMOJI - props.server.emojis.length}{" "}
|
||||
emoji slots remaining
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Match when={editGroup.errors?.error}>
|
||||
{err(editGroup.errors!.error)}
|
||||
</Match>
|
||||
<Match when={editGroup.isPending}>
|
||||
<CircularProgress />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Row>
|
||||
</Column>
|
||||
</Row>
|
||||
</Column>
|
||||
</form>
|
||||
|
||||
<Column gap="sm">
|
||||
<For
|
||||
each={props.server.emojis.toSorted((b, a) =>
|
||||
a.id.localeCompare(b.id),
|
||||
)}
|
||||
>
|
||||
{(emoji) => (
|
||||
<CategoryButton
|
||||
roundedIcon={false}
|
||||
icon={<Avatar src={emoji.url} shape="rounded-square" />}
|
||||
onClick={() => openModal({ type: "emoji_preview", emoji })}
|
||||
>
|
||||
<Column gap="none">
|
||||
<span class={css({ flex: 1 })}>:{emoji.name}:</span>
|
||||
<span
|
||||
class={css({
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--gap-sm)",
|
||||
})}
|
||||
>
|
||||
<Avatar
|
||||
size={12}
|
||||
fallback={emoji.creator?.displayName}
|
||||
src={emoji.creator?.animatedAvatarURL}
|
||||
/>
|
||||
<Text class="label">{emoji.creator?.displayName}</Text>
|
||||
</span>
|
||||
</Column>
|
||||
</CategoryButton>
|
||||
)}
|
||||
</For>
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { For, Match, Switch } from "solid-js";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
import { useQuery, useQueryClient } from "@tanstack/solid-query";
|
||||
import { Server, ServerInvite } from "stoat.js";
|
||||
|
||||
import { useModals } from "@revolt/modal";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Column,
|
||||
DataTable,
|
||||
Row,
|
||||
Text,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid";
|
||||
|
||||
/**
|
||||
* List and invalidate server invites
|
||||
*/
|
||||
export function ListServerInvites(props: { server: Server }) {
|
||||
const { t } = useLingui();
|
||||
const client = useQueryClient();
|
||||
const { showError, openModal } = useModals();
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ["invites", props.server.id],
|
||||
queryFn: () => props.server.fetchInvites() as Promise<ServerInvite[]>,
|
||||
}));
|
||||
|
||||
const serverDoesntHaveChannels = () =>
|
||||
!props.server.defaultChannel || props.server.channels.length == 0;
|
||||
|
||||
async function deleteInvite(invite: ServerInvite) {
|
||||
try {
|
||||
await invite.delete();
|
||||
client.setQueryData(
|
||||
["invites", props.server.id],
|
||||
query.data!.filter((entry) => entry.id !== entry.id),
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createInvite() {
|
||||
const defaultChannel =
|
||||
props.server.defaultChannel || props.server.channels[0] || null;
|
||||
if (defaultChannel) {
|
||||
openModal({
|
||||
type: "create_invite",
|
||||
channel: defaultChannel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<Button
|
||||
group="standard"
|
||||
onPress={createInvite}
|
||||
isDisabled={serverDoesntHaveChannels()}
|
||||
use:floating={{
|
||||
tooltip: serverDoesntHaveChannels()
|
||||
? {
|
||||
content: t`Create a channel before inviting others!`,
|
||||
placement: "bottom",
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Trans>Create invite</Trans>
|
||||
</Button>
|
||||
<DataTable
|
||||
columns={[<Trans>Inviter</Trans>, <Trans>Invite Code</Trans>, <></>]}
|
||||
itemCount={query.data?.length}
|
||||
>
|
||||
{(page, itemsPerPage) => (
|
||||
<Switch>
|
||||
<Match when={query.isLoading}>
|
||||
<DataTable.Row>
|
||||
<DataTable.Cell colspan={3}>
|
||||
<CircularProgress />
|
||||
</DataTable.Cell>
|
||||
</DataTable.Row>
|
||||
</Match>
|
||||
<Match when={query.data}>
|
||||
<For
|
||||
each={query.data!.slice(
|
||||
page * itemsPerPage,
|
||||
page * itemsPerPage + itemsPerPage,
|
||||
)}
|
||||
>
|
||||
{(item) => (
|
||||
<DataTable.Row>
|
||||
<DataTable.Cell>
|
||||
<Row align>
|
||||
<Avatar
|
||||
src={item.creator?.animatedAvatarURL}
|
||||
size={32}
|
||||
/>
|
||||
<Column gap="none">
|
||||
<span>
|
||||
{item.creator?.displayName ?? "Unknown User"}
|
||||
</span>
|
||||
<Text class="label">#{item.channel?.name}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</DataTable.Cell>
|
||||
<DataTable.Cell>{item.id}</DataTable.Cell>
|
||||
<DataTable.Cell width="40px">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="filled"
|
||||
use:floating={{
|
||||
tooltip: {
|
||||
placement: "bottom",
|
||||
content: t`Delete Invite`,
|
||||
},
|
||||
}}
|
||||
onPress={() => deleteInvite(item)}
|
||||
>
|
||||
<MdDelete />
|
||||
</Button>
|
||||
</DataTable.Cell>
|
||||
</DataTable.Row>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</DataTable>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
import { createFormControl, createFormGroup } from "solid-forms";
|
||||
import { For, Show, createMemo, createSignal } from "solid-js";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
import { API, Server, ServerRole } from "stoat.js";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { useModals } from "@revolt/modal";
|
||||
import {
|
||||
Button,
|
||||
CategoryButton,
|
||||
CircularProgress,
|
||||
Column,
|
||||
Form2,
|
||||
IconButton,
|
||||
Row,
|
||||
Text,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdContentCopy from "@material-design-icons/svg/outlined/content_copy.svg?component-solid";
|
||||
import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid";
|
||||
import MDPalette from "@material-design-icons/svg/outlined/palette.svg?component-solid";
|
||||
|
||||
import { useSettingsNavigation } from "../../Settings";
|
||||
import { ChannelPermissionsEditor } from "../../channel/permissions/ChannelPermissionsEditor";
|
||||
|
||||
/**
|
||||
* Role editor
|
||||
*/
|
||||
export function ServerRoleEditor(props: { context: Server; roleId: string }) {
|
||||
const { t } = useLingui();
|
||||
const { openModal } = useModals();
|
||||
const { navigate } = useSettingsNavigation();
|
||||
|
||||
const role = createMemo(
|
||||
() =>
|
||||
props.context.orderedRoles.find(
|
||||
(r) => r.id == props.roleId,
|
||||
) as ServerRole,
|
||||
);
|
||||
|
||||
/* eslint-disable solid/reactivity */
|
||||
const editGroup = createFormGroup({
|
||||
name: createFormControl(role()?.name || ""),
|
||||
colour: createFormControl(role()?.colour || null),
|
||||
hoist: createFormControl(role()?.hoist == true),
|
||||
});
|
||||
/* eslint-enable solid/reactivity */
|
||||
|
||||
const [pickerRef, setPickerRef] = createSignal<HTMLDivElement>();
|
||||
|
||||
async function onSubmit() {
|
||||
const changes: API.DataEditRole = {};
|
||||
|
||||
if (editGroup.controls.name.isDirty) {
|
||||
changes.name = editGroup.controls.name.value.trim();
|
||||
}
|
||||
|
||||
if (editGroup.controls.hoist.isDirty) {
|
||||
changes.hoist = editGroup.controls.hoist.value;
|
||||
}
|
||||
|
||||
if (editGroup.controls.colour.isDirty) {
|
||||
changes.colour = editGroup.controls.colour.value ?? null;
|
||||
}
|
||||
|
||||
await props.context.editRole(props.roleId, changes);
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
editGroup.controls.name.setValue(role()?.name || "");
|
||||
editGroup.controls.hoist.setValue(role()?.hoist || false);
|
||||
editGroup.controls.colour.setValue(role()?.colour || null);
|
||||
}
|
||||
|
||||
const submit = Form2.useSubmitHandler(editGroup, onSubmit, onReset);
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<form onSubmit={submit}>
|
||||
<Column gap="lg">
|
||||
<Column>
|
||||
<Form2.TextField
|
||||
name="name"
|
||||
control={editGroup.controls.name}
|
||||
label={t`Role Name`}
|
||||
/>
|
||||
</Column>
|
||||
<Column>
|
||||
<Row align>
|
||||
<IconButton
|
||||
ref={setPickerRef}
|
||||
variant="filled"
|
||||
shape="square"
|
||||
size="lg"
|
||||
onPress={() => pickerRef()?.click()}
|
||||
>
|
||||
<MDPalette />
|
||||
</IconButton>
|
||||
<input
|
||||
ref={setPickerRef}
|
||||
type="color"
|
||||
value={editGroup.controls.colour.value ?? "#ffffff"}
|
||||
onInput={(e) => {
|
||||
const colour = (e.currentTarget as HTMLInputElement).value;
|
||||
editGroup.controls.colour.setValue(colour);
|
||||
editGroup.controls.colour.markDirty(true);
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
opacity: 0,
|
||||
width: "0px",
|
||||
height: "0px",
|
||||
padding: 0,
|
||||
border: "none",
|
||||
}}
|
||||
/>
|
||||
<Column gap="lg">
|
||||
<Row justify>
|
||||
<For
|
||||
each={[
|
||||
"#7B68EE",
|
||||
"#3498DB",
|
||||
"#1ABC9C",
|
||||
"#F1C40F",
|
||||
"#FF7F50",
|
||||
"#FD6671",
|
||||
"#E91E63",
|
||||
"#D468EE",
|
||||
]}
|
||||
>
|
||||
{(colour) => (
|
||||
<Button
|
||||
size="sm"
|
||||
bg={colour}
|
||||
group="standard"
|
||||
groupActive={editGroup.controls.colour.value === colour}
|
||||
onPress={() => {
|
||||
editGroup.controls.colour.setValue(colour);
|
||||
editGroup.controls.colour.markDirty(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Row>
|
||||
|
||||
<Row justify>
|
||||
<For
|
||||
each={[
|
||||
"#594CAD",
|
||||
"#206694",
|
||||
"#11806A",
|
||||
"#C27C0E",
|
||||
"#CD5B45",
|
||||
"#FF424F",
|
||||
"#AD1457",
|
||||
"#954AA8",
|
||||
]}
|
||||
>
|
||||
{(colour) => (
|
||||
<Button
|
||||
size="sm"
|
||||
bg={colour}
|
||||
group="standard"
|
||||
groupActive={editGroup.controls.colour.value === colour}
|
||||
onPress={() => {
|
||||
editGroup.controls.colour.setValue(colour);
|
||||
editGroup.controls.colour.markDirty(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Row>
|
||||
</Column>
|
||||
</Row>
|
||||
</Column>
|
||||
|
||||
<Column>
|
||||
<Text class="label">Hoist Role</Text>
|
||||
<Form2.Checkbox control={editGroup.controls.hoist}>
|
||||
Display this role above others
|
||||
</Form2.Checkbox>
|
||||
</Column>
|
||||
|
||||
<Column>
|
||||
<Row>
|
||||
<Form2.Reset group={editGroup} onReset={onReset} />
|
||||
<Form2.Submit group={editGroup} requireDirty>
|
||||
<Trans>Save</Trans>
|
||||
</Form2.Submit>
|
||||
<Show when={editGroup.isPending}>
|
||||
<CircularProgress />
|
||||
</Show>
|
||||
</Row>
|
||||
</Column>
|
||||
</Column>
|
||||
</form>
|
||||
<Divider />
|
||||
<ChannelPermissionsEditor
|
||||
type="server_role"
|
||||
context={props.context}
|
||||
roleId={props.roleId}
|
||||
/>
|
||||
<Column>
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
icon={<MdContentCopy />}
|
||||
onClick={() => navigator.clipboard.writeText(`${props.roleId}`)}
|
||||
>
|
||||
<Trans>Copy role ID</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
icon={<MdDelete />}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
type: "delete_role",
|
||||
role: role(),
|
||||
cb: () => navigate("roles"),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Delete Role</Trans>
|
||||
</CategoryButton>
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
export const Divider = styled("div", {
|
||||
base: {
|
||||
height: "1px",
|
||||
margin: "var(--gap-sm) 0",
|
||||
background: "var(--md-sys-color-outline-variant)",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { BiRegularListUl } from "solid-icons/bi";
|
||||
import { Show } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { useMutation } from "@tanstack/solid-query";
|
||||
import { Server } from "stoat.js";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { CategoryButton, Column, Draggable, Text, iconSize } from "@revolt/ui";
|
||||
import { createDragHandle } from "@revolt/ui/components/utils/Draggable";
|
||||
|
||||
import MdDragIndicator from "@material-design-icons/svg/outlined/drag_indicator.svg?component-solid";
|
||||
import MdGroupAdd from "@material-design-icons/svg/outlined/group_add.svg?component-solid";
|
||||
|
||||
import { useSettingsNavigation } from "../../Settings";
|
||||
|
||||
/**
|
||||
* Menu to see all roles
|
||||
*/
|
||||
export function ServerRoleOverview(props: { context: Server }) {
|
||||
const { navigate } = useSettingsNavigation();
|
||||
const { openModal, showError } = useModals();
|
||||
|
||||
const change = useMutation(() => ({
|
||||
mutationFn: (order: string[]) => props.context.setRoleOrdering(order),
|
||||
onError: showError,
|
||||
}));
|
||||
|
||||
function createRole() {
|
||||
openModal({
|
||||
type: "create_role",
|
||||
server: props.context,
|
||||
callback(roleId) {
|
||||
navigate(`roles/${roleId}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<Column gap="sm">
|
||||
<CategoryButton
|
||||
icon={<BiRegularListUl size={20} />}
|
||||
action="chevron"
|
||||
description={<Trans>Affects all roles and users</Trans>}
|
||||
onClick={() => navigate("roles/default")}
|
||||
>
|
||||
<Trans>Default Permissions</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon={<MdGroupAdd {...iconSize(20)} />}
|
||||
action="chevron"
|
||||
description={<Trans>Create a new role</Trans>}
|
||||
onClick={createRole}
|
||||
>
|
||||
<Trans>Create Role</Trans>
|
||||
</CategoryButton>
|
||||
</Column>
|
||||
|
||||
<Column gap="sm">
|
||||
<Text class="label">
|
||||
<Trans>Server Roles</Trans>
|
||||
<Show when={change.isPending}>
|
||||
{" "}
|
||||
<Trans>(changes are being saved…)</Trans>
|
||||
</Show>
|
||||
</Text>
|
||||
<Draggable
|
||||
dragHandles
|
||||
items={props.context.orderedRoles}
|
||||
onChange={change.mutate}
|
||||
>
|
||||
{(entry) => (
|
||||
<ItemContainer>
|
||||
<MdDragIndicator
|
||||
{...createDragHandle(entry.dragDisabled, entry.setDragDisabled)}
|
||||
/>
|
||||
|
||||
<CategoryButton
|
||||
icon={
|
||||
<RoleIcon
|
||||
style={{
|
||||
background:
|
||||
entry.item.colour ??
|
||||
"var(--md-sys-color-outline-variant)",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
action="chevron"
|
||||
onClick={() => navigate(`roles/${entry.item.id}`)}
|
||||
>
|
||||
{entry.item.name}
|
||||
</CategoryButton>
|
||||
</ItemContainer>
|
||||
)}
|
||||
</Draggable>
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const RoleIcon = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
aspectRatio: "1/1",
|
||||
borderRadius: "100%",
|
||||
},
|
||||
});
|
||||
|
||||
const ItemContainer = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--gap-md)",
|
||||
paddingBottom: "var(--gap-md)",
|
||||
|
||||
// grow the button to full width
|
||||
"& > :nth-child(2)": {
|
||||
flexGrow: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Column } from "@revolt/ui";
|
||||
|
||||
/**
|
||||
* Accessibility settings page
|
||||
*/
|
||||
export default function Accessibility() {
|
||||
return (
|
||||
<Column gap="lg">
|
||||
{/* <CategoryButtonGroup>
|
||||
<FormGroup>
|
||||
<CategoryButton
|
||||
action={<Checkbox value onChange={(value) => void value} />}
|
||||
onClick={() => void 0}
|
||||
icon={<MdAnimation {...iconSize(22)} />}
|
||||
description={
|
||||
<Trans>
|
||||
If this is enabled, animations and motion effects won't play or
|
||||
will be less intense.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Reduced Motion</Trans>
|
||||
</CategoryButton>
|
||||
</FormGroup>
|
||||
</CategoryButtonGroup> */}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
import { Match, Show, Switch, createMemo, createSignal } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useClient, useClientLifecycle } from "@revolt/client";
|
||||
import {
|
||||
createMfaResource,
|
||||
createOwnProfileResource,
|
||||
} from "@revolt/client/resources";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { CategoryButton, Column, Row, iconSize } from "@revolt/ui";
|
||||
|
||||
import MdAlternateEmail from "@material-design-icons/svg/outlined/alternate_email.svg?component-solid";
|
||||
import MdBlock from "@material-design-icons/svg/outlined/block.svg?component-solid";
|
||||
import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid";
|
||||
import MdLock from "@material-design-icons/svg/outlined/lock.svg?component-solid";
|
||||
import MdMail from "@material-design-icons/svg/outlined/mail.svg?component-solid";
|
||||
import MdPassword from "@material-design-icons/svg/outlined/password.svg?component-solid";
|
||||
import MdVerifiedUser from "@material-design-icons/svg/outlined/verified_user.svg?component-solid";
|
||||
|
||||
import { useSettingsNavigation } from "../Settings";
|
||||
|
||||
import { UserSummary } from "./account/index";
|
||||
|
||||
/**
|
||||
* Account Page
|
||||
*/
|
||||
export function MyAccount() {
|
||||
const client = useClient();
|
||||
const profile = createOwnProfileResource();
|
||||
const { navigate } = useSettingsNavigation();
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<UserSummary
|
||||
user={client().user!}
|
||||
bannerUrl={profile.data?.animatedBannerURL}
|
||||
onEdit={() => navigate("profile")}
|
||||
showBadges
|
||||
/>
|
||||
<EditAccount />
|
||||
<MultiFactorAuth />
|
||||
<ManageAccount />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit account details
|
||||
*/
|
||||
function EditAccount() {
|
||||
const client = useClient();
|
||||
const { openModal } = useModals();
|
||||
const [email, setEmail] = createSignal("•••••••••••@•••••••••••");
|
||||
|
||||
return (
|
||||
<CategoryButton.Group>
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
type: "edit_username",
|
||||
client: client(),
|
||||
})
|
||||
}
|
||||
icon={<MdAlternateEmail {...iconSize(22)} />}
|
||||
description={client().user?.username}
|
||||
>
|
||||
<Trans>Username</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
type: "edit_email",
|
||||
client: client(),
|
||||
})
|
||||
}
|
||||
icon={<MdMail {...iconSize(22)} />}
|
||||
description={
|
||||
<Row>
|
||||
{email()}{" "}
|
||||
<Show when={email().startsWith("•")}>
|
||||
<a
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
client().account.fetchEmail().then(setEmail);
|
||||
}}
|
||||
>
|
||||
Reveal
|
||||
</a>
|
||||
</Show>
|
||||
</Row>
|
||||
}
|
||||
>
|
||||
<Trans>Email</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
type: "edit_password",
|
||||
client: client(),
|
||||
})
|
||||
}
|
||||
icon={<MdPassword {...iconSize(22)} />}
|
||||
description={"•••••••••"}
|
||||
>
|
||||
<Trans>Password</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryButton.Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-factor authentication
|
||||
*/
|
||||
function MultiFactorAuth() {
|
||||
const client = useClient();
|
||||
const mfa = createMfaResource();
|
||||
const { openModal, mfaFlow, mfaEnableTOTP, showError } = useModals();
|
||||
|
||||
/**
|
||||
* Show recovery codes
|
||||
*/
|
||||
async function showRecoveryCodes() {
|
||||
const ticket = await mfaFlow(mfa.data!);
|
||||
|
||||
ticket!.fetchRecoveryCodes().then((codes) =>
|
||||
openModal({
|
||||
type: "mfa_recovery",
|
||||
mfa: mfa.data!,
|
||||
codes,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recovery codes
|
||||
*/
|
||||
async function generateRecoveryCodes() {
|
||||
const ticket = await mfaFlow(mfa.data!);
|
||||
|
||||
ticket!.generateRecoveryCodes().then((codes) =>
|
||||
openModal({
|
||||
type: "mfa_recovery",
|
||||
mfa: mfa.data!,
|
||||
codes,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure authenticator app
|
||||
*/
|
||||
async function setupAuthenticatorApp() {
|
||||
const ticket = await mfaFlow(mfa.data!);
|
||||
const secret = await ticket!.generateAuthenticatorSecret();
|
||||
|
||||
let success;
|
||||
while (!success) {
|
||||
try {
|
||||
const code = await mfaEnableTOTP(secret, client().user!.username);
|
||||
|
||||
if (code) {
|
||||
await mfa.data!.enableAuthenticator(code);
|
||||
success = true;
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable authenticator app
|
||||
*/
|
||||
function disableAuthenticatorApp() {
|
||||
mfaFlow(mfa.data!).then((ticket) => ticket!.disableAuthenticator());
|
||||
}
|
||||
|
||||
return (
|
||||
<CategoryButton.Group>
|
||||
<CategoryButton.Collapse
|
||||
icon={<MdVerifiedUser {...iconSize(22)} />}
|
||||
title={<Trans>Recovery Codes</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Configure a way to get back into your account in case your 2FA is
|
||||
lost
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
fallback={
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
disabled={mfa.isLoading}
|
||||
onClick={generateRecoveryCodes}
|
||||
description={<Trans>Setup recovery codes</Trans>}
|
||||
>
|
||||
<Trans>Generate Recovery Codes</Trans>
|
||||
</CategoryButton>
|
||||
}
|
||||
>
|
||||
<Match when={!mfa.isLoading && mfa.data?.recoveryEnabled}>
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
description={<Trans>Get active recovery codes</Trans>}
|
||||
onClick={showRecoveryCodes}
|
||||
>
|
||||
<Trans>View Recovery Codes</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
description={<Trans>Get a new set of recovery codes</Trans>}
|
||||
onClick={generateRecoveryCodes}
|
||||
>
|
||||
<Trans>Reset Recovery Codes</Trans>
|
||||
</CategoryButton>
|
||||
</Match>
|
||||
</Switch>
|
||||
</CategoryButton.Collapse>
|
||||
<CategoryButton.Collapse
|
||||
icon={<MdLock {...iconSize(22)} />}
|
||||
title={<Trans>Authenticator App</Trans>}
|
||||
description={<Trans>Configure one-time password authentication</Trans>}
|
||||
>
|
||||
<Switch
|
||||
fallback={
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
disabled={mfa.isLoading}
|
||||
onClick={setupAuthenticatorApp}
|
||||
description={<Trans>Setup one-time password authenticator</Trans>}
|
||||
>
|
||||
<Trans>Enable Authenticator</Trans>
|
||||
</CategoryButton>
|
||||
}
|
||||
>
|
||||
<Match when={!mfa.isLoading && mfa.data?.authenticatorEnabled}>
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
description={
|
||||
<Trans>Disable one-time password authenticator</Trans>
|
||||
}
|
||||
onClick={disableAuthenticatorApp}
|
||||
>
|
||||
<Trans>Remove Authenticator</Trans>
|
||||
</CategoryButton>
|
||||
</Match>
|
||||
</Switch>
|
||||
</CategoryButton.Collapse>
|
||||
</CategoryButton.Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage account
|
||||
*/
|
||||
function ManageAccount() {
|
||||
const client = useClient();
|
||||
const mfa = createMfaResource();
|
||||
const { mfaFlow } = useModals();
|
||||
const { logout } = useClientLifecycle();
|
||||
|
||||
const stillOwnServers = createMemo(
|
||||
() =>
|
||||
client().servers.filter((server) => server.owner?.self || false).length >
|
||||
0,
|
||||
);
|
||||
|
||||
/**
|
||||
* Disable account
|
||||
*/
|
||||
function disableAccount() {
|
||||
mfaFlow(mfa.data!).then((ticket) =>
|
||||
ticket!.disableAccount().then(() => logout()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete account
|
||||
*/
|
||||
function deleteAccount() {
|
||||
mfaFlow(mfa.data!).then((ticket) =>
|
||||
ticket!.deleteAccount().then(() => logout()),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CategoryButton.Group>
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
disabled={mfa.isLoading}
|
||||
onClick={disableAccount}
|
||||
icon={<MdBlock {...iconSize(22)} fill="var(--md-sys-color-error)" />}
|
||||
description={
|
||||
<Trans>
|
||||
You won't be able to access your account unless you contact support
|
||||
- however, your data will not be deleted.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Disable Account</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action={stillOwnServers() ? undefined : "chevron"}
|
||||
disabled={mfa.isLoading || stillOwnServers()}
|
||||
onClick={deleteAccount}
|
||||
icon={<MdDelete {...iconSize(22)} fill="var(--md-sys-color-error)" />}
|
||||
description={
|
||||
<Trans>
|
||||
Your account and all of your data (including your messages and
|
||||
friends list) will be queued for deletion. A confirmation email will
|
||||
be sent - you can cancel this within 7 days by contacting support.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Switch fallback={<Trans>Delete Account</Trans>}>
|
||||
<Match when={stillOwnServers()}>
|
||||
<Trans>
|
||||
Cannot delete account until servers are deleted or transferred
|
||||
</Trans>
|
||||
</Match>
|
||||
</Switch>
|
||||
</CategoryButton>
|
||||
</CategoryButton.Group>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { For } from "solid-js";
|
||||
|
||||
import { useState } from "@revolt/state";
|
||||
import {
|
||||
AVAILABLE_EXPERIMENTS,
|
||||
EXPERIMENTS,
|
||||
} from "@revolt/state/stores/Experiments";
|
||||
import {
|
||||
CategoryButton,
|
||||
CategoryButtonGroup,
|
||||
Checkbox,
|
||||
Column,
|
||||
} from "@revolt/ui";
|
||||
|
||||
/**
|
||||
* Advanced settings
|
||||
*/
|
||||
export default function AdvancedSettings() {
|
||||
const state = useState();
|
||||
|
||||
return (
|
||||
<Column gap="xl">
|
||||
<Column>
|
||||
<Checkbox
|
||||
checked={state.settings.getValue("appearance:compact_mode")}
|
||||
onChange={(e) =>
|
||||
state.settings.setValue(
|
||||
"appearance:compact_mode",
|
||||
e.currentTarget.checked,
|
||||
)
|
||||
}
|
||||
>
|
||||
Compact mode
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={state.settings.getValue("advanced:copy_id")}
|
||||
onChange={(e) =>
|
||||
state.settings.setValue("advanced:copy_id", e.currentTarget.checked)
|
||||
}
|
||||
>
|
||||
Show 'copy ID' in context menus
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={state.settings.getValue("advanced:admin_panel")}
|
||||
onChange={(e) =>
|
||||
state.settings.setValue(
|
||||
"advanced:admin_panel",
|
||||
e.currentTarget.checked,
|
||||
)
|
||||
}
|
||||
>
|
||||
Show admin panel shortcuts in context menus
|
||||
</Checkbox>
|
||||
</Column>
|
||||
<CategoryButtonGroup>
|
||||
<For each={AVAILABLE_EXPERIMENTS}>
|
||||
{(key) => (
|
||||
<CategoryButton
|
||||
action={
|
||||
<Checkbox
|
||||
checked={state.experiments.isEnabled(key)}
|
||||
onChange={(event) =>
|
||||
state.experiments.setEnabled(
|
||||
key,
|
||||
event.currentTarget.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
description={EXPERIMENTS[key].description}
|
||||
onClick={() => void 0}
|
||||
>
|
||||
{EXPERIMENTS[key].title}
|
||||
</CategoryButton>
|
||||
)}
|
||||
</For>
|
||||
</CategoryButtonGroup>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import {
|
||||
CategoryButton,
|
||||
CategoryButtonGroup,
|
||||
Column,
|
||||
iconSize,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdGroups3 from "@material-design-icons/svg/filled/groups_3.svg?component-solid";
|
||||
import MdBugReport from "@material-design-icons/svg/outlined/bug_report.svg?component-solid";
|
||||
import MdFormatListNumbered from "@material-design-icons/svg/outlined/format_list_numbered.svg?component-solid";
|
||||
import MdStar from "@material-design-icons/svg/outlined/star_outline.svg?component-solid";
|
||||
import { useClient } from "@revolt/client";
|
||||
import { CONFIGURATION } from "@revolt/common";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Match, Switch } from "solid-js";
|
||||
import { PublicChannelInvite } from "stoat.js";
|
||||
|
||||
/**
|
||||
* Feedback
|
||||
*/
|
||||
export function Feedback() {
|
||||
const { openModal, pop } = useModals();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient();
|
||||
|
||||
const showLoungeButton = CONFIGURATION.IS_STOAT;
|
||||
const isInLounge =
|
||||
client()!.servers.get("01F7ZSBSFHQ8TA81725KQCSDDP") !== undefined;
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<CategoryButtonGroup>
|
||||
{/* <Link
|
||||
href="https://example.com"
|
||||
target="_blank"
|
||||
>
|
||||
<CategoryButton
|
||||
action="external"
|
||||
icon={<MdViewKanban {...iconSize(22)} />}
|
||||
onClick={() => void 0}
|
||||
description={<Trans>See what we're currently working on.</Trans>}
|
||||
>
|
||||
<Trans>Roadmap</Trans>
|
||||
</CategoryButton>
|
||||
</Link> */}
|
||||
<Link
|
||||
href="https://github.com/orgs/stoatchat/discussions/categories/feature-suggestions"
|
||||
target="_blank"
|
||||
>
|
||||
<CategoryButton
|
||||
action="external"
|
||||
icon={<MdStar {...iconSize(22)} />}
|
||||
onClick={() => void 0}
|
||||
description={
|
||||
<Trans>Suggest new Stoat features on GitHub discussions.</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Submit feature suggestion</Trans>
|
||||
</CategoryButton>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/orgs/stoatchat/discussions/categories/feedback"
|
||||
target="_blank"
|
||||
>
|
||||
<CategoryButton
|
||||
action="external"
|
||||
icon={<MdFormatListNumbered {...iconSize(22)} />}
|
||||
onClick={() => void 0}
|
||||
description={<Trans>Submit feedback</Trans>}
|
||||
>
|
||||
<Trans>Feedback</Trans>
|
||||
</CategoryButton>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/stoatchat/for-web/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug"
|
||||
target="_blank"
|
||||
>
|
||||
<CategoryButton
|
||||
action="external"
|
||||
icon={<MdBugReport {...iconSize(22)} />}
|
||||
onClick={() => void 0}
|
||||
description={<Trans>View currently active bug reports here.</Trans>}
|
||||
>
|
||||
<Trans>Bug Tracker</Trans>
|
||||
</CategoryButton>
|
||||
</Link>
|
||||
<Switch fallback={null}>
|
||||
<Match when={showLoungeButton && isInLounge}>
|
||||
<CategoryButton
|
||||
onClick={() => {
|
||||
navigate("/server/01F7ZSBSFHQ8TA81725KQCSDDP");
|
||||
pop();
|
||||
}}
|
||||
description={
|
||||
<Trans>
|
||||
You can report issues and discuss improvements with us
|
||||
directly here.
|
||||
</Trans>
|
||||
}
|
||||
icon={<MdGroups3 />}
|
||||
>
|
||||
<Trans>Go to the Stoat Lounge</Trans>
|
||||
</CategoryButton>
|
||||
</Match>
|
||||
<Match when={showLoungeButton && !isInLounge}>
|
||||
<CategoryButton
|
||||
onClick={() => {
|
||||
client()
|
||||
.api.get("/invites/Testers")
|
||||
.then((invite) => PublicChannelInvite.from(client(), invite))
|
||||
.then((invite) => openModal({ type: "invite", invite }));
|
||||
}}
|
||||
description={
|
||||
<Trans>
|
||||
You can report issues and discuss improvements with us
|
||||
directly here.
|
||||
</Trans>
|
||||
}
|
||||
icon={<MdGroups3 />}
|
||||
>
|
||||
<Trans>Join the Stoat Lounge</Trans>
|
||||
</CategoryButton>
|
||||
</Match>
|
||||
</Switch>
|
||||
</CategoryButtonGroup>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link without decorations
|
||||
*/
|
||||
const Link = styled("a", {
|
||||
base: {
|
||||
textDecoration: "none",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
import { For, createMemo } from "solid-js";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { Language, Languages, browserPreferredLanguage } from "@revolt/i18n";
|
||||
import type { LanguageEntry } from "@revolt/i18n/Languages";
|
||||
import { timeLocale } from "@revolt/i18n/dayjs";
|
||||
import { UnicodeEmoji } from "@revolt/markdown/emoji";
|
||||
import { useState } from "@revolt/state";
|
||||
import {
|
||||
CategoryButton,
|
||||
CategoryButtonGroup,
|
||||
CategoryCollapse,
|
||||
Checkbox,
|
||||
Column,
|
||||
Row,
|
||||
Time,
|
||||
iconSize,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdErrorFill from "@material-design-icons/svg/filled/error.svg?component-solid";
|
||||
import MdVerifiedFill from "@material-design-icons/svg/filled/verified.svg?component-solid";
|
||||
import MdCalendarMonth from "@material-design-icons/svg/outlined/calendar_month.svg?component-solid";
|
||||
import MdLanguage from "@material-design-icons/svg/outlined/language.svg?component-solid";
|
||||
import MdSchedule from "@material-design-icons/svg/outlined/schedule.svg?component-solid";
|
||||
import MdTranslate from "@material-design-icons/svg/outlined/translate.svg?component-solid";
|
||||
|
||||
/**
|
||||
* Language
|
||||
*/
|
||||
export function LanguageSettings() {
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<CategoryButtonGroup>
|
||||
<PickLanguage />
|
||||
{/* <ConfigureRTL /> */}
|
||||
</CategoryButtonGroup>
|
||||
<CategoryButtonGroup>
|
||||
<PickDateFormat />
|
||||
<PickTimeFormat />
|
||||
</CategoryButtonGroup>
|
||||
<CategoryButtonGroup>
|
||||
<ContributeLanguageLink />
|
||||
</CategoryButtonGroup>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick user's preferred language
|
||||
*/
|
||||
function PickLanguage() {
|
||||
const state = useState();
|
||||
const { i18n } = useLingui();
|
||||
|
||||
/**
|
||||
* Determine the current language
|
||||
*/
|
||||
const currentLanguage = () =>
|
||||
Languages[i18n().locale as never] as LanguageEntry;
|
||||
|
||||
// Generate languages array.
|
||||
const languages = createMemo(() => {
|
||||
const languages = Object.keys(Languages).map(
|
||||
(x) => [x, Languages[x as keyof typeof Languages]] as const,
|
||||
);
|
||||
|
||||
const preferredLanguage = browserPreferredLanguage();
|
||||
|
||||
if (preferredLanguage) {
|
||||
// This moves the user's system language to the top of the language list
|
||||
const prefLangKey = languages.find(
|
||||
(lang) => lang[0].replace(/_/g, "-") == preferredLanguage,
|
||||
);
|
||||
|
||||
if (prefLangKey) {
|
||||
languages.splice(
|
||||
0,
|
||||
0,
|
||||
languages.splice(languages.indexOf(prefLangKey), 1)[0],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return languages;
|
||||
});
|
||||
|
||||
return (
|
||||
<CategoryCollapse
|
||||
icon={<MdLanguage {...iconSize(22)} />}
|
||||
title={<Trans>Select your language</Trans>}
|
||||
description={currentLanguage().display}
|
||||
scrollable
|
||||
>
|
||||
<For each={languages()}>
|
||||
{([id, lang]) => (
|
||||
<CategoryButton
|
||||
icon={<UnicodeEmoji emoji={lang.emoji} />}
|
||||
action={<Checkbox checked={id === i18n().locale} />}
|
||||
onClick={() => state.locale.switch(id as Language)}
|
||||
>
|
||||
<Row>
|
||||
{lang.display}{" "}
|
||||
{lang.verified && (
|
||||
<MdVerifiedFill
|
||||
{...iconSize(18)}
|
||||
fill="var(--md-sys-color-on-surface)"
|
||||
/>
|
||||
)}{" "}
|
||||
{lang.incomplete && (
|
||||
<MdErrorFill
|
||||
{...iconSize(18)}
|
||||
fill="var(--md-sys-color-on-surface)"
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</CategoryButton>
|
||||
)}
|
||||
</For>
|
||||
</CategoryCollapse>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick user's preferred date format
|
||||
*/
|
||||
function PickDateFormat() {
|
||||
const state = useState();
|
||||
const { t } = useLingui();
|
||||
const date = () => timeLocale()[1].formats.L;
|
||||
|
||||
const LastWeek = new Date();
|
||||
LastWeek.setDate(LastWeek.getDate() - 7);
|
||||
|
||||
return (
|
||||
<CategoryCollapse
|
||||
icon={<MdCalendarMonth {...iconSize(22)} />}
|
||||
title="Select date format"
|
||||
description={
|
||||
date() === "DD/MM/YYYY"
|
||||
? t`Traditional (DD/MM/YYYY)`
|
||||
: date() === "MM/DD/YYYY"
|
||||
? t`American (MM/DD/YYYY)`
|
||||
: date() === "YYYY-MM-DD"
|
||||
? t`ISO Standard (YYYY-MM-DD)`
|
||||
: date()
|
||||
}
|
||||
>
|
||||
<CategoryButton
|
||||
icon={"blank"}
|
||||
onClick={() => state.locale.setDateFormat("DD/MM/YYYY")}
|
||||
action={<Checkbox checked={date() === "DD/MM/YYYY"} />}
|
||||
description={<Time format="date" value={LastWeek} />}
|
||||
>
|
||||
<Trans>Traditional (DD/MM/YYYY)</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon={"blank"}
|
||||
onClick={() => state.locale.setDateFormat("MM/DD/YYYY")}
|
||||
action={<Checkbox checked={date() === "MM/DD/YYYY"} />}
|
||||
description={<Time format="dateAmerican" value={LastWeek} />}
|
||||
>
|
||||
<Trans>American (MM/DD/YYYY)</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon={"blank"}
|
||||
onClick={() => state.locale.setDateFormat("YYYY-MM-DD")}
|
||||
action={<Checkbox checked={date() === "YYYY-MM-DD"} />}
|
||||
description={<Time format="iso8601" value={LastWeek} />}
|
||||
>
|
||||
<Trans>ISO Standard (YYYY-MM-DD)</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryCollapse>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick user's preferred time format
|
||||
*/
|
||||
function PickTimeFormat() {
|
||||
const state = useState();
|
||||
const { t } = useLingui();
|
||||
const time = () => timeLocale()[1].formats.LT;
|
||||
|
||||
return (
|
||||
<CategoryCollapse
|
||||
icon={<MdSchedule {...iconSize(22)} />}
|
||||
title="Select time format"
|
||||
description={time() === "HH:mm" ? t`24 hours` : t`12 hours`}
|
||||
>
|
||||
<CategoryButton
|
||||
icon={"blank"}
|
||||
onClick={() => state.locale.setTimeFormat("HH:mm")}
|
||||
action={<Checkbox checked={time() === "HH:mm"} />}
|
||||
description={<Time format="time24" value={new Date()} />}
|
||||
>
|
||||
<Trans>24 hours</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon={"blank"}
|
||||
onClick={() => state.locale.setTimeFormat("h:mm A")}
|
||||
action={<Checkbox checked={time() === "h:mm A"} />}
|
||||
description={<Time format="time12" value={new Date()} />}
|
||||
>
|
||||
<Trans>12 hours</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryCollapse>
|
||||
);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Configure right-to-left display
|
||||
// */
|
||||
// function ConfigureRTL() {
|
||||
// /**
|
||||
// * Determine the current language
|
||||
// */
|
||||
// const currentLanguage = () => Languages[language()];
|
||||
|
||||
// return (
|
||||
// <Switch
|
||||
// fallback={
|
||||
// <CategoryButton
|
||||
// icon={<MdKeyboardTabRtl {...iconSize(22)} />}
|
||||
// description={<Trans>Flip the user interface right to left</Trans>}
|
||||
// action={<Checkbox />}
|
||||
// onClick={() => void 0}
|
||||
// >
|
||||
// <Trans>Enable RTL layout</Trans>
|
||||
// </CategoryButton>
|
||||
// }
|
||||
// >
|
||||
// <Match when={currentLanguage().rtl}>
|
||||
// <CategoryButton
|
||||
// icon={<MdKeyboardTab {...iconSize(22)} />}
|
||||
// description={<Trans>Keep the user interface left to right</Trans>}
|
||||
// action={<Checkbox />}
|
||||
// onClick={() => void 0}
|
||||
// >
|
||||
// <Trans>Force LTR layout</Trans>
|
||||
// </CategoryButton>
|
||||
// </Match>
|
||||
// </Switch>
|
||||
// );
|
||||
// }
|
||||
|
||||
/**
|
||||
* Language contribution link
|
||||
*/
|
||||
function ContributeLanguageLink() {
|
||||
return (
|
||||
<a href="https://weblate.insrt.uk/engage/revolt/" target="_blank">
|
||||
<CategoryButton
|
||||
action="external"
|
||||
icon={<MdTranslate {...iconSize(22)} />}
|
||||
onClick={() => void 0}
|
||||
description={
|
||||
<Trans>Help contribute to an existing or new language</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Contribute a language</Trans>
|
||||
</CategoryButton>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { createSignal, onMount } from "solid-js";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { CategoryButton, Checkbox, Column } from "@revolt/ui";
|
||||
import { Symbol } from "@revolt/ui/components/utils/Symbol";
|
||||
|
||||
declare type DesktopConfig = {
|
||||
firstLaunch: boolean;
|
||||
customFrame: boolean;
|
||||
minimiseToTray: boolean;
|
||||
spellchecker: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
discordRpc: boolean;
|
||||
windowState: {
|
||||
isMaximised: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
native: {
|
||||
versions: {
|
||||
node(): string;
|
||||
chrome(): string;
|
||||
electron(): string;
|
||||
desktop(): string;
|
||||
};
|
||||
minimise(): void;
|
||||
maximise(): void;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
desktopConfig: {
|
||||
get(): DesktopConfig;
|
||||
set(config: Partial<DesktopConfig>): void;
|
||||
getAutostart(): Promise<boolean>;
|
||||
setAutostart(value: boolean): Promise<boolean>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop Configuration Page
|
||||
*/
|
||||
export default function Native() {
|
||||
const { t } = useLingui();
|
||||
const [autostart, setAutostart] = createSignal(false);
|
||||
const [config, setConfig] = createSignal(window.desktopConfig.get());
|
||||
|
||||
function set(config: Partial<DesktopConfig>) {
|
||||
window.desktopConfig.set(config);
|
||||
setConfig((conf) => ({ ...conf, ...config }));
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const value = await window.desktopConfig.getAutostart();
|
||||
setAutostart(value);
|
||||
});
|
||||
|
||||
async function toggleAutostart() {
|
||||
const newValue = !autostart();
|
||||
const savedValue = await window.desktopConfig.setAutostart(newValue);
|
||||
setAutostart(savedValue);
|
||||
}
|
||||
|
||||
const toggles: Partial<Record<keyof DesktopConfig, () => void>> = {
|
||||
minimiseToTray: () => set({ minimiseToTray: !config().minimiseToTray }),
|
||||
customFrame: () => set({ customFrame: !config().customFrame }),
|
||||
discordRpc: () => set({ discordRpc: !config().discordRpc }),
|
||||
spellchecker: () => set({ spellchecker: !config().spellchecker }),
|
||||
hardwareAcceleration: () =>
|
||||
set({ hardwareAcceleration: !config().hardwareAcceleration }),
|
||||
};
|
||||
|
||||
function CheckboxButton<K extends keyof DesktopConfig>(
|
||||
key: K,
|
||||
icon: string,
|
||||
label: string,
|
||||
description: string,
|
||||
) {
|
||||
return (
|
||||
<CategoryButton
|
||||
action={
|
||||
<Checkbox
|
||||
checked={config()[key]}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
toggles[key]!();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={toggles[key]}
|
||||
icon={<Symbol>{icon}</Symbol>}
|
||||
description={description}
|
||||
>
|
||||
{label}
|
||||
</CategoryButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<CategoryButton.Group>
|
||||
<CategoryButton
|
||||
action={
|
||||
<Checkbox
|
||||
checked={autostart()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={toggleAutostart}
|
||||
/>
|
||||
}
|
||||
onClick={toggleAutostart}
|
||||
icon={<Symbol>exit_to_app</Symbol>}
|
||||
description={
|
||||
<Trans>Launch Stoat when you log into your computer.</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Start with Computer</Trans>
|
||||
</CategoryButton>
|
||||
{CheckboxButton(
|
||||
"minimiseToTray",
|
||||
"cancel_presentation",
|
||||
t`Minimise to Tray`,
|
||||
t`Instead of closing, Stoat will hide in your tray.`,
|
||||
)}
|
||||
{CheckboxButton(
|
||||
"customFrame",
|
||||
"web_asset",
|
||||
t`Custom window frame`,
|
||||
t`Let Stoat use its own custom titlebar.`,
|
||||
)}
|
||||
</CategoryButton.Group>
|
||||
|
||||
<CategoryButton.Group>
|
||||
{CheckboxButton(
|
||||
"discordRpc",
|
||||
"groups_2",
|
||||
t`Discord RPC`,
|
||||
t`Rep Stoat using Discord rich presence.`,
|
||||
)}
|
||||
{CheckboxButton(
|
||||
"spellchecker",
|
||||
"spellcheck",
|
||||
t`Spellchecker`,
|
||||
t`Show corrections and suggestions as you type.`,
|
||||
)}
|
||||
{CheckboxButton(
|
||||
"hardwareAcceleration",
|
||||
"speed",
|
||||
t`Hardware Acceleration`,
|
||||
t`Use the graphics card to improve performance.`,
|
||||
)}
|
||||
</CategoryButton.Group>
|
||||
|
||||
<CategoryButton.Group>
|
||||
<CategoryButton
|
||||
icon={<Symbol>desktop_windows</Symbol>}
|
||||
description={
|
||||
<>
|
||||
<Trans>Version:</Trans> {window.native.versions.desktop()}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Trans>Stoat for Desktop</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryButton.Group>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import {
|
||||
CategoryButton,
|
||||
CategoryButtonGroup,
|
||||
CategoryCollapse,
|
||||
Checkbox,
|
||||
iconSize,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdNotifications from "@material-design-icons/svg/outlined/notifications.svg?component-solid";
|
||||
import MdSpeaker from "@material-design-icons/svg/outlined/speaker.svg?component-solid";
|
||||
|
||||
/**
|
||||
* Notifications Page
|
||||
*/
|
||||
export default function Notifications() {
|
||||
return (
|
||||
<CategoryButtonGroup>
|
||||
<CategoryButton
|
||||
action={<Checkbox checked onChange={(value) => void value} />}
|
||||
onClick={() => void 0}
|
||||
icon={<MdNotifications {...iconSize(22)} />}
|
||||
description={
|
||||
<Trans>
|
||||
Receive notifications while the app is open and in the background.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Enable Desktop Notifications</Trans>
|
||||
</CategoryButton>
|
||||
{/* <FormGroup>
|
||||
<CategoryButton
|
||||
action={<Checkbox value onChange={(value) => void value} />}
|
||||
onClick={() => void 0}
|
||||
icon={<MdMarkUnreadChatAlt {...iconSize(22)} />}
|
||||
description={t(
|
||||
"app.settings.pages.notifications.descriptions.enable_push"
|
||||
)}
|
||||
>
|
||||
{t("app.settings.pages.notifications.enable_push")}
|
||||
</CategoryButton>
|
||||
</FormGroup> */}
|
||||
<CategoryCollapse
|
||||
title={<Trans>Sounds</Trans>}
|
||||
icon={<MdSpeaker {...iconSize(22)} />}
|
||||
>
|
||||
<CategoryButton
|
||||
action={<Checkbox checked onChange={(value) => void value} />}
|
||||
onClick={() => void 0}
|
||||
icon="blank"
|
||||
>
|
||||
<Trans>Message Received</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action={<Checkbox onChange={(value) => void value} />}
|
||||
onClick={() => void 0}
|
||||
icon="blank"
|
||||
>
|
||||
<Trans>Message Sent</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action={<Checkbox checked onChange={(value) => void value} />}
|
||||
onClick={() => void 0}
|
||||
icon="blank"
|
||||
>
|
||||
<Trans>User Joined Call</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action={<Checkbox checked onChange={(value) => void value} />}
|
||||
onClick={() => void 0}
|
||||
icon="blank"
|
||||
>
|
||||
<Trans>User Left Call</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryCollapse>
|
||||
</CategoryButtonGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
import {
|
||||
BiLogosAndroid,
|
||||
BiLogosApple,
|
||||
BiLogosWindows,
|
||||
BiRegularQuestionMark,
|
||||
} from "solid-icons/bi";
|
||||
import { FaBrandsLinux } from "solid-icons/fa";
|
||||
import {
|
||||
Accessor,
|
||||
For,
|
||||
Match,
|
||||
Show,
|
||||
Switch,
|
||||
createMemo,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { Session } from "stoat.js";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import {
|
||||
CategoryButton,
|
||||
CategoryButtonGroup,
|
||||
CategoryCollapse,
|
||||
CircularProgress,
|
||||
Column,
|
||||
Time,
|
||||
iconSize,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdLogout from "@material-design-icons/svg/outlined/logout.svg?component-solid";
|
||||
|
||||
/**
|
||||
* Sessions
|
||||
*/
|
||||
export function Sessions() {
|
||||
const client = useClient();
|
||||
onMount(() => client().sessions.fetch());
|
||||
|
||||
/**
|
||||
* Sort the other sessions by created date
|
||||
*/
|
||||
const otherSessions = createMemo(() =>
|
||||
client()
|
||||
.sessions.filter((session) => !session.current)
|
||||
.sort((a, b) => +b.createdAt - +a.createdAt),
|
||||
);
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<Switch fallback={<CircularProgress />}>
|
||||
<Match when={client().sessions.size()}>
|
||||
<ManageCurrentSession otherSessions={otherSessions} />
|
||||
<ListOtherSessions otherSessions={otherSessions} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage user's current session
|
||||
*/
|
||||
function ManageCurrentSession(props: { otherSessions: Accessor<Session[]> }) {
|
||||
const client = useClient();
|
||||
const { openModal } = useModals();
|
||||
|
||||
/**
|
||||
* Resolve current session
|
||||
*/
|
||||
const currentSession = () => client().sessions.get(client().sessionId!);
|
||||
|
||||
return (
|
||||
<CategoryButtonGroup>
|
||||
<CategoryCollapse
|
||||
title={<Trans>Current Session</Trans>}
|
||||
description={currentSession()?.name}
|
||||
icon={<SessionIcon session={currentSession()} />}
|
||||
>
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
action="chevron"
|
||||
onClick={() =>
|
||||
currentSession() &&
|
||||
openModal({
|
||||
type: "rename_session",
|
||||
session: currentSession()!,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Rename</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryCollapse>
|
||||
{/* <CategoryButton
|
||||
action="chevron"
|
||||
icon={
|
||||
<MdAutoMode
|
||||
{...iconSize(24)}
|
||||
fill="var(--md-sys-color-error)"
|
||||
/>
|
||||
}
|
||||
description={Keeps your last sessions active and automatically logs you out of other ones"}
|
||||
>
|
||||
Keep Last Active Sessions
|
||||
</CategoryButton> */}
|
||||
<Show when={props.otherSessions().length}>
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
type: "sign_out_sessions",
|
||||
client: client(),
|
||||
})
|
||||
}
|
||||
icon={<MdLogout {...iconSize(24)} fill="var(--md-sys-color-error)" />}
|
||||
description={
|
||||
<Trans>Logs you out of all sessions except this device.</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Log Out Other Sessions</Trans>
|
||||
</CategoryButton>
|
||||
</Show>
|
||||
</CategoryButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List other logged in sessions
|
||||
*/
|
||||
function ListOtherSessions(props: { otherSessions: Accessor<Session[]> }) {
|
||||
const { openModal } = useModals();
|
||||
|
||||
return (
|
||||
<Show when={props.otherSessions().length}>
|
||||
<Column>
|
||||
<CategoryButtonGroup>
|
||||
<For each={props.otherSessions()}>
|
||||
{(session) => (
|
||||
<CategoryCollapse
|
||||
icon={<SessionIcon session={session} />}
|
||||
title={<Capitalise>{session.name}</Capitalise>}
|
||||
description={
|
||||
<Trans>
|
||||
Created <Time value={session.createdAt} format="relative" />
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
action="chevron"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
type: "rename_session",
|
||||
session,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Rename</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
action="chevron"
|
||||
onClick={() => session.delete()}
|
||||
>
|
||||
<Trans>Log Out</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryCollapse>
|
||||
)}
|
||||
</For>
|
||||
</CategoryButtonGroup>
|
||||
</Column>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize session titles
|
||||
*/
|
||||
const Capitalise = styled("div", {
|
||||
base: {
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Show icon for session
|
||||
*/
|
||||
function SessionIcon(props: { session?: Session }) {
|
||||
return (
|
||||
<Switch fallback={<BiRegularQuestionMark size={22} />}>
|
||||
<Match when={/linux/i.test(props.session?.name ?? "")}>
|
||||
<FaBrandsLinux size={22} />
|
||||
</Match>
|
||||
<Match when={/windows/i.test(props.session?.name ?? "")}>
|
||||
<BiLogosWindows size={22} />
|
||||
</Match>
|
||||
<Match when={/android/i.test(props.session?.name ?? "")}>
|
||||
<BiLogosAndroid size={22} />
|
||||
</Match>
|
||||
<Match when={/mac.*os|i(Pad)?os/i.test(props.session?.name ?? "")}>
|
||||
<BiLogosApple size={22} />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import {
|
||||
CategoryButton,
|
||||
CategoryButtonGroup,
|
||||
Checkbox,
|
||||
Column,
|
||||
Time,
|
||||
iconSize,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdBrush from "@material-design-icons/svg/outlined/brush.svg?component-solid";
|
||||
import MdLanguage from "@material-design-icons/svg/outlined/language.svg?component-solid";
|
||||
import MdPalette from "@material-design-icons/svg/outlined/palette.svg?component-solid";
|
||||
|
||||
/**
|
||||
* Sync Configuration Page
|
||||
*/
|
||||
export default function Sync() {
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<CategoryButtonGroup>
|
||||
<CategoryButton
|
||||
action={<Checkbox checked onChange={(value) => void value} />}
|
||||
onClick={() => void 0}
|
||||
icon={<MdPalette {...iconSize(22)} />}
|
||||
description={
|
||||
<Trans>
|
||||
Sync appearance options, such as chosen emoji pack and message
|
||||
density.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Appearance</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action={<Checkbox checked onChange={(value) => void value} />}
|
||||
onClick={() => void 0}
|
||||
icon={<MdBrush {...iconSize(22)} />}
|
||||
description={
|
||||
<Trans>Sync your chosen theme, colours, and any custom CSS.</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Theme</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action={<Checkbox checked onChange={(value) => void value} />}
|
||||
onClick={() => void 0}
|
||||
icon={<MdLanguage {...iconSize(22)} />}
|
||||
description={<Trans>Sync your currently chosen language.</Trans>}
|
||||
>
|
||||
<Trans>Language</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryButtonGroup>
|
||||
<CategoryButtonGroup>
|
||||
<CategoryButton>
|
||||
<Trans>
|
||||
Last sync <Time format="relative" value={0} />
|
||||
</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryButtonGroup>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { Avatar, OverflowingText, Ripple, typography } from "@revolt/ui";
|
||||
|
||||
import { useSettingsNavigation } from "../Settings";
|
||||
import {
|
||||
SidebarButton,
|
||||
SidebarButtonContent,
|
||||
SidebarButtonTitle,
|
||||
} from "../_layout/SidebarButton";
|
||||
|
||||
/**
|
||||
* Account Card
|
||||
*/
|
||||
export function AccountCard() {
|
||||
const client = useClient();
|
||||
const { page, navigate } = useSettingsNavigation();
|
||||
|
||||
return (
|
||||
<SidebarButton
|
||||
onClick={() => navigate("account")}
|
||||
aria-selected={page() === "account"}
|
||||
>
|
||||
<Ripple />
|
||||
<SidebarButtonTitle>
|
||||
<Avatar size={36} src={client().user!.animatedAvatarURL} />
|
||||
<SidebarButtonContent>
|
||||
<OverflowingText
|
||||
class={typography({ class: "label", size: "small" })}
|
||||
>
|
||||
{client().user!.displayName}
|
||||
</OverflowingText>
|
||||
<Trans>My Account</Trans>
|
||||
</SidebarButtonContent>
|
||||
</SidebarButtonTitle>
|
||||
{/*<SidebarButtonIcon>
|
||||
<MdError {...iconSize(20)} fill={theme!.colour("primary")} />
|
||||
</SidebarButtonIcon>*/}
|
||||
</SidebarButton>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { Show } from "solid-js";
|
||||
|
||||
import { User } from "stoat.js";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { useTime } from "@revolt/i18n";
|
||||
import { Avatar, CategoryButton, IconButton, iconSize } from "@revolt/ui";
|
||||
|
||||
import MdCakeFill from "@material-design-icons/svg/filled/cake.svg?component-solid";
|
||||
import MdEdit from "@material-design-icons/svg/outlined/edit.svg?component-solid";
|
||||
|
||||
export function UserSummary(props: {
|
||||
user: User;
|
||||
showBadges?: boolean;
|
||||
bannerUrl?: string;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const dayjs = useTime();
|
||||
const bannerStyle = () =>
|
||||
props.bannerUrl
|
||||
? {
|
||||
"background-image": `linear-gradient(color-mix(in srgb, var(--md-sys-color-surface-container-low) 70%, transparent), color-mix(in srgb, var(--md-sys-color-surface-container-low) 70%, transparent)), url("${props.bannerUrl}")`,
|
||||
color: "black",
|
||||
}
|
||||
: {
|
||||
background: `var(--md-sys-color-primary-container)`,
|
||||
color: "var(--md-sys-color-on-primary)",
|
||||
};
|
||||
|
||||
return (
|
||||
<CategoryButton.Group>
|
||||
<AccountBox style={bannerStyle()}>
|
||||
<ProfileDetails>
|
||||
<Avatar src={props.user.animatedAvatarURL} size={58} />
|
||||
<Username>
|
||||
<span>{props.user.displayName}</span>
|
||||
<span>
|
||||
{props.user.username}#{props.user.discriminator}
|
||||
</span>
|
||||
</Username>
|
||||
<Show when={props.onEdit}>
|
||||
<IconButton variant="filled" shape="square" onPress={props.onEdit}>
|
||||
<MdEdit />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</ProfileDetails>
|
||||
<Show when={props.showBadges}>
|
||||
<BottomBar>
|
||||
<DummyPadding />
|
||||
{/* <ProfileBadges>
|
||||
<MdDraw {...iconSize(20)} />
|
||||
<MdDraw {...iconSize(20)} />
|
||||
<MdDraw {...iconSize(20)} />
|
||||
</ProfileBadges> */}
|
||||
<ProfileBadges>
|
||||
<span
|
||||
use:floating={{
|
||||
tooltip: {
|
||||
placement: "top",
|
||||
// todo
|
||||
content: dayjs(props.user.createdAt).format(
|
||||
"[Account created] Do MMMM YYYY [at] HH:mm",
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MdCakeFill {...iconSize(14)} />
|
||||
</span>
|
||||
</ProfileBadges>
|
||||
</BottomBar>
|
||||
</Show>
|
||||
</AccountBox>
|
||||
</CategoryButton.Group>
|
||||
);
|
||||
}
|
||||
|
||||
const AccountBox = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
padding: "var(--gap-lg)",
|
||||
flexDirection: "column",
|
||||
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
},
|
||||
});
|
||||
|
||||
const ProfileDetails = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
gap: "var(--gap-lg)",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
|
||||
const Username = styled("div", {
|
||||
base: {
|
||||
flexGrow: 1,
|
||||
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
|
||||
color: "var(--md-sys-color-on-secondary-container)",
|
||||
|
||||
// Display Name
|
||||
"& :nth-child(1)": {
|
||||
fontSize: "18px",
|
||||
fontWeight: 600,
|
||||
},
|
||||
|
||||
// Username#Discrim
|
||||
"& :nth-child(2)": {
|
||||
fontSize: "14px",
|
||||
fontWeight: 400,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const BottomBar = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
},
|
||||
});
|
||||
|
||||
const DummyPadding = styled("div", {
|
||||
base: {
|
||||
flexShrink: 0,
|
||||
// Matches with avatar size
|
||||
width: "58px",
|
||||
// Matches with ProfileDetails
|
||||
marginInlineEnd: "var(--gap-lg)",
|
||||
},
|
||||
});
|
||||
|
||||
const ProfileBadges = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
gap: "var(--gap-sm)",
|
||||
width: "fit-content",
|
||||
padding: "var(--gap-md)",
|
||||
borderRadius: "var(--borderRadius-md)",
|
||||
|
||||
fill: "var(--md-sys-color-on-secondary)",
|
||||
background: "var(--md-sys-color-secondary)",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { UserSummary } from "./UserSummary";
|
||||
|
|
@ -0,0 +1,459 @@
|
|||
import { For, Match, Show, Switch, createSignal } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { css } from "styled-system/css";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { useUser } from "@revolt/client";
|
||||
import {
|
||||
UNICODE_EMOJI_PACKS,
|
||||
UnicodeEmoji,
|
||||
UnicodeEmojiPacks,
|
||||
} from "@revolt/markdown/emoji/UnicodeEmoji";
|
||||
import { useState } from "@revolt/state";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Checkbox,
|
||||
Column,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
MessageContainer,
|
||||
Row,
|
||||
Slider,
|
||||
Text,
|
||||
TextField,
|
||||
} from "@revolt/ui";
|
||||
import {
|
||||
FONT_KEYS,
|
||||
Fonts,
|
||||
MONOSPACE_FONT_KEYS,
|
||||
MonospaceFonts,
|
||||
} from "@revolt/ui/themes/fonts";
|
||||
|
||||
import MDPalette from "@material-design-icons/svg/outlined/palette.svg?component-solid";
|
||||
|
||||
/**
|
||||
* All appearance options for the client
|
||||
*/
|
||||
export function AppearanceMenu() {
|
||||
const user = useUser();
|
||||
const state = useState();
|
||||
const [pickerRef, setPickerRef] = createSignal<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<MessagePreview>
|
||||
<Text>
|
||||
Welcome to the new appearance menu, custom themes are not available
|
||||
just yet but we are looking for feedback on how to best implement
|
||||
them!
|
||||
</Text>
|
||||
</MessagePreview>
|
||||
|
||||
<Column>
|
||||
<Text class="title" size="small">
|
||||
Colours
|
||||
</Text>
|
||||
|
||||
<Row justify="stretch">
|
||||
<Button
|
||||
group="connected-start"
|
||||
groupActive={state.theme.mode === "light"}
|
||||
onPress={() => state.theme.setMode("light")}
|
||||
>
|
||||
<Trans>Light</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
group="connected"
|
||||
groupActive={state.theme.mode === "dark"}
|
||||
onPress={() => state.theme.setMode("dark")}
|
||||
>
|
||||
<Trans>Dark</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
group="connected-end"
|
||||
groupActive={state.theme.mode === "system"}
|
||||
onPress={() => state.theme.setMode("system")}
|
||||
>
|
||||
<Trans>System</Trans>
|
||||
</Button>
|
||||
</Row>
|
||||
|
||||
{/* <Row justify="stretch">
|
||||
<Button
|
||||
group="connected-start"
|
||||
groupActive={state.theme.preset === "stoat"}
|
||||
onPress={() => state.theme.setPreset("stoat")}
|
||||
>
|
||||
<Trans>Stoat</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
group="connected-end"
|
||||
groupActive={state.theme.preset === "you"}
|
||||
onPress={() => state.theme.setPreset("you")}
|
||||
>
|
||||
<Trans>Material You</Trans>
|
||||
</Button>
|
||||
</Row> */}
|
||||
|
||||
<Show when={state.theme.preset === "you"}>
|
||||
<Row align justify>
|
||||
<IconButton
|
||||
ref={setPickerRef}
|
||||
variant="filled"
|
||||
shape="square"
|
||||
size="md"
|
||||
onPress={() => pickerRef()?.click()}
|
||||
>
|
||||
<MDPalette />
|
||||
</IconButton>
|
||||
<input
|
||||
ref={setPickerRef}
|
||||
type="color"
|
||||
value={state.theme.m3Accent ?? "#ffffff"}
|
||||
onInput={(e) => {
|
||||
const colour = (e.currentTarget as HTMLInputElement).value;
|
||||
state.theme.setM3Accent(colour);
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
opacity: 0,
|
||||
width: "0px",
|
||||
height: "0px",
|
||||
padding: 0,
|
||||
border: "none",
|
||||
}}
|
||||
/>
|
||||
<For
|
||||
each={[
|
||||
"#FF5733",
|
||||
"#ffdc2f",
|
||||
"#9bf088",
|
||||
"#54ecc1",
|
||||
"#549bec",
|
||||
"#5470ec",
|
||||
"#8C5FD3",
|
||||
]}
|
||||
>
|
||||
{(colour) => (
|
||||
<Button
|
||||
size="md"
|
||||
bg={colour}
|
||||
group="standard"
|
||||
groupActive={state.theme.m3Accent === colour}
|
||||
onPress={() => state.theme.setM3Accent(colour)}
|
||||
/>
|
||||
// <div
|
||||
// class={css({
|
||||
// borderRadius: "var(--borderRadius-full)",
|
||||
// width: "48px",
|
||||
// height: "48px",
|
||||
// cursor: "pointer",
|
||||
// })}
|
||||
// style={{ "background-color": colour }}
|
||||
// onClick={() => state.theme.setM3Accent(colour)}
|
||||
// />
|
||||
)}
|
||||
</For>
|
||||
{/* <div
|
||||
class={css({
|
||||
borderRadius: "var(--borderRadius-full)",
|
||||
width: "48px",
|
||||
height: "48px",
|
||||
cursor: "pointer",
|
||||
})}
|
||||
>
|
||||
<MdColorize />
|
||||
</div> */}
|
||||
</Row>
|
||||
|
||||
<Row justify="stretch">
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected-start"
|
||||
groupActive={state.theme.m3Contrast.toFixed(1) === "-1.0"}
|
||||
onPress={() => state.theme.setM3Contrast(-1.0)}
|
||||
>
|
||||
<Trans>Reduced</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected"
|
||||
groupActive={state.theme.m3Contrast.toFixed(1) === "0.0"}
|
||||
onPress={() => state.theme.setM3Contrast(0)}
|
||||
>
|
||||
<Trans>Normal</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected"
|
||||
groupActive={state.theme.m3Contrast.toFixed(1) === "0.5"}
|
||||
onPress={() => state.theme.setM3Contrast(0.5)}
|
||||
>
|
||||
<Trans>More Contrast</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected-end"
|
||||
groupActive={state.theme.m3Contrast.toFixed(1) === "1.0"}
|
||||
onPress={() => state.theme.setM3Contrast(1.0)}
|
||||
>
|
||||
<Trans>High Contrast</Trans>
|
||||
</Button>
|
||||
</Row>
|
||||
|
||||
<Row justify="stretch">
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected-start"
|
||||
groupActive={state.theme.m3Variant === "monochrome"}
|
||||
onPress={() => state.theme.setM3Variant("monochrome")}
|
||||
>
|
||||
<Trans>Monochrome</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected"
|
||||
groupActive={state.theme.m3Variant === "neutral"}
|
||||
onPress={() => state.theme.setM3Variant("neutral")}
|
||||
>
|
||||
<Trans>Neutral</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected"
|
||||
groupActive={state.theme.m3Variant === "tonal_spot"}
|
||||
onPress={() => state.theme.setM3Variant("tonal_spot")}
|
||||
>
|
||||
<Trans>Tonal Spot</Trans>
|
||||
</Button>
|
||||
{/* <Button
|
||||
size="xs"
|
||||
group="connected"
|
||||
groupActive={state.theme.m3Variant === "vibrant"}
|
||||
onPress={() => state.theme.setM3Variant("vibrant")}
|
||||
>
|
||||
<Trans>Vibrant</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected"
|
||||
groupActive={state.theme.m3Variant === "expressive"}
|
||||
onPress={() => state.theme.setM3Variant("expressive")}
|
||||
>
|
||||
<Trans>Expressive</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected"
|
||||
groupActive={state.theme.m3Variant === "fidelity"}
|
||||
onPress={() => state.theme.setM3Variant("fidelity")}
|
||||
>
|
||||
<Trans>Fidelity</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected"
|
||||
groupActive={state.theme.m3Variant === "content"}
|
||||
onPress={() => state.theme.setM3Variant("content")}
|
||||
>
|
||||
<Trans>Content</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected"
|
||||
groupActive={state.theme.m3Variant === "rainbow"}
|
||||
onPress={() => state.theme.setM3Variant("rainbow")}
|
||||
>
|
||||
<Trans>Rainbow</Trans>
|
||||
</Button> */}
|
||||
<Button
|
||||
size="xs"
|
||||
group="connected-end"
|
||||
groupActive={state.theme.m3Variant === "fruit_salad"}
|
||||
onPress={() => state.theme.setM3Variant("fruit_salad")}
|
||||
>
|
||||
<Trans>Fruit Salad</Trans>
|
||||
</Button>
|
||||
</Row>
|
||||
</Show>
|
||||
</Column>
|
||||
|
||||
<Column>
|
||||
<Text class="title" size="small">
|
||||
<Trans>Display & Text</Trans>
|
||||
</Text>
|
||||
|
||||
<Checkbox checked={state.theme.blur} onChange={state.theme.toggleBlur}>
|
||||
<Trans>
|
||||
Enable transparency glass/blur effects (slow on older machines)
|
||||
</Trans>
|
||||
</Checkbox>
|
||||
|
||||
<Preview>
|
||||
<MessagePreview>
|
||||
<MessageContainer
|
||||
avatar={
|
||||
<Avatar
|
||||
size={36}
|
||||
src={user()?.animatedAvatarURL}
|
||||
fallback={user()?.displayName}
|
||||
/>
|
||||
}
|
||||
timestamp={new Date()}
|
||||
username={user()?.displayName}
|
||||
isLink="hide"
|
||||
>
|
||||
Sphinx of black quartz, judge my vow
|
||||
</MessageContainer>
|
||||
<MessageContainer
|
||||
avatar={<Avatar size={36} fallback={"M"} />}
|
||||
timestamp={new Date()}
|
||||
username={"MysticPixie"}
|
||||
isLink="hide"
|
||||
>
|
||||
<code class={css({ fontFamily: `var(--fonts-monospace)` })}>
|
||||
The quick brown fox jumped over the lazy dog
|
||||
</code>
|
||||
</MessageContainer>
|
||||
</MessagePreview>
|
||||
</Preview>
|
||||
|
||||
<Text class="label">
|
||||
<Trans>Message Size</Trans>
|
||||
</Text>
|
||||
<Slider
|
||||
min={12}
|
||||
max={24}
|
||||
value={state.theme.messageSize}
|
||||
onInput={(event) =>
|
||||
(state.theme.messageSize = event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</Column>
|
||||
|
||||
<Text class="label">
|
||||
<Trans>Message Group Spacing</Trans>
|
||||
</Text>
|
||||
<Slider
|
||||
min={0}
|
||||
max={16}
|
||||
value={state.theme.messageGroupSpacing}
|
||||
onInput={(event) =>
|
||||
(state.theme.messageGroupSpacing = event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Text class="label">
|
||||
<Trans>Interface Font</Trans>
|
||||
</Text>
|
||||
<TextField.Select
|
||||
title="Interface Font"
|
||||
value={state.theme.interfaceFont}
|
||||
onChange={(e) =>
|
||||
state.theme.setInterfaceFont(e.currentTarget.value as Fonts)
|
||||
}
|
||||
>
|
||||
<For each={FONT_KEYS}>
|
||||
{(key) => <MenuItem value={key}>{key}</MenuItem>}
|
||||
</For>
|
||||
</TextField.Select>
|
||||
|
||||
<Text class="label">
|
||||
<Trans>Monospace Font</Trans>
|
||||
</Text>
|
||||
<TextField.Select
|
||||
title="Monospace Font"
|
||||
value={state.theme.monospaceFont}
|
||||
onChange={(e) =>
|
||||
state.theme.setMonospaceFont(e.currentTarget.value as MonospaceFonts)
|
||||
}
|
||||
>
|
||||
<For each={MONOSPACE_FONT_KEYS}>
|
||||
{(key) => <MenuItem value={key}>{key}</MenuItem>}
|
||||
</For>
|
||||
</TextField.Select>
|
||||
|
||||
<Column>
|
||||
<Text class="title" size="small">
|
||||
<Trans>Chat Input</Trans>
|
||||
</Text>
|
||||
|
||||
<Checkbox
|
||||
checked={state.settings.getValue("appearance:show_send_button")}
|
||||
onChange={(event) =>
|
||||
state.settings.setValue(
|
||||
"appearance:show_send_button",
|
||||
event.currentTarget.checked,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Show send message button</Trans>
|
||||
</Checkbox>
|
||||
|
||||
<Text class="label">
|
||||
<Trans>Emoji Pack (affects your messages only)</Trans>
|
||||
</Text>
|
||||
<TextField.Select
|
||||
value={state.settings.getValue("appearance:unicode_emoji")}
|
||||
onChange={(e) =>
|
||||
state.settings.setValue(
|
||||
"appearance:unicode_emoji",
|
||||
e.currentTarget.value as never,
|
||||
)
|
||||
}
|
||||
>
|
||||
<For each={UNICODE_EMOJI_PACKS}>
|
||||
{(pack) => <EmojiPack pack={pack} />}
|
||||
</For>
|
||||
</TextField.Select>
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an individual emoji pack
|
||||
* @param pack Pack
|
||||
*/
|
||||
function EmojiPack(props: { pack: UnicodeEmojiPacks }) {
|
||||
return (
|
||||
<MenuItem value={props.pack}>
|
||||
<Row>
|
||||
<UnicodeEmoji emoji="😃" pack={props.pack} />
|
||||
<UnicodeEmoji emoji="😂" pack={props.pack} />
|
||||
<UnicodeEmoji emoji="😶🌫️" pack={props.pack} />
|
||||
<UnicodeEmoji emoji="🤨" pack={props.pack} />
|
||||
<UnicodeEmoji emoji="🤔" pack={props.pack} />
|
||||
<Switch>
|
||||
<Match when={props.pack === "fluent-3d"}>Fluent 3D</Match>
|
||||
<Match when={props.pack === "fluent-color"}>Fluent Color</Match>
|
||||
<Match when={props.pack === "fluent-flat"}>Fluent Flat</Match>
|
||||
<Match when={props.pack === "mutant"}>Mutant Remix</Match>
|
||||
<Match when={props.pack === "noto"}>Noto</Match>
|
||||
<Match when={props.pack === "openmoji"}>OpenMoji</Match>
|
||||
<Match when={props.pack === "twemoji"}>Twemoji</Match>
|
||||
</Switch>
|
||||
</Row>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Preview = styled("div", {
|
||||
base: {
|
||||
height: "126px",
|
||||
overflow: "hidden",
|
||||
borderRadius: "var(--borderRadius-lg)",
|
||||
background: "var(--md-sys-color-surface-container-highest)",
|
||||
},
|
||||
});
|
||||
|
||||
const MessagePreview = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "var(--gap-md)",
|
||||
gap: "var(--message-group-spacing)",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { AppearanceMenu } from "./AppearanceMenu";
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import { ErrorBoundary, For, Suspense } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { createOwnBotsResource } from "@revolt/client/resources";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import {
|
||||
Avatar,
|
||||
CategoryButton,
|
||||
CategoryButtonGroup,
|
||||
CircularProgress,
|
||||
Column,
|
||||
iconSize,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdLibraryBooks from "@material-design-icons/svg/outlined/library_books.svg?component-solid";
|
||||
import MdSmartToy from "@material-design-icons/svg/outlined/smart_toy.svg?component-solid";
|
||||
|
||||
import { useSettingsNavigation } from "../../Settings";
|
||||
|
||||
/**
|
||||
* View all owned bots
|
||||
*/
|
||||
export function MyBots() {
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<CreateBot />
|
||||
<ListBots />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt to create a new bot
|
||||
*/
|
||||
function CreateBot() {
|
||||
const client = useClient();
|
||||
const { openModal } = useModals();
|
||||
const { navigate } = useSettingsNavigation();
|
||||
|
||||
return (
|
||||
<CategoryButtonGroup>
|
||||
<CategoryButton
|
||||
action="chevron"
|
||||
icon={<MdSmartToy {...iconSize(22)} />}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
type: "create_bot",
|
||||
client: client(),
|
||||
onCreate(bot) {
|
||||
navigate(`bots/${bot.id}`);
|
||||
},
|
||||
})
|
||||
}
|
||||
description={
|
||||
<Trans>
|
||||
You agree that your bot is subject to the Acceptable Usage Policy.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Create Bot</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
action="external"
|
||||
icon={<MdLibraryBooks {...iconSize(22)} />}
|
||||
onClick={() => window.open("https://developers.stoat.chat", "_blank")}
|
||||
description={
|
||||
<Trans>Learn more about how to create bots on Stoat.</Trans>
|
||||
}
|
||||
>
|
||||
<Trans>Developer Documentation</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List owned bots by current user
|
||||
*/
|
||||
function ListBots() {
|
||||
const { navigate } = useSettingsNavigation();
|
||||
const bots = createOwnBotsResource();
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback="Failed to load bots...">
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<CategoryButtonGroup>
|
||||
<For each={bots.data}>
|
||||
{(bot) => (
|
||||
<CategoryButton
|
||||
icon={
|
||||
<Avatar
|
||||
src={bot.user!.animatedAvatarURL}
|
||||
size={24}
|
||||
fallback={bot.user!.displayName}
|
||||
/>
|
||||
}
|
||||
onClick={() => navigate(`bots/${bot.id}`)}
|
||||
action="chevron"
|
||||
// description={bot.id}
|
||||
>
|
||||
{bot.user!.displayName}
|
||||
</CategoryButton>
|
||||
)}
|
||||
</For>
|
||||
</CategoryButtonGroup>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { Bot } from "stoat.js";
|
||||
|
||||
import { createProfileResource } from "@revolt/client/resources";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { CategoryButton, Column, iconSize } from "@revolt/ui";
|
||||
|
||||
import MdContentCopy from "@material-design-icons/svg/outlined/content_copy.svg?component-solid";
|
||||
import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid";
|
||||
import MdKey from "@material-design-icons/svg/outlined/key.svg?component-solid";
|
||||
import MdLink from "@material-design-icons/svg/outlined/link.svg?component-solid";
|
||||
import MdPersonAdd from "@material-design-icons/svg/outlined/person_add.svg?component-solid";
|
||||
import MdPublic from "@material-design-icons/svg/outlined/public.svg?component-solid";
|
||||
import MdToken from "@material-design-icons/svg/outlined/token.svg?component-solid";
|
||||
|
||||
import { UserSummary } from "../account/index";
|
||||
import { UserProfileEditor } from "../profile/UserProfileEditor";
|
||||
|
||||
/**
|
||||
* View a specific bot
|
||||
*/
|
||||
export function ViewBot(props: { bot: Bot }) {
|
||||
// `bot` will never change, so we don't care about reactivity here
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const profile = createProfileResource(props.bot.user!);
|
||||
const { openModal } = useModals();
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<UserSummary
|
||||
user={props.bot.user!}
|
||||
showBadges
|
||||
bannerUrl={profile.data?.animatedBannerURL}
|
||||
/>
|
||||
|
||||
<UserProfileEditor user={props.bot.user!} />
|
||||
{/* <ErrorBoundary fallback={<>Failed to load profile</>}>
|
||||
<Suspense fallback={<>loading...</>}>{profile.data?.content}</Suspense>
|
||||
</ErrorBoundary> */}
|
||||
|
||||
<CategoryButton.Group>
|
||||
<CategoryButton
|
||||
description={
|
||||
<Trans>Generate a new token if it gets lost or compromised</Trans>
|
||||
}
|
||||
icon={<MdToken {...iconSize(22)} />}
|
||||
action="chevron"
|
||||
onClick={() => openModal({ type: "reset_bot_token", bot: props.bot })}
|
||||
>
|
||||
<Trans>Reset Token</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
description={
|
||||
<Trans>
|
||||
Allow others to add your bot to their servers from Discover
|
||||
</Trans>
|
||||
}
|
||||
icon={<MdPublic {...iconSize(22)} />}
|
||||
action="chevron"
|
||||
>
|
||||
<Trans>Submit to Discover</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryButton.Group>
|
||||
|
||||
<CategoryButton.Group>
|
||||
<CategoryButton
|
||||
icon={<MdPersonAdd {...iconSize(22)} />}
|
||||
action="chevron"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
type: "add_bot",
|
||||
invite: props.bot.publicBot,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Invite Bot</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon={<MdLink {...iconSize(22)} />}
|
||||
action="copy"
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
new URL(`/bot/${props.bot.id}`, window.origin).toString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Copy Invite URL</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon={<MdContentCopy {...iconSize(22)} />}
|
||||
action="copy"
|
||||
onClick={() => navigator.clipboard.writeText(props.bot.id)}
|
||||
>
|
||||
<Trans>Copy ID</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon={<MdKey {...iconSize(22)} />}
|
||||
action="copy"
|
||||
onClick={() => navigator.clipboard.writeText(props.bot.token)}
|
||||
>
|
||||
<Trans>Copy Token</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon={<MdDelete {...iconSize(22)} />}
|
||||
action="chevron"
|
||||
onClick={() => openModal({ type: "delete_bot", bot: props.bot })}
|
||||
>
|
||||
<Trans>Delete Bot</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryButton.Group>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { MyBots } from "./MyBots";
|
||||
export { ViewBot } from "./ViewBot";
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { For } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { createOwnProfileResource } from "@revolt/client/resources";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { Avatar, CategoryButton, Column, Text, iconSize } from "@revolt/ui";
|
||||
|
||||
import MdGroups from "@material-design-icons/svg/outlined/groups.svg?component-solid";
|
||||
|
||||
import { UserSummary } from "../account/index";
|
||||
|
||||
import { UserProfileEditor } from "./UserProfileEditor";
|
||||
|
||||
/**
|
||||
* Edit profile
|
||||
*/
|
||||
export function EditProfile() {
|
||||
const client = useClient();
|
||||
const { openModal } = useModals();
|
||||
const profile = createOwnProfileResource();
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<UserSummary
|
||||
user={client().user!}
|
||||
bannerUrl={profile.data?.animatedBannerURL}
|
||||
/>
|
||||
|
||||
<CategoryButton.Group>
|
||||
<CategoryButton.Collapse
|
||||
icon={<MdGroups {...iconSize(22)} />}
|
||||
title={<Trans>Server Identities</Trans>}
|
||||
description={<Trans>Change your profile per-server</Trans>}
|
||||
scrollable
|
||||
>
|
||||
<For each={client().servers.toList()}>
|
||||
{(server) => (
|
||||
<CategoryButton
|
||||
icon={
|
||||
<Avatar
|
||||
src={server.animatedIconURL}
|
||||
size={24}
|
||||
fallback={server.name}
|
||||
/>
|
||||
}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
type: "server_identity",
|
||||
member: server.member!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{server.name}
|
||||
</CategoryButton>
|
||||
)}
|
||||
</For>
|
||||
</CategoryButton.Collapse>
|
||||
</CategoryButton.Group>
|
||||
|
||||
<Column>
|
||||
<Text class="title" size="large">
|
||||
<Trans>Edit Global Profile</Trans>
|
||||
</Text>
|
||||
<UserProfileEditor user={client().user!} />
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
import { createFormControl, createFormGroup } from "solid-forms";
|
||||
import { Show, createEffect, createSignal, on } from "solid-js";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
import { useQuery, useQueryClient } from "@tanstack/solid-query";
|
||||
import { API, User } from "stoat.js";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { CONFIGURATION } from "@revolt/common";
|
||||
import {
|
||||
CategoryButton,
|
||||
CircularProgress,
|
||||
Column,
|
||||
Form2,
|
||||
Row,
|
||||
Text,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdBadge from "@material-design-icons/svg/filled/badge.svg?component-solid";
|
||||
|
||||
import { useSettingsNavigation } from "../../Settings";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function UserProfileEditor(props: Props) {
|
||||
const { t } = useLingui();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const profile = useQuery(() => ({
|
||||
queryKey: ["profile", props.user.id],
|
||||
queryFn: () => props.user.fetchProfile(),
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
}));
|
||||
|
||||
const { navigate } = useSettingsNavigation();
|
||||
|
||||
/* eslint-disable solid/reactivity */
|
||||
const editGroup = createFormGroup({
|
||||
displayName: createFormControl(props.user.displayName),
|
||||
// username: createFormControl(props.user.username),
|
||||
avatar: createFormControl<string | File[] | null>(
|
||||
props.user.animatedAvatarURL,
|
||||
),
|
||||
banner: createFormControl<string | File[] | null>(null),
|
||||
bio: createFormControl(""),
|
||||
});
|
||||
/* eslint-enable solid/reactivity */
|
||||
|
||||
// unlike the other forms, this one does not react to
|
||||
// further changes outside of our control because it's
|
||||
// unlikely that the user is going to be doing this
|
||||
|
||||
const [initialBio, setInitialBio] = createSignal<readonly [string]>();
|
||||
|
||||
// once profile data is loaded, copy it into the form
|
||||
createEffect(
|
||||
on(
|
||||
() => profile.data,
|
||||
(profileData) => {
|
||||
if (profileData) {
|
||||
editGroup.controls.banner.setValue(
|
||||
profileData.animatedBannerURL || null,
|
||||
);
|
||||
|
||||
editGroup.controls.bio.setValue(profileData.content || "");
|
||||
setInitialBio([profileData.content || ""]);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function onReset() {
|
||||
editGroup.controls.displayName.setValue(props.user.displayName);
|
||||
editGroup.controls.avatar.setValue(props.user.animatedAvatarURL);
|
||||
|
||||
if (profile.data) {
|
||||
editGroup.controls.banner.setValue(
|
||||
profile.data.animatedBannerURL || null,
|
||||
);
|
||||
editGroup.controls.bio.setValue(profile.data.content || "");
|
||||
setInitialBio([profile.data.content || ""]);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const changes: API.DataEditUser = {
|
||||
remove: [],
|
||||
};
|
||||
|
||||
if (editGroup.controls.displayName.isDirty) {
|
||||
changes.display_name = editGroup.controls.displayName.value.trim();
|
||||
}
|
||||
|
||||
if (editGroup.controls.avatar.isDirty) {
|
||||
if (!editGroup.controls.avatar.value) {
|
||||
changes.remove!.push("Avatar");
|
||||
} else if (Array.isArray(editGroup.controls.avatar.value)) {
|
||||
changes.avatar = await client().uploadFile(
|
||||
"avatars",
|
||||
editGroup.controls.avatar.value[0],
|
||||
CONFIGURATION.DEFAULT_MEDIA_URL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (editGroup.controls.bio.isDirty) {
|
||||
if (!editGroup.controls.bio.value) {
|
||||
changes.remove!.push("ProfileContent");
|
||||
} else {
|
||||
changes.profile ??= {};
|
||||
changes.profile.content = editGroup.controls.bio.value;
|
||||
}
|
||||
}
|
||||
|
||||
let newBannerUrl: string | null = null;
|
||||
|
||||
if (editGroup.controls.banner.isDirty) {
|
||||
if (!editGroup.controls.banner.value) {
|
||||
changes.remove!.push("ProfileBackground");
|
||||
} else if (Array.isArray(editGroup.controls.banner.value)) {
|
||||
changes.profile ??= {};
|
||||
changes.profile.background = await client().uploadFile(
|
||||
"backgrounds",
|
||||
editGroup.controls.banner.value[0],
|
||||
CONFIGURATION.DEFAULT_MEDIA_URL,
|
||||
);
|
||||
|
||||
newBannerUrl = `${CONFIGURATION.DEFAULT_MEDIA_URL}/backgrounds/${changes.profile.background}`;
|
||||
} else {
|
||||
newBannerUrl = editGroup.controls.banner.value;
|
||||
}
|
||||
}
|
||||
|
||||
await props.user.edit(changes);
|
||||
|
||||
if (editGroup.controls.banner.isDirty && profile.data) {
|
||||
queryClient.setQueryData(["profile", props.user.id], {
|
||||
...profile.data,
|
||||
animatedBannerURL: newBannerUrl,
|
||||
bannerURL: newBannerUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const submit = Form2.useSubmitHandler(editGroup, onSubmit, onReset);
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<Column>
|
||||
<Form2.FileInput
|
||||
control={editGroup.controls.avatar}
|
||||
accept="image/*"
|
||||
label={t`Avatar`}
|
||||
imageJustify={false}
|
||||
/>
|
||||
<Form2.FileInput
|
||||
control={editGroup.controls.banner}
|
||||
accept="image/*"
|
||||
label={t`Banner`}
|
||||
imageAspect="232/100"
|
||||
imageRounded={false}
|
||||
imageJustify={false}
|
||||
/>
|
||||
<Form2.TextField
|
||||
name="displayName"
|
||||
control={editGroup.controls.displayName}
|
||||
label={t`Display Name`}
|
||||
/>
|
||||
|
||||
<Show when={!props.user.bot}>
|
||||
<CategoryButton
|
||||
icon={<MdBadge />}
|
||||
action="chevron"
|
||||
description={
|
||||
<Trans>Go to account settings to edit your username</Trans>
|
||||
}
|
||||
onClick={() => navigate("account")}
|
||||
>
|
||||
<Trans>Want to change username?</Trans>
|
||||
</CategoryButton>
|
||||
</Show>
|
||||
|
||||
<Text class="label">
|
||||
<Trans>Profile Bio</Trans>
|
||||
</Text>
|
||||
<Form2.TextEditor
|
||||
initialValue={initialBio()}
|
||||
control={editGroup.controls.bio}
|
||||
placeholder={t`Something cool about me...`}
|
||||
/>
|
||||
|
||||
<Row>
|
||||
<Form2.Reset group={editGroup} onReset={onReset} />
|
||||
<Form2.Submit group={editGroup} requireDirty>
|
||||
<Trans>Save</Trans>
|
||||
</Form2.Submit>
|
||||
<Show when={editGroup.isPending}>
|
||||
<CircularProgress />
|
||||
</Show>
|
||||
</Row>
|
||||
</Column>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./EditProfile";
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import {
|
||||
EditSubscriptionJoinFlow,
|
||||
TempMountStripe,
|
||||
} from "./EditSubscriptionJoinFlow";
|
||||
|
||||
/**
|
||||
* Settings menu for joining or changing [premium subscription name here]
|
||||
*/
|
||||
export function EditSubscription() {
|
||||
return (
|
||||
<TempMountStripe>
|
||||
<EditSubscriptionJoinFlow />
|
||||
</TempMountStripe>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { JSXElement, createSignal, onMount } from "solid-js";
|
||||
import { Elements, PaymentElement, useElements, useStripe } from "solid-stripe";
|
||||
|
||||
import { loadStripe } from "@stripe/stripe-js/pure";
|
||||
|
||||
/**
|
||||
* Tier selection and purchase flow
|
||||
*/
|
||||
export function EditSubscriptionJoinFlow() {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<PaymentElement />
|
||||
<button type="submit" disabled={!stripe() || !elements()}>
|
||||
Pay
|
||||
</button>
|
||||
{/* Show error message to your customers */}
|
||||
{/* {errorMessage() && <div>{errorMessage()}</div>} */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function TempMountStripe(props: { children: JSXElement }) {
|
||||
const [stripe, setStripe] = createSignal(null);
|
||||
|
||||
onMount(async () => {
|
||||
const _stripe = await loadStripe(
|
||||
"pk_test_51QeG4NQS9UmC2GH3zIQp8F8hpOtSiq1Cix94Xjf0giCm6MW5qj0Wtdf4RY5HpvtG2Z8CmlR1W5ELLAqjSxgZjTAn00899Z6cfd",
|
||||
);
|
||||
|
||||
setStripe(_stripe as never);
|
||||
});
|
||||
|
||||
const theme = window.getComputedStyle(document.body);
|
||||
|
||||
return (
|
||||
<Elements
|
||||
stripe={stripe()}
|
||||
options={{
|
||||
mode: "payment",
|
||||
amount: 1099,
|
||||
currency: "gbp",
|
||||
|
||||
appearance: {
|
||||
variables: {
|
||||
colorPrimary: theme.getPropertyValue("--md-sys-color-tertiary"),
|
||||
colorText: theme.getPropertyValue("--md-sys-color-on-surface"),
|
||||
colorBackground: theme.getPropertyValue("--md-sys-color-surface"),
|
||||
colorDanger: theme.getPropertyValue("--md-sys-color-error"),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./EditSubscription";
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { For } from "solid-js";
|
||||
import { useMediaDeviceSelect } from "solid-livekit-components";
|
||||
|
||||
import { Trans, useLingui } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useState } from "@revolt/state";
|
||||
import { Checkbox, Column, Slider, Text } from "@revolt/ui";
|
||||
import {
|
||||
CategoryButton,
|
||||
CategoryCollapse,
|
||||
} from "@revolt/ui/components/design/CategoryButton";
|
||||
import { Symbol } from "@revolt/ui/components/utils/Symbol";
|
||||
|
||||
/**
|
||||
* Input options
|
||||
*/
|
||||
export function VoiceInputOptions() {
|
||||
return (
|
||||
<Column>
|
||||
<CategoryButton.Group>
|
||||
<SelectMicrophone />
|
||||
<SelectSpeaker />
|
||||
</CategoryButton.Group>
|
||||
<VolumeSliders />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select audio input
|
||||
*/
|
||||
function SelectMicrophone() {
|
||||
const { t } = useLingui();
|
||||
const state = useState();
|
||||
const { activeDeviceId, devices, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({
|
||||
kind: "audioinput",
|
||||
});
|
||||
|
||||
const activeId = () =>
|
||||
(activeDeviceId() === "default"
|
||||
? state.voice.preferredAudioInputDevice
|
||||
: undefined) ?? activeDeviceId();
|
||||
|
||||
const description = () =>
|
||||
devices().find((device) => device.deviceId === activeId())?.label ??
|
||||
t`Using default microphone`;
|
||||
|
||||
return (
|
||||
<CategoryCollapse
|
||||
icon={<Symbol>mic</Symbol>}
|
||||
title={<Trans>Select audio input</Trans>}
|
||||
description={description()}
|
||||
scrollable
|
||||
>
|
||||
<For each={devices()}>
|
||||
{(device) => (
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
action={<Checkbox checked={device.deviceId === activeId()} />}
|
||||
onClick={() => {
|
||||
state.voice.preferredAudioInputDevice = device.deviceId;
|
||||
setActiveMediaDevice(device.deviceId);
|
||||
}}
|
||||
>
|
||||
{device.label}
|
||||
</CategoryButton>
|
||||
)}
|
||||
</For>
|
||||
</CategoryCollapse>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select audio output
|
||||
*/
|
||||
function SelectSpeaker() {
|
||||
const { t } = useLingui();
|
||||
const state = useState();
|
||||
const { activeDeviceId, devices, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({
|
||||
kind: "audiooutput",
|
||||
});
|
||||
|
||||
const activeId = () =>
|
||||
(activeDeviceId() === "default"
|
||||
? state.voice.preferredAudioOutputDevice
|
||||
: undefined) ?? activeDeviceId();
|
||||
|
||||
const description = () =>
|
||||
devices().find((device) => device.deviceId === activeId())?.label ??
|
||||
t`Using default speaker`;
|
||||
|
||||
return (
|
||||
<CategoryCollapse
|
||||
icon={<Symbol>speaker</Symbol>}
|
||||
title={<Trans>Select audio output</Trans>}
|
||||
description={description()}
|
||||
scrollable
|
||||
>
|
||||
<For each={devices()}>
|
||||
{(device) => (
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
action={<Checkbox checked={device.deviceId === activeId()} />}
|
||||
onClick={() => {
|
||||
state.voice.preferredAudioOutputDevice = device.deviceId;
|
||||
setActiveMediaDevice(device.deviceId);
|
||||
}}
|
||||
>
|
||||
{device.label}
|
||||
</CategoryButton>
|
||||
)}
|
||||
</For>
|
||||
</CategoryCollapse>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select volume
|
||||
*/
|
||||
function VolumeSliders() {
|
||||
const state = useState();
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<Text class="label">
|
||||
<Trans>Output Volume</Trans>
|
||||
</Text>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={state.voice.outputVolume}
|
||||
onInput={(event) =>
|
||||
(state.voice.outputVolume = event.currentTarget.value)
|
||||
}
|
||||
labelFormatter={(label) => (label * 100).toFixed(0) + "%"}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useState } from "@revolt/state";
|
||||
import { CategoryButton, Checkbox, Column, Text } from "@revolt/ui";
|
||||
|
||||
/**
|
||||
* Voice processing options
|
||||
*/
|
||||
export function VoiceProcessingOptions() {
|
||||
const state = useState();
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<Text class="title">
|
||||
<Trans>Voice Processing</Trans>
|
||||
</Text>
|
||||
<CategoryButton.Group>
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
action={<Checkbox checked={state.voice.noiseSupression} />}
|
||||
onClick={() =>
|
||||
(state.voice.noiseSupression = !state.voice.noiseSupression)
|
||||
}
|
||||
>
|
||||
<Trans>Browser Noise Supression</Trans>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon="blank"
|
||||
action={<Checkbox checked={state.voice.echoCancellation} />}
|
||||
onClick={() =>
|
||||
(state.voice.echoCancellation = !state.voice.echoCancellation)
|
||||
}
|
||||
>
|
||||
<Trans>Browser Echo Cancellation</Trans>
|
||||
</CategoryButton>
|
||||
</CategoryButton.Group>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { Column } from "@revolt/ui";
|
||||
|
||||
import { VoiceInputOptions } from "./VoiceInputOptions";
|
||||
import { VoiceProcessingOptions } from "./VoiceProcessingOptions";
|
||||
|
||||
/**
|
||||
* Configure voice options
|
||||
*/
|
||||
export function VoiceSettings() {
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<VoiceInputOptions />
|
||||
<VoiceProcessingOptions />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { Show } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import type { API } from "stoat.js";
|
||||
import { Channel, Server } from "stoat.js";
|
||||
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { useState } from "@revolt/state";
|
||||
|
||||
import MdBadge from "@material-design-icons/svg/outlined/badge.svg?component-solid";
|
||||
import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid";
|
||||
import MdLibraryAdd from "@material-design-icons/svg/outlined/library_add.svg?component-solid";
|
||||
import MdMarkChatRead from "@material-design-icons/svg/outlined/mark_chat_read.svg?component-solid";
|
||||
|
||||
import { Symbol } from "@revolt/ui/components/utils/Symbol";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
ContextMenuDivider,
|
||||
} from "./ContextMenu";
|
||||
|
||||
export type CategoryData = Omit<API.Category, "channels"> & {
|
||||
channels: Channel[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Context menu for categories
|
||||
*/
|
||||
export function CategoryContextMenu(props: {
|
||||
server: Server;
|
||||
category: CategoryData;
|
||||
}) {
|
||||
const state = useState();
|
||||
const { openModal } = useModals();
|
||||
|
||||
/**
|
||||
* Mark category as read
|
||||
*/
|
||||
function markAsRead() {
|
||||
props.category.channels
|
||||
.filter((channel) => channel.unread)
|
||||
.forEach((channel) => channel.ack());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new category
|
||||
*/
|
||||
function createCategory() {
|
||||
openModal({
|
||||
type: "create_category",
|
||||
server: props.server,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete category
|
||||
*/
|
||||
function deleteCategory() {
|
||||
openModal({
|
||||
type: "delete_category",
|
||||
server: props.server,
|
||||
categoryId: props.category.id,
|
||||
});
|
||||
}
|
||||
|
||||
function editCategoryName() {
|
||||
openModal({
|
||||
type: "edit_category",
|
||||
server: props.server,
|
||||
category: props.category,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy category id to clipboard
|
||||
*/
|
||||
function copyId() {
|
||||
navigator.clipboard.writeText(props.category.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if any channel in category has unread messages
|
||||
*/
|
||||
const hasUnread = () => {
|
||||
return props.category.channels.some((channel) => channel?.unread);
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<Show when={hasUnread()}>
|
||||
<ContextMenuButton icon={MdMarkChatRead} onClick={markAsRead}>
|
||||
<Trans>Mark as read</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<Show when={props.server.havePermission("ManageChannel")}>
|
||||
<ContextMenuButton icon={MdLibraryAdd} onClick={createCategory}>
|
||||
<Trans>Create category</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.server.havePermission("ManageChannel")}>
|
||||
<ContextMenuButton
|
||||
icon={<Symbol size={16}>edit</Symbol>}
|
||||
onClick={editCategoryName}
|
||||
>
|
||||
<Trans>Rename category</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.server.havePermission("ManageChannel")}>
|
||||
<ContextMenuButton icon={MdDelete} onClick={deleteCategory} destructive>
|
||||
<Trans>Delete category</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
|
||||
<Show when={state.settings.getValue("advanced:copy_id")}>
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<Show when={state.settings.getValue("advanced:copy_id")}>
|
||||
<ContextMenuButton icon={MdBadge} onClick={copyId}>
|
||||
<Trans>Copy category ID</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import { Match, Show, Switch } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { Channel } from "stoat.js";
|
||||
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { useState } from "@revolt/state";
|
||||
|
||||
import MdBadge from "@material-design-icons/svg/outlined/badge.svg?component-solid";
|
||||
import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid";
|
||||
import MdGroupAdd from "@material-design-icons/svg/outlined/group_add.svg?component-solid";
|
||||
import MdLibraryAdd from "@material-design-icons/svg/outlined/library_add.svg?component-solid";
|
||||
import MdLogout from "@material-design-icons/svg/outlined/logout.svg?component-solid";
|
||||
import MdMarkChatRead from "@material-design-icons/svg/outlined/mark_chat_read.svg?component-solid";
|
||||
import MdSettings from "@material-design-icons/svg/outlined/settings.svg?component-solid";
|
||||
import MdShare from "@material-design-icons/svg/outlined/share.svg?component-solid";
|
||||
import MdShield from "@material-design-icons/svg/outlined/shield.svg?component-solid";
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
ContextMenuDivider,
|
||||
} from "./ContextMenu";
|
||||
import { NotificationContextMenu } from "./shared/NotificationContextMenu";
|
||||
|
||||
/**
|
||||
* Context menu for channels
|
||||
*/
|
||||
export function ChannelContextMenu(props: { channel: Channel }) {
|
||||
const state = useState();
|
||||
const { openModal } = useModals();
|
||||
|
||||
/**
|
||||
* Mark channel as read
|
||||
*/
|
||||
function markAsRead() {
|
||||
props.channel.ack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new invite
|
||||
*/
|
||||
function createInvite() {
|
||||
openModal({
|
||||
type: "create_invite",
|
||||
channel: props.channel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new channel
|
||||
*/
|
||||
function createChannel() {
|
||||
openModal({
|
||||
type: "create_channel",
|
||||
server: props.channel.server!,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit channel
|
||||
*/
|
||||
function editChannel() {
|
||||
openModal({
|
||||
type: "settings",
|
||||
config: "channel",
|
||||
context: props.channel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete channel
|
||||
*/
|
||||
function deleteChannel() {
|
||||
openModal({
|
||||
type: "delete_channel",
|
||||
channel: props.channel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open channel in Stoat Admin Panel
|
||||
*/
|
||||
function openAdminPanel() {
|
||||
window.open(
|
||||
`https://old-admin.stoatinternal.com/panel/inspect/channel/${props.channel.id}`,
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy channel link to clipboard
|
||||
*/
|
||||
function copyLink() {
|
||||
navigator.clipboard.writeText(
|
||||
`${location.origin}${
|
||||
props.channel.server ? `/server/${props.channel.server?.id}` : ""
|
||||
}/channel/${props.channel.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy channel id to clipboard
|
||||
*/
|
||||
function copyId() {
|
||||
navigator.clipboard.writeText(props.channel.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<Show
|
||||
when={
|
||||
props.channel.unread || props.channel.havePermission("InviteOthers")
|
||||
}
|
||||
>
|
||||
<Show when={props.channel.unread}>
|
||||
<ContextMenuButton icon={MdMarkChatRead} onClick={markAsRead}>
|
||||
<Trans>Mark as read</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.channel.havePermission("InviteOthers")}>
|
||||
<ContextMenuButton icon={MdGroupAdd} onClick={createInvite}>
|
||||
<Trans>Create invite</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<NotificationContextMenu channel={props.channel} />
|
||||
|
||||
<ContextMenuDivider />
|
||||
|
||||
<Show when={props.channel.server?.havePermission("ManageChannel")}>
|
||||
<ContextMenuButton icon={MdLibraryAdd} onClick={createChannel}>
|
||||
<Trans>Create channel</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.channel.havePermission("ManageChannel")}>
|
||||
<ContextMenuButton icon={MdSettings} onClick={editChannel}>
|
||||
<Trans>Open channel settings</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuButton
|
||||
icon={props.channel.type === "Group" ? MdLogout : MdDelete}
|
||||
onClick={deleteChannel}
|
||||
destructive
|
||||
>
|
||||
<Switch fallback={<Trans>Delete channel</Trans>}>
|
||||
<Match when={props.channel.type === "Group"}>
|
||||
<Trans>Leave group</Trans>
|
||||
</Match>
|
||||
</Switch>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
props.channel.server?.havePermission("ManageChannel") ||
|
||||
props.channel.havePermission("ManageChannel")
|
||||
}
|
||||
>
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<Show when={state.settings.getValue("advanced:admin_panel")}>
|
||||
<ContextMenuButton icon={MdShield} onClick={openAdminPanel}>
|
||||
<Trans>Admin Panel</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<ContextMenuButton icon={MdShare} onClick={copyLink}>
|
||||
<Trans>Copy link</Trans>
|
||||
</ContextMenuButton>
|
||||
<Show when={state.settings.getValue("advanced:copy_id")}>
|
||||
<ContextMenuButton icon={MdBadge} onClick={copyId}>
|
||||
<Trans>Copy channel ID</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
import { useFloating } from "solid-floating-ui";
|
||||
import {
|
||||
Component,
|
||||
ComponentProps,
|
||||
JSX,
|
||||
Show,
|
||||
createSignal,
|
||||
splitProps,
|
||||
} from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
import { Motion, Presence } from "solid-motionone";
|
||||
|
||||
import { autoUpdate, offset, shift } from "@floating-ui/dom";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { Text, iconSize, symbolSize } from "@revolt/ui";
|
||||
|
||||
import MdChevronRight from "@material-design-icons/svg/outlined/chevron_right.svg?component-solid";
|
||||
|
||||
const Base = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "var(--gap-md) 0",
|
||||
overflow: "hidden",
|
||||
borderRadius: "var(--borderRadius-xs)",
|
||||
background: "var(--md-sys-color-surface-container)",
|
||||
color: "var(--md-sys-color-on-surface)",
|
||||
fill: "var(--md-sys-color-on-surface)",
|
||||
boxShadow: "0 0 3px var(--md-sys-color-shadow)",
|
||||
|
||||
userSelect: "none",
|
||||
},
|
||||
});
|
||||
|
||||
export function ContextMenu(props: ComponentProps<typeof Base>) {
|
||||
return (
|
||||
<Base
|
||||
// prevent context menu closing itself before click event
|
||||
onMouseDown={(e) => e.stopImmediatePropagation()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const ContextMenuDivider = styled("div", {
|
||||
base: {
|
||||
height: "1px",
|
||||
margin: "var(--gap-sm) 0",
|
||||
background: "var(--md-sys-color-outline-variant)",
|
||||
},
|
||||
});
|
||||
|
||||
export const ContextMenuItem = styled("a", {
|
||||
base: {
|
||||
display: "flex",
|
||||
gap: "var(--gap-md)",
|
||||
alignItems: "center",
|
||||
padding: "var(--gap-md) var(--gap-lg)",
|
||||
|
||||
"&:hover": {
|
||||
background:
|
||||
"color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent)",
|
||||
},
|
||||
|
||||
"& span": {
|
||||
flexGrow: 1,
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
selected: {
|
||||
true: {
|
||||
background:
|
||||
"color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent)",
|
||||
},
|
||||
false: {},
|
||||
},
|
||||
action: {
|
||||
true: {
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
button: {
|
||||
true: {
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--gap-md)",
|
||||
"& span": {
|
||||
marginTop: "1px",
|
||||
},
|
||||
},
|
||||
},
|
||||
_titleCase: {
|
||||
true: {},
|
||||
false: {},
|
||||
},
|
||||
destructive: {
|
||||
true: {
|
||||
fill: "var(--md-sys-color-error)",
|
||||
color: "var(--md-sys-color-error)",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
_titleCase: true,
|
||||
selected: false,
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
_titleCase: true,
|
||||
button: true,
|
||||
css: {
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
type ButtonProps = ComponentProps<typeof ContextMenuItem> & {
|
||||
icon?: JSX.Element | Component<JSX.SvgSVGAttributes<SVGSVGElement>>;
|
||||
symbol?: Component<JSX.SvgSVGAttributes<SVGSVGElement>>;
|
||||
destructive?: boolean;
|
||||
actionIcon?: JSX.Element | Component<JSX.SvgSVGAttributes<SVGSVGElement>>;
|
||||
actionSymbol?: Component<JSX.SvgSVGAttributes<SVGSVGElement>>;
|
||||
};
|
||||
|
||||
export function ContextMenuButton(props: ButtonProps) {
|
||||
const [local, remote] = splitProps(props, [
|
||||
"icon",
|
||||
"symbol",
|
||||
"actionIcon",
|
||||
"actionSymbol",
|
||||
"children",
|
||||
]);
|
||||
|
||||
return (
|
||||
<ContextMenuItem button {...remote}>
|
||||
{typeof local.icon === "function"
|
||||
? local.icon?.(iconSize(16))
|
||||
: local.icon}
|
||||
{local.symbol?.(symbolSize(16))}
|
||||
<Text>{local.children}</Text>
|
||||
{typeof local.actionIcon === "function"
|
||||
? local.actionIcon?.(iconSize(20))
|
||||
: local.actionIcon}
|
||||
{local.actionSymbol?.(symbolSize(20))}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenuSubMenu(
|
||||
props: Omit<
|
||||
ButtonProps,
|
||||
"ref" | "onClick" | "onMouseEnter" | "onMouseLeave"
|
||||
> & {
|
||||
buttonContent: JSX.Element;
|
||||
onClick?: () => void;
|
||||
},
|
||||
) {
|
||||
const [anchor, setAnchor] = createSignal<HTMLDivElement>();
|
||||
const [ref, setRef] = createSignal<HTMLDivElement>();
|
||||
|
||||
const [show, setShow] = createSignal<"hide" | "show" | boolean>(false);
|
||||
const [local, buttonProps] = splitProps(props, [
|
||||
"children",
|
||||
"buttonContent",
|
||||
"onClick",
|
||||
]);
|
||||
|
||||
function isShowing() {
|
||||
return show() === true || show() === "show";
|
||||
}
|
||||
|
||||
const position = useFloating(anchor, ref, {
|
||||
placement: "right-start",
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [offset(5), shift()],
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenuButton
|
||||
ref={setAnchor}
|
||||
selected={isShowing()}
|
||||
actionIcon={MdChevronRight}
|
||||
onMouseDown={(e) => {
|
||||
e.stopImmediatePropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (local.onClick) {
|
||||
local.onClick();
|
||||
} else {
|
||||
e.stopImmediatePropagation();
|
||||
setShow(isShowing() ? false : "show");
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setShow((show) => (show === "hide" ? show : true))}
|
||||
{...buttonProps}
|
||||
>
|
||||
{local.buttonContent}
|
||||
</ContextMenuButton>
|
||||
<Portal mount={document.getElementById("floating")!}>
|
||||
<Presence>
|
||||
<Show when={isShowing()}>
|
||||
<Motion
|
||||
ref={setRef}
|
||||
style={{
|
||||
position: position.strategy,
|
||||
top: `${position.y ?? 0}px`,
|
||||
left: `${position.x ?? 0}px`,
|
||||
"z-index": 1000,
|
||||
}}
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, easing: [0.87, 0, 0.13, 1] }}
|
||||
onMouseLeave={() =>
|
||||
setShow((show) => (show === true ? false : show))
|
||||
}
|
||||
// stop submenu from closing context menu
|
||||
onMouseDown={(e) => e.stopImmediatePropagation()}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (local.onClick) {
|
||||
local.onClick();
|
||||
} else {
|
||||
// prevent submenu trigger from closing context menu
|
||||
e.stopImmediatePropagation();
|
||||
setShow((show) => (show ? "hide" : true));
|
||||
}
|
||||
}}
|
||||
// float a virtual element to ensure the mouseLeave event covers
|
||||
// both the anchor/button we attached to and the newly created context menu
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: `-${(anchor()?.clientWidth ?? 0) + 5}px`,
|
||||
width: `${(anchor()?.clientWidth ?? 0) + 5}px`,
|
||||
height: `${anchor()?.clientHeight ?? 0}px`,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
<ContextMenu>{local.children}</ContextMenu>
|
||||
</Motion>
|
||||
</Show>
|
||||
</Presence>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { Show } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { Channel } from "stoat.js";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { useState } from "@revolt/state";
|
||||
import { UnsentMessage } from "@revolt/state/stores/Draft";
|
||||
|
||||
import MdClose from "@material-design-icons/svg/outlined/close.svg?component-solid";
|
||||
import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid";
|
||||
import MdRefresh from "@material-design-icons/svg/outlined/refresh.svg?component-solid";
|
||||
|
||||
import { ContextMenu, ContextMenuButton } from "./ContextMenu";
|
||||
|
||||
interface Props {
|
||||
draft: UnsentMessage;
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu for draft messages
|
||||
*/
|
||||
export function DraftMessageContextMenu(props: Props) {
|
||||
const state = useState();
|
||||
const client = useClient();
|
||||
|
||||
/**
|
||||
* Retry sending the draft message
|
||||
*/
|
||||
function retrySend() {
|
||||
state.draft.retrySend(client(), props.channel, props.draft.idempotencyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the draft message
|
||||
*/
|
||||
function deleteMessage() {
|
||||
state.draft.cancelSend(props.channel, props.draft.idempotencyKey);
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.draft.status !== "sending"}>
|
||||
<ContextMenu>
|
||||
<Show when={false}>
|
||||
<ContextMenuButton icon={MdClose} onClick={deleteMessage} destructive>
|
||||
<Trans>Cancel message</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
props.draft.status === "failed" || props.draft.status === "unsent"
|
||||
}
|
||||
>
|
||||
<ContextMenuButton icon={MdRefresh} onClick={retrySend}>
|
||||
<Trans>Retry sending</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuButton
|
||||
icon={MdDelete}
|
||||
onClick={deleteMessage}
|
||||
destructive
|
||||
>
|
||||
<Trans>Delete message</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
</ContextMenu>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
import { For, Match, Show, Switch } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { File, Message } from "stoat.js";
|
||||
|
||||
import { useClient, useUser } from "@revolt/client";
|
||||
import { CustomEmoji, UnicodeEmoji } from "@revolt/markdown/emoji";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { useState } from "@revolt/state";
|
||||
|
||||
import MdBadge from "@material-design-icons/svg/outlined/badge.svg?component-solid";
|
||||
import MdContentCopy from "@material-design-icons/svg/outlined/content_copy.svg?component-solid";
|
||||
import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid";
|
||||
import MdDeleteSweep from "@material-design-icons/svg/outlined/delete_sweep.svg?component-solid";
|
||||
import MdDownload from "@material-design-icons/svg/outlined/download.svg?component-solid";
|
||||
import MdEdit from "@material-design-icons/svg/outlined/edit.svg?component-solid";
|
||||
import MdLink from "@material-design-icons/svg/outlined/link.svg?component-solid";
|
||||
import MdMarkChatUnread from "@material-design-icons/svg/outlined/mark_chat_unread.svg?component-solid";
|
||||
import MdOpenInNew from "@material-design-icons/svg/outlined/open_in_new.svg?component-solid";
|
||||
import MdPin from "@material-design-icons/svg/outlined/pin_invoke.svg?component-solid";
|
||||
import MdReply from "@material-design-icons/svg/outlined/reply.svg?component-solid";
|
||||
import MdReport from "@material-design-icons/svg/outlined/report.svg?component-solid";
|
||||
import MdShare from "@material-design-icons/svg/outlined/share.svg?component-solid";
|
||||
import MdShield from "@material-design-icons/svg/outlined/shield.svg?component-solid";
|
||||
|
||||
import MdSentimentContent from "@material-symbols/svg-400/outlined/sentiment_content.svg?component-solid";
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
ContextMenuDivider,
|
||||
ContextMenuSubMenu,
|
||||
} from "./ContextMenu";
|
||||
|
||||
/**
|
||||
* Context menu for messages
|
||||
*/
|
||||
export function MessageContextMenu(props: { message?: Message; file?: File }) {
|
||||
const user = useUser();
|
||||
const state = useState();
|
||||
const client = useClient();
|
||||
const { openModal, showError } = useModals();
|
||||
|
||||
/**
|
||||
* Reply to this message
|
||||
*/
|
||||
function reply() {
|
||||
state.draft.addReply(props.message!, user()!.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark message as unread
|
||||
*/
|
||||
function markAsUnread() {
|
||||
props.message!.ack(true, false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy message contents to clipboard
|
||||
*/
|
||||
function copyText() {
|
||||
navigator.clipboard.writeText(props.message!.content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the message
|
||||
*/
|
||||
function report() {
|
||||
openModal({
|
||||
type: "report_content",
|
||||
target: props.message!,
|
||||
client: client(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the message
|
||||
*/
|
||||
function deleteMessage(ev: MouseEvent) {
|
||||
if (ev.shiftKey) {
|
||||
props.message!.delete();
|
||||
} else {
|
||||
openModal({
|
||||
type: "delete_message",
|
||||
message: props.message!,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open message in Stoat Admin Panel
|
||||
*/
|
||||
function openAdminPanel() {
|
||||
window.open(
|
||||
`https://old-admin.stoatinternal.com/panel/inspect/message/${props.message!.id}`,
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy message link to clipboard
|
||||
*/
|
||||
function copyLink() {
|
||||
navigator.clipboard.writeText(
|
||||
`${location.origin}${
|
||||
props.message!.server ? `/server/${props.message!.server?.id}` : ""
|
||||
}/channel/${props.message!.channelId}/${props.message!.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy message id to clipboard
|
||||
*/
|
||||
function copyId() {
|
||||
navigator.clipboard.writeText(props.message!.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the file preview in a new tab
|
||||
*/
|
||||
function OpenFile() {
|
||||
window.open(props.file?.originalUrl, "_blank");
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the link to the original url of the file
|
||||
*/
|
||||
function CopyLink() {
|
||||
navigator.clipboard.writeText(props.file?.originalUrl ?? "");
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<Show when={props.file}>
|
||||
<ContextMenuButton icon={MdOpenInNew} onClick={OpenFile}>
|
||||
<Trans>Open file</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuButton icon={MdLink} onClick={CopyLink}>
|
||||
<Trans>Copy link</Trans>
|
||||
</ContextMenuButton>
|
||||
<a
|
||||
target="_blank"
|
||||
download={props.file?.filename}
|
||||
href={props.file?.originalUrl}
|
||||
>
|
||||
<ContextMenuButton icon={MdDownload}>
|
||||
<Trans>Save file</Trans>
|
||||
</ContextMenuButton>
|
||||
</a>
|
||||
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
<Show when={props.message}>
|
||||
<Show when={props.message!.channel?.havePermission("SendMessage")}>
|
||||
<ContextMenuButton icon={MdReply} onClick={reply}>
|
||||
<Trans>Reply</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<ContextMenuButton icon={MdMarkChatUnread} onClick={markAsUnread}>
|
||||
<Trans>Mark as unread</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuButton icon={MdContentCopy} onClick={copyText}>
|
||||
<Trans>Copy text</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuDivider />
|
||||
<Show
|
||||
when={
|
||||
props.message!.author?.self &&
|
||||
props.message!.channel?.havePermission("SendMessage")
|
||||
}
|
||||
>
|
||||
<ContextMenuButton
|
||||
icon={MdEdit}
|
||||
onClick={() => state.draft.setEditingMessage(props.message!)}
|
||||
>
|
||||
<Trans>Edit message</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
props.message!.channel?.type === "DirectMessage" ||
|
||||
props.message!.channel?.havePermission("ManageMessages")
|
||||
}
|
||||
>
|
||||
<ContextMenuButton
|
||||
icon={MdPin}
|
||||
onClick={() => {
|
||||
if (props.message!.pinned) {
|
||||
props.message!.unpin().catch(showError);
|
||||
} else {
|
||||
props.message!.pin().catch(showError);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Switch fallback={<Trans>Pin message</Trans>}>
|
||||
<Match when={props.message!.pinned}>
|
||||
<Trans>Unpin message</Trans>
|
||||
</Match>
|
||||
</Switch>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
props.message!.reactions.size &&
|
||||
props.message!.channel?.havePermission("ManageMessages")
|
||||
}
|
||||
>
|
||||
<ContextMenuSubMenu
|
||||
icon={MdDeleteSweep}
|
||||
onClick={() => props.message!.clearReactions()}
|
||||
destructive
|
||||
buttonContent={<Trans>Remove reaction</Trans>}
|
||||
>
|
||||
<For each={[...props.message!.reactions.keys()]}>
|
||||
{(key) => (
|
||||
<ContextMenuButton
|
||||
onClick={() => props.message!.unreact(key, true)}
|
||||
>
|
||||
<Switch fallback={<UnicodeEmoji emoji={key} />}>
|
||||
<Match when={key.length === 26}>
|
||||
<CustomEmoji id={key} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</ContextMenuButton>
|
||||
)}
|
||||
</For>
|
||||
</ContextMenuSubMenu>
|
||||
</Show>
|
||||
<Show when={props.message!.reactions.size}>
|
||||
<ContextMenuButton
|
||||
symbol={MdSentimentContent}
|
||||
onClick={() => props.message!.clearReactions()}
|
||||
destructive
|
||||
>
|
||||
<Trans>Remove all reactions</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
props.message!.author?.self ||
|
||||
props.message!.channel?.havePermission("ManageMessages")
|
||||
}
|
||||
>
|
||||
<ContextMenuButton
|
||||
icon={MdDelete}
|
||||
onClick={deleteMessage}
|
||||
destructive
|
||||
>
|
||||
<Trans>Delete message</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={!props.message!.author?.self}>
|
||||
<ContextMenuButton icon={MdReport} onClick={report} destructive>
|
||||
<Trans>Report message</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<ContextMenuDivider />
|
||||
<Show when={state.settings.getValue("advanced:admin_panel")}>
|
||||
<ContextMenuButton icon={MdShield} onClick={openAdminPanel}>
|
||||
<Trans>Admin Panel</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<ContextMenuButton icon={MdShare} onClick={copyLink}>
|
||||
<Trans>Copy link</Trans>
|
||||
</ContextMenuButton>
|
||||
<Show when={state.settings.getValue("advanced:copy_id")}>
|
||||
<ContextMenuButton icon={MdBadge} onClick={copyId}>
|
||||
<Trans>Copy message ID</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
</Show>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
import { For, Show } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import dayjs from "dayjs";
|
||||
import { Server } from "stoat.js";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { useState } from "@revolt/state";
|
||||
import { Column, Text, Time } from "@revolt/ui";
|
||||
|
||||
import MdAlternateEmail from "@material-design-icons/svg/outlined/alternate_email.svg?component-solid";
|
||||
import MdBadge from "@material-design-icons/svg/outlined/badge.svg?component-solid";
|
||||
import MdFace from "@material-design-icons/svg/outlined/face.svg?component-solid";
|
||||
import MdLogout from "@material-design-icons/svg/outlined/logout.svg?component-solid";
|
||||
import MdMarkChatRead from "@material-design-icons/svg/outlined/mark_chat_read.svg?component-solid";
|
||||
import MdNotificationsActive from "@material-design-icons/svg/outlined/notifications_active.svg?component-solid";
|
||||
import MdNotificationsOff from "@material-design-icons/svg/outlined/notifications_off.svg?component-solid";
|
||||
import MdPersonAdd from "@material-design-icons/svg/outlined/person_add.svg?component-solid";
|
||||
import MdReport from "@material-design-icons/svg/outlined/report.svg?component-solid";
|
||||
import MdSettings from "@material-design-icons/svg/outlined/settings.svg?component-solid";
|
||||
import MdShield from "@material-design-icons/svg/outlined/shield.svg?component-solid";
|
||||
|
||||
import MdDoNotDisturbOff from "@material-symbols/svg-400/outlined/do_not_disturb_off.svg?component-solid";
|
||||
import MdDoNotDisturbOn from "@material-symbols/svg-400/outlined/do_not_disturb_on.svg?component-solid";
|
||||
import MdNotificationSettings from "@material-symbols/svg-400/outlined/notification_settings.svg?component-solid";
|
||||
import MdRadioButtonChecked from "@material-symbols/svg-400/outlined/radio_button_checked-fill.svg?component-solid";
|
||||
import MdRadioButtonUnchecked from "@material-symbols/svg-400/outlined/radio_button_unchecked.svg?component-solid";
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
ContextMenuDivider,
|
||||
ContextMenuSubMenu,
|
||||
} from "./ContextMenu";
|
||||
|
||||
/**
|
||||
* Context menu for servers
|
||||
*/
|
||||
export function ServerContextMenu(props: { server: Server }) {
|
||||
const state = useState();
|
||||
const client = useClient();
|
||||
const { openModal } = useModals();
|
||||
|
||||
/**
|
||||
* Mark server as read
|
||||
*/
|
||||
function markAsRead() {
|
||||
props.server.ack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new invite
|
||||
*/
|
||||
function createInvite() {
|
||||
// Find the first channel we can invite people to
|
||||
const channel = props.server.orderedChannels
|
||||
.find((category) =>
|
||||
category.channels.find((channel) =>
|
||||
channel.havePermission("InviteOthers"),
|
||||
),
|
||||
)!
|
||||
.channels.find((channel) => channel.havePermission("InviteOthers"))!;
|
||||
|
||||
openModal({
|
||||
type: "create_invite",
|
||||
channel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open server settings
|
||||
*/
|
||||
function editIdentity() {
|
||||
openModal({
|
||||
type: "server_identity",
|
||||
member: props.server.member!,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open server settings
|
||||
*/
|
||||
function openSettings() {
|
||||
openModal({
|
||||
type: "settings",
|
||||
config: "server",
|
||||
context: props.server,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the server
|
||||
*/
|
||||
function report() {
|
||||
openModal({
|
||||
type: "report_content",
|
||||
target: props.server,
|
||||
client: client(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the server
|
||||
*/
|
||||
function leave() {
|
||||
openModal({
|
||||
type: "leave_server",
|
||||
server: props.server,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open server in Stoat Admin Panel
|
||||
*/
|
||||
function openAdminPanel() {
|
||||
window.open(
|
||||
`https://old-admin.stoatinternal.com/panel/inspect/server/${props.server.id}`,
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy server id to clipboard
|
||||
*/
|
||||
function copyId() {
|
||||
navigator.clipboard.writeText(props.server.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether we can invite others to any channels
|
||||
*/
|
||||
const permissionInviteOthers = () =>
|
||||
props.server.channels.find((channel) =>
|
||||
channel.havePermission("InviteOthers"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Determine whether we can edit our identity
|
||||
*/
|
||||
const permissionEditIdentity = () =>
|
||||
props.server.havePermission("ChangeNickname") ||
|
||||
props.server.havePermission("ChangeAvatar");
|
||||
|
||||
/**
|
||||
* Determine whether we can access settings
|
||||
*/
|
||||
const permissionServerSettings = () =>
|
||||
props.server.owner?.self ||
|
||||
props.server.havePermission("AssignRoles") ||
|
||||
props.server.havePermission("BanMembers") ||
|
||||
props.server.havePermission("KickMembers") ||
|
||||
props.server.havePermission("ManageChannel") ||
|
||||
props.server.havePermission("ManageCustomisation") ||
|
||||
props.server.havePermission("ManageNicknames") ||
|
||||
props.server.havePermission("ManagePermissions") ||
|
||||
props.server.havePermission("ManageRole") ||
|
||||
props.server.havePermission("ManageServer") ||
|
||||
props.server.havePermission("ManageWebhooks");
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<Show when={props.server.unread}>
|
||||
<ContextMenuButton icon={MdMarkChatRead} onClick={markAsRead}>
|
||||
<Trans>Mark as read</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!state.notifications.isMuted(props.server)}
|
||||
fallback={
|
||||
<ContextMenuButton
|
||||
onClick={() =>
|
||||
state.notifications.setServerMute(props.server, undefined)
|
||||
}
|
||||
symbol={MdDoNotDisturbOff}
|
||||
_titleCase={false}
|
||||
>
|
||||
<Column gap="none">
|
||||
<Trans>Unmute Server</Trans>
|
||||
<Show
|
||||
when={state.notifications.getServerMute(props.server)?.until}
|
||||
>
|
||||
<Text class="label" size="small">
|
||||
<Trans>
|
||||
Muted until{" "}
|
||||
<Time
|
||||
format="datetime"
|
||||
value={
|
||||
state.notifications.getServerMute(props.server)!.until
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
</Show>
|
||||
</Column>
|
||||
</ContextMenuButton>
|
||||
}
|
||||
>
|
||||
<ContextMenuSubMenu
|
||||
onClick={() => state.notifications.setServerMute(props.server, {})}
|
||||
buttonContent={<Trans>Mute Server</Trans>}
|
||||
symbol={MdDoNotDisturbOn}
|
||||
>
|
||||
<For
|
||||
each={
|
||||
[
|
||||
[15, <Trans>For 15 minutes</Trans>],
|
||||
[60, <Trans>For 1 hour</Trans>],
|
||||
[180, <Trans>For 3 hours</Trans>],
|
||||
[480, <Trans>For 8 hours</Trans>],
|
||||
[1440, <Trans>For 24 hours</Trans>],
|
||||
[undefined, <Trans>Until I turn it back on</Trans>],
|
||||
] as const
|
||||
}
|
||||
>
|
||||
{([timeMin, i18n]) => (
|
||||
<ContextMenuButton
|
||||
onClick={() =>
|
||||
state.notifications.setServerMute(props.server, {
|
||||
until: timeMin
|
||||
? +dayjs().add(timeMin, "minutes")
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
_titleCase={false}
|
||||
>
|
||||
{i18n}
|
||||
</ContextMenuButton>
|
||||
)}
|
||||
</For>
|
||||
</ContextMenuSubMenu>
|
||||
</Show>
|
||||
|
||||
<ContextMenuSubMenu
|
||||
symbol={MdNotificationSettings}
|
||||
buttonContent={<Trans>Notifications</Trans>}
|
||||
>
|
||||
<ContextMenuButton
|
||||
icon={MdNotificationsActive}
|
||||
onClick={() => state.notifications.setServer(props.server, "all")}
|
||||
actionSymbol={
|
||||
state.notifications.computeForServer(props.server) === "all"
|
||||
? MdRadioButtonChecked
|
||||
: MdRadioButtonUnchecked
|
||||
}
|
||||
>
|
||||
<Trans>All Messages</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuButton
|
||||
icon={MdAlternateEmail}
|
||||
onClick={() => state.notifications.setServer(props.server, "mention")}
|
||||
actionSymbol={
|
||||
state.notifications.computeForServer(props.server) === "mention"
|
||||
? MdRadioButtonChecked
|
||||
: MdRadioButtonUnchecked
|
||||
}
|
||||
>
|
||||
<Trans>Mentions Only</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuButton
|
||||
icon={MdNotificationsOff}
|
||||
onClick={() => state.notifications.setServer(props.server, "none")}
|
||||
actionSymbol={
|
||||
state.notifications.computeForServer(props.server) === "none"
|
||||
? MdRadioButtonChecked
|
||||
: MdRadioButtonUnchecked
|
||||
}
|
||||
>
|
||||
<Trans>None</Trans>
|
||||
</ContextMenuButton>
|
||||
</ContextMenuSubMenu>
|
||||
<ContextMenuDivider />
|
||||
|
||||
<Show when={permissionInviteOthers()}>
|
||||
<ContextMenuButton icon={MdPersonAdd} onClick={createInvite}>
|
||||
<Trans>Create invite</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={permissionEditIdentity()}>
|
||||
<ContextMenuButton icon={MdFace} onClick={editIdentity}>
|
||||
<Trans>Edit your identity</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={permissionServerSettings()}>
|
||||
<ContextMenuButton icon={MdSettings} onClick={openSettings}>
|
||||
<Trans>Open server settings</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
permissionInviteOthers() ||
|
||||
permissionEditIdentity() ||
|
||||
permissionServerSettings()
|
||||
}
|
||||
>
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<ContextMenuButton icon={MdReport} onClick={report} destructive>
|
||||
<Trans>Report server</Trans>
|
||||
</ContextMenuButton>
|
||||
<Show when={!props.server.owner?.self}>
|
||||
<ContextMenuButton icon={MdLogout} onClick={leave} destructive>
|
||||
<Trans>Leave server</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
state.settings.getValue("advanced:admin_panel") &&
|
||||
state.settings.getValue("advanced:copy_id")
|
||||
}
|
||||
>
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<Show when={state.settings.getValue("advanced:admin_panel")}>
|
||||
<ContextMenuButton icon={MdShield} onClick={openAdminPanel}>
|
||||
<Trans>Admin Panel</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={state.settings.getValue("advanced:copy_id")}>
|
||||
<ContextMenuButton icon={MdBadge} onClick={copyId}>
|
||||
<Trans>Copy server ID</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { Show } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { Server } from "stoat.js";
|
||||
|
||||
import { useModals } from "@revolt/modal";
|
||||
|
||||
import MdLibraryAdd from "@material-design-icons/svg/outlined/library_add.svg?component-solid";
|
||||
|
||||
import { ContextMenu, ContextMenuButton } from "./ContextMenu";
|
||||
|
||||
/**
|
||||
* Context menu for server sidebar
|
||||
*/
|
||||
export function ServerSidebarContextMenu(props: { server: Server }) {
|
||||
const { openModal } = useModals();
|
||||
|
||||
/**
|
||||
* Create a new channel
|
||||
*/
|
||||
function createChannel() {
|
||||
openModal({
|
||||
type: "create_channel",
|
||||
server: props.server!,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new category
|
||||
*/
|
||||
function createCategory() {
|
||||
openModal({
|
||||
type: "create_category",
|
||||
server: props.server!,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<Show when={props.server?.havePermission("ManageChannel")}>
|
||||
<ContextMenuButton icon={MdLibraryAdd} onClick={createChannel}>
|
||||
<Trans>Create channel</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuButton icon={MdLibraryAdd} onClick={createCategory}>
|
||||
<Trans>Create category</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,449 @@
|
|||
import { JSX, Match, Show, Switch } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Channel, Message, ServerMember, User } from "stoat.js";
|
||||
|
||||
import { useClient } from "@revolt/client";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { useSmartParams } from "@revolt/routing";
|
||||
import { useState } from "@revolt/state";
|
||||
import { Slider, Text } from "@revolt/ui";
|
||||
|
||||
import MdAddCircleOutline from "@material-design-icons/svg/outlined/add_circle_outline.svg?component-solid";
|
||||
import MdAdminPanelSettings from "@material-design-icons/svg/outlined/admin_panel_settings.svg?component-solid";
|
||||
import MdAlternateEmail from "@material-design-icons/svg/outlined/alternate_email.svg?component-solid";
|
||||
import MdAssignmentInd from "@material-design-icons/svg/outlined/assignment_ind.svg?component-solid";
|
||||
import MdBadge from "@material-design-icons/svg/outlined/badge.svg?component-solid";
|
||||
import MdBlock from "@material-design-icons/svg/outlined/block.svg?component-solid";
|
||||
import MdCancel from "@material-design-icons/svg/outlined/cancel.svg?component-solid";
|
||||
import MdChat from "@material-design-icons/svg/outlined/chat.svg?component-solid";
|
||||
import MdClose from "@material-design-icons/svg/outlined/close.svg?component-solid";
|
||||
import MdDoNotDisturbOn from "@material-design-icons/svg/outlined/do_not_disturb_on.svg?component-solid";
|
||||
import MdFace from "@material-design-icons/svg/outlined/face.svg?component-solid";
|
||||
import MdMicOff from "@material-design-icons/svg/outlined/mic_off.svg?component-solid";
|
||||
import MdPersonAddAlt from "@material-design-icons/svg/outlined/person_add_alt.svg?component-solid";
|
||||
import MdPersonRemove from "@material-design-icons/svg/outlined/person_remove.svg?component-solid";
|
||||
import MdReport from "@material-design-icons/svg/outlined/report.svg?component-solid";
|
||||
|
||||
import MdChecked from "@material-symbols/svg-400/outlined/check_box.svg?component-solid";
|
||||
import MdUnchecked from "@material-symbols/svg-400/outlined/check_box_outline_blank.svg?component-solid";
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
ContextMenuDivider,
|
||||
} from "./ContextMenu";
|
||||
import { NotificationContextMenu } from "./shared/NotificationContextMenu";
|
||||
|
||||
/**
|
||||
* Context menu for users
|
||||
*/
|
||||
export function UserContextMenu(props: {
|
||||
user: User;
|
||||
channel?: Channel;
|
||||
member?: ServerMember;
|
||||
contextMessage?: Message;
|
||||
inVoice?: boolean;
|
||||
}) {
|
||||
// TODO: if we take serverId instead, we could dynamically fetch server member here
|
||||
// same for the floating menu I guess?
|
||||
const state = useState();
|
||||
const client = useClient();
|
||||
const navigate = useNavigate();
|
||||
const { openModal } = useModals();
|
||||
|
||||
// server context
|
||||
const params = useSmartParams();
|
||||
|
||||
/**
|
||||
* Open direct message channel
|
||||
*/
|
||||
function openDm() {
|
||||
props.user.openDM().then((channel) => navigate(channel.url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete channel
|
||||
*/
|
||||
function closeDm() {
|
||||
openModal({
|
||||
type: "delete_channel",
|
||||
channel: props.channel!,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mention the user
|
||||
*/
|
||||
function mention() {
|
||||
if (!state.draft._setNodeReplacement) return;
|
||||
state.draft._setNodeReplacement([props.user.toString()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit server identity for user
|
||||
*/
|
||||
function editIdentity() {
|
||||
openModal({
|
||||
type: "server_identity",
|
||||
member: props.member!,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the user
|
||||
*/
|
||||
function reportUser() {
|
||||
openModal({
|
||||
type: "report_content",
|
||||
target: props.user!,
|
||||
client: client(),
|
||||
contextMessage: props.contextMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit this user's roles
|
||||
*/
|
||||
function editRoles() {
|
||||
openModal({
|
||||
type: "user_profile_roles",
|
||||
member: props.member!,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick the member
|
||||
*/
|
||||
function kickMember() {
|
||||
openModal({
|
||||
type: "kick_member",
|
||||
member: props.member!,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ban the member
|
||||
*/
|
||||
function banMember() {
|
||||
openModal({
|
||||
type: "ban_member",
|
||||
member: props.member!,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ban the user
|
||||
*/
|
||||
function banUser() {
|
||||
openModal({
|
||||
type: "ban_non_member",
|
||||
user: props.user!,
|
||||
server: client().servers.get(params().serverId!)!,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add friend
|
||||
*/
|
||||
function addFriend() {
|
||||
props.user.addFriend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove friend
|
||||
*/
|
||||
function removeFriend() {
|
||||
props.user.removeFriend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Block user
|
||||
*/
|
||||
function blockUser() {
|
||||
props.user.blockUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock user
|
||||
*/
|
||||
function unblockUser() {
|
||||
props.user.unblockUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open user in Stoat Admin Panel
|
||||
*/
|
||||
function openAdminPanel() {
|
||||
window.open(
|
||||
`https://old-admin.stoatinternal.com/panel/inspect/user/${props.user.id}`,
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy user id to clipboard
|
||||
*/
|
||||
function copyId() {
|
||||
navigator.clipboard.writeText(props.user.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu class="UserContextMenu">
|
||||
<Show when={props.inVoice && !props.user.self}>
|
||||
<ContextMenuButton
|
||||
onMouseDown={(e) => e.stopImmediatePropagation()}
|
||||
onClick={(e) => e.stopImmediatePropagation()}
|
||||
>
|
||||
<Text class="label">
|
||||
<Trans>Volume</Trans>
|
||||
</Text>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={state.voice.getUserVolume(props.user.id)}
|
||||
onInput={(event) =>
|
||||
state.voice.setUserVolume(
|
||||
props.user.id,
|
||||
event.currentTarget.value,
|
||||
)
|
||||
}
|
||||
labelFormatter={(label) => (label * 100).toFixed(0) + "%"}
|
||||
/>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuButton
|
||||
icon={MdMicOff}
|
||||
onClick={() =>
|
||||
state.voice.setUserMuted(
|
||||
props.user.id,
|
||||
!state.voice.getUserMuted(props.user.id),
|
||||
)
|
||||
}
|
||||
actionSymbol={
|
||||
state.voice.getUserMuted(props.user.id) ? MdChecked : MdUnchecked
|
||||
}
|
||||
>
|
||||
<Trans>Mute</Trans>
|
||||
</ContextMenuButton>
|
||||
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<Show when={props.channel?.type === "DirectMessage"}>
|
||||
<ContextMenuButton icon={MdClose} onClick={closeDm}>
|
||||
<Trans>Close chat</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.channel?.type === "TextChannel"}>
|
||||
<ContextMenuButton icon={MdAlternateEmail} onClick={mention}>
|
||||
<Trans>Mention</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.user.relationship === "Friend"}>
|
||||
<ContextMenuButton icon={MdChat} onClick={openDm}>
|
||||
<Trans>Message</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
props.user.relationship === "Friend" ||
|
||||
(props.channel &&
|
||||
(props.channel.type === "DirectMessage" ||
|
||||
props.channel.type === "TextChannel"))
|
||||
}
|
||||
>
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<Show when={props.channel?.type === "DirectMessage"}>
|
||||
<NotificationContextMenu channel={props.channel!} />
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
props.member &&
|
||||
(props.user.self
|
||||
? props.member!.server!.havePermission("ChangeNickname") ||
|
||||
props.member!.server!.havePermission("ChangeAvatar")
|
||||
: (props.member!.server!.havePermission("ManageNicknames") ||
|
||||
props.member!.server!.havePermission("RemoveAvatars")) &&
|
||||
props.member!.inferiorTo(props.member!.server!.member!))
|
||||
}
|
||||
>
|
||||
<ContextMenuButton icon={MdFace} onClick={editIdentity}>
|
||||
<Switch fallback={<Trans>Edit identity</Trans>}>
|
||||
<Match when={props.user.self}>
|
||||
<Trans>Edit your identity</Trans>
|
||||
</Match>
|
||||
</Switch>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
|
||||
<Show when={props.member}>
|
||||
<Show
|
||||
when={
|
||||
props.member?.server?.owner?.self ||
|
||||
(props.member?.server?.havePermission("AssignRoles") &&
|
||||
props.member.inferiorTo(props.member.server.member!))
|
||||
}
|
||||
>
|
||||
<ContextMenuButton icon={MdAssignmentInd} onClick={editRoles}>
|
||||
<Trans>Edit roles</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
{/** TODO: #287 timeout users */}
|
||||
<Show
|
||||
when={
|
||||
!props.user.self &&
|
||||
props.member?.server?.havePermission("KickMembers") &&
|
||||
props.member.inferiorTo(props.member.server.member!)
|
||||
}
|
||||
>
|
||||
<ContextMenuButton
|
||||
icon={MdPersonRemove}
|
||||
onClick={kickMember}
|
||||
destructive
|
||||
>
|
||||
<Trans>Kick member</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!props.user.self &&
|
||||
props.member?.server?.havePermission("BanMembers") &&
|
||||
props.member.inferiorTo(props.member.server.member!)
|
||||
}
|
||||
>
|
||||
<ContextMenuButton
|
||||
icon={MdDoNotDisturbOn}
|
||||
onClick={banMember}
|
||||
destructive
|
||||
>
|
||||
<Trans>Ban member</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
!props.user.self &&
|
||||
props.member?.server?.havePermission("BanMembers") &&
|
||||
params().serverId &&
|
||||
!props.member
|
||||
}
|
||||
>
|
||||
<ContextMenuButton
|
||||
icon={MdDoNotDisturbOn}
|
||||
onClick={banUser}
|
||||
destructive
|
||||
>
|
||||
<Trans>Ban user</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.user.self}>
|
||||
<ContextMenuButton icon={MdReport} onClick={reportUser} destructive>
|
||||
<Trans>Report user</Trans>
|
||||
</ContextMenuButton>
|
||||
{/* TODO: #286 show profile / message */}
|
||||
<Show when={props.user.relationship === "None" && !props.user.bot}>
|
||||
<ContextMenuButton icon={MdPersonAddAlt} onClick={addFriend}>
|
||||
<Trans>Add friend</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.user.relationship === "Friend"}>
|
||||
<ContextMenuButton icon={MdPersonRemove} onClick={removeFriend}>
|
||||
<Trans>Remove friend</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.user.relationship === "Incoming"}>
|
||||
<ContextMenuButton icon={MdPersonAddAlt} onClick={addFriend}>
|
||||
<Trans>Accept friend request</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.user.relationship === "Incoming"}>
|
||||
<ContextMenuButton icon={MdCancel} onClick={removeFriend}>
|
||||
<Trans>Reject friend request</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.user.relationship === "Outgoing"}>
|
||||
<ContextMenuButton icon={MdCancel} onClick={removeFriend}>
|
||||
<Trans>Cancel friend request</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.user.relationship !== "Blocked"}>
|
||||
<ContextMenuButton icon={MdBlock} onClick={blockUser}>
|
||||
<Trans>Block user</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={props.user.relationship === "Blocked"}>
|
||||
<ContextMenuButton icon={MdAddCircleOutline} onClick={unblockUser}>
|
||||
<Trans>Unblock user</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
state.settings.getValue("advanced:admin_panel") ||
|
||||
state.settings.getValue("advanced:copy_id")
|
||||
}
|
||||
>
|
||||
<ContextMenuDivider />
|
||||
</Show>
|
||||
|
||||
<Show when={state.settings.getValue("advanced:admin_panel")}>
|
||||
<ContextMenuButton icon={MdAdminPanelSettings} onClick={openAdminPanel}>
|
||||
<Trans>Admin Panel</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
<Show when={state.settings.getValue("advanced:copy_id")}>
|
||||
<ContextMenuButton icon={MdBadge} onClick={copyId}>
|
||||
<Trans>Copy user ID</Trans>
|
||||
</ContextMenuButton>
|
||||
</Show>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide floating user menus on this element
|
||||
* @param user User
|
||||
* @param member Server Member
|
||||
*/
|
||||
export function floatingUserMenus(
|
||||
user: User,
|
||||
member?: ServerMember,
|
||||
contextMessage?: Message,
|
||||
): JSX.Directives["floating"] & object {
|
||||
return {
|
||||
userCard: {
|
||||
user,
|
||||
member,
|
||||
// we could use message to display masquerade info in user card
|
||||
},
|
||||
/**
|
||||
* Build user context menu
|
||||
*/
|
||||
contextMenu() {
|
||||
return (
|
||||
<UserContextMenu
|
||||
user={user}
|
||||
member={member}
|
||||
contextMessage={contextMessage}
|
||||
channel={contextMessage?.channel}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function floatingUserMenusFromMessage(message: Message) {
|
||||
return message.author
|
||||
? floatingUserMenus(message.author!, message.member, message)
|
||||
: {}; // TODO: webhook menu
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export { CategoryContextMenu } from "./CategoryContextMenu";
|
||||
export { ChannelContextMenu } from "./ChannelContextMenu";
|
||||
export { MessageContextMenu } from "./MessageContextMenu";
|
||||
export { ServerContextMenu } from "./ServerContextMenu";
|
||||
export { ServerSidebarContextMenu } from "./ServerSidebarContextMenu";
|
||||
export { UserContextMenu } from "./UserContextMenu";
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import { For, Match, Show, Switch } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import dayjs from "dayjs";
|
||||
import { Channel } from "stoat.js";
|
||||
|
||||
import { useState } from "@revolt/state";
|
||||
import { Column, Text, Time } from "@revolt/ui";
|
||||
|
||||
import MdAlternateEmail from "@material-design-icons/svg/outlined/alternate_email.svg?component-solid";
|
||||
import MdNotificationsActive from "@material-design-icons/svg/outlined/notifications_active.svg?component-solid";
|
||||
import MdNotificationsOff from "@material-design-icons/svg/outlined/notifications_off.svg?component-solid";
|
||||
|
||||
import MdDoNotDisturbOff from "@material-symbols/svg-400/outlined/do_not_disturb_off.svg?component-solid";
|
||||
import MdDoNotDisturbOn from "@material-symbols/svg-400/outlined/do_not_disturb_on.svg?component-solid";
|
||||
import MdNotificationSettings from "@material-symbols/svg-400/outlined/notification_settings.svg?component-solid";
|
||||
import MdRadioButtonChecked from "@material-symbols/svg-400/outlined/radio_button_checked-fill.svg?component-solid";
|
||||
import MdRadioButtonUnchecked from "@material-symbols/svg-400/outlined/radio_button_unchecked.svg?component-solid";
|
||||
|
||||
import { ContextMenuButton, ContextMenuSubMenu } from "../ContextMenu";
|
||||
|
||||
export function NotificationContextMenu(props: { channel: Channel }) {
|
||||
const state = useState();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show
|
||||
when={!state.notifications.isChannelMuted(props.channel)}
|
||||
fallback={
|
||||
<ContextMenuButton
|
||||
onClick={() =>
|
||||
state.notifications.setChannelMute(props.channel, undefined)
|
||||
}
|
||||
symbol={MdDoNotDisturbOff}
|
||||
_titleCase={false}
|
||||
>
|
||||
<Column gap="none">
|
||||
<Trans>Unmute Channel</Trans>
|
||||
<Show
|
||||
when={state.notifications.getChannelMute(props.channel)?.until}
|
||||
>
|
||||
<Text class="label" size="small">
|
||||
<Trans>
|
||||
Muted until{" "}
|
||||
<Time
|
||||
format="datetime"
|
||||
value={
|
||||
state.notifications.getChannelMute(props.channel)!.until
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
</Show>
|
||||
</Column>
|
||||
</ContextMenuButton>
|
||||
}
|
||||
>
|
||||
<ContextMenuSubMenu
|
||||
onClick={() => state.notifications.setChannelMute(props.channel, {})}
|
||||
buttonContent={<Trans>Mute Channel</Trans>}
|
||||
symbol={MdDoNotDisturbOn}
|
||||
>
|
||||
<For
|
||||
each={
|
||||
[
|
||||
[15, <Trans>For 15 minutes</Trans>],
|
||||
[60, <Trans>For 1 hour</Trans>],
|
||||
[180, <Trans>For 3 hours</Trans>],
|
||||
[480, <Trans>For 8 hours</Trans>],
|
||||
[1440, <Trans>For 24 hours</Trans>],
|
||||
[undefined, <Trans>Until I turn it back on</Trans>],
|
||||
] as const
|
||||
}
|
||||
>
|
||||
{([timeMin, i18n]) => (
|
||||
<ContextMenuButton
|
||||
onClick={() =>
|
||||
state.notifications.setChannelMute(props.channel, {
|
||||
until: timeMin
|
||||
? +dayjs().add(timeMin, "minutes")
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
_titleCase={false}
|
||||
>
|
||||
{i18n}
|
||||
</ContextMenuButton>
|
||||
)}
|
||||
</For>
|
||||
</ContextMenuSubMenu>
|
||||
</Show>
|
||||
|
||||
<ContextMenuSubMenu
|
||||
symbol={MdNotificationSettings}
|
||||
buttonContent={<Trans>Notifications</Trans>}
|
||||
>
|
||||
<ContextMenuButton
|
||||
onClick={() =>
|
||||
state.notifications.setChannel(props.channel, undefined)
|
||||
}
|
||||
actionSymbol={
|
||||
typeof state.notifications.getChannel(props.channel) === "undefined"
|
||||
? MdRadioButtonChecked
|
||||
: MdRadioButtonUnchecked
|
||||
}
|
||||
>
|
||||
<Column gap="none">
|
||||
<Show when={props.channel.server} fallback={<Trans>Default</Trans>}>
|
||||
<Trans>Server Default</Trans>
|
||||
</Show>
|
||||
<Text class="label" size="small">
|
||||
<Switch fallback={<Trans>None</Trans>}>
|
||||
<Match
|
||||
when={
|
||||
props.channel.server
|
||||
? state.notifications.computeForServer(
|
||||
props.channel.server!,
|
||||
) === "all"
|
||||
: true
|
||||
}
|
||||
>
|
||||
<Trans>All Messages</Trans>
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
props.channel.server &&
|
||||
state.notifications.computeForServer(
|
||||
props.channel.server!,
|
||||
) === "mention"
|
||||
}
|
||||
>
|
||||
<Trans>Mentions Only</Trans>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Text>
|
||||
</Column>
|
||||
</ContextMenuButton>
|
||||
|
||||
<ContextMenuButton
|
||||
icon={MdNotificationsActive}
|
||||
onClick={() => state.notifications.setChannel(props.channel, "all")}
|
||||
actionSymbol={
|
||||
state.notifications.getChannel(props.channel) === "all"
|
||||
? MdRadioButtonChecked
|
||||
: MdRadioButtonUnchecked
|
||||
}
|
||||
>
|
||||
<Trans>All Messages</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuButton
|
||||
icon={MdAlternateEmail}
|
||||
onClick={() =>
|
||||
state.notifications.setChannel(props.channel, "mention")
|
||||
}
|
||||
actionSymbol={
|
||||
state.notifications.getChannel(props.channel) === "mention"
|
||||
? MdRadioButtonChecked
|
||||
: MdRadioButtonUnchecked
|
||||
}
|
||||
>
|
||||
<Trans>Mentions Only</Trans>
|
||||
</ContextMenuButton>
|
||||
<ContextMenuButton
|
||||
icon={MdNotificationsOff}
|
||||
onClick={() => state.notifications.setChannel(props.channel, "none")}
|
||||
actionSymbol={
|
||||
state.notifications.getChannel(props.channel) === "none"
|
||||
? MdRadioButtonChecked
|
||||
: MdRadioButtonUnchecked
|
||||
}
|
||||
>
|
||||
<Trans>None</Trans>
|
||||
</ContextMenuButton>
|
||||
</ContextMenuSubMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { AuthPage } from "./src/AuthPage";
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import { BiLogosGithub } from "solid-icons/bi";
|
||||
import { JSX } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { Titlebar } from "@revolt/app/interface/desktop/Titlebar";
|
||||
import { useState } from "@revolt/state";
|
||||
import { IconButton, iconSize } from "@revolt/ui";
|
||||
|
||||
import MdDarkMode from "@material-design-icons/svg/filled/dark_mode.svg?component-solid";
|
||||
|
||||
import background from "./background.jpg";
|
||||
import { FlowBase } from "./flows/Flow";
|
||||
import bluesky from "./flows/bluesky.svg";
|
||||
|
||||
/**
|
||||
* Authentication page layout
|
||||
*/
|
||||
const Base = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "40px 35px",
|
||||
|
||||
userSelect: "none",
|
||||
overflowY: "scroll",
|
||||
|
||||
color: "var(--md-sys-color-on-surface)",
|
||||
background: "var(--md-sys-color-surface)",
|
||||
// background: `var(--url)`,
|
||||
// backgroundPosition: "center",
|
||||
// backgroundRepeat: "no-repeat",
|
||||
// backgroundSize: "cover",
|
||||
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
|
||||
mdDown: {
|
||||
padding: "30px 20px",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Top and bottom navigation bars
|
||||
*/
|
||||
const Nav = styled("div", {
|
||||
base: {
|
||||
height: "32px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
|
||||
textDecoration: "none",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigation items
|
||||
*/
|
||||
const NavItems = styled("div", {
|
||||
base: {
|
||||
gap: "10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
fontSize: "0.9em",
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
default: {},
|
||||
stack: {
|
||||
md: {
|
||||
flexDirection: "column",
|
||||
},
|
||||
},
|
||||
hide: {
|
||||
md: {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Link with an icon inside
|
||||
*/
|
||||
const LinkWithIcon = styled("a", {
|
||||
base: { height: "24px" },
|
||||
});
|
||||
|
||||
/**
|
||||
* Middot-like bullet
|
||||
*/
|
||||
const Bullet = styled("div", {
|
||||
base: {
|
||||
height: "5px",
|
||||
width: "5px",
|
||||
background: "grey",
|
||||
borderRadius: "50%",
|
||||
|
||||
md: {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Authentication page
|
||||
*/
|
||||
export function AuthPage(props: { children: JSX.Element }) {
|
||||
const state = useState();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<Titlebar />
|
||||
<Base
|
||||
style={{ "--url": `url('${background}')` }}
|
||||
css={{ scrollbar: "hidden" }}
|
||||
>
|
||||
<Nav>
|
||||
<div />
|
||||
<IconButton
|
||||
variant="tonal"
|
||||
onPress={() =>
|
||||
state.theme.setMode(
|
||||
state.theme.activeTheme.darkMode ? "light" : "dark",
|
||||
)
|
||||
}
|
||||
>
|
||||
<MdDarkMode {...iconSize("24px")} />
|
||||
</IconButton>
|
||||
</Nav>
|
||||
<FlowBase>{props.children}</FlowBase>
|
||||
<Nav>
|
||||
<NavItems variant="stack">
|
||||
<NavItems>
|
||||
<LinkWithIcon href="https://github.com/stoatchat" target="_blank">
|
||||
<BiLogosGithub size={24} />
|
||||
</LinkWithIcon>
|
||||
<LinkWithIcon
|
||||
href="https://bsky.app/profile/stoat.chat"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
src={bluesky}
|
||||
style={{ height: "22px", "padding-top": "3px" }}
|
||||
/>
|
||||
</LinkWithIcon>
|
||||
</NavItems>
|
||||
<Bullet />
|
||||
<NavItems>
|
||||
<a href="https://stoat.chat/about" target="_blank">
|
||||
<Trans>About</Trans>
|
||||
</a>
|
||||
<a href="https://stoat.chat/terms" target="_blank">
|
||||
<Trans>Terms of Service</Trans>
|
||||
</a>
|
||||
<a href="https://stoat.chat/privacy" target="_blank">
|
||||
<Trans>Privacy Policy</Trans>
|
||||
</a>
|
||||
</NavItems>
|
||||
</NavItems>
|
||||
<NavItems variant="hide">
|
||||
<Trans>Image by {"@fakurian"}</Trans>
|
||||
<Bullet />
|
||||
<a href="https://unsplash.com/" target="_blank" rel="noreferrer">
|
||||
unsplash.com
|
||||
</a>
|
||||
</NavItems>
|
||||
</Nav>
|
||||
</Base>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
|
|
@ -0,0 +1,120 @@
|
|||
import { JSX, Show } from "solid-js";
|
||||
|
||||
import { defineKeyframes } from "@pandacss/dev";
|
||||
import { styled } from "styled-system/jsx";
|
||||
|
||||
import { Column, Row, Text } from "@revolt/ui";
|
||||
|
||||
import envelope from "./envelope.svg";
|
||||
import wave from "./wave.svg";
|
||||
|
||||
/**
|
||||
* Container for authentication page flows
|
||||
*/
|
||||
export const FlowBase = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "var(--gap-lg)",
|
||||
flexGrow: 0,
|
||||
background: "var(--md-sys-color-surface-container)",
|
||||
color: "var(--md-sys-color-on-surface)",
|
||||
width: "360px",
|
||||
maxWidth: "360px",
|
||||
maxHeight: "600px",
|
||||
padding: "45px 40px",
|
||||
borderRadius: "32px",
|
||||
marginTop: "20px",
|
||||
marginBottom: "20px",
|
||||
justifySelf: "center",
|
||||
marginInline: "auto",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Wave animation
|
||||
* TODO: I don't think this is how you use it
|
||||
*/
|
||||
const WaveAnimation = defineKeyframes({
|
||||
fadeIn: {
|
||||
"0%": { transform: "rotate(0)" },
|
||||
"10%": { transform: "rotate(14deg)" },
|
||||
"20%": { transform: "rotate(-8deg)" },
|
||||
"30%": { transform: "rotate(14deg)" },
|
||||
"40%": { transform: "rotate(-4deg)" },
|
||||
"50%": { transform: "rotate(10deg)" },
|
||||
"60%": { transform: "rotate(0)" },
|
||||
"100%": { transform: "rotate(0)" },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Envelope animation
|
||||
* TODO: I don't think this is how you use it
|
||||
*/
|
||||
const EnvelopeAnimation = defineKeyframes({
|
||||
fadeIn: {
|
||||
"0%": {
|
||||
opacity: 0,
|
||||
transform: "translateY(-24px)",
|
||||
},
|
||||
"100%": {
|
||||
opacity: 1,
|
||||
transform: "translateY(-4px)",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Wave emoji
|
||||
*/
|
||||
const Wave = styled("img", {
|
||||
base: {
|
||||
height: "1.8em",
|
||||
animationDuration: "2.5s",
|
||||
animationIterationCount: 1,
|
||||
animationName: WaveAnimation,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mail emoji
|
||||
*/
|
||||
const Mail = styled("img", {
|
||||
base: {
|
||||
height: "1.8em",
|
||||
transform: "translateY(-4px)",
|
||||
animationDuration: "0.5s",
|
||||
animationIterationCount: 1,
|
||||
animationTimingFunction: "ease",
|
||||
animationName: EnvelopeAnimation,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Common flow title component
|
||||
*/
|
||||
export function FlowTitle(props: {
|
||||
children: JSX.Element;
|
||||
subtitle?: JSX.Element;
|
||||
emoji?: "wave" | "mail";
|
||||
}) {
|
||||
return (
|
||||
<Column>
|
||||
<Row align gap="sm">
|
||||
<Show when={props.emoji === "wave"}>
|
||||
<Wave src={wave} />
|
||||
</Show>
|
||||
<Show when={props.emoji === "mail"}>
|
||||
<Mail src={envelope} />
|
||||
</Show>
|
||||
<Text class="title" size="large">
|
||||
{props.children}
|
||||
</Text>
|
||||
</Row>
|
||||
<Show when={props.subtitle}>
|
||||
<Text class="title">{props.subtitle}</Text>
|
||||
</Show>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { Show } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useNavigate } from "@revolt/routing";
|
||||
import { Button, Row, iconSize } from "@revolt/ui";
|
||||
|
||||
import MdArrowBack from "@material-design-icons/svg/filled/arrow_back.svg?component-solid";
|
||||
|
||||
import { FlowTitle } from "./Flow";
|
||||
import { MailProvider } from "./MailProvider";
|
||||
|
||||
/**
|
||||
* Keep track of email within the same session
|
||||
*/
|
||||
let email = "postmaster@revolt.wtf";
|
||||
|
||||
/**
|
||||
* Persist email information temporarily
|
||||
*/
|
||||
export function setFlowCheckEmail(e: string) {
|
||||
email = e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow to tell the user to check their email
|
||||
*/
|
||||
export default function FlowCheck() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlowTitle
|
||||
subtitle={
|
||||
<Trans>
|
||||
We've sent you a verification email. Please allow up to 10 minutes
|
||||
for it to arrive.
|
||||
</Trans>
|
||||
}
|
||||
emoji="mail"
|
||||
>
|
||||
<Trans>Check your mail!</Trans>
|
||||
</FlowTitle>
|
||||
<Row align justify>
|
||||
<a href="..">
|
||||
<Button variant="text">
|
||||
<MdArrowBack {...iconSize("1.2em")} /> <Trans>Back</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
<Show when={email}>
|
||||
<MailProvider email={email} />
|
||||
</Show>
|
||||
</Row>
|
||||
{import.meta.env.DEV && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
background: "white",
|
||||
color: "black",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate("/login/verify/abc", { replace: true });
|
||||
}}
|
||||
>
|
||||
Mock Verify
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useApi } from "@revolt/client";
|
||||
import { useNavigate, useParams } from "@revolt/routing";
|
||||
import { Button } from "@revolt/ui";
|
||||
|
||||
import { FlowTitle } from "./Flow";
|
||||
import { Fields, Form } from "./Form";
|
||||
|
||||
/**
|
||||
* Flow for confirming a new password
|
||||
*/
|
||||
export default function FlowConfirmReset() {
|
||||
const api = useApi();
|
||||
const { token } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/**
|
||||
* Confirm new password
|
||||
* @param data Form Data
|
||||
*/
|
||||
async function reset(data: FormData) {
|
||||
const password = data.get("new-password") as string;
|
||||
const remove_sessions = !!(data.get("log-out") as "on" | undefined);
|
||||
|
||||
await api.patch("/auth/account/reset_password", {
|
||||
password,
|
||||
token,
|
||||
remove_sessions,
|
||||
});
|
||||
|
||||
navigate("/login/auth", { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlowTitle>
|
||||
<Trans>Reset password</Trans>
|
||||
</FlowTitle>
|
||||
<Form onSubmit={reset}>
|
||||
<Fields fields={["new-password", "log-out"]} />
|
||||
<Button type="submit">
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
</Form>
|
||||
<a href="/login/auth">
|
||||
<Button variant="text">
|
||||
<Trans>Go back to login</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { CONFIGURATION } from "@revolt/common";
|
||||
import { useNavigate } from "@revolt/routing";
|
||||
import { Button, Row, iconSize } from "@revolt/ui";
|
||||
|
||||
import MdArrowBack from "@material-design-icons/svg/filled/arrow_back.svg?component-solid";
|
||||
|
||||
import { useApi } from "../../../client";
|
||||
|
||||
import { FlowTitle } from "./Flow";
|
||||
import { setFlowCheckEmail } from "./FlowCheck";
|
||||
import { Fields, Form } from "./Form";
|
||||
|
||||
/**
|
||||
* Flow for creating a new account
|
||||
*/
|
||||
export default function FlowCreate() {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/**
|
||||
* Create an account
|
||||
* @param data Form Data
|
||||
*/
|
||||
async function create(data: FormData) {
|
||||
const email = data.get("email") as string;
|
||||
const password = data.get("password") as string;
|
||||
const captcha = data.get("captcha") as string;
|
||||
|
||||
await api.post("/auth/account/create", {
|
||||
email,
|
||||
password,
|
||||
captcha,
|
||||
});
|
||||
|
||||
// FIXME: should tell client if email was sent
|
||||
// or if email even needs to be confirmed
|
||||
|
||||
// TODO: log straight in if no email confirmation?
|
||||
|
||||
setFlowCheckEmail(email);
|
||||
navigate("/login/check", { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlowTitle subtitle={<Trans>Create an account</Trans>} emoji="wave">
|
||||
<Trans>Hello!</Trans>
|
||||
</FlowTitle>
|
||||
<Form onSubmit={create} captcha={CONFIGURATION.HCAPTCHA_SITEKEY}>
|
||||
<Fields fields={["email", "password"]} />
|
||||
<Row justify>
|
||||
<a href="..">
|
||||
<Button variant="text">
|
||||
<MdArrowBack {...iconSize("1.2em")} /> <Trans>Back</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
<Button type="submit">
|
||||
<Trans>Register</Trans>
|
||||
</Button>
|
||||
</Row>
|
||||
</Form>
|
||||
{import.meta.env.DEV && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
background: "white",
|
||||
color: "black",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
setFlowCheckEmail("insert@stoat.chat");
|
||||
navigate("/login/check", { replace: true });
|
||||
}}
|
||||
>
|
||||
Mock Submission
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Match, Switch, createSignal, onMount } from "solid-js";
|
||||
|
||||
import { useApi } from "@revolt/client";
|
||||
import { useParams } from "@revolt/routing";
|
||||
|
||||
import { FlowTitle } from "./Flow";
|
||||
|
||||
/**
|
||||
* Temporary flow for account deletion
|
||||
*/
|
||||
export default function FlowDeleteAccount() {
|
||||
const api = useApi();
|
||||
const params = useParams();
|
||||
const [deleted, setDeleted] = createSignal<boolean | "error">(false);
|
||||
|
||||
onMount(() => {
|
||||
api
|
||||
.put("/auth/account/delete", {
|
||||
token: params.token,
|
||||
})
|
||||
.then(() => setDeleted(true))
|
||||
.catch(() => setDeleted("error"));
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlowTitle>Delete Account</FlowTitle>
|
||||
<span>
|
||||
<Switch fallback={"Please wait..."}>
|
||||
<Match when={deleted() === "error"}>
|
||||
Error occurred, please email support.
|
||||
</Match>
|
||||
<Match when={deleted() === true}>
|
||||
Account has been queued for deletion!
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { Match, Show, Switch } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
import { css } from "styled-system/css";
|
||||
|
||||
import { useClientLifecycle } from "@revolt/client";
|
||||
import { TransitionType } from "@revolt/client/Controller";
|
||||
import { Navigate } from "@revolt/routing";
|
||||
import { Button, Column } from "@revolt/ui";
|
||||
|
||||
import { useState } from "@revolt/state";
|
||||
import Wordmark from "../../../../public/assets/web/wordmark.svg?component-solid";
|
||||
|
||||
/**
|
||||
* Flow for logging into an account
|
||||
*/
|
||||
export default function FlowHome() {
|
||||
const state = useState();
|
||||
const { lifecycle, isLoggedIn, isError } = useClientLifecycle();
|
||||
|
||||
return (
|
||||
<Switch
|
||||
fallback={
|
||||
<>
|
||||
<Show when={isLoggedIn()}>
|
||||
<Navigate href={state.layout.popNextPath() ?? "/app"} />
|
||||
</Show>
|
||||
|
||||
<Column gap="xl">
|
||||
<Wordmark
|
||||
class={css({
|
||||
width: "60%",
|
||||
margin: "auto",
|
||||
fill: "var(--md-sys-color-on-surface)",
|
||||
})}
|
||||
/>
|
||||
|
||||
<Column>
|
||||
<b
|
||||
style={{
|
||||
"font-weight": 800,
|
||||
"font-size": "1.4em",
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
"align-items": "center",
|
||||
"text-align": "center",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<Trans>
|
||||
Find your com
|
||||
<wbr />
|
||||
munity,
|
||||
<br />
|
||||
connect with the world.
|
||||
</Trans>
|
||||
</span>
|
||||
</b>
|
||||
<span style={{ "text-align": "center", opacity: "0.5" }}>
|
||||
<Trans>
|
||||
Stoat is one of the best ways to stay connected with your
|
||||
friends and community, anywhere, anytime.
|
||||
</Trans>
|
||||
</span>
|
||||
</Column>
|
||||
|
||||
<Column>
|
||||
<a href="/login/auth">
|
||||
<Column>
|
||||
<Button>
|
||||
<Trans>Log In</Trans>
|
||||
</Button>
|
||||
</Column>
|
||||
</a>
|
||||
<a href="/login/create">
|
||||
<Column>
|
||||
<Button variant="tonal">
|
||||
<Trans>Sign Up</Trans>
|
||||
</Button>
|
||||
</Column>
|
||||
</a>
|
||||
</Column>
|
||||
</Column>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Match when={isError()}>
|
||||
<Switch fallback={"an unknown error occurred"}>
|
||||
<Match when={lifecycle.permanentError === "InvalidSession"}>
|
||||
<h1>
|
||||
<Trans>You were logged out!</Trans>
|
||||
</h1>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<Button
|
||||
variant="filled"
|
||||
onPress={() =>
|
||||
lifecycle.transition({
|
||||
type: TransitionType.Dismiss,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>OK</Trans>
|
||||
</Button>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { Match, Switch } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useClientLifecycle } from "@revolt/client";
|
||||
import { State, TransitionType } from "@revolt/client/Controller";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { Navigate } from "@revolt/routing";
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
Column,
|
||||
Row,
|
||||
Text,
|
||||
iconSize,
|
||||
} from "@revolt/ui";
|
||||
|
||||
import MdArrowBack from "@material-design-icons/svg/filled/arrow_back.svg?component-solid";
|
||||
|
||||
import { useState } from "@revolt/state";
|
||||
import { FlowTitle } from "./Flow";
|
||||
import { Fields, Form } from "./Form";
|
||||
|
||||
/**
|
||||
* Flow for logging into an account
|
||||
*/
|
||||
export default function FlowLogin() {
|
||||
const state = useState();
|
||||
const modals = useModals();
|
||||
const { lifecycle, isLoggedIn, login, selectUsername } = useClientLifecycle();
|
||||
|
||||
/**
|
||||
* Log into account
|
||||
* @param data Form Data
|
||||
*/
|
||||
async function performLogin(data: FormData) {
|
||||
const email = data.get("email") as string;
|
||||
const password = data.get("password") as string;
|
||||
|
||||
await login(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
},
|
||||
modals,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a new username
|
||||
* @param data Form Data
|
||||
*/
|
||||
async function select(data: FormData) {
|
||||
const username = data.get("username") as string;
|
||||
await selectUsername(username);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch
|
||||
fallback={
|
||||
<>
|
||||
<FlowTitle subtitle={<Trans>Sign into Stoat</Trans>} emoji="wave">
|
||||
<Trans>Welcome!</Trans>
|
||||
</FlowTitle>
|
||||
<Form onSubmit={performLogin}>
|
||||
<Fields fields={["email", "password"]} />
|
||||
<Column gap="xl" align>
|
||||
<a href="/login/reset">
|
||||
<Button variant="text">
|
||||
<Trans>Reset password</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
<a href="/login/resend">
|
||||
<Button variant="text">
|
||||
<Trans>Resend verification</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
</Column>
|
||||
<Row align justify>
|
||||
<a href="..">
|
||||
<Button variant="text">
|
||||
<MdArrowBack {...iconSize("1.2em")} /> <Trans>Back</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
<Button type="submit">
|
||||
<Trans>Login</Trans>
|
||||
</Button>
|
||||
</Row>
|
||||
</Form>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Match when={isLoggedIn()}>
|
||||
<Navigate href={state.layout.popNextPath() ?? "/app"} />
|
||||
</Match>
|
||||
<Match when={lifecycle.state() === State.LoggingIn}>
|
||||
<CircularProgress />
|
||||
</Match>
|
||||
<Match when={lifecycle.state() === State.Onboarding}>
|
||||
<FlowTitle>
|
||||
<Trans>Choose a username</Trans>
|
||||
</FlowTitle>
|
||||
|
||||
<Text>
|
||||
<Trans>
|
||||
Pick a username that you want people to be able to find you by.
|
||||
This can be changed later in your user settings.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Form onSubmit={select}>
|
||||
<Fields fields={["username"]} />
|
||||
<Row align justify>
|
||||
<Button
|
||||
variant="text"
|
||||
onPress={() =>
|
||||
lifecycle.transition({
|
||||
type: TransitionType.Cancel,
|
||||
})
|
||||
}
|
||||
>
|
||||
<MdArrowBack {...iconSize("1.2em")} /> <Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Trans>Confirm</Trans>
|
||||
</Button>
|
||||
</Row>
|
||||
</Form>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useApi } from "@revolt/client";
|
||||
import { CONFIGURATION } from "@revolt/common";
|
||||
import { useNavigate } from "@revolt/routing";
|
||||
import { Button } from "@revolt/ui";
|
||||
|
||||
import { FlowTitle } from "./Flow";
|
||||
import { setFlowCheckEmail } from "./FlowCheck";
|
||||
import { Fields, Form } from "./Form";
|
||||
|
||||
/**
|
||||
* Flow for resending email verification
|
||||
*/
|
||||
export default function FlowResend() {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/**
|
||||
* Resend email verification
|
||||
* @param data Form Data
|
||||
*/
|
||||
async function resend(data: FormData) {
|
||||
const email = data.get("email") as string;
|
||||
const captcha = data.get("captcha") as string;
|
||||
|
||||
await api.post("/auth/account/reverify", {
|
||||
email,
|
||||
captcha,
|
||||
});
|
||||
|
||||
setFlowCheckEmail(email);
|
||||
navigate("/login/check", { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlowTitle>
|
||||
<Trans>Resend verification</Trans>
|
||||
</FlowTitle>
|
||||
<Form onSubmit={resend} captcha={CONFIGURATION.HCAPTCHA_SITEKEY}>
|
||||
<Fields fields={["email"]} />
|
||||
<Button type="submit">
|
||||
<Trans>Resend</Trans>
|
||||
</Button>
|
||||
</Form>
|
||||
<a href="/login/auth">
|
||||
<Button variant="text">
|
||||
<Trans>Go back to login</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useApi } from "@revolt/client";
|
||||
import { CONFIGURATION } from "@revolt/common";
|
||||
import { useNavigate } from "@revolt/routing";
|
||||
import { Button } from "@revolt/ui";
|
||||
|
||||
import { FlowTitle } from "./Flow";
|
||||
import { setFlowCheckEmail } from "./FlowCheck";
|
||||
import { Fields, Form } from "./Form";
|
||||
|
||||
/**
|
||||
* Flow for sending password reset
|
||||
*/
|
||||
export default function FlowReset() {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/**
|
||||
* Send password reset
|
||||
* @param data Form Data
|
||||
*/
|
||||
async function reset(data: FormData) {
|
||||
const email = data.get("email") as string;
|
||||
const captcha = data.get("captcha") as string;
|
||||
|
||||
await api.post("/auth/account/reset_password", {
|
||||
email,
|
||||
captcha,
|
||||
});
|
||||
|
||||
setFlowCheckEmail(email);
|
||||
navigate("/login/check", { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlowTitle>
|
||||
<Trans>Reset password</Trans>
|
||||
</FlowTitle>
|
||||
<Form onSubmit={reset} captcha={CONFIGURATION.HCAPTCHA_SITEKEY}>
|
||||
<Fields fields={["email"]} />
|
||||
<Button type="submit">
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
</Form>
|
||||
<a href="/login/auth">
|
||||
<Button variant="text">
|
||||
<Trans>Go back to login</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
{import.meta.env.DEV && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
background: "white",
|
||||
color: "black",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate("/login/reset/abc", { replace: true });
|
||||
}}
|
||||
>
|
||||
Mock Reset Screen
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { Match, Show, Switch, createSignal, onMount } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useApi, useClientLifecycle } from "@revolt/client";
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { useNavigate, useParams } from "@revolt/routing";
|
||||
import { Button, CircularProgress } from "@revolt/ui";
|
||||
|
||||
import { FlowTitle } from "./Flow";
|
||||
|
||||
type State =
|
||||
| {
|
||||
state: "verifying";
|
||||
}
|
||||
| {
|
||||
state: "error";
|
||||
error: unknown;
|
||||
}
|
||||
| {
|
||||
state: "success";
|
||||
mfa_ticket?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flow for confirming email
|
||||
*/
|
||||
export default function FlowVerify() {
|
||||
const api = useApi();
|
||||
const params = useParams();
|
||||
const modals = useModals();
|
||||
const navigate = useNavigate();
|
||||
const { login } = useClientLifecycle();
|
||||
|
||||
const [state, setState] = createSignal<State>({
|
||||
state: "verifying",
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
if (import.meta.env.DEV) {
|
||||
if (confirm("Mock verification?")) {
|
||||
if (confirm("Successful verification?")) {
|
||||
setState({ state: "success", mfa_ticket: "token" });
|
||||
} else {
|
||||
setState({ state: "error", error: "InvalidToken" });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await api.post(`/auth/account/verify/${params.token}`)) as {
|
||||
ticket?: { token: string };
|
||||
};
|
||||
|
||||
setState({ state: "success", mfa_ticket: data.ticket?.token });
|
||||
} catch (err) {
|
||||
setState({ state: "error", error: err });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Use MFA ticket to login
|
||||
*/
|
||||
async function performLogin() {
|
||||
const v = state();
|
||||
if (v.state === "success" && v.mfa_ticket) {
|
||||
await login(
|
||||
{
|
||||
mfa_ticket: v.mfa_ticket,
|
||||
},
|
||||
modals,
|
||||
);
|
||||
|
||||
navigate("/login/auth", { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={state().state === "verifying"}>
|
||||
<FlowTitle>
|
||||
<Trans>Verifying your account…</Trans>
|
||||
</FlowTitle>
|
||||
<CircularProgress />
|
||||
</Match>
|
||||
<Match when={state().state === "error"}>
|
||||
<FlowTitle>
|
||||
<Trans>Failed to verify!</Trans>
|
||||
</FlowTitle>
|
||||
{/* <Text class="body" size="small">
|
||||
{t(
|
||||
`error.${(state() as State & { state: "error" }).error}` as any,
|
||||
undefined,
|
||||
(state() as State & { state: "error" }).error
|
||||
)}
|
||||
</Text> TODO */}
|
||||
<a href="/login/auth">
|
||||
<Button variant="text">
|
||||
<Trans>Go back to login</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
</Match>
|
||||
<Match when={state().state === "success"}>
|
||||
<FlowTitle>
|
||||
<Trans>Your account has been verified!</Trans>
|
||||
</FlowTitle>
|
||||
<Show when={"mfa_ticket" in state()}>
|
||||
<Button onPress={performLogin}>
|
||||
<Trans>Continue to app</Trans>
|
||||
</Button>
|
||||
</Show>
|
||||
<a href="/login/auth">
|
||||
<Button variant="text">
|
||||
<Trans>Go back to login</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import HCaptcha, { HCaptchaFunctions } from "solid-hcaptcha";
|
||||
import { For, JSX, Show, createSignal } from "solid-js";
|
||||
|
||||
import { useLingui } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { useError } from "@revolt/i18n";
|
||||
import { Checkbox2, Column, Text, TextField } from "@revolt/ui";
|
||||
|
||||
/**
|
||||
* Available field types
|
||||
*/
|
||||
type Field = "email" | "password" | "new-password" | "log-out" | "username";
|
||||
|
||||
/**
|
||||
* Properties to apply to fields
|
||||
*/
|
||||
const useFieldConfiguration = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return {
|
||||
email: {
|
||||
type: "email" as const,
|
||||
name: () => t`Email`,
|
||||
placeholder: () => t`Please enter your email.`,
|
||||
autocomplete: "email",
|
||||
},
|
||||
password: {
|
||||
minLength: 8,
|
||||
type: "password" as const,
|
||||
name: () => t`Password`,
|
||||
placeholder: () => t`Enter your current password.`,
|
||||
},
|
||||
"new-password": {
|
||||
minLength: 8,
|
||||
type: "password" as const,
|
||||
autocomplete: "new-password",
|
||||
name: () => t`New Password`,
|
||||
placeholder: () => t`Enter a new password.`,
|
||||
},
|
||||
"log-out": {
|
||||
name: () => t`Log out of all other sessions`,
|
||||
},
|
||||
username: {
|
||||
minLength: 2,
|
||||
type: "text" as const,
|
||||
autocomplete: "none",
|
||||
name: () => t`Username`,
|
||||
placeholder: () => t`Enter your preferred username.`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface FieldProps {
|
||||
/**
|
||||
* Fields to gather
|
||||
*/
|
||||
fields: Field[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a bunch of fields with preset values
|
||||
*/
|
||||
export function Fields(props: FieldProps) {
|
||||
const fieldConfiguration = useFieldConfiguration();
|
||||
|
||||
return (
|
||||
<For each={props.fields}>
|
||||
{(field) => (
|
||||
<label>
|
||||
{field === "log-out" ? (
|
||||
<Checkbox2 name="log-out">
|
||||
{fieldConfiguration["log-out"].name()}
|
||||
</Checkbox2>
|
||||
) : (
|
||||
<TextField
|
||||
required
|
||||
{...fieldConfiguration[field]}
|
||||
name={field}
|
||||
label={fieldConfiguration[field].name()}
|
||||
placeholder={fieldConfiguration[field].placeholder()}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Form children
|
||||
*/
|
||||
children: JSX.Element;
|
||||
|
||||
/**
|
||||
* Whether to include captcha token
|
||||
*/
|
||||
captcha?: string;
|
||||
|
||||
/**
|
||||
* Submission handler
|
||||
*/
|
||||
onSubmit: (data: FormData) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small wrapper for HTML form
|
||||
*/
|
||||
export function Form(props: Props) {
|
||||
const [error, setError] = createSignal();
|
||||
const err = useError();
|
||||
let hcaptcha: HCaptchaFunctions | undefined;
|
||||
|
||||
/**
|
||||
* Handle submission
|
||||
* @param event Form Event
|
||||
*/
|
||||
async function onSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(event.currentTarget as HTMLFormElement);
|
||||
|
||||
if (props.captcha) {
|
||||
if (!hcaptcha) return alert("hCaptcha not loaded!");
|
||||
const response = await hcaptcha.execute();
|
||||
formData.set("captcha", response!.response);
|
||||
}
|
||||
|
||||
try {
|
||||
await props.onSubmit(formData);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<Column gap="lg">
|
||||
{props.children}
|
||||
<Show when={error()}>
|
||||
<Text class="label" size="small">
|
||||
{err(error())}
|
||||
</Text>
|
||||
</Show>
|
||||
</Column>
|
||||
<Show when={props.captcha}>
|
||||
<HCaptcha
|
||||
sitekey={props.captcha!}
|
||||
onLoad={(instance) => (hcaptcha = instance)}
|
||||
size="invisible"
|
||||
/>
|
||||
</Show>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { Show } from "solid-js";
|
||||
|
||||
import { Trans } from "@lingui-solid/solid/macro";
|
||||
|
||||
import { Button } from "@revolt/ui";
|
||||
|
||||
interface Props {
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert email to provider
|
||||
* @param email Email
|
||||
* @returns Provider
|
||||
*/
|
||||
function mapMailProvider(email?: string): [string, string] | undefined {
|
||||
if (!email) return;
|
||||
|
||||
const match = /@(.+)/.exec(email);
|
||||
if (match === null) return;
|
||||
|
||||
const domain = match[1];
|
||||
switch (domain) {
|
||||
case "gmail.com":
|
||||
case "googlemail.com":
|
||||
return ["Gmail", "https://gmail.com"];
|
||||
case "tuta.io":
|
||||
return ["Tutanota", "https://mail.tutanota.com"];
|
||||
case "outlook.com":
|
||||
case "hotmail.com":
|
||||
case "outlook.jp":
|
||||
case "outlook.fr":
|
||||
case "outlook.dk":
|
||||
case "outlook.com.ar":
|
||||
case "outlook.com.au":
|
||||
case "outlook.at":
|
||||
case "outlook.be":
|
||||
case "outlook.com.br":
|
||||
case "outlook.cl":
|
||||
case "outlook.cz":
|
||||
case "outlook.com.gr":
|
||||
case "outlook.co.il":
|
||||
case "outlook.in":
|
||||
case "outlook.co.id":
|
||||
case "outlook.ie":
|
||||
case "outlook.it":
|
||||
case "outlook.hu":
|
||||
case "outlook.kr":
|
||||
case "outlook.lv":
|
||||
case "outlook.my":
|
||||
case "outlook.co.nz":
|
||||
case "outlook.com.pe":
|
||||
case "outlook.ph":
|
||||
case "outlook.pt":
|
||||
case "outlook.sa":
|
||||
case "outlook.sg":
|
||||
case "outlook.sk":
|
||||
case "outlook.es":
|
||||
case "outlook.co.th":
|
||||
case "outlook.com.tr":
|
||||
case "outlook.com.vn":
|
||||
return ["Outlook", "https://outlook.live.com"];
|
||||
case "yahoo.com":
|
||||
return ["Yahoo", "https://mail.yahoo.com"];
|
||||
case "wp.pl":
|
||||
return ["WP Poczta", "https://poczta.wp.pl"];
|
||||
case "protonmail.com":
|
||||
case "protonmail.ch":
|
||||
case "pm.me":
|
||||
return ["ProtonMail", "https://mail.protonmail.com"];
|
||||
case "seznam.cz":
|
||||
case "email.cz":
|
||||
case "post.cz":
|
||||
return ["Seznam", "https://email.seznam.cz"];
|
||||
case "zoho.com":
|
||||
return ["Zoho Mail", "https://mail.zoho.com/zm/"];
|
||||
case "aol.com":
|
||||
case "aim.com":
|
||||
return ["AOL Mail", "https://mail.aol.com/"];
|
||||
case "icloud.com":
|
||||
return ["iCloud Mail", "https://mail.icloud.com/"];
|
||||
case "mail.com":
|
||||
case "email.com":
|
||||
return ["mail.com", "https://www.mail.com/mail/"];
|
||||
case "yandex.ru":
|
||||
case "yandex.by":
|
||||
case "yandex.ua":
|
||||
case "yandex.com":
|
||||
return ["Yandex Mail", "https://mail.yandex.com/"];
|
||||
case "hey.com":
|
||||
return ["HEY", "https://app.hey.com/"];
|
||||
case "mail.ru":
|
||||
case "bk.ru":
|
||||
case "inbox.ru":
|
||||
case "list.ru":
|
||||
case "internet.ru":
|
||||
return ["Mail.ru", "https://mail.ru/"];
|
||||
case "rambler.ru":
|
||||
case "lenta.ru":
|
||||
case "autorambler.ru":
|
||||
case "myrambler.ru":
|
||||
case "ro.ru":
|
||||
case "rambler.ua":
|
||||
return ["Rambler", "https://rambler.ru/"];
|
||||
case "revolt.chat":
|
||||
case "revolt.wtf":
|
||||
case "stoat.chat":
|
||||
return ["Stoat Mail", "https://webmail.revolt.wtf"];
|
||||
default:
|
||||
return [domain, `https://${domain}`];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide button to navigate to email provider
|
||||
*/
|
||||
export function MailProvider(props: Props) {
|
||||
/**
|
||||
* Convert email to provider
|
||||
* @returns Provider
|
||||
*/
|
||||
const provider = () => mapMailProvider(props.email);
|
||||
|
||||
return (
|
||||
<Show when={provider()}>
|
||||
<a href={provider()![1]} target="_blank" rel="noreferrer">
|
||||
<Button>
|
||||
<Trans>Open {provider()![0]}</Trans>
|
||||
</Button>
|
||||
</a>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="600" height="530" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" fill="#1185fe"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 745 B |
|
|
@ -0,0 +1 @@
|
|||
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><clipPath id="a"><path clip-rule="evenodd" d="m0 0h32v32h-32z"/></clipPath><clipPath id="b"><path clip-rule="evenodd" d="m28 16c0-.53-.211-1.039-.586-1.414s-.884-.586-1.414-.586c-4.52 0-15.48 0-20 0-.53 0-1.039.211-1.414.586s-.586.884-.586 1.414v12c0 .53.211 1.039.586 1.414s.884.586 1.414.586h20c.53 0 1.039-.211 1.414-.586s.586-.884.586-1.414c0-3.074 0-8.926 0-12z"/></clipPath><path d="m0 0h32v32h-32z" fill="none"/><g clip-path="url(#a)"><g fill="none"><path d="m28 16c0-.53-.211-1.039-.586-1.414s-.884-.586-1.414-.586c-4.52 0-15.48 0-20 0-.53 0-1.039.211-1.414.586s-.586.884-.586 1.414v12c0 .53.211 1.039.586 1.414s.884.586 1.414.586h20c.53 0 1.039-.211 1.414-.586s.586-.884.586-1.414c0-3.074 0-8.926 0-12z" stroke="#000" stroke-width="4"/><path d="m8 10v-4.5" stroke="#000" stroke-width="7"/><path d="m24 10.5v-5" stroke="#000" stroke-width="7"/><path d="m16 7.5v-4" stroke="#000" stroke-width="7"/><path d="m8 10.5v-5" stroke="#fff" stroke-width="3"/><path d="m24 10.5v-5" stroke="#fff" stroke-width="3"/><path d="m16 7.5v-4" stroke="#fff" stroke-width="3"/></g><path d="m28 16c0-.53-.211-1.039-.586-1.414s-.884-.586-1.414-.586c-4.52 0-15.48 0-20 0-.53 0-1.039.211-1.414.586s-.586.884-.586 1.414v12c0 .53.211 1.039.586 1.414s.884.586 1.414.586h20c.53 0 1.039-.211 1.414-.586s.586-.884.586-1.414c0-3.074 0-8.926 0-12z" fill="#fff"/><g clip-path="url(#b)" stroke-width="2"><path d="m4 30 12-11.556 12 11.556" fill="none" stroke="#ccc"/><path d="m4 14s6.595 5.862 10.007 8.895c1.137 1.01 2.849 1.01 3.986 0 3.412-3.033 10.007-8.895 10.007-8.895" fill="#fff" stroke="#999"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<metadata>
|
||||
<rdf:RDF xmlns:cc="http://web.resource.org/cc/"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:dc = "http://purl.org/dc/elements/1.1/"
|
||||
>
|
||||
|
||||
<rdf:Description rdf:about="">
|
||||
<dc:title>Mutant Standard emoji 2020.04</dc:title>
|
||||
</rdf:Description>
|
||||
|
||||
<cc:work rdf:about="">
|
||||
<cc:license rdf:resource="http://creativecommons.org/licenses/by-nc-sa/4.0/"/>
|
||||
<cc:attributionName>Dzuk</cc:attributionName>
|
||||
<cc:attributionURL>http://mutant.tech/</cc:attributionURL>
|
||||
</cc:work>
|
||||
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
|
||||
<rect id="wave--hmn-" serif:id="wave [hmn]" x="0" y="0" width="32" height="32" style="fill:none;"/>
|
||||
<g id="outline">
|
||||
<path d="M17.788,2.366c0.095,-0.129 0.202,-0.249 0.318,-0.359c0.225,-0.215 0.488,-0.391 0.772,-0.518c0.328,-0.147 0.681,-0.224 1.04,-0.238c0.047,-0.001 0.094,-0.001 0.14,0c0.501,0.014 0.99,0.157 1.414,0.426c0.158,0.1 0.304,0.217 0.439,0.346c0.706,0.693 1.417,1.382 2.098,2.1c0.265,0.287 0.47,0.623 0.595,0.994c0.115,0.339 0.163,0.7 0.14,1.058c-0.006,0.097 -0.017,0.195 -0.034,0.291l3.753,11.257c0.07,0.219 0.135,0.439 0.188,0.663c0.139,0.581 0.211,1.176 0.215,1.773c0.006,0.679 -0.077,1.358 -0.244,2.016c-0.212,0.835 -0.562,1.636 -1.031,2.359c-0.488,0.753 -1.106,1.407 -1.798,1.974c-1.357,1.112 -2.996,1.863 -4.723,2.164c-1.161,0.203 -2.356,0.203 -3.516,0c-1.149,-0.2 -2.261,-0.599 -3.276,-1.172c-0.183,-0.104 -0.361,-0.212 -0.537,-0.327c-0.03,0.444 -0.167,0.88 -0.405,1.262c-0.139,0.224 -0.31,0.428 -0.508,0.604c-0.213,0.19 -0.456,0.346 -0.718,0.462c-0.302,0.133 -0.627,0.211 -0.956,0.229c-0.315,0.018 -0.632,-0.019 -0.934,-0.108c-0.097,0.133 -0.205,0.257 -0.326,0.371c-0.225,0.215 -0.488,0.391 -0.772,0.518c-0.313,0.139 -0.651,0.22 -0.994,0.236c-0.357,0.017 -0.718,-0.037 -1.055,-0.158c-0.369,-0.132 -0.701,-0.343 -0.984,-0.612c-0.706,-0.693 -1.417,-1.382 -2.098,-2.1c-0.244,-0.264 -0.437,-0.569 -0.564,-0.906c-0.288,-0.764 -0.218,-1.635 0.189,-2.343c0.133,-0.229 0.298,-0.439 0.49,-0.621c0.082,-0.078 0.169,-0.151 0.26,-0.218c-0.065,-0.217 -0.103,-0.442 -0.113,-0.668c-0.012,-0.264 0.015,-0.529 0.078,-0.785c0.193,-0.774 0.726,-1.439 1.439,-1.796c0.24,-0.12 0.494,-0.201 0.755,-0.247c-0.386,-0.39 -0.771,-0.782 -1.154,-1.175c-0.312,-0.337 -0.556,-0.723 -0.708,-1.157c-0.12,-0.343 -0.181,-0.705 -0.181,-1.068c0,-0.363 0.061,-0.725 0.181,-1.067c0.176,-0.504 0.48,-0.957 0.87,-1.316c-0.117,-0.191 -0.215,-0.394 -0.29,-0.609c-0.12,-0.343 -0.181,-0.705 -0.181,-1.068c0,-0.31 0.045,-0.62 0.133,-0.917c0.247,-0.836 0.838,-1.549 1.609,-1.95c-0.005,-0.485 0.099,-0.97 0.308,-1.413c0.09,-0.19 0.198,-0.372 0.324,-0.541c0.076,-0.104 0.16,-0.199 0.246,-0.294c0.005,-0.004 0.014,-0.014 0.011,-0.011l-0.011,0.011c-0.001,0.002 -0.002,0.003 -0.002,0.001c0.005,-0.007 0.011,-0.013 0.017,-0.019c0.036,-0.036 0.073,-0.072 0.11,-0.107c0.095,-0.092 0.197,-0.177 0.303,-0.256c0.382,-0.282 0.824,-0.478 1.29,-0.571c0.156,-0.032 0.314,-0.049 0.473,-0.059c0.064,-0.002 0.129,-0.003 0.194,-0.003c0.327,-0.627 0.859,-1.142 1.505,-1.441c0.285,-0.132 0.588,-0.222 0.898,-0.267c0.136,-0.019 0.172,-0.019 0.308,-0.029c0.154,-0.004 0.154,-0.004 0.309,0c0.207,0.015 0.41,0.039 0.613,0.088c0.192,0.047 0.381,0.112 0.561,0.194c-0.004,-0.049 -0.007,-0.099 -0.009,-0.149c-0.03,-0.827 0.324,-1.64 0.95,-2.181c0.202,-0.174 0.43,-0.319 0.673,-0.429c0.333,-0.149 0.69,-0.226 1.054,-0.239c0.057,-0.001 0.057,-0.001 0.114,-0.001c0.191,0.005 0.38,0.026 0.567,0.068c0.06,0.014 0.12,0.03 0.178,0.048Z"/>
|
||||
</g>
|
||||
<g id="emoji">
|
||||
<path d="M21.05,13.409c-0.306,-0.921 -0.235,-1.927 0.2,-2.796c0.435,-0.87 1.197,-1.531 2.12,-1.838c0.001,-0.001 0.001,-0.001 0.001,-0.001c0,0 1.903,5.707 3.191,9.573c0.71,2.13 0.156,4.478 -1.432,6.066c0,0.001 -0.001,0.001 -0.002,0.002c-1.542,1.543 -3.635,2.409 -5.816,2.409c-2.182,0 -4.274,-0.866 -5.817,-2.409l-6.652,-6.652c-0.48,-0.481 -0.48,-1.259 0,-1.74c0,0 0,0 0,0c0.481,-0.48 1.259,-0.48 1.74,0l4.349,4.35l0.58,-0.58l-6.089,-6.09c-0.48,-0.48 -0.48,-1.259 0,-1.739c0,0 0,0 0,-0.001c0.481,-0.48 1.259,-0.48 1.74,0l5.509,5.51l0.58,-0.58l-6.089,-6.089c-0.231,-0.231 -0.361,-0.544 -0.361,-0.87c0,-0.326 0.13,-0.639 0.361,-0.87c0,0 0,0 0,0c0.231,-0.231 0.544,-0.361 0.87,-0.361c0.326,0 0.639,0.13 0.869,0.361l5.51,5.509l0.58,-0.58l-4.93,-4.929c-0.48,-0.481 -0.48,-1.259 0,-1.74c0,0 0.001,0 0.001,0c0.48,-0.48 1.259,-0.48 1.739,0l5.458,5.458l1.421,2.218l0.951,0.153l-0.582,-1.744Z" style="fill:#FFC20B;"/>
|
||||
<path d="M19.26,12.782l2.372,2.371l0.652,1.957c-0.289,0.082 -0.59,0.161 -0.881,0.252c-0.058,0.02 -0.114,0.041 -0.171,0.064c-0.221,0.095 -0.429,0.218 -0.614,0.373c-0.512,0.43 -0.824,1.07 -0.847,1.739c-0.009,0.263 0.028,0.522 0.102,0.775c0.228,0.717 0.475,1.428 0.713,2.142c0.033,0.112 0.048,0.224 0.031,0.341c-0.043,0.31 -0.282,0.566 -0.589,0.63c-0.075,0.016 -0.152,0.02 -0.228,0.013c-0.076,-0.008 -0.151,-0.027 -0.222,-0.058c-0.123,-0.053 -0.231,-0.139 -0.31,-0.248c-0.035,-0.047 -0.06,-0.097 -0.085,-0.15c-0.265,-0.704 -0.478,-1.428 -0.716,-2.142c-0.12,-0.377 -0.191,-0.764 -0.197,-1.159c-0.017,-1.024 0.383,-2.032 1.097,-2.766c0.298,-0.306 0.643,-0.553 1.021,-0.751l-1.128,-3.383Z" style="fill:#CE8D15;"/>
|
||||
<path d="M6.03,25.251c0.182,0.01 0.348,0.079 0.483,0.202c0.695,0.672 1.401,1.336 2.051,2.052c0.068,0.081 0.121,0.172 0.153,0.274c0.028,0.093 0.039,0.191 0.03,0.288c-0.008,0.088 -0.032,0.176 -0.07,0.256c-0.032,0.066 -0.073,0.127 -0.122,0.181c-0.193,0.213 -0.501,0.298 -0.776,0.213c-0.11,-0.034 -0.207,-0.093 -0.292,-0.17c-0.695,-0.672 -1.401,-1.336 -2.051,-2.052c-0.068,-0.081 -0.121,-0.172 -0.153,-0.274c-0.021,-0.069 -0.032,-0.142 -0.033,-0.215c-0.002,-0.28 0.157,-0.543 0.405,-0.672c0.102,-0.053 0.213,-0.079 0.327,-0.084c0.024,0 0.024,0 0.048,0.001Zm1.011,-3c0.175,0.014 0.331,0.079 0.464,0.194c1.379,1.313 2.729,2.658 4.048,4.033c0.083,0.095 0.145,0.204 0.175,0.328c0.084,0.341 -0.088,0.704 -0.405,0.855c-0.082,0.04 -0.171,0.064 -0.262,0.071c-0.091,0.008 -0.183,-0.002 -0.27,-0.027c-0.111,-0.032 -0.209,-0.09 -0.296,-0.165c-1.379,-1.314 -2.729,-2.659 -4.048,-4.033c-0.076,-0.087 -0.134,-0.184 -0.166,-0.295c-0.096,-0.325 0.044,-0.683 0.335,-0.856c0.09,-0.054 0.187,-0.085 0.289,-0.1c0.046,-0.005 0.09,-0.006 0.136,-0.005Zm9.981,-18.001c0.184,0.009 0.349,0.077 0.487,0.199c1.034,0.994 2.048,2.008 3.042,3.042c0.08,0.09 0.14,0.194 0.172,0.31c0.072,0.263 -0.007,0.551 -0.203,0.74c-0.058,0.055 -0.124,0.101 -0.196,0.135c-0.08,0.039 -0.168,0.063 -0.257,0.071c-0.099,0.009 -0.2,-0.002 -0.294,-0.032c-0.106,-0.034 -0.199,-0.091 -0.282,-0.164c-1.034,-0.994 -2.048,-2.008 -3.042,-3.042c-0.073,-0.083 -0.13,-0.176 -0.164,-0.282c-0.105,-0.33 0.038,-0.701 0.338,-0.875c0.104,-0.061 0.22,-0.093 0.34,-0.101c0.03,-0.001 0.029,-0.001 0.059,-0.001Zm3.008,-0.999c0.182,0.01 0.348,0.079 0.483,0.202c0.695,0.672 1.401,1.336 2.051,2.052c0.073,0.087 0.129,0.186 0.159,0.297c0.02,0.07 0.029,0.143 0.027,0.216c-0.007,0.288 -0.185,0.553 -0.449,0.669c-0.082,0.036 -0.17,0.057 -0.259,0.062c-0.097,0.005 -0.195,-0.008 -0.286,-0.04c-0.101,-0.035 -0.19,-0.091 -0.269,-0.162c-0.695,-0.672 -1.401,-1.336 -2.051,-2.052c-0.068,-0.081 -0.121,-0.172 -0.153,-0.274c-0.082,-0.267 -0.006,-0.565 0.195,-0.76c0.053,-0.051 0.112,-0.094 0.177,-0.127c0.102,-0.053 0.213,-0.079 0.327,-0.084c0.024,0 0.024,0 0.048,0.001Z" style="fill:#565656;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
|
@ -0,0 +1,600 @@
|
|||
import { Accessor, Setter, createSignal } from "solid-js";
|
||||
|
||||
import { detect } from "detect-browser";
|
||||
import { API, Client, ConnectionState } from "stoat.js";
|
||||
import { ProtocolV1 } from "stoat.js/lib/events/v1";
|
||||
|
||||
import { CONFIGURATION } from "@revolt/common";
|
||||
import { ModalControllerExtended } from "@revolt/modal";
|
||||
import type { State as ApplicationState } from "@revolt/state";
|
||||
import type { Session } from "@revolt/state/stores/Auth";
|
||||
|
||||
export enum State {
|
||||
Ready = "Ready",
|
||||
LoggingIn = "Logging In",
|
||||
Onboarding = "Onboarding",
|
||||
Error = "Error",
|
||||
Dispose = "Dispose",
|
||||
Connecting = "Connecting",
|
||||
Connected = "Connected",
|
||||
Disconnected = "Disconnected",
|
||||
Reconnecting = "Reconnecting",
|
||||
Offline = "Offline",
|
||||
}
|
||||
|
||||
export enum TransitionType {
|
||||
LoginUncached = "uncached login",
|
||||
LoginCached = "cached login",
|
||||
SocketConnected = "socket connected",
|
||||
DeviceOffline = "device offline",
|
||||
DeviceOnline = "device online",
|
||||
PermanentFailure = "permanent failure",
|
||||
TemporaryFailure = "temporary failure",
|
||||
UserCreated = "user created",
|
||||
NoUser = "no user",
|
||||
Cancel = "cancel",
|
||||
Dispose = "dispose",
|
||||
DisposeOnly = "dispose only",
|
||||
Dismiss = "dismiss",
|
||||
Ready = "ready",
|
||||
Retry = "retry",
|
||||
Logout = "logout",
|
||||
}
|
||||
|
||||
export type Transition =
|
||||
| {
|
||||
type: TransitionType.LoginUncached | TransitionType.LoginCached;
|
||||
session: Session;
|
||||
}
|
||||
| {
|
||||
type: TransitionType.PermanentFailure;
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
type:
|
||||
| TransitionType.NoUser
|
||||
| TransitionType.UserCreated
|
||||
| TransitionType.TemporaryFailure
|
||||
| TransitionType.SocketConnected
|
||||
| TransitionType.DeviceOffline
|
||||
| TransitionType.DeviceOnline
|
||||
| TransitionType.Cancel
|
||||
| TransitionType.Dismiss
|
||||
| TransitionType.Ready
|
||||
| TransitionType.Retry
|
||||
| TransitionType.Dispose
|
||||
| TransitionType.DisposeOnly
|
||||
| TransitionType.Logout;
|
||||
};
|
||||
|
||||
type PolicyAttentionRequired = [
|
||||
ProtocolV1["types"]["policyChange"][],
|
||||
() => Promise<void>,
|
||||
];
|
||||
|
||||
class Lifecycle {
|
||||
#controller: ClientController;
|
||||
|
||||
readonly state: Accessor<State>;
|
||||
#setStateSetter: Setter<State>;
|
||||
|
||||
readonly loadedOnce: Accessor<boolean>;
|
||||
#setLoadedOnce: Setter<boolean>;
|
||||
|
||||
readonly policyAttentionRequired: Accessor<
|
||||
undefined | PolicyAttentionRequired
|
||||
>;
|
||||
#policyAttentionRequired: Setter<undefined | PolicyAttentionRequired>;
|
||||
|
||||
client: Client;
|
||||
|
||||
#connectionFailures = 0;
|
||||
#permanentError: string | undefined;
|
||||
#retryTimeout: number | undefined;
|
||||
|
||||
constructor(controller: ClientController) {
|
||||
this.#controller = controller;
|
||||
|
||||
this.onState = this.onState.bind(this);
|
||||
this.onReady = this.onReady.bind(this);
|
||||
this.onPolicyChanges = this.onPolicyChanges.bind(this);
|
||||
|
||||
const [state, setState] = createSignal(State.Ready);
|
||||
this.state = state;
|
||||
this.#setStateSetter = setState;
|
||||
|
||||
const [loadedOnce, setLoadedOnce] = createSignal(false);
|
||||
this.loadedOnce = loadedOnce;
|
||||
this.#setLoadedOnce = setLoadedOnce;
|
||||
|
||||
const [policyAttentionRequired, setPolicyAttentionRequired] = createSignal<
|
||||
undefined | PolicyAttentionRequired
|
||||
>(undefined);
|
||||
|
||||
this.policyAttentionRequired = policyAttentionRequired;
|
||||
this.#policyAttentionRequired = setPolicyAttentionRequired;
|
||||
|
||||
this.client = null!;
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
private dispose() {
|
||||
if (this.client) {
|
||||
this.client.events.removeAllListeners();
|
||||
this.client.removeAllListeners();
|
||||
this.client.events.disconnect();
|
||||
}
|
||||
|
||||
this.client = new Client({
|
||||
baseURL: CONFIGURATION.DEFAULT_API_URL,
|
||||
autoReconnect: false,
|
||||
syncUnreads: true,
|
||||
debug: import.meta.env.DEV,
|
||||
channelIsMuted: (channel) =>
|
||||
this.#controller.state.notifications.isMuted(channel),
|
||||
channelExclusiveMuted: (channel) =>
|
||||
this.#controller.state.notifications.isChannelMuted(channel),
|
||||
});
|
||||
|
||||
this.client.configuration = {
|
||||
revolt: String(),
|
||||
app: String(),
|
||||
build: {} as never,
|
||||
features: {
|
||||
autumn: {
|
||||
enabled: true,
|
||||
url: CONFIGURATION.DEFAULT_MEDIA_URL,
|
||||
},
|
||||
january: {
|
||||
enabled: true,
|
||||
url: CONFIGURATION.DEFAULT_PROXY_URL,
|
||||
},
|
||||
captcha: {} as never,
|
||||
email: true,
|
||||
invite_only: false,
|
||||
livekit: {
|
||||
enabled: false,
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
vapid: String(),
|
||||
ws: CONFIGURATION.DEFAULT_WS_URL,
|
||||
};
|
||||
|
||||
this.client.events.on("state", this.onState);
|
||||
this.client.on("ready", this.onReady);
|
||||
this.client.on("policyChanges", this.onPolicyChanges);
|
||||
}
|
||||
|
||||
#enter(nextState: State) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.info("[lifecycle] entering state", nextState);
|
||||
}
|
||||
|
||||
this.#setStateSetter(nextState);
|
||||
|
||||
// Clean up retry timer
|
||||
if (this.#retryTimeout) {
|
||||
clearTimeout(this.#retryTimeout);
|
||||
this.#retryTimeout = undefined;
|
||||
}
|
||||
|
||||
switch (nextState) {
|
||||
case State.LoggingIn:
|
||||
this.client.api.get("/onboard/hello").then(({ onboarding }) => {
|
||||
if (onboarding) {
|
||||
this.transition({
|
||||
type: TransitionType.NoUser,
|
||||
});
|
||||
} else {
|
||||
this.client.connect();
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
case State.Connecting:
|
||||
case State.Reconnecting:
|
||||
this.client.connect();
|
||||
break;
|
||||
case State.Connected:
|
||||
this.#controller.state.auth.markValid();
|
||||
this.#setLoadedOnce(true);
|
||||
this.#connectionFailures = 0;
|
||||
break;
|
||||
case State.Dispose:
|
||||
this.dispose();
|
||||
this.transition({
|
||||
type: TransitionType.Ready,
|
||||
});
|
||||
this.#setLoadedOnce(false);
|
||||
break;
|
||||
case State.Disconnected:
|
||||
this.#connectionFailures++;
|
||||
|
||||
if (!navigator.onLine) {
|
||||
this.transition({
|
||||
type: TransitionType.DeviceOffline,
|
||||
});
|
||||
} else {
|
||||
const retryIn =
|
||||
(Math.pow(2, this.#connectionFailures) - 1) *
|
||||
(0.8 + Math.random() * 0.4);
|
||||
|
||||
console.info(
|
||||
"Will try to reconnect in",
|
||||
retryIn.toFixed(2),
|
||||
"seconds!",
|
||||
);
|
||||
|
||||
this.#retryTimeout = setTimeout(() => {
|
||||
this.#retryTimeout = undefined;
|
||||
this.transition({
|
||||
type: TransitionType.Retry,
|
||||
});
|
||||
}, retryIn * 1e3) as never;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
transition(transition: Transition) {
|
||||
console.debug("Received transition", transition.type);
|
||||
|
||||
if (transition.type === TransitionType.DisposeOnly) {
|
||||
this.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.state();
|
||||
switch (currentState) {
|
||||
case State.Ready:
|
||||
if (transition.type === TransitionType.LoginUncached) {
|
||||
this.client.useExistingSession({
|
||||
...transition.session,
|
||||
user_id: transition.session.userId,
|
||||
});
|
||||
|
||||
this.#enter(State.LoggingIn);
|
||||
} else if (transition.type === TransitionType.LoginCached) {
|
||||
this.client.useExistingSession({
|
||||
...transition.session,
|
||||
user_id: transition.session.userId,
|
||||
});
|
||||
|
||||
this.#enter(State.Connecting);
|
||||
}
|
||||
break;
|
||||
case State.LoggingIn:
|
||||
switch (transition.type) {
|
||||
case TransitionType.SocketConnected:
|
||||
this.#enter(State.Connected);
|
||||
break;
|
||||
case TransitionType.NoUser:
|
||||
this.#enter(State.Onboarding);
|
||||
break;
|
||||
case TransitionType.PermanentFailure:
|
||||
case TransitionType.TemporaryFailure:
|
||||
// TODO: relay error
|
||||
this.#enter(State.Error);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case State.Onboarding:
|
||||
if (transition.type === TransitionType.UserCreated) {
|
||||
this.#enter(State.Connecting);
|
||||
} else if (transition.type === TransitionType.Cancel) {
|
||||
this.#enter(State.Dispose);
|
||||
}
|
||||
break;
|
||||
case State.Error:
|
||||
if (transition.type === TransitionType.Dismiss) {
|
||||
this.#enter(State.Dispose);
|
||||
}
|
||||
break;
|
||||
case State.Dispose:
|
||||
if (transition.type === TransitionType.Ready) {
|
||||
this.#enter(State.Ready);
|
||||
}
|
||||
break;
|
||||
case State.Connecting:
|
||||
switch (transition.type) {
|
||||
case TransitionType.SocketConnected:
|
||||
this.#enter(State.Connected);
|
||||
break;
|
||||
case TransitionType.TemporaryFailure:
|
||||
this.#enter(State.Disconnected);
|
||||
break;
|
||||
case TransitionType.PermanentFailure:
|
||||
this.#permanentError = transition.error;
|
||||
this.#enter(State.Error);
|
||||
break;
|
||||
case TransitionType.Logout:
|
||||
this.#enter(State.Dispose);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case State.Connected:
|
||||
switch (transition.type) {
|
||||
case TransitionType.TemporaryFailure:
|
||||
this.#enter(State.Disconnected);
|
||||
break;
|
||||
case TransitionType.Logout:
|
||||
this.#enter(State.Dispose);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case State.Disconnected:
|
||||
switch (transition.type) {
|
||||
case TransitionType.DeviceOffline:
|
||||
this.#enter(State.Offline);
|
||||
break;
|
||||
case TransitionType.Retry:
|
||||
this.#enter(State.Reconnecting);
|
||||
break;
|
||||
case TransitionType.Logout:
|
||||
this.#enter(State.Dispose);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case State.Reconnecting:
|
||||
switch (transition.type) {
|
||||
case TransitionType.SocketConnected:
|
||||
this.#enter(State.Connected);
|
||||
break;
|
||||
case TransitionType.TemporaryFailure:
|
||||
this.#enter(State.Disconnected);
|
||||
break;
|
||||
case TransitionType.PermanentFailure:
|
||||
// TODO: relay error
|
||||
this.#enter(State.Error);
|
||||
break;
|
||||
case TransitionType.Logout:
|
||||
this.#enter(State.Dispose);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case State.Offline:
|
||||
switch (transition.type) {
|
||||
case TransitionType.DeviceOnline:
|
||||
this.#enter(State.Reconnecting);
|
||||
break;
|
||||
case TransitionType.Retry:
|
||||
this.#enter(State.Reconnecting);
|
||||
break;
|
||||
case TransitionType.Logout:
|
||||
this.#enter(State.Dispose);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentState === this.state()) {
|
||||
console.error(
|
||||
"An unhandled transition occurred!",
|
||||
transition,
|
||||
"was received on",
|
||||
currentState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private onReady() {
|
||||
this.transition({
|
||||
type: TransitionType.SocketConnected,
|
||||
});
|
||||
}
|
||||
|
||||
private onPolicyChanges(
|
||||
changes: ProtocolV1["types"]["policyChange"][],
|
||||
ack: () => Promise<void>,
|
||||
) {
|
||||
this.#policyAttentionRequired([
|
||||
changes,
|
||||
() => ack().then(() => this.#policyAttentionRequired(undefined)),
|
||||
]);
|
||||
}
|
||||
|
||||
private onState(state: ConnectionState) {
|
||||
switch (state) {
|
||||
case ConnectionState.Disconnected:
|
||||
if (this.client.events.lastError) {
|
||||
if (this.client.events.lastError.type === "revolt") {
|
||||
// if (this.client.events.lastError.data.type == 'InvalidSession') {
|
||||
|
||||
this.transition({
|
||||
type: TransitionType.PermanentFailure,
|
||||
error: this.client.events.lastError.data.type,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.transition({
|
||||
type: TransitionType.TemporaryFailure,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permanent error
|
||||
*/
|
||||
get permanentError() {
|
||||
return this.#permanentError!;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls lifecycle of clients
|
||||
*/
|
||||
export default class ClientController {
|
||||
/**
|
||||
* API Client
|
||||
*/
|
||||
readonly api: API.API;
|
||||
|
||||
/**
|
||||
* Lifecycle
|
||||
*/
|
||||
readonly lifecycle: Lifecycle;
|
||||
|
||||
/**
|
||||
* Reference to application state
|
||||
*/
|
||||
readonly state: ApplicationState;
|
||||
|
||||
/**
|
||||
* Construct new client controller
|
||||
*/
|
||||
constructor(state: ApplicationState) {
|
||||
this.state = state;
|
||||
this.api = new API.API({
|
||||
baseURL: CONFIGURATION.DEFAULT_API_URL,
|
||||
});
|
||||
|
||||
this.lifecycle = new Lifecycle(this);
|
||||
|
||||
this.login = this.login.bind(this);
|
||||
this.logout = this.logout.bind(this);
|
||||
this.selectUsername = this.selectUsername.bind(this);
|
||||
this.isLoggedIn = this.isLoggedIn.bind(this);
|
||||
this.isError = this.isError.bind(this);
|
||||
|
||||
const session = state.auth.getSession();
|
||||
if (session) {
|
||||
this.lifecycle.transition({
|
||||
type: TransitionType.LoginCached,
|
||||
session,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentClient() {
|
||||
return this.lifecycle.client;
|
||||
}
|
||||
|
||||
isLoggedIn() {
|
||||
return [
|
||||
State.Connecting,
|
||||
State.Connected,
|
||||
State.Disconnected,
|
||||
State.Offline,
|
||||
State.Reconnecting,
|
||||
].includes(this.lifecycle.state());
|
||||
}
|
||||
|
||||
isError() {
|
||||
return this.lifecycle.state() === State.Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login given a set of credentials
|
||||
* @param credentials Credentials
|
||||
*/
|
||||
async login(credentials: API.DataLogin, modals: ModalControllerExtended) {
|
||||
const browser = detect();
|
||||
|
||||
// Generate a friendly name for this browser
|
||||
let friendly_name;
|
||||
if (browser) {
|
||||
let { name, os } = browser as { name: string; os: string };
|
||||
if (name === "ios") {
|
||||
name = "safari";
|
||||
} else if (name === "fxios") {
|
||||
name = "firefox";
|
||||
} else if (name === "crios") {
|
||||
name = "chrome";
|
||||
} else if (os === "Mac OS" && navigator.maxTouchPoints > 0) {
|
||||
os = "iPadOS";
|
||||
}
|
||||
|
||||
friendly_name = `Stoat for Web (${name} on ${os})`;
|
||||
} else {
|
||||
friendly_name = "Stoat for Web (Unknown Device)";
|
||||
}
|
||||
|
||||
// Try to login with given credentials
|
||||
let session = await this.api.post("/auth/session/login", {
|
||||
...credentials,
|
||||
friendly_name,
|
||||
});
|
||||
|
||||
// Prompt for MFA verification if necessary
|
||||
if (session.result === "MFA") {
|
||||
const { allowed_methods } = session;
|
||||
while (session.result === "MFA") {
|
||||
const mfa_response: API.MFAResponse | undefined = await new Promise(
|
||||
(callback) =>
|
||||
modals.openModal({
|
||||
type: "mfa_flow",
|
||||
state: "unknown",
|
||||
available_methods: allowed_methods,
|
||||
callback,
|
||||
}),
|
||||
);
|
||||
|
||||
if (typeof mfa_response === "undefined") {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
session = await this.api.post("/auth/session/login", {
|
||||
mfa_response,
|
||||
mfa_ticket: session.ticket,
|
||||
friendly_name,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed login:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (session.result === "MFA") {
|
||||
throw "Cancelled";
|
||||
}
|
||||
}
|
||||
|
||||
if (session.result === "Disabled") {
|
||||
// TODO
|
||||
alert("Account is disabled, run special logic here.");
|
||||
return;
|
||||
}
|
||||
|
||||
const createdSession = {
|
||||
_id: session._id,
|
||||
token: session.token,
|
||||
userId: session.user_id,
|
||||
valid: false,
|
||||
};
|
||||
|
||||
this.state.auth.setSession(createdSession);
|
||||
this.lifecycle.transition({
|
||||
type: TransitionType.LoginUncached,
|
||||
session: createdSession,
|
||||
});
|
||||
}
|
||||
|
||||
async selectUsername(username: string) {
|
||||
await this.lifecycle.client.api.post("/onboard/complete", {
|
||||
username,
|
||||
});
|
||||
|
||||
this.lifecycle.transition({
|
||||
type: TransitionType.UserCreated,
|
||||
});
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.state.auth.removeSession();
|
||||
this.lifecycle.transition({
|
||||
type: TransitionType.Logout,
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.lifecycle.transition({
|
||||
type: TransitionType.DisposeOnly,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { createEffect, onCleanup, onMount } from "solid-js";
|
||||
|
||||
import { useLingui } from "@lingui-solid/solid/macro";
|
||||
import {
|
||||
ChannelEditSystemMessage,
|
||||
ChannelOwnershipChangeSystemMessage,
|
||||
ChannelRenamedSystemMessage,
|
||||
Message,
|
||||
MessagePinnedSystemMessage,
|
||||
TextSystemMessage,
|
||||
UserModeratedSystemMessage,
|
||||
UserSystemMessage,
|
||||
} from "stoat.js";
|
||||
|
||||
import { useNavigate, useSmartParams } from "@revolt/routing";
|
||||
import { useState } from "@revolt/state";
|
||||
|
||||
import { useClient } from ".";
|
||||
|
||||
/**
|
||||
* Process and display desktop notifications
|
||||
*/
|
||||
export function NotificationsWorker() {
|
||||
const state = useState();
|
||||
const { t } = useLingui();
|
||||
const client = useClient();
|
||||
const navigate = useNavigate();
|
||||
const params = useSmartParams();
|
||||
|
||||
/**
|
||||
* Handle incoming messages
|
||||
* @param message Message
|
||||
*/
|
||||
function onMessage(message: Message) {
|
||||
const us = client().user!;
|
||||
|
||||
// Ignore if we are currently looking at the channel
|
||||
if (params().channelId === message.channelId && document.hasFocus()) return;
|
||||
|
||||
// Ignore our own messages
|
||||
if (message.author?.self) return;
|
||||
|
||||
// Ignore blocked users
|
||||
if (message.author?.relationship === "Blocked") return;
|
||||
|
||||
// Ignore muted channels
|
||||
if (state.notifications.isMuted(message.channel)) return;
|
||||
|
||||
// Check channel notification settings
|
||||
switch (state.notifications.computeForChannel(message.channel!)) {
|
||||
case "none":
|
||||
return; // ignore if muted/none
|
||||
case "mention":
|
||||
if (!message.mentioned) return; // ignore if not mentioned
|
||||
}
|
||||
|
||||
// Ignore if we're busy or focused
|
||||
if (
|
||||
us.status?.presence === "Busy" ||
|
||||
(us.status?.presence === "Focus" && !message.mentioned)
|
||||
)
|
||||
return;
|
||||
|
||||
// Generate the title
|
||||
let title;
|
||||
switch (message.channel!.type) {
|
||||
case "SavedMessages":
|
||||
return;
|
||||
case "DirectMessage":
|
||||
title = `@${message.username}`;
|
||||
break;
|
||||
case "Group":
|
||||
if (message.author?.id === "00000000000000000000000000") {
|
||||
title = message.channel?.name;
|
||||
} else {
|
||||
title = `@${message.username} - ${message.channel?.name}`;
|
||||
}
|
||||
break;
|
||||
case "TextChannel":
|
||||
title = `@${message.username} (#${message.channel?.name}, ${message.channel?.server?.name})`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Find image if applicable
|
||||
const image = message.attachments?.find(
|
||||
(x) => x.metadata.type === "Image",
|
||||
)?.previewUrl;
|
||||
|
||||
// Find body/icon
|
||||
let body, icon;
|
||||
if (message.content) {
|
||||
body = message.contentPlain;
|
||||
icon = message.avatarURL;
|
||||
} else if (message.systemMessage) {
|
||||
switch (message.systemMessage.type) {
|
||||
case "text":
|
||||
body = (message.systemMessage as TextSystemMessage).content;
|
||||
break;
|
||||
case "user_added":
|
||||
body = t`${(message.systemMessage as UserModeratedSystemMessage).user?.username} was added by ${(message.systemMessage as UserModeratedSystemMessage).by?.username}`;
|
||||
icon = (message.systemMessage as UserModeratedSystemMessage).user
|
||||
?.avatarURL;
|
||||
break;
|
||||
case "user_remove":
|
||||
body = t`${(message.systemMessage as UserModeratedSystemMessage).user?.username} was removed by ${(message.systemMessage as UserModeratedSystemMessage).by?.username}`;
|
||||
icon = (message.systemMessage as UserModeratedSystemMessage).user
|
||||
?.avatarURL;
|
||||
break;
|
||||
case "user_joined":
|
||||
body = t`${(message.systemMessage as UserSystemMessage).user?.username} joined`;
|
||||
icon = (message.systemMessage as UserSystemMessage).user?.avatarURL;
|
||||
break;
|
||||
case "user_left":
|
||||
body = t`${(message.systemMessage as UserSystemMessage).user?.username} left`;
|
||||
icon = (message.systemMessage as UserSystemMessage).user?.avatarURL;
|
||||
break;
|
||||
case "user_kicked":
|
||||
body = t`${(message.systemMessage as UserSystemMessage).user?.username} was kicked`;
|
||||
icon = (message.systemMessage as UserSystemMessage).user?.avatarURL;
|
||||
break;
|
||||
case "user_banned":
|
||||
body = t`${(message.systemMessage as UserSystemMessage).user?.username} was banned`;
|
||||
icon = (message.systemMessage as UserSystemMessage).user?.avatarURL;
|
||||
break;
|
||||
case "channel_renamed":
|
||||
body = t`${(message.systemMessage as ChannelRenamedSystemMessage).by?.username} renamed the channel`;
|
||||
icon = (message.systemMessage as ChannelRenamedSystemMessage).by
|
||||
?.avatarURL;
|
||||
break;
|
||||
case "channel_description_changed":
|
||||
body = t`${(message.systemMessage as ChannelEditSystemMessage).by?.username} changed the channel description`;
|
||||
icon = (message.systemMessage as ChannelEditSystemMessage).by
|
||||
?.avatarURL;
|
||||
break;
|
||||
case "channel_icon_changed":
|
||||
body = t`${(message.systemMessage as ChannelEditSystemMessage).by?.username} changed the channel icon`;
|
||||
icon = (message.systemMessage as ChannelEditSystemMessage).by
|
||||
?.avatarURL;
|
||||
break;
|
||||
case "channel_ownership_changed":
|
||||
body = t`${(message.systemMessage as ChannelOwnershipChangeSystemMessage).from?.username} made ${(message.systemMessage as ChannelOwnershipChangeSystemMessage).to?.username} the new group owner`;
|
||||
icon = (message.systemMessage as ChannelOwnershipChangeSystemMessage)
|
||||
.from?.avatarURL;
|
||||
break;
|
||||
case "message_pinned":
|
||||
body = t`${(message.systemMessage as MessagePinnedSystemMessage).by?.username} pinned a message`;
|
||||
icon = (message.systemMessage as MessagePinnedSystemMessage).by
|
||||
?.avatarURL;
|
||||
break;
|
||||
case "message_unpinned":
|
||||
body = t`${(message.systemMessage as MessagePinnedSystemMessage).by?.username} unpinned a message`;
|
||||
icon = (message.systemMessage as MessagePinnedSystemMessage).by
|
||||
?.avatarURL;
|
||||
break;
|
||||
}
|
||||
} else if (message.attachments?.length) {
|
||||
body = t`Sent ${message.attachments!.length} attachments`;
|
||||
}
|
||||
|
||||
// todo: play sound
|
||||
|
||||
// Don't continue if we don't have notification permissions
|
||||
if (Notification.permission !== "granted") return;
|
||||
|
||||
console.info(`[notification] ${title} ${icon} ${body}`);
|
||||
|
||||
const notification = new Notification(title!, {
|
||||
icon,
|
||||
// @ts-expect-error this does exist on some platforms
|
||||
image,
|
||||
body,
|
||||
timestamp: message.createdAt,
|
||||
tag: message.channelId,
|
||||
badge: "/assets/web/android-chrome-512x512.png",
|
||||
silent: true,
|
||||
});
|
||||
|
||||
notification.addEventListener("click", () => {
|
||||
window.focus();
|
||||
navigate(message.path);
|
||||
});
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
client().addListener("messageCreate", onMessage);
|
||||
onCleanup(() => client().removeListener("messageCreate", onMessage));
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle page click to request notifications
|
||||
*/
|
||||
function tryRequest() {
|
||||
document.removeEventListener("click", tryRequest);
|
||||
|
||||
if (!localStorage.getItem("denied-notifications")) {
|
||||
Notification.requestPermission().then(
|
||||
(permission) =>
|
||||
permission === "denied" &&
|
||||
localStorage.setItem("denied-notifications", "1"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// don't bother mounting if denied before
|
||||
if (!localStorage.getItem("denied-notifications")) {
|
||||
document.addEventListener("click", tryRequest);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => document.removeEventListener("click", tryRequest));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import {
|
||||
type JSXElement,
|
||||
Accessor,
|
||||
createContext,
|
||||
createEffect,
|
||||
on,
|
||||
onCleanup,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
|
||||
import type { Client, User } from "stoat.js";
|
||||
|
||||
import { useModals } from "@revolt/modal";
|
||||
import { State } from "@revolt/state";
|
||||
|
||||
import { State as LifecycleState } from "./Controller";
|
||||
|
||||
import { CHANGELOG_MODAL_CONST } from "@revolt/modal/modals/Changelog";
|
||||
import ClientController from "./Controller";
|
||||
|
||||
export type { default as ClientController } from "./Controller";
|
||||
|
||||
const clientContext = createContext(null! as ClientController);
|
||||
|
||||
/**
|
||||
* Mount the modal controller
|
||||
*/
|
||||
export function ClientContext(props: { state: State; children: JSXElement }) {
|
||||
const { openModal } = useModals();
|
||||
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const controller = new ClientController(props.state);
|
||||
onCleanup(() => controller.dispose());
|
||||
|
||||
createEffect(() => {
|
||||
const lastIndex = props.state.settings.getValue("changelog:last_index");
|
||||
if (controller.lifecycle.state() === LifecycleState.Ready) return;
|
||||
|
||||
if (
|
||||
lastIndex !== CHANGELOG_MODAL_CONST.index &&
|
||||
new Date() < CHANGELOG_MODAL_CONST.until
|
||||
) {
|
||||
openModal({
|
||||
type: "changelog",
|
||||
initial: CHANGELOG_MODAL_CONST.index,
|
||||
});
|
||||
|
||||
props.state.settings.setValue(
|
||||
"changelog:last_index",
|
||||
CHANGELOG_MODAL_CONST.index,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => controller.lifecycle.policyAttentionRequired(),
|
||||
(attentionRequired) => {
|
||||
if (typeof attentionRequired !== "undefined") {
|
||||
const [changes, acknowledge] = attentionRequired;
|
||||
|
||||
openModal({
|
||||
type: "policy_change",
|
||||
changes,
|
||||
acknowledge,
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<clientContext.Provider value={controller}>
|
||||
{props.children}
|
||||
</clientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get various lifecycle objects
|
||||
* @returns Lifecycle information
|
||||
*/
|
||||
export function useClientLifecycle() {
|
||||
const { login, logout, selectUsername, lifecycle, isLoggedIn, isError } =
|
||||
useContext(clientContext);
|
||||
|
||||
return {
|
||||
login,
|
||||
logout,
|
||||
selectUsername,
|
||||
lifecycle,
|
||||
isLoggedIn,
|
||||
isError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active client if one is available
|
||||
* @returns Client
|
||||
*/
|
||||
export function useClient(): Accessor<Client> {
|
||||
const controller = useContext(clientContext);
|
||||
return () => controller.getCurrentClient()!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently logged in user
|
||||
* @returns User
|
||||
*/
|
||||
export function useUser(): Accessor<User | undefined> {
|
||||
const controller = useContext(clientContext);
|
||||
return () => controller.getCurrentClient()!.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain API client with no authentication
|
||||
* @returns API Client
|
||||
*/
|
||||
export function useApi() {
|
||||
return useContext(clientContext).api;
|
||||
}
|
||||
|
||||
export const IS_DEV = import.meta.env.DEV;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue