39 Commits

Author SHA1 Message Date
jmug
ee91016ee3 unfiltered action. 2026-03-04 14:55:44 -08:00
jmug
952a92ba1b [chore] Unfiltered push trigger. 2026-03-04 14:45:07 -08:00
jmug
b6d763df35 [chore] Readme updates. 2026-03-04 14:20:48 -08:00
jmug
bb3f114e01 [chore] Try different push filter. 2026-03-04 14:18:22 -08:00
jmug
39284db8d1 [chore] Remove old templates. 2026-03-04 14:12:40 -08:00
52980560dd [feat] Message reaction visualization. (#2)
Allow message user reaction list by hovering on desktop and long-tapping on mobile.

Co-authored-by: jmug <u.g.a.mariano@gmail.com>
Reviewed-on: HMC/handmade-revolt#2
2026-03-04 13:40:45 +00:00
jmug
1b2a980a47 [chore] Workflow cleanup. 2026-02-23 01:55:29 -08:00
Mariano Uvalle
1ce522579e Mobile reactions and WS reconnection
* [feat] Add reaction menu item for mobile clients.

* [fix] Reconnection to the websocket with exponential backoff.

* [chore] Dev flake.

* [hack] Push to my personal gcr.
2026-02-22 13:47:39 -08:00
Paul Makles
41f47a1a3f merge: pull request #1143 from Asraye/fix/member-sidebar 2025-09-07 13:09:46 +01:00
Paul Makles
c6130a19bf merge: pull request #1109 from Docteh/smaller3 2025-09-07 13:06:39 +01:00
Paul Makles
3c7cb2a222 merge: pull request #1141 from Asraye/fixemoji-pack 2025-09-07 13:05:41 +01:00
Paul Makles
2625b37f70 merge: pull request #1142 from Asraye/ignore-default-category 2025-09-07 13:04:47 +01:00
Paul Makles
ca3613725a merge: pull request #1137 from Asraye/master 2025-09-07 13:04:05 +01:00
Paul Makles
707d0a2d7d merge: pull request #1146 from Asraye/fix/attachment-actions 2025-09-07 13:01:20 +01:00
Asraye
33e686ab1b fix: correct open/download link behaviour 2025-09-04 04:23:09 +10:00
Asraye
f22e6fa9fe fix: correct open/download link behaviour 2025-09-04 04:22:20 +10:00
Asraye
2e2886f01b fix: filter out members without ViewChannel
Small fix to add a ViewChannel check when rendering members in the sidebar
2025-09-02 16:14:10 +10:00
Asraye
cbdeb1ba6d FIX: Ignore Default category (parity with beta)
Just a little change so people still using Revite don't need to see an ugly "Default" category.
2025-08-31 03:47:43 +10:00
Asraye
608b0a3ef2 FIX: Emoji pack now persists on reload 2025-08-29 21:55:56 +10:00
Asraye
59226959ba Add scroll on mobile 2025-08-21 16:40:51 +10:00
izzy
9d82873d2a chore: make sure role ordering updates are instantenous 2025-08-07 16:57:20 +02:00
izzy
56af4b9423 chore: add default.nix 2025-08-07 12:04:45 +02:00
izzy
cce94d2e0e chore: bump lang submodule 2025-08-07 12:03:54 +02:00
izzy
8ba7a769b3 merge: remote-tracking branch 'origin/role-reordering' 2025-08-07 12:03:35 +02:00
Paul Makles
92e84309ec merge: pull request #1123 from Asraye/patch 2025-08-04 18:03:53 +01:00
Paul Makles
dd5548c5e6 merge: pull request #1126 from solunareclipse1/master 2025-08-04 17:44:49 +01:00
Declan Chidlow
e7d7420d5b feat: Finish crappy role ranking implementation that works but is full of sin 2025-08-04 22:28:24 +08:00
izzy
ee3b54b373 docs: shorten age gate message 2025-07-25 20:34:45 +01:00
izzy
3dc9f6b045 feat: geoblock age restricted content
https://www.ofcom.org.uk/online-safety/protecting-children/online-age-checks-must-be-in-force-from-tomorrow
https://petition.parliament.uk/petitions/722903
https://wikimediafoundation.org/news/2025/07/17/wikimedia-foundation-challenges-uk-online-safety-act-regulations/
2025-07-25 19:49:00 +01:00
izzy
2a1f7cb8bb chore: bump lang submodule 2025-07-25 19:48:16 +01:00
solunareclipse1
1136e8e31b fix: Make animated WebP emotes properly animate
Signed-off-by: solunareclipse1 <sol@solunareclipse1.net>
2025-07-10 23:41:57 -05:00
Asraye
5337031359 Update EmbedInvite.tsx 2025-06-12 16:26:09 +10:00
Asraye
670c4e5f6e Update EmbedInvite.tsx 2025-06-12 15:46:07 +10:00
Declan Chidlow
20c8dde197 feat: Add role re-ordering
Doesn't currently update order in UI
2025-06-12 12:33:39 +08:00
Declan Chidlow
ddaca7b0c4 feat: remove legacy role ranking 2025-06-12 09:59:57 +08:00
Asraye
94fab0852f Ugly ahh patch 2025-06-11 18:08:33 +10:00
Kyle Kienapfel
2c41c5789c feat: drastically shrink the docker image
Right now the generated docker container contains a copy of the development environment used to compile the revite interface.
This PR loses about 440MB compressed, or around 800MB uncompressed from the final image size. node in the final image has been bumped from 16 to 24, sirv-cli, and the dependencies for inject.js have been updated
2025-05-18 01:59:51 -07:00
Kyle Kienapfel
184754ca0f chore: have GHA generate ARM64 image, add description to images
The box where githubs container website complains about a lack
a description is really big. Okay its not important.
2025-05-18 01:29:54 -07:00
Kyle Kienapfel
6bacbfbb56 fix: move disabled-js.svg to proper location 2025-05-18 01:29:17 -07:00
37 changed files with 1104 additions and 633 deletions

1
.envrc Normal file
View File

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

View File

@@ -1,66 +0,0 @@
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

View File

@@ -1,7 +0,0 @@
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.

View File

@@ -1,24 +0,0 @@
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

View File

@@ -1,7 +0,0 @@
## 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,38 +1,16 @@
name: Docker
on:
push:
branches:
- "master"
tags:
- "*"
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:
push:
pull_request:
branches:
- "handmade"
jobs:
publish:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
# NOTE: Running on pull requests for now, but without pushing.
# if: github.event_name != 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -44,27 +22,23 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: revoltchat/client, ghcr.io/revoltchat/client
images: handmadecities/handmade-revolt-web-client
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
- name: Login to DockerHub
uses: docker/login-action@v1
if: github.event_name != 'pull_request'
# if: github.event_name != 'pull_request'
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
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
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64
push: true # ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,54 +0,0 @@
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

View File

@@ -1,79 +0,0 @@
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,3 +15,5 @@ public/assets_*
!public/assets_default
.vscode/chrome_data
.direnv

View File

@@ -1,4 +1,5 @@
FROM node:16-buster AS builder
# syntax=docker.io/docker/dockerfile:1.7-labs
FROM --platform=$BUILDPLATFORM node:16-buster AS builder
WORKDIR /usr/src/app
COPY . .
@@ -10,9 +11,11 @@ RUN yarn build:deps
RUN yarn build:highmem
RUN yarn workspaces focus --production --all
FROM node:16-alpine
FROM node:24-alpine
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app .
COPY docker/package.json docker/yarn.lock .
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
CMD [ "yarn", "start:inject" ]

View File

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

8
default.nix Normal file
View File

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

16
docker/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"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"
}
}

116
docker/yarn.lock Normal file
View File

@@ -0,0 +1,116 @@
# 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 Normal file
View File

@@ -0,0 +1,64 @@
{
"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
}

28
flake.nix Normal file
View File

@@ -0,0 +1,28 @@
{
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

Before

Width:  |  Height:  |  Size: 828 B

After

Width:  |  Height:  |  Size: 828 B

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,11 @@ import { IconButton } from "@revoltchat/ui";
import { emojiDictionary } from "../../../../assets/emojis";
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 { HackAlertThisFileWillBeReplaced } from "../MessageBox";
@@ -85,6 +90,38 @@ 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
*/
@@ -98,16 +135,97 @@ export const Reactions = observer(({ message }: Props) => {
const Entry = useCallback(
observer(({ id, user_ids }: { id: string; user_ids?: Set<string> }) => {
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);
return (
const handleClick = () => {
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
active={active}
onClick={() =>
active ? message.unreact(id) : message.react(id)
}>
onClick={handleClick}
onTouchStart={isTouchscreenDevice ? handleTouchStart : undefined}
onTouchEnd={isTouchscreenDevice ? handleTouchEnd : undefined}
onTouchCancel={isTouchscreenDevice ? handleTouchEnd : undefined}>
<RenderEmoji match={id} /> {user_ids?.size || 0}
</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,8 +126,16 @@ export function EmbedInvite({ code }: Props) {
<EmbedInviteName>{invite.server_name}</EmbedInviteName>
<EmbedInviteMemberCount>
<Group size={12} />
{invite.member_count.toLocaleString()}{" "}
{invite.member_count === 1 ? "member" : "members"}
{invite.member_count != null ? (
<>
{invite.member_count.toLocaleString()}{" "}
{invite.member_count === 1
? "member"
: "members"}
</>
) : (
"N/A"
)}
</EmbedInviteMemberCount>
</EmbedInviteDetails>
{processing ? (

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ type Transition =
| "SUCCESS"
| "DISCONNECT"
| "RETRY"
| "RETRY_FAILED"
| "LOGOUT"
| "ONLINE"
| "OFFLINE";
@@ -40,6 +41,7 @@ export default class Session {
state: State = window.navigator.onLine ? "Ready" : "Offline";
user_id: string | null = null;
client: Client | null = null;
retryAttempts: number = 0;
/**
* Create a new Session
@@ -89,9 +91,11 @@ export default class Session {
* Called when the client signals it has disconnected
*/
private onDropped() {
this.emit({
action: "DISCONNECT",
});
if (this.state === "Connecting") {
this.emit({ action: "RETRY_FAILED" });
} else {
this.emit({ action: "DISCONNECT" });
}
}
/**
@@ -211,6 +215,7 @@ export default class Session {
// Ready successfully received
case "SUCCESS": {
this.assert("Connecting");
this.retryAttempts = 0;
this.state = "Online";
break;
}
@@ -239,6 +244,18 @@ export default class Session {
this.state = "Connecting";
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
case "LOGOUT": {
this.assert("Connecting", "Online", "Disconnected");

View File

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

View File

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,92 @@
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,6 +153,15 @@ export type Modal = {
type: "delete_message";
target: Message;
}
| {
type: "react_message",
target: Message;
}
| {
type: "reaction_users";
emoji: string;
userIds: string[];
}
| {
type: "kick_member";
member: Member;

View File

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

View File

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

View File

@@ -10,6 +10,22 @@ import UserIcon from "../../components/common/user/UserIcon";
import Markdown from "../../components/markdown/Markdown";
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`
gap: 12px;
display: flex;
@@ -42,7 +58,7 @@ export default function InviteBot() {
const [group, setGroup] = useState("none");
return (
<div style={{ padding: "6em" }}>
<Page>
<Tip palette="warning">This section is under construction.</Tip>
{typeof data === "undefined" && <Preloader type="spinner" />}
{data && (
@@ -106,6 +122,7 @@ export default function InviteBot() {
</Option>
</>
)}
</div>
</Page>
);
}

View File

@@ -1,4 +1,8 @@
import { HelpCircle } from "@styled-icons/boxicons-solid";
import {
HelpCircle,
ChevronUp,
ChevronDown,
} from "@styled-icons/boxicons-solid";
import isEqual from "lodash.isequal";
import { observer } from "mobx-react-lite";
import { Server } from "revolt.js";
@@ -16,17 +20,208 @@ import {
ColourSwatches,
InputBox,
Category,
Row,
} from "@revoltchat/ui";
import Tooltip from "../../../components/common/Tooltip";
import { PermissionList } from "../../../components/settings/roles/PermissionList";
import { RoleOrDefault } from "../../../components/settings/roles/RoleSelection";
import { useSession } from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController";
interface Props {
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.
* @param server Target server
@@ -50,9 +245,11 @@ export function useRoles(server: Server) {
}
/**
* Roles settings menu
* Updated Roles settings menu with reordering panel
*/
export const Roles = observer(({ server }: Props) => {
const [showReorderPanel, setShowReorderPanel] = useState(false);
// Consolidate all permissions that we can change right now.
const currentRoles = useRoles(server);
@@ -74,213 +271,219 @@ export const Roles = observer(({ server }: Props) => {
margin: 16px 0;
`;
const ReorderButton = styled(Button)`
margin-inline: auto 8px;
`;
if (showReorderPanel) {
return (
<div>
<RoleReorderPanel
server={server}
onExit={() => setShowReorderPanel(false)}
/>
</div>
);
}
return (
<PermissionsLayout
server={server}
rank={server.member?.ranking ?? Infinity}
onCreateRole={(callback) =>
modalController.push({
type: "create_role",
server,
callback,
})
}
editor={({ selected }) => {
const currentRole = currentRoles.find(
(x) => x.id === selected,
)!;
<div>
<PermissionsLayout
server={server}
rank={server.member?.ranking ?? Infinity}
onCreateRole={(callback) =>
modalController.push({
type: "create_role",
server,
callback,
})
}
editor={({ selected }) => {
const currentRole = currentRoles.find(
(x) => x.id === selected,
)!;
if (!currentRole) return null;
if (!currentRole) return null;
// Keep track of whatever role we're editing right now.
const [value, setValue] = useState<Partial<RoleOrDefault>>({});
const [value, setValue] = useState<Partial<RoleOrDefault>>(
{},
);
const currentRoleValue = { ...currentRole, ...value };
const currentRoleValue = { ...currentRole, ...value };
// Calculate permissions we have access to on this server.
const current = server.permission;
function save() {
const { permissions: permsCurrent, ...current } =
currentRole;
const { permissions: permsValue, ...value } =
currentRoleValue;
// 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(permsCurrent, permsValue)) {
server.setPermissions(
selected,
typeof permsValue === "number"
? permsValue
: {
allow: permsValue.a,
deny: permsValue.d,
},
);
if (!isEqual(current, value)) {
server.editRole(selected, value);
}
}
if (!isEqual(current, value)) {
server.editRole(selected, value);
function deleteRole() {
server.deleteRole(selected);
}
}
// Delete the role from this server.
function deleteRole() {
server.deleteRole(selected);
}
return (
<div>
<SpaceBetween>
<H1>
<Text
id="app.settings.actions.edit"
fields={{ name: currentRole.name }}
/>
</H1>
<Button
palette="secondary"
disabled={isEqual(
currentRole,
currentRoleValue,
)}
onClick={save}>
<Text id="app.special.modals.actions.save" />
</Button>
</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} />
</Tooltip>
<Tooltip
content={
<Text id="app.special.copy" />
}>
<a
onClick={() =>
modalController.writeText(
currentRole.id,
)
return (
<div>
<SpaceBetween>
<H1>
<Text
id="app.settings.actions.edit"
fields={{ name: currentRole.name }}
/>
</H1>
<ReorderButton
palette="secondary"
onClick={() => setShowReorderPanel(true)}>
<Text id="app.settings.permissions.role_ranking" />
</ReorderButton>
<Button
palette="secondary"
disabled={isEqual(
currentRole,
currentRoleValue,
)}
onClick={save}>
<Text id="app.special.modals.actions.save" />
</Button>
</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."
}>
{currentRole.id}
</a>
</Tooltip>
</RoleId>
</section>
<section>
<Category>
<Text id="app.settings.permissions.role_colour" />
</Category>
<p>
<ColourSwatches
value={
currentRoleValue.colour ??
"gray"
}
onChange={(colour) =>
setValue({ ...value, colour })
}
/>
</p>
</section>
<section>
<Category>
<Text id="app.settings.permissions.role_options" />
</Category>
<p>
<Checkbox
value={
currentRoleValue.hoist ?? false
}
onChange={(hoist) =>
setValue({ ...value, hoist })
}
title={
<Text id="app.settings.permissions.hoist_role" />
}
description={
<Text id="app.settings.permissions.hoist_desc" />
}
/>
</p>
</section>
<section>
<Category>
<Text id="app.settings.permissions.role_ranking" />
</Category>
<p>
<InputBox
type="number"
value={currentRoleValue.rank ?? 0}
onChange={(e) =>
setValue({
...value,
rank: parseInt(
e.currentTarget.value,
),
})
}
palette="secondary"
/>
</p>
</section>
</>
)}
<h1>
<Text id="app.settings.permissions.edit_title" />
</h1>
<PermissionList
value={currentRoleValue.permissions}
onChange={(permissions) =>
setValue({
...value,
permissions,
} as RoleOrDefault)
}
target={server}
/>
{selected !== "default" && (
<>
<hr />
<h1>
<Text id="app.settings.categories.danger_zone" />
</h1>
<DeleteRoleButton
palette="error"
compact
onClick={deleteRole}>
<Text id="app.settings.permissions.delete_role" />
</DeleteRoleButton>
</>
)}
</div>
);
}}
/>
<HelpCircle size={16} />
</Tooltip>
<Tooltip
content={
<Text id="app.special.copy" />
}>
<a
onClick={() =>
modalController.writeText(
currentRole.id,
)
}>
{currentRole.id}
</a>
</Tooltip>
</RoleId>
</section>
<section>
<Category>
<Text id="app.settings.permissions.role_colour" />
</Category>
<p>
<ColourSwatches
value={
currentRoleValue.colour ??
"gray"
}
onChange={(colour) =>
setValue({
...value,
colour,
})
}
/>
</p>
</section>
<section>
<Category>
<Text id="app.settings.permissions.role_options" />
</Category>
<p>
<Checkbox
value={
currentRoleValue.hoist ??
false
}
onChange={(hoist) =>
setValue({
...value,
hoist,
})
}
title={
<Text id="app.settings.permissions.hoist_role" />
}
description={
<Text id="app.settings.permissions.hoist_desc" />
}
/>
</p>
</section>
</>
)}
<h1>
<Text id="app.settings.permissions.edit_title" />
</h1>
<PermissionList
value={currentRoleValue.permissions}
onChange={(permissions) =>
setValue({
...value,
permissions,
} as RoleOrDefault)
}
target={server}
/>
{selected !== "default" && (
<>
<hr />
<h1>
<Text id="app.settings.categories.danger_zone" />
</h1>
<DeleteRoleButton
palette="error"
compact
onClick={deleteRole}>
<Text id="app.settings.permissions.delete_role" />
</DeleteRoleButton>
</>
)}
</div>
);
}}
/>
</div>
);
});