31 Commits

Author SHA1 Message Date
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
19 changed files with 685 additions and 346 deletions

View File

@@ -44,9 +44,11 @@ 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@v3 uses: docker/metadata-action@v5
with: with:
images: revoltchat/client, ghcr.io/revoltchat/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'
@@ -65,6 +67,7 @@ jobs:
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64 platforms: linux/amd64,linux/arm64
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 }}

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 WORKDIR /usr/src/app
COPY . . COPY . .
@@ -10,9 +11,11 @@ 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:16-alpine FROM node:24-alpine
WORKDIR /usr/src/app 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 EXPOSE 5000
CMD [ "yarn", "start:inject" ] CMD [ "yarn", "start:inject" ]

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

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 styled from "styled-components/macro";
import { Text } from "preact-i18n"; 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 { useApplicationState } from "../../mobx/State";
import { SECTION_NSFW } from "../../mobx/stores/Layout"; import { SECTION_NSFW } from "../../mobx/stores/Layout";
@@ -45,14 +45,36 @@ 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);
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}</>; return <>{props.children}</>;
} }
if ( if (
!( !(
props.channel.channel_type === "Group" || props.channel.channel_type === "Group" ||
@@ -76,23 +98,40 @@ export default observer((props: Props) => {
</a> </a>
</span> </span>
<Checkbox {geoBlock.isAgeRestrictedGeo ? (
title={<Text id="app.main.channel.nsfw.confirm" />} <div style={{ maxWidth: "420px", textAlign: "center" }}>
value={layout.getSectionState(SECTION_NSFW, false)} {geoBlock.countryCode === "GB"
onChange={() => layout.toggleSectionState(SECTION_NSFW, false)} ? "This channel is not available in your region while we review options on legal compliance."
/> : "This content is not available in your region."}
<div className="actions"> </div>
<Button palette="secondary" onClick={() => history.goBack()}> ) : (
<Text id="app.special.modals.actions.back" /> <>
</Button> <Checkbox
<Button title={<Text id="app.main.channel.nsfw.confirm" />}
palette="secondary" value={layout.getSectionState(SECTION_NSFW, false)}
onClick={() => onChange={() =>
layout.getSectionState(SECTION_NSFW) && setAgeGate(true) layout.toggleSectionState(SECTION_NSFW, false)
}> }
<Text id={`app.main.channel.nsfw.${props.type}.confirm`} /> />
</Button> <div className="actions">
</div> <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> </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, Role } from "revolt.js"; import { Channel, User } 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,16 +29,11 @@ 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 = {
@@ -64,7 +59,7 @@ export function useAutoComplete(
function findSearchString( function findSearchString(
el: HTMLTextAreaElement, el: HTMLTextAreaElement,
): ["emoji" | "user" | "channel" | "role", string, number] | undefined { ): ["emoji" | "user" | "channel", 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);
@@ -76,8 +71,6 @@ 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])) {
@@ -87,12 +80,7 @@ export function useAutoComplete(
if (j === -1) return; if (j === -1) return;
const current = content[j]; const current = content[j];
if ( if (current === ":" || current === "@" || current === "#") {
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;
@@ -102,8 +90,6 @@ 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,
@@ -244,42 +230,6 @@ 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") {
@@ -312,14 +262,6 @@ 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,
@@ -550,7 +492,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._id} key={match}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
@@ -575,7 +517,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._id} key={match}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
@@ -597,40 +539,6 @@ 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,7 +567,6 @@ 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}/${filename}`; const open_url = url;
const download_url = url; const download_url = `${url}/${filename}`;
const filesize = determineFileSize(size); const filesize = determineFileSize(size);

View File

@@ -126,8 +126,16 @@ 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.toLocaleString()}{" "} {invite.member_count != null ? (
{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}` }/emojis/${match}/original`
: parseEmoji( : parseEmoji(
match in emojiDictionary match in emojiDictionary
? emojiDictionary[match as keyof typeof emojiDictionary] ? emojiDictionary[match as keyof typeof emojiDictionary]

View File

@@ -50,8 +50,10 @@ 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 } = const { server: server_id, channel: channel_id } = useParams<{
useParams<{ server: string; channel?: string }>(); server: 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="/" />;
@@ -119,14 +121,18 @@ export default observer(() => {
channels.push(addChannel(id)); channels.push(addChannel(id));
} }
elements.push( if (category.title === "Default") {
<CollapsibleSection elements.push(...channels);
id={`category_${category.id}`} } else {
defaultValue elements.push(
summary={<Category>{category.title}</Category>}> <CollapsibleSection
{channels} id={`category_${category.id}`}
</CollapsibleSection>, defaultValue
); summary={<Category>{category.title}</Category>}>
{channels}
</CollapsibleSection>,
);
}
} }
} }

View File

@@ -76,17 +76,25 @@ 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

@@ -66,11 +66,16 @@ export default class Settings
} }
@action hydrate(data: ISettings) { @action hydrate(data: ISettings) {
Object.keys(data).forEach( Object.keys(data).forEach((key) => {
(key) => const val = (data as any)[key];
typeof (data as any)[key] !== "undefined" && if (typeof val !== "undefined") {
this.data.set(key, (data as any)[key]), 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 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;
@@ -42,7 +58,7 @@ export default function InviteBot() {
const [group, setGroup] = useState("none"); const [group, setGroup] = useState("none");
return ( return (
<div style={{ padding: "6em" }}> <Page>
<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 && (
@@ -106,6 +122,7 @@ export default function InviteBot() {
</Option> </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 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";
@@ -16,17 +20,208 @@ 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
@@ -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) => { 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);
@@ -74,213 +271,219 @@ 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 (
<PermissionsLayout <div>
server={server} <PermissionsLayout
rank={server.member?.ranking ?? Infinity} server={server}
onCreateRole={(callback) => rank={server.member?.ranking ?? Infinity}
modalController.push({ onCreateRole={(callback) =>
type: "create_role", modalController.push({
server, type: "create_role",
callback, server,
}) callback,
} })
editor={({ selected }) => { }
const currentRole = currentRoles.find( editor={({ selected }) => {
(x) => x.id === 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. function save() {
const current = server.permission; const { permissions: permsCurrent, ...current } =
currentRole;
const { permissions: permsValue, ...value } =
currentRoleValue;
// Upload new role information to server. if (!isEqual(permsCurrent, permsValue)) {
function save() { server.setPermissions(
const { permissions: permsCurrent, ...current } = selected,
currentRole; typeof permsValue === "number"
const { permissions: permsValue, ...value } = ? permsValue
currentRoleValue; : {
allow: permsValue.a,
deny: permsValue.d,
},
);
}
if (!isEqual(permsCurrent, permsValue)) { if (!isEqual(current, value)) {
server.setPermissions( server.editRole(selected, value);
selected, }
typeof permsValue === "number"
? permsValue
: {
allow: permsValue.a,
deny: permsValue.d,
},
);
} }
if (!isEqual(current, value)) { function deleteRole() {
server.editRole(selected, value); server.deleteRole(selected);
} }
}
// Delete the role from this server. return (
function deleteRole() { <div>
server.deleteRole(selected); <SpaceBetween>
} <H1>
<Text
return ( id="app.settings.actions.edit"
<div> fields={{ name: currentRole.name }}
<SpaceBetween> />
<H1> </H1>
<Text <ReorderButton
id="app.settings.actions.edit" palette="secondary"
fields={{ name: currentRole.name }} onClick={() => setShowReorderPanel(true)}>
/> <Text id="app.settings.permissions.role_ranking" />
</H1> </ReorderButton>
<Button <Button
palette="secondary" palette="secondary"
disabled={isEqual( disabled={isEqual(
currentRole, currentRole,
currentRoleValue, currentRoleValue,
)} )}
onClick={save}> onClick={save}>
<Text id="app.special.modals.actions.save" /> <Text id="app.special.modals.actions.save" />
</Button> </Button>
</SpaceBetween> </SpaceBetween>
<hr /> <hr />
{selected !== "default" && ( {selected !== "default" && (
<> <>
<section> <section>
<Category> <Category>
<Text id="app.settings.permissions.role_name" /> <Text id="app.settings.permissions.role_name" />
</Category> </Category>
<p> <p>
<InputBox <InputBox
value={currentRoleValue.name} value={currentRoleValue.name}
onChange={(e) => onChange={(e) =>
setValue({ setValue({
...value, ...value,
name: e.currentTarget.value, name: e.currentTarget
}) .value,
} })
palette="secondary" }
/> palette="secondary"
</p> />
</section> </p>
<section> </section>
<Category>{"Role ID"}</Category> <section>
<RoleId> <Category>{"Role ID"}</Category>
<Tooltip <RoleId>
content={ <Tooltip
"This is a unique identifier for this role." 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,
)
}> }>
{currentRole.id} <HelpCircle size={16} />
</a> </Tooltip>
</Tooltip> <Tooltip
</RoleId> content={
</section> <Text id="app.special.copy" />
<section> }>
<Category> <a
<Text id="app.settings.permissions.role_colour" /> onClick={() =>
</Category> modalController.writeText(
<p> currentRole.id,
<ColourSwatches )
value={ }>
currentRoleValue.colour ?? {currentRole.id}
"gray" </a>
} </Tooltip>
onChange={(colour) => </RoleId>
setValue({ ...value, colour }) </section>
} <section>
/> <Category>
</p> <Text id="app.settings.permissions.role_colour" />
</section> </Category>
<section> <p>
<Category> <ColourSwatches
<Text id="app.settings.permissions.role_options" /> value={
</Category> currentRoleValue.colour ??
<p> "gray"
<Checkbox }
value={ onChange={(colour) =>
currentRoleValue.hoist ?? false setValue({
} ...value,
onChange={(hoist) => colour,
setValue({ ...value, hoist }) })
} }
title={ />
<Text id="app.settings.permissions.hoist_role" /> </p>
} </section>
description={ <section>
<Text id="app.settings.permissions.hoist_desc" /> <Category>
} <Text id="app.settings.permissions.role_options" />
/> </Category>
</p> <p>
</section> <Checkbox
<section> value={
<Category> currentRoleValue.hoist ??
<Text id="app.settings.permissions.role_ranking" /> false
</Category> }
<p> onChange={(hoist) =>
<InputBox setValue({
type="number" ...value,
value={currentRoleValue.rank ?? 0} hoist,
onChange={(e) => })
setValue({ }
...value, title={
rank: parseInt( <Text id="app.settings.permissions.hoist_role" />
e.currentTarget.value, }
), description={
}) <Text id="app.settings.permissions.hoist_desc" />
} }
palette="secondary" />
/> </p>
</p> </section>
</section> </>
</> )}
)} <h1>
<h1> <Text id="app.settings.permissions.edit_title" />
<Text id="app.settings.permissions.edit_title" /> </h1>
</h1> <PermissionList
<PermissionList value={currentRoleValue.permissions}
value={currentRoleValue.permissions} onChange={(permissions) =>
onChange={(permissions) => setValue({
setValue({ ...value,
...value, permissions,
permissions, } as RoleOrDefault)
} as RoleOrDefault) }
} target={server}
target={server} />
/> {selected !== "default" && (
{selected !== "default" && ( <>
<> <hr />
<hr /> <h1>
<h1> <Text id="app.settings.categories.danger_zone" />
<Text id="app.settings.categories.danger_zone" /> </h1>
</h1> <DeleteRoleButton
<DeleteRoleButton palette="error"
palette="error" compact
compact onClick={deleteRole}>
onClick={deleteRole}> <Text id="app.settings.permissions.delete_role" />
<Text id="app.settings.permissions.delete_role" /> </DeleteRoleButton>
</DeleteRoleButton> </>
</> )}
)} </div>
</div> );
); }}
}} />
/> </div>
); );
}); });