Migrate to Tuari

This commit is contained in:
Ryan Fong 2026-02-26 19:45:09 -08:00
parent 331d0fb7fc
commit 54549f47e1
919 changed files with 335120 additions and 5507 deletions

13
.env.example Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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\"}"

View File

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

100
.gitignore vendored
View File

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

View File

@ -1,3 +0,0 @@
{
".": "1.4.2"
}

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}

20
.vscode/settings.json vendored
View File

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

View File

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

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

View File

@ -1,84 +1,11 @@
<div align="center">
<h1>
Stoat for Desktop
# Revamped [Stoat Desktop App](https://github.com/stoatchat/for-desktop) for QStoat
[![Stars](https://img.shields.io/github/stars/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/stargazers)
[![Forks](https://img.shields.io/github/forks/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/network/members)
[![Pull Requests](https://img.shields.io/github/issues-pr/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/pulls)
[![Issues](https://img.shields.io/github/issues/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/issues)
[![Contributors](https://img.shields.io/github/contributors/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/graphs/contributors)
[![License](https://img.shields.io/github/license/stoatchat/for-desktop?style=flat-square&logoColor=white)](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.

View File

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

View File

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

64
codegen.plugin.ts Normal file
View File

@ -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;
}
},
};
}

6
components/app/index.tsx Normal file
View File

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

View File

@ -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",
},
},
});

View File

@ -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>
</>
);
}

View File

@ -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>{" "}
&middot; 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)",
},
});

View File

@ -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 &middot; </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",
},
},
});

View File

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

View File

@ -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",
},
});

View File

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

View File

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

View File

@ -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)!;

View File

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

View File

@ -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",
},
},
});

View File

@ -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)",
},
});

View File

@ -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",
},
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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%",
},
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,
};

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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)",
},
});

View File

@ -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,
},
},
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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",
},
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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)",
},
});

View File

@ -0,0 +1 @@
export { UserSummary } from "./UserSummary";

View File

@ -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)",
},
});

View File

@ -0,0 +1 @@
export { AppearanceMenu } from "./AppearanceMenu";

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,2 @@
export { MyBots } from "./MyBots";
export { ViewBot } from "./ViewBot";

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1 @@
export * from "./EditProfile";

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1 @@
export * from "./EditSubscription";

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

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

View File

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

View File

@ -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>
</>
);
}

View File

@ -0,0 +1 @@
export { AuthPage } from "./src/AuthPage";

View File

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

View File

@ -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>
);
}

View File

@ -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>
)}
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
)}
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
)}
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

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

View File

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

View File

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

View File

@ -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,
});
}
}

View File

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

123
components/client/index.tsx Normal file
View File

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