1 Commits

Author SHA1 Message Date
Declan Chidlow
87dfca26d7 Add AutoComplete for role completion 2025-06-02 10:47:08 +08:00
37 changed files with 638 additions and 1109 deletions

1
.envrc
View File

@@ -1 +0,0 @@
use flake

66
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Bug report
description: File a bug report
title: "bug: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What did you expect to happen?
validations:
required: true
- type: dropdown
id: branch
attributes:
label: Branch
description: What branch of Revolt are you using?
options:
- Production (app.revolt.chat)
- Nightly (nightly.revolt.chat)
validations:
required: true
- type: textarea
id: commit-hash
attributes:
label: Commit hash
description: What is your commit hash? You can find this at the bottom of Settings, next to the branch name.
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- Other (please specify in the "What happened" form)
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. (To get this, press `CTRL`- or `CMD`-`SHIFT`-`I` and navigate to the "Console" tab.)
render: shell
- type: checkboxes
id: desktop
attributes:
label: Desktop
description: Is this bug specific to [the desktop client](https://github.com/revoltchat/desktop)? (If not, leave this unchecked.)
options:
- label: Yes, this bug is specific to Revolt Desktop and is *not* an issue with Revolt Desktop itself.
required: false
- type: checkboxes
id: pwa
attributes:
label: PWA
description: Is this bug specific to the PWA (i.e. "installing" the web app on iOS or Android)? (If not, leave this unchecked.)
options:
- label: Yes, this bug is specific to the PWA.
required: false

7
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
contact_links:
- name: Lounge Chat
url: https://rvlt.gg/Testers
about: Ask questions and discuss with others.
- name: Discussions
url: https://github.com/orgs/revoltchat/discussions
about: For larger feature requests and general question & answer.

24
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Feature request
description: Make a feature request
title: "feature request: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Before you start, a lot of bigger features may be better suited as [API issues](https://github.com/revoltchat/delta/issues/new) or [centralised discussions](https://github.com/revoltchat/revolt/discussions/new).
- type: textarea
id: your-idea
attributes:
label: What do you want to see?
description: Describe your idea in as much detail as possible - if applicable, screenshots/mockups are really useful.
validations:
required: true
- type: checkboxes
id: pwa
attributes:
label: PWA
description: Is this feature request specific to the PWA (i.e. "installing" the web app on iOS or Android)? (If not, leave this unchecked.)
options:
- label: Yes, this feature request is specific to the PWA.
required: false

7
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,7 @@
## Please make sure to check the following tasks before opening and submitting a PR
* [ ] I understand and have followed the [contribution guide](https://developers.revolt.chat/contrib.html)
* [ ] I have tested my changes locally and they are working as intended
* [ ] These changes do not have any notable side effects on other Revolt projects
* [ ] (optional) I have opened a pull request on [the translation repository](https://github.com/revoltchat/translations)
* [ ] I have included screenshots to demonstrate my changes

View File

@@ -1,16 +1,38 @@
name: Docker name: Docker
on: on:
push: push:
branches: branches:
- "handmade" - "master"
pull_request: tags:
branches: - "*"
- "handmade" paths-ignore:
- ".github/**"
- "!.github/workflows/docker.yml"
- "!.github/workflows/preview_*.yml"
- ".vscode/**"
- ".gitignore"
- ".gitlab-ci.yml"
- "LICENSE"
- "README"
pull_request:
branches:
- "master"
paths-ignore:
- ".github/**"
- "!.github/workflows/docker.yml"
- "!.github/workflows/preview_*.yml"
- ".vscode/**"
- ".gitignore"
- ".gitlab-ci.yml"
- "LICENSE"
- "README"
workflow_dispatch:
jobs: jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -22,23 +44,27 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v3
with: with:
images: handmadecities/handmade-revolt-web-client images: revoltchat/client, ghcr.io/revoltchat/client
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Github Container Registry
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish - name: Build and publish
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}

54
.github/workflows/triage_issue.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: Add Issue to Board
on:
issues:
types: [opened]
jobs:
track_issue:
runs-on: ubuntu-latest
steps:
- name: Get project data
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
run: |
gh api graphql -f query='
query {
organization(login: "revoltchat"){
projectV2(number: 3) {
id
fields(first:20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'TODO_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
- name: Add issue to project
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
ISSUE_ID: ${{ github.event.issue.node_id }}
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $issue:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV

79
.github/workflows/triage_pr.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: Add PR to Board
on:
pull_request_target:
types: [opened, synchronize, ready_for_review, review_requested]
jobs:
track_pr:
runs-on: ubuntu-latest
steps:
- name: Get project data
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
run: |
gh api graphql -f query='
query {
organization(login: "revoltchat"){
projectV2(number: 5) {
id
fields(first:20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="🆕 Untriaged") |.id' project_data.json) >> $GITHUB_ENV
- name: Add PR to project
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
PR_ID: ${{ github.event.pull_request.node_id }}
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $pr:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
- name: Set fields
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
run: |
gh api graphql -f query='
mutation (
$project: ID!
$item: ID!
$status_field: ID!
$status_value: String!
) {
set_status: updateProjectV2ItemFieldValue(input: {
projectId: $project
itemId: $item
fieldId: $status_field
value: {
singleSelectOptionId: $status_value
}
}) {
projectV2Item {
id
}
}
}' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent

2
.gitignore vendored
View File

@@ -15,5 +15,3 @@ public/assets_*
!public/assets_default !public/assets_default
.vscode/chrome_data .vscode/chrome_data
.direnv

View File

@@ -1,5 +1,4 @@
# syntax=docker.io/docker/dockerfile:1.7-labs FROM node:16-buster AS builder
FROM --platform=$BUILDPLATFORM node:16-buster AS builder
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY . . COPY . .
@@ -11,11 +10,9 @@ RUN yarn build:deps
RUN yarn build:highmem RUN yarn build:highmem
RUN yarn workspaces focus --production --all RUN yarn workspaces focus --production --all
FROM node:24-alpine FROM node:16-alpine
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY docker/package.json docker/yarn.lock . COPY --from=builder /usr/src/app .
RUN yarn install --frozen-lockfile
COPY --from=builder --exclude=package.json --exclude=yarn.lock --exclude=.yarn* --exclude=.git --exclude=external --exclude=node_modules /usr/src/app .
EXPOSE 5000 EXPOSE 5000
CMD [ "yarn", "start:inject" ] CMD [ "yarn", "start:inject" ]

View File

@@ -1,5 +1,8 @@
# Handmade Revolt # Deprecation Notice
Fork of Revolt (now Stoat chat) maintained by the handmade cities community.
This project is deprecated, however it still may receive maintenance updates.
PRs for small fixes are more than welcome.
## Deploying a new release ## Deploying a new release

View File

@@ -1,8 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell rec {
buildInputs = [
pkgs.nodejs
pkgs.nodejs.pkgs.yarn
];
}

View File

Before

Width:  |  Height:  |  Size: 828 B

After

Width:  |  Height:  |  Size: 828 B

View File

@@ -1,16 +0,0 @@
{
"name": "inject-and-sirv",
"version": "0.0.1",
"scripts": {
"start:inject": "node scripts/inject.js && sirv dist_injected --port 5000 --cors --single --host"
},
"private": true,
"dependencies": {
"fs-extra": "^11.3.0",
"klaw": "^4.1.0",
"sirv-cli": "^3.0.1"
},
"engines": {
"node": ">=18"
}
}

View File

@@ -1,116 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@polka/url@^1.0.0-next.24":
version "1.0.0-next.29"
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==
console-clear@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/console-clear/-/console-clear-1.1.1.tgz#995e20cbfbf14dd792b672cde387bd128d674bf7"
integrity sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==
fs-extra@^11.3.0:
version "11.3.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d"
integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^6.0.1"
universalify "^2.0.0"
get-port@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193"
integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
graceful-fs@^4.1.6, graceful-fs@^4.2.0:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
jsonfile@^6.0.1:
version "6.1.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==
dependencies:
universalify "^2.0.0"
optionalDependencies:
graceful-fs "^4.1.6"
klaw@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/klaw/-/klaw-4.1.0.tgz#5df608067d8cb62bbfb24374f8e5d956323338f3"
integrity sha512-1zGZ9MF9H22UnkpVeuaGKOjfA2t6WrfdrJmGjy16ykcjnKQDmHVX+KI477rpbGevz/5FD4MC3xf1oxylBgcaQw==
kleur@^4.1.4:
version "4.1.5"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
local-access@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/local-access/-/local-access-1.1.0.tgz#e007c76ba2ca83d5877ba1a125fc8dfe23ba4798"
integrity sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==
mri@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
mrmime@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc"
integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==
sade@^1.6.0:
version "1.8.1"
resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"
integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==
dependencies:
mri "^1.1.0"
semiver@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/semiver/-/semiver-1.1.0.tgz#9c97fb02c21c7ce4fcf1b73e2c7a24324bdddd5f"
integrity sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==
sirv-cli@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/sirv-cli/-/sirv-cli-3.0.1.tgz#9a53e4fa85fdc08d54a76fd76a7c866cd4c3988b"
integrity sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==
dependencies:
console-clear "^1.1.0"
get-port "^5.1.1"
kleur "^4.1.4"
local-access "^1.0.1"
sade "^1.6.0"
semiver "^1.0.0"
sirv "^3.0.0"
tinydate "^1.0.0"
sirv@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.1.tgz#32a844794655b727f9e2867b777e0060fbe07bf3"
integrity sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==
dependencies:
"@polka/url" "^1.0.0-next.24"
mrmime "^2.0.0"
totalist "^3.0.0"
tinydate@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/tinydate/-/tinydate-1.3.0.tgz#e6ca8e5a22b51bb4ea1c3a2a4fd1352dbd4c57fb"
integrity sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==
totalist@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"
integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==
universalify@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==

2
external/lang vendored

64
flake.lock generated
View File

@@ -1,64 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": [
"systems"
]
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1770169770,
"narHash": "sha256-awR8qIwJxJJiOmcEGgP2KUqYmHG4v/z8XpL9z8FnT1A=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "aa290c9891fa4ebe88f8889e59633d20cc06a5f2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,28 +0,0 @@
{
description = "Node+yarn dev shell flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
systems.url = "github:nix-systems/default";
flake-utils = {
url = "github:numtide/flake-utils";
inputs.systems.follows = "systems";
};
};
outputs =
{ nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
nodejs_24
yarn
];
};
}
);
}

View File

@@ -4,9 +4,9 @@ import { Channel } from "revolt.js";
import styled from "styled-components/macro"; import styled from "styled-components/macro";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Button, Checkbox, Preloader } from "@revoltchat/ui"; import { Button, Checkbox } from "@revoltchat/ui";
import { useApplicationState } from "../../mobx/State"; import { useApplicationState } from "../../mobx/State";
import { SECTION_NSFW } from "../../mobx/stores/Layout"; import { SECTION_NSFW } from "../../mobx/stores/Layout";
@@ -45,36 +45,14 @@ type Props = {
channel: Channel; channel: Channel;
}; };
let geoBlock:
| undefined
| {
countryCode: string;
isAgeRestrictedGeo: true;
};
export default observer((props: Props) => { export default observer((props: Props) => {
const history = useHistory(); const history = useHistory();
const layout = useApplicationState().layout; const layout = useApplicationState().layout;
const [geoLoaded, setGeoLoaded] = useState(typeof geoBlock !== "undefined");
const [ageGate, setAgeGate] = useState(false); const [ageGate, setAgeGate] = useState(false);
useEffect(() => { if (ageGate || !props.gated) {
if (!geoLoaded) {
fetch("https://geo.revolt.chat")
.then((res) => res.json())
.then((data) => {
geoBlock = data;
setGeoLoaded(true);
});
}
}, []);
if (!geoBlock) return <Preloader type="spinner" />;
if ((ageGate && !geoBlock.isAgeRestrictedGeo) || !props.gated) {
return <>{props.children}</>; return <>{props.children}</>;
} }
if ( if (
!( !(
props.channel.channel_type === "Group" || props.channel.channel_type === "Group" ||
@@ -98,40 +76,23 @@ export default observer((props: Props) => {
</a> </a>
</span> </span>
{geoBlock.isAgeRestrictedGeo ? ( <Checkbox
<div style={{ maxWidth: "420px", textAlign: "center" }}> title={<Text id="app.main.channel.nsfw.confirm" />}
{geoBlock.countryCode === "GB" value={layout.getSectionState(SECTION_NSFW, false)}
? "This channel is not available in your region while we review options on legal compliance." onChange={() => layout.toggleSectionState(SECTION_NSFW, false)}
: "This content is not available in your region."} />
</div> <div className="actions">
) : ( <Button palette="secondary" onClick={() => history.goBack()}>
<> <Text id="app.special.modals.actions.back" />
<Checkbox </Button>
title={<Text id="app.main.channel.nsfw.confirm" />} <Button
value={layout.getSectionState(SECTION_NSFW, false)} palette="secondary"
onChange={() => onClick={() =>
layout.toggleSectionState(SECTION_NSFW, false) layout.getSectionState(SECTION_NSFW) && setAgeGate(true)
} }>
/> <Text id={`app.main.channel.nsfw.${props.type}.confirm`} />
<div className="actions"> </Button>
<Button </div>
palette="secondary"
onClick={() => history.goBack()}>
<Text id="app.special.modals.actions.back" />
</Button>
<Button
palette="secondary"
onClick={() =>
layout.getSectionState(SECTION_NSFW) &&
setAgeGate(true)
}>
<Text
id={`app.main.channel.nsfw.${props.type}.confirm`}
/>
</Button>
</div>
</>
)}
</Base> </Base>
); );
}); });

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Channel, User } from "revolt.js"; import { Channel, User, Role } from "revolt.js";
import { Emoji as CustomEmoji } from "revolt.js/esm/maps/Emojis"; import { Emoji as CustomEmoji } from "revolt.js/esm/maps/Emojis";
import styled, { css } from "styled-components/macro"; import styled, { css } from "styled-components/macro";
@@ -29,11 +29,16 @@ export type AutoCompleteState =
type: "channel"; type: "channel";
matches: Channel[]; matches: Channel[];
} }
| {
type: "role";
matches: Role[];
}
)); ));
export type SearchClues = { export type SearchClues = {
users?: { type: "channel"; id: string } | { type: "all" }; users?: { type: "channel"; id: string } | { type: "all" };
channels?: { server: string }; channels?: { server: string };
roles?: { server: string };
}; };
export type AutoCompleteProps = { export type AutoCompleteProps = {
@@ -59,7 +64,7 @@ export function useAutoComplete(
function findSearchString( function findSearchString(
el: HTMLTextAreaElement, el: HTMLTextAreaElement,
): ["emoji" | "user" | "channel", string, number] | undefined { ): ["emoji" | "user" | "channel" | "role", string, number] | undefined {
if (el.selectionStart === el.selectionEnd) { if (el.selectionStart === el.selectionEnd) {
const cursor = el.selectionStart; const cursor = el.selectionStart;
const content = el.value.slice(0, cursor); const content = el.value.slice(0, cursor);
@@ -71,6 +76,8 @@ export function useAutoComplete(
return ["user", "", j]; return ["user", "", j];
} else if (content[j] === "#") { } else if (content[j] === "#") {
return ["channel", "", j]; return ["channel", "", j];
} else if (content[j] === "%") {
return ["role", "", j];
} }
while (j >= 0 && valid.test(content[j])) { while (j >= 0 && valid.test(content[j])) {
@@ -80,7 +87,12 @@ export function useAutoComplete(
if (j === -1) return; if (j === -1) return;
const current = content[j]; const current = content[j];
if (current === ":" || current === "@" || current === "#") { if (
current === ":" ||
current === "@" ||
current === "#" ||
current === "%"
) {
const search = content.slice(j + 1, content.length); const search = content.slice(j + 1, content.length);
const minLen = current === ":" ? 2 : 1; const minLen = current === ":" ? 2 : 1;
@@ -90,6 +102,8 @@ export function useAutoComplete(
? "channel" ? "channel"
: current === ":" : current === ":"
? "emoji" ? "emoji"
: current === "%"
? "role"
: "user", : "user",
search.toLowerCase(), search.toLowerCase(),
current === ":" ? j + 1 : j, current === ":" ? j + 1 : j,
@@ -230,6 +244,42 @@ export function useAutoComplete(
return; return;
} }
} }
if (type === "role" && searchClues?.roles) {
const server = client.servers.get(searchClues.roles.server);
let roles: (Role & { id: string })[] = [];
if (server?.roles) {
roles = Object.entries(server.roles).map(([id, role]) => ({
...role,
id,
}));
}
const matches = (
search.length > 0
? roles.filter((role) =>
role.name.toLowerCase().match(regex),
)
: roles
)
.splice(0, 5)
.filter((x) => typeof x !== "undefined");
if (matches.length > 0) {
const currentPosition =
state.type !== "none" ? state.selected : 0;
setState({
type: "role",
matches,
selected: Math.min(currentPosition, matches.length - 1),
within: false,
});
return;
}
}
} }
if (state.type !== "none") { if (state.type !== "none") {
@@ -262,6 +312,14 @@ export function useAutoComplete(
state.matches[state.selected]._id, state.matches[state.selected]._id,
"> ", "> ",
); );
} else if (state.type === "role") {
content.splice(
index,
search.length + 1,
"<%",
state.matches[state.selected].id,
"> ",
);
} else { } else {
content.splice( content.splice(
index, index,
@@ -492,7 +550,7 @@ export default function AutoComplete({
{state.type === "user" && {state.type === "user" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
key={match} key={match._id}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
@@ -517,7 +575,7 @@ export default function AutoComplete({
{state.type === "channel" && {state.type === "channel" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
key={match} key={match._id}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
@@ -539,6 +597,40 @@ export default function AutoComplete({
{match.name} {match.name}
</button> </button>
))} ))}
{state.type === "role" &&
state.matches.map((match, i) => (
<button
key={match._id}
className={i === state.selected ? "active" : ""}
onMouseEnter={() =>
(i !== state.selected || !state.within) &&
setState({
...state,
selected: i,
within: true,
})
}
onMouseLeave={() =>
state.within &&
setState({
...state,
within: false,
})
}
onClick={onClick}>
<div
style={{
width: "16px",
height: "16px",
borderRadius: "50%",
backgroundColor: match.colour || "#7c7c7c",
marginRight: "8px",
flexShrink: 0,
}}
/>
{match.name}
</button>
))}
</div> </div>
</Base> </Base>
); );

View File

@@ -567,6 +567,7 @@ export default observer(({ channel }: Props) => {
channel.channel_type === "TextChannel" channel.channel_type === "TextChannel"
? { server: channel.server_id! } ? { server: channel.server_id! }
: undefined, : undefined,
roles: { server: channel.server_id! },
}); });
return ( return (

View File

@@ -26,8 +26,8 @@ export default function AttachmentActions({ attachment }: Props) {
const { filename, metadata, size } = attachment; const { filename, metadata, size } = attachment;
const url = client.generateFileURL(attachment); const url = client.generateFileURL(attachment);
const open_url = url; const open_url = `${url}/${filename}`;
const download_url = `${url}/${filename}`; const download_url = url;
const filesize = determineFileSize(size); const filesize = determineFileSize(size);

View File

@@ -16,11 +16,6 @@ import { IconButton } from "@revoltchat/ui";
import { emojiDictionary } from "../../../../assets/emojis"; import { emojiDictionary } from "../../../../assets/emojis";
import { useClient } from "../../../../controllers/client/ClientController"; import { useClient } from "../../../../controllers/client/ClientController";
import { modalController } from "../../../../controllers/modals/ModalController";
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
import Tooltip from "../../Tooltip";
import UserIcon from "../../user/UserIcon";
import { Username } from "../../user/UserShort";
import { RenderEmoji } from "../../../markdown/plugins/emoji"; import { RenderEmoji } from "../../../markdown/plugins/emoji";
import { HackAlertThisFileWillBeReplaced } from "../MessageBox"; import { HackAlertThisFileWillBeReplaced } from "../MessageBox";
@@ -90,38 +85,6 @@ const Reaction = styled.div<{ active: boolean }>`
`} `}
`; `;
/**
* Tooltip content for reaction users
*/
const TooltipUserList = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
max-width: 200px;
`;
const TooltipUserRow = styled.div`
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9em;
padding: 4px 6px;
margin: -4px -6px;
border-radius: var(--border-radius);
cursor: pointer;
&:hover {
background: var(--secondary-background);
}
`;
const TooltipMoreText = styled.div`
font-size: 0.85em;
color: var(--secondary-foreground);
padding-top: 2px;
`;
/** /**
* Render reactions on a message * Render reactions on a message
*/ */
@@ -135,97 +98,16 @@ export const Reactions = observer(({ message }: Props) => {
const Entry = useCallback( const Entry = useCallback(
observer(({ id, user_ids }: { id: string; user_ids?: Set<string> }) => { observer(({ id, user_ids }: { id: string; user_ids?: Set<string> }) => {
const active = user_ids?.has(client.user!._id) || false; const active = user_ids?.has(client.user!._id) || false;
const userIds = user_ids ? Array.from(user_ids) : [];
const longPressTimer = useRef<number | null>(null);
const didLongPress = useRef(false);
const handleClick = () => { return (
if (didLongPress.current) {
didLongPress.current = false;
return;
}
active ? message.unreact(id) : message.react(id);
};
const openModal = () => {
modalController.push({
type: "reaction_users",
emoji: id,
userIds,
});
};
const handleTouchStart = (e: TouchEvent) => {
e.preventDefault();
e.stopPropagation();
didLongPress.current = false;
longPressTimer.current = window.setTimeout(() => {
didLongPress.current = true;
openModal();
}, 500);
};
const handleTouchEnd = (e: TouchEvent) => {
e.stopPropagation();
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
// Short tap - trigger manually since preventDefault blocks click
if (!didLongPress.current) {
active ? message.unreact(id) : message.react(id);
}
}
didLongPress.current = false;
};
const openUserProfile = (userId: string) => {
modalController.push({ type: "user_profile", user_id: userId });
};
// Build tooltip content showing up to 10 users
const tooltipContent = (
<TooltipUserList>
{userIds.slice(0, 10).map((userId) => {
const user = client.users.get(userId);
return (
<TooltipUserRow
key={userId}
onClick={() => openUserProfile(userId)}>
<UserIcon target={user} size={18} />
<Username user={user} />
</TooltipUserRow>
);
})}
{userIds.length > 10 && (
<TooltipMoreText>
and {userIds.length - 10} more...
</TooltipMoreText>
)}
</TooltipUserList>
);
const reactionElement = (
<Reaction <Reaction
active={active} active={active}
onClick={handleClick} onClick={() =>
onTouchStart={isTouchscreenDevice ? handleTouchStart : undefined} active ? message.unreact(id) : message.react(id)
onTouchEnd={isTouchscreenDevice ? handleTouchEnd : undefined} }>
onTouchCancel={isTouchscreenDevice ? handleTouchEnd : undefined}>
<RenderEmoji match={id} /> {user_ids?.size || 0} <RenderEmoji match={id} /> {user_ids?.size || 0}
</Reaction> </Reaction>
); );
// Desktop: show tooltip on hover
// Mobile: long-press opens modal, handled by touch events
if (!isTouchscreenDevice && userIds.length > 0) {
return (
<Tooltip content={tooltipContent} delay={300} interactive>
{reactionElement}
</Tooltip>
);
}
return reactionElement;
}), }),
[], [],
); );

View File

@@ -126,16 +126,8 @@ export function EmbedInvite({ code }: Props) {
<EmbedInviteName>{invite.server_name}</EmbedInviteName> <EmbedInviteName>{invite.server_name}</EmbedInviteName>
<EmbedInviteMemberCount> <EmbedInviteMemberCount>
<Group size={12} /> <Group size={12} />
{invite.member_count != null ? ( {invite.member_count.toLocaleString()}{" "}
<> {invite.member_count === 1 ? "member" : "members"}
{invite.member_count.toLocaleString()}{" "}
{invite.member_count === 1
? "member"
: "members"}
</>
) : (
"N/A"
)}
</EmbedInviteMemberCount> </EmbedInviteMemberCount>
</EmbedInviteDetails> </EmbedInviteDetails>
{processing ? ( {processing ? (

View File

@@ -34,7 +34,7 @@ export function RenderEmoji({ match }: CustomComponentProps) {
? `${ ? `${
clientController.getAvailableClient().configuration?.features clientController.getAvailableClient().configuration?.features
.autumn.url .autumn.url
}/emojis/${match}/original` }/emojis/${match}`
: parseEmoji( : parseEmoji(
match in emojiDictionary match in emojiDictionary
? emojiDictionary[match as keyof typeof emojiDictionary] ? emojiDictionary[match as keyof typeof emojiDictionary]

View File

@@ -50,10 +50,8 @@ const ServerList = styled.div`
export default observer(() => { export default observer(() => {
const client = useClient(); const client = useClient();
const state = useApplicationState(); const state = useApplicationState();
const { server: server_id, channel: channel_id } = useParams<{ const { server: server_id, channel: channel_id } =
server: string; useParams<{ server: string; channel?: string }>();
channel?: string;
}>();
const server = client.servers.get(server_id); const server = client.servers.get(server_id);
if (!server) return <Redirect to="/" />; if (!server) return <Redirect to="/" />;
@@ -121,18 +119,14 @@ export default observer(() => {
channels.push(addChannel(id)); channels.push(addChannel(id));
} }
if (category.title === "Default") { elements.push(
elements.push(...channels); <CollapsibleSection
} else { id={`category_${category.id}`}
elements.push( defaultValue
<CollapsibleSection summary={<Category>{category.title}</Category>}>
id={`category_${category.id}`} {channels}
defaultValue </CollapsibleSection>,
summary={<Category>{category.title}</Category>}> );
{channels}
</CollapsibleSection>,
);
}
} }
} }

View File

@@ -76,25 +76,17 @@ function useEntries(
keys.forEach((key) => { keys.forEach((key) => {
let u; let u;
let member;
if (isServer) { if (isServer) {
const { server, user } = JSON.parse(key); const { server, user } = JSON.parse(key);
if (server !== channel.server_id) return; if (server !== channel.server_id) return;
u = client.users.get(user); u = client.users.get(user);
member = client.members.get(key);
if (!member?.hasPermission(channel, "ViewChannel")) {
return;
}
} else { } else {
u = client.users.get(key); u = client.users.get(key);
member = client.members.get(key);
} }
if (!u) return; if (!u) return;
const member = client.members.get(key);
const sort = member?.nickname ?? u.username; const sort = member?.nickname ?? u.username;
const entry = [u, sort] as [User, string]; const entry = [u, sort] as [User, string];

View File

@@ -28,7 +28,6 @@ type Transition =
| "SUCCESS" | "SUCCESS"
| "DISCONNECT" | "DISCONNECT"
| "RETRY" | "RETRY"
| "RETRY_FAILED"
| "LOGOUT" | "LOGOUT"
| "ONLINE" | "ONLINE"
| "OFFLINE"; | "OFFLINE";
@@ -41,7 +40,6 @@ export default class Session {
state: State = window.navigator.onLine ? "Ready" : "Offline"; state: State = window.navigator.onLine ? "Ready" : "Offline";
user_id: string | null = null; user_id: string | null = null;
client: Client | null = null; client: Client | null = null;
retryAttempts: number = 0;
/** /**
* Create a new Session * Create a new Session
@@ -91,11 +89,9 @@ export default class Session {
* Called when the client signals it has disconnected * Called when the client signals it has disconnected
*/ */
private onDropped() { private onDropped() {
if (this.state === "Connecting") { this.emit({
this.emit({ action: "RETRY_FAILED" }); action: "DISCONNECT",
} else { });
this.emit({ action: "DISCONNECT" });
}
} }
/** /**
@@ -215,7 +211,6 @@ export default class Session {
// Ready successfully received // Ready successfully received
case "SUCCESS": { case "SUCCESS": {
this.assert("Connecting"); this.assert("Connecting");
this.retryAttempts = 0;
this.state = "Online"; this.state = "Online";
break; break;
} }
@@ -244,18 +239,6 @@ export default class Session {
this.state = "Connecting"; this.state = "Connecting";
break; break;
} }
// Reconnect attempt failed, schedule another with backoff
case "RETRY_FAILED": {
this.assert("Connecting");
this.retryAttempts++;
const delay = Math.min(500 * Math.pow(2, this.retryAttempts), 16000);
setTimeout(() => {
if (this.state === "Connecting") {
this.client!.websocket.connect();
}
}, delay);
break;
}
// User instructed logout // User instructed logout
case "LOGOUT": { case "LOGOUT": {
this.assert("Connecting", "Online", "Disconnected"); this.assert("Connecting", "Online", "Disconnected");

View File

@@ -32,8 +32,6 @@ import CreateRole from "./components/CreateRole";
import CreateServer from "./components/CreateServer"; import CreateServer from "./components/CreateServer";
import CustomStatus from "./components/CustomStatus"; import CustomStatus from "./components/CustomStatus";
import DeleteMessage from "./components/DeleteMessage"; import DeleteMessage from "./components/DeleteMessage";
import ReactMessage from "./components/ReactMessage";
import ReactionUsers from "./components/ReactionUsers";
import Error from "./components/Error"; import Error from "./components/Error";
import ImageViewer from "./components/ImageViewer"; import ImageViewer from "./components/ImageViewer";
import KickMember from "./components/KickMember"; import KickMember from "./components/KickMember";
@@ -277,8 +275,6 @@ export const modalController = new ModalControllerExtended({
create_bot: CreateBot, create_bot: CreateBot,
custom_status: CustomStatus, custom_status: CustomStatus,
delete_message: DeleteMessage, delete_message: DeleteMessage,
react_message: ReactMessage,
reaction_users: ReactionUsers,
error: Error, error: Error,
image_viewer: ImageViewer, image_viewer: ImageViewer,
kick_member: KickMember, kick_member: KickMember,

View File

@@ -1,46 +0,0 @@
import styled from "styled-components";
import { Modal } from "@revoltchat/ui";
import { ModalProps } from "../types"
import { Message } from "revolt.js";
import { emojiDictionary } from "../../../assets/emojis"
import { HackAlertThisFileWillBeReplaced } from "../../../components/common/messaging/MessageBox"
const PickerContainer = styled.div`
max-height: 420px;
max-width: 370px;
overflow: hidden;
> div {
position: unset;
}
`
export default function ReactMessage({
target: message,
onClose,
...props
}: ModalProps<"react_message">) {
return (
<Modal
{...props}
padding={false}
maxWidth="370px"
>
<PickerContainer>
<HackAlertThisFileWillBeReplaced
onSelect={(emoji) =>{
message.react(
emojiDictionary[
emoji as keyof typeof emojiDictionary
] ?? emoji,
);
onClose();
}}
onClose={onClose}
/>
</PickerContainer>
</Modal>
)
}

View File

@@ -1,92 +0,0 @@
import styled from "styled-components";
import { Modal } from "@revoltchat/ui";
import UserIcon from "../../../components/common/user/UserIcon";
import { Username } from "../../../components/common/user/UserShort";
import { RenderEmoji } from "../../../components/markdown/plugins/emoji";
import { useClient } from "../../client/ClientController";
import { modalController } from "../ModalController";
import { ModalProps } from "../types";
const List = styled.div`
max-width: 100%;
max-height: 360px;
overflow-y: auto;
`;
const UserRow = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
cursor: pointer;
border-radius: var(--border-radius);
&:hover {
background: var(--secondary-background);
}
`;
const Header = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 1.1em;
img {
width: 1.5em;
height: 1.5em;
object-fit: contain;
}
`;
export default function ReactionUsers({
emoji,
userIds,
onClose,
...props
}: ModalProps<"reaction_users">) {
const client = useClient();
const openProfile = (userId: string) => {
onClose();
modalController.push({ type: "user_profile", user_id: userId });
};
// Modal must be nonDismissable, it conflicts with the message context menu otherwise.
return (
<Modal
{...props}
nonDismissable
title={
<Header>
<RenderEmoji match={emoji} />
<span>{userIds.length}</span>
</Header>
}
actions={[
{
onClick: () => {
onClose();
return true;
},
children: "Close",
},
]}>
<List>
{userIds.map((userId) => {
const user = client.users.get(userId);
return (
<UserRow
key={userId}
onClick={() => openProfile(userId)}>
<UserIcon target={user} size={32} />
<Username user={user} />
</UserRow>
);
})}
</List>
</Modal>
);
}

View File

@@ -153,15 +153,6 @@ export type Modal = {
type: "delete_message"; type: "delete_message";
target: Message; target: Message;
} }
| {
type: "react_message",
target: Message;
}
| {
type: "reaction_users";
emoji: string;
userIds: string[];
}
| { | {
type: "kick_member"; type: "kick_member";
member: Member; member: Member;

View File

@@ -65,7 +65,6 @@ type Action =
| { action: "quote_message"; content: string } | { action: "quote_message"; content: string }
| { action: "edit_message"; id: string } | { action: "edit_message"; id: string }
| { action: "delete_message"; target: Message } | { action: "delete_message"; target: Message }
| { action: "react_message"; target: Message }
| { action: "open_file"; attachment: API.File } | { action: "open_file"; attachment: API.File }
| { action: "save_file"; attachment: API.File } | { action: "save_file"; attachment: API.File }
| { action: "copy_file_link"; attachment: API.File } | { action: "copy_file_link"; attachment: API.File }
@@ -403,13 +402,6 @@ export default function ContextMenus() {
}); });
break; break;
case "react_message":
modalController.push({
type: "react_message",
target: data.target,
});
break;
case "leave_group": case "leave_group":
case "close_dm": case "close_dm":
case "delete_channel": case "delete_channel":
@@ -516,8 +508,6 @@ export default function ContextMenus() {
"Open in Admin Panel" "Open in Admin Panel"
) : locale === "admin_system" ? ( ) : locale === "admin_system" ? (
"Open User in Admin Panel" "Open User in Admin Panel"
) : locale === "react_message" ? (
"React"
) : ( ) : (
<Text <Text
id={`app.context_menu.${ id={`app.context_menu.${
@@ -843,16 +833,6 @@ export default function ContextMenus() {
}); });
} }
if (message.channel?.havePermission("React")) {
generateAction(
{
action: "react_message",
target: message,
},
"react_message",
);
}
if (message.author_id !== userId) { if (message.author_id !== userId) {
generateAction( generateAction(
{ {

View File

@@ -66,16 +66,11 @@ export default class Settings
} }
@action hydrate(data: ISettings) { @action hydrate(data: ISettings) {
Object.keys(data).forEach((key) => { Object.keys(data).forEach(
const val = (data as any)[key]; (key) =>
if (typeof val !== "undefined") { typeof (data as any)[key] !== "undefined" &&
if (key === "appearance:emoji") { this.data.set(key, (data as any)[key]),
setGlobalEmojiPack(val); );
}
this.data.set(key, val);
}
});
} }
/** /**

View File

@@ -10,22 +10,6 @@ import UserIcon from "../../components/common/user/UserIcon";
import Markdown from "../../components/markdown/Markdown"; import Markdown from "../../components/markdown/Markdown";
import { useClient } from "../../controllers/client/ClientController"; import { useClient } from "../../controllers/client/ClientController";
const Page = styled.div`
padding: 6em;
min-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
@media (max-width: 768px) {
padding: 2em;
}
@media (max-width: 480px) {
padding: 1em;
}
`;
const BotInfo = styled.div` const BotInfo = styled.div`
gap: 12px; gap: 12px;
display: flex; display: flex;
@@ -58,7 +42,7 @@ export default function InviteBot() {
const [group, setGroup] = useState("none"); const [group, setGroup] = useState("none");
return ( return (
<Page> <div style={{ padding: "6em" }}>
<Tip palette="warning">This section is under construction.</Tip> <Tip palette="warning">This section is under construction.</Tip>
{typeof data === "undefined" && <Preloader type="spinner" />} {typeof data === "undefined" && <Preloader type="spinner" />}
{data && ( {data && (
@@ -122,7 +106,6 @@ export default function InviteBot() {
</Option> </Option>
</> </>
)} )}
</Page> </div>
); );
} }

View File

@@ -1,8 +1,4 @@
import { import { HelpCircle } from "@styled-icons/boxicons-solid";
HelpCircle,
ChevronUp,
ChevronDown,
} from "@styled-icons/boxicons-solid";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Server } from "revolt.js"; import { Server } from "revolt.js";
@@ -20,208 +16,17 @@ import {
ColourSwatches, ColourSwatches,
InputBox, InputBox,
Category, Category,
Row,
} from "@revoltchat/ui"; } from "@revoltchat/ui";
import Tooltip from "../../../components/common/Tooltip"; import Tooltip from "../../../components/common/Tooltip";
import { PermissionList } from "../../../components/settings/roles/PermissionList"; import { PermissionList } from "../../../components/settings/roles/PermissionList";
import { RoleOrDefault } from "../../../components/settings/roles/RoleSelection"; import { RoleOrDefault } from "../../../components/settings/roles/RoleSelection";
import { useSession } from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController"; import { modalController } from "../../../controllers/modals/ModalController";
interface Props { interface Props {
server: Server; server: Server;
} }
const RoleReorderContainer = styled.div`
margin: 16px 0;
`;
const RoleItem = styled.div`
display: flex;
align-items: center;
padding: 12px 16px;
margin: 12px 0;
background: var(--secondary-background);
border-radius: var(--border-radius);
`;
const RoleInfo = styled.div`
flex: 1;
display: flex;
flex-direction: column;
`;
const RoleName = styled.div`
font-weight: 600;
color: var(--foreground);
`;
const RoleRank = styled.div`
font-size: 12px;
color: var(--secondary-foreground);
`;
const RoleControls = styled.div`
display: flex;
gap: 4px;
`;
const MoveButton = styled(Button)`
padding: 4px 8px;
min-width: auto;
`;
/**
* Hook to memo-ize role information with proper ordering
* @param server Target server
* @returns Role array with default at bottom
*/
export function useRolesForReorder(server: Server) {
return useMemo(() => {
const roles = [...server.orderedRoles] as RoleOrDefault[];
roles.push({
id: "default",
name: "Default",
permissions: server.default_permissions,
});
return roles;
}, [server.roles, server.default_permissions]);
}
/**
* Role reordering component
*/
const RoleReorderPanel = observer(
({ server, onExit }: Props & { onExit: () => void }) => {
const initialRoles = useRolesForReorder(server);
const [roles, setRoles] = useState(initialRoles);
const [isReordering, setIsReordering] = useState(false);
// Update local state when server roles change
useMemo(() => {
setRoles(useRolesForReorder(server));
}, [server.roles, server.default_permissions]);
const moveRoleUp = (index: number) => {
if (index === 0 || roles[index].id === "default") return;
const newRoles = [...roles];
[newRoles[index - 1], newRoles[index]] = [
newRoles[index],
newRoles[index - 1],
];
setRoles(newRoles);
};
const moveRoleDown = (index: number) => {
// Can't move down if it's the last non-default role or if it's default
if (index >= roles.length - 2 || roles[index].id === "default")
return;
const newRoles = [...roles];
[newRoles[index], newRoles[index + 1]] = [
newRoles[index + 1],
newRoles[index],
];
setRoles(newRoles);
};
const saveReorder = async () => {
setIsReordering(true);
try {
const nonDefaultRoles = roles.filter(
(role) => role.id !== "default",
);
const roleIds = nonDefaultRoles.map((role) => role.id);
const session = useSession()!;
const client = session.client!;
// Make direct API request since it's not in r.js as of writing
await client.api.patch(`/servers/${server._id}/roles/ranks`, {
ranks: roleIds,
});
console.log("Roles reordered successfully");
} catch (error) {
console.error("Failed to reorder roles:", error);
setRoles(initialRoles);
} finally {
setIsReordering(false);
}
};
const hasChanges = !isEqual(
roles.filter((r) => r.id !== "default").map((r) => r.id),
initialRoles.filter((r) => r.id !== "default").map((r) => r.id),
);
return (
<div>
<SpaceBetween>
<H1>
<Text id="app.settings.permissions.role_ranking" />
</H1>
<Row>
<Button
palette="secondary"
onClick={onExit}
style={{ marginBottom: "16px" }}>
<Text id="app.special.modals.actions.back" />
</Button>
<Button
palette="secondary"
disabled={!hasChanges || isReordering}
onClick={saveReorder}>
<Text id="app.special.modals.actions.save" />
</Button>
</Row>
</SpaceBetween>
<RoleReorderContainer>
{roles.map((role, index) => (
<RoleItem key={role.id}>
<RoleInfo>
<RoleName>{role.name}</RoleName>
<RoleRank>
{role.id === "default" ? (
<Text id="app.settings.permissions.default_desc" />
) : (
<>
<Text id="app.settings.permissions.role_ranking" />{" "}
{index}
</>
)}
</RoleRank>
</RoleInfo>
{role.id !== "default" && (
<RoleControls>
<MoveButton
palette="secondary"
disabled={index === 0}
onClick={() => moveRoleUp(index)}>
<ChevronUp size={16} />
</MoveButton>
<MoveButton
palette="secondary"
disabled={index >= roles.length - 2}
onClick={() => moveRoleDown(index)}>
<ChevronDown size={16} />
</MoveButton>
</RoleControls>
)}
</RoleItem>
))}
</RoleReorderContainer>
</div>
);
},
);
/** /**
* Hook to memo-ize role information. * Hook to memo-ize role information.
* @param server Target server * @param server Target server
@@ -245,11 +50,9 @@ export function useRoles(server: Server) {
} }
/** /**
* Updated Roles settings menu with reordering panel * Roles settings menu
*/ */
export const Roles = observer(({ server }: Props) => { export const Roles = observer(({ server }: Props) => {
const [showReorderPanel, setShowReorderPanel] = useState(false);
// Consolidate all permissions that we can change right now. // Consolidate all permissions that we can change right now.
const currentRoles = useRoles(server); const currentRoles = useRoles(server);
@@ -271,219 +74,213 @@ export const Roles = observer(({ server }: Props) => {
margin: 16px 0; margin: 16px 0;
`; `;
const ReorderButton = styled(Button)`
margin-inline: auto 8px;
`;
if (showReorderPanel) {
return (
<div>
<RoleReorderPanel
server={server}
onExit={() => setShowReorderPanel(false)}
/>
</div>
);
}
return ( return (
<div> <PermissionsLayout
<PermissionsLayout server={server}
server={server} rank={server.member?.ranking ?? Infinity}
rank={server.member?.ranking ?? Infinity} onCreateRole={(callback) =>
onCreateRole={(callback) => modalController.push({
modalController.push({ type: "create_role",
type: "create_role", server,
server, callback,
callback, })
}) }
editor={({ selected }) => {
const currentRole = currentRoles.find(
(x) => x.id === selected,
)!;
if (!currentRole) return null;
// Keep track of whatever role we're editing right now.
const [value, setValue] = useState<Partial<RoleOrDefault>>({});
const currentRoleValue = { ...currentRole, ...value };
// Calculate permissions we have access to on this server.
const current = server.permission;
// Upload new role information to server.
function save() {
const { permissions: permsCurrent, ...current } =
currentRole;
const { permissions: permsValue, ...value } =
currentRoleValue;
if (!isEqual(permsCurrent, permsValue)) {
server.setPermissions(
selected,
typeof permsValue === "number"
? permsValue
: {
allow: permsValue.a,
deny: permsValue.d,
},
);
}
if (!isEqual(current, value)) {
server.editRole(selected, value);
}
} }
editor={({ selected }) => {
const currentRole = currentRoles.find(
(x) => x.id === selected,
)!;
if (!currentRole) return null; // Delete the role from this server.
function deleteRole() {
server.deleteRole(selected);
}
const [value, setValue] = useState<Partial<RoleOrDefault>>( return (
{}, <div>
); <SpaceBetween>
<H1>
const currentRoleValue = { ...currentRole, ...value }; <Text
id="app.settings.actions.edit"
function save() { fields={{ name: currentRole.name }}
const { permissions: permsCurrent, ...current } = />
currentRole; </H1>
const { permissions: permsValue, ...value } = <Button
currentRoleValue; palette="secondary"
disabled={isEqual(
if (!isEqual(permsCurrent, permsValue)) { currentRole,
server.setPermissions( currentRoleValue,
selected, )}
typeof permsValue === "number" onClick={save}>
? permsValue <Text id="app.special.modals.actions.save" />
: { </Button>
allow: permsValue.a, </SpaceBetween>
deny: permsValue.d, <hr />
}, {selected !== "default" && (
); <>
} <section>
<Category>
if (!isEqual(current, value)) { <Text id="app.settings.permissions.role_name" />
server.editRole(selected, value); </Category>
} <p>
} <InputBox
value={currentRoleValue.name}
function deleteRole() { onChange={(e) =>
server.deleteRole(selected); setValue({
} ...value,
name: e.currentTarget.value,
return ( })
<div> }
<SpaceBetween> palette="secondary"
<H1> />
<Text </p>
id="app.settings.actions.edit" </section>
fields={{ name: currentRole.name }} <section>
/> <Category>{"Role ID"}</Category>
</H1> <RoleId>
<ReorderButton <Tooltip
palette="secondary" content={
onClick={() => setShowReorderPanel(true)}> "This is a unique identifier for this role."
<Text id="app.settings.permissions.role_ranking" /> }>
</ReorderButton> <HelpCircle size={16} />
<Button </Tooltip>
palette="secondary" <Tooltip
disabled={isEqual( content={
currentRole, <Text id="app.special.copy" />
currentRoleValue, }>
)} <a
onClick={save}> onClick={() =>
<Text id="app.special.modals.actions.save" /> modalController.writeText(
</Button> currentRole.id,
</SpaceBetween> )
<hr />
{selected !== "default" && (
<>
<section>
<Category>
<Text id="app.settings.permissions.role_name" />
</Category>
<p>
<InputBox
value={currentRoleValue.name}
onChange={(e) =>
setValue({
...value,
name: e.currentTarget
.value,
})
}
palette="secondary"
/>
</p>
</section>
<section>
<Category>{"Role ID"}</Category>
<RoleId>
<Tooltip
content={
"This is a unique identifier for this role."
}> }>
<HelpCircle size={16} /> {currentRole.id}
</Tooltip> </a>
<Tooltip </Tooltip>
content={ </RoleId>
<Text id="app.special.copy" /> </section>
}> <section>
<a <Category>
onClick={() => <Text id="app.settings.permissions.role_colour" />
modalController.writeText( </Category>
currentRole.id, <p>
) <ColourSwatches
}> value={
{currentRole.id} currentRoleValue.colour ??
</a> "gray"
</Tooltip> }
</RoleId> onChange={(colour) =>
</section> setValue({ ...value, colour })
<section> }
<Category> />
<Text id="app.settings.permissions.role_colour" /> </p>
</Category> </section>
<p> <section>
<ColourSwatches <Category>
value={ <Text id="app.settings.permissions.role_options" />
currentRoleValue.colour ?? </Category>
"gray" <p>
} <Checkbox
onChange={(colour) => value={
setValue({ currentRoleValue.hoist ?? false
...value, }
colour, onChange={(hoist) =>
}) setValue({ ...value, hoist })
} }
/> title={
</p> <Text id="app.settings.permissions.hoist_role" />
</section> }
<section> description={
<Category> <Text id="app.settings.permissions.hoist_desc" />
<Text id="app.settings.permissions.role_options" /> }
</Category> />
<p> </p>
<Checkbox </section>
value={ <section>
currentRoleValue.hoist ?? <Category>
false <Text id="app.settings.permissions.role_ranking" />
} </Category>
onChange={(hoist) => <p>
setValue({ <InputBox
...value, type="number"
hoist, value={currentRoleValue.rank ?? 0}
}) onChange={(e) =>
} setValue({
title={ ...value,
<Text id="app.settings.permissions.hoist_role" /> rank: parseInt(
} e.currentTarget.value,
description={ ),
<Text id="app.settings.permissions.hoist_desc" /> })
} }
/> palette="secondary"
</p> />
</section> </p>
</> </section>
)} </>
<h1> )}
<Text id="app.settings.permissions.edit_title" /> <h1>
</h1> <Text id="app.settings.permissions.edit_title" />
<PermissionList </h1>
value={currentRoleValue.permissions} <PermissionList
onChange={(permissions) => value={currentRoleValue.permissions}
setValue({ onChange={(permissions) =>
...value, setValue({
permissions, ...value,
} as RoleOrDefault) permissions,
} } as RoleOrDefault)
target={server} }
/> target={server}
{selected !== "default" && ( />
<> {selected !== "default" && (
<hr /> <>
<h1> <hr />
<Text id="app.settings.categories.danger_zone" /> <h1>
</h1> <Text id="app.settings.categories.danger_zone" />
<DeleteRoleButton </h1>
palette="error" <DeleteRoleButton
compact palette="error"
onClick={deleteRole}> compact
<Text id="app.settings.permissions.delete_role" /> onClick={deleteRole}>
</DeleteRoleButton> <Text id="app.settings.permissions.delete_role" />
</> </DeleteRoleButton>
)} </>
</div> )}
); </div>
}} );
/> }}
</div> />
); );
}); });