;
15 |
16 | /**
17 | * Construct new ServerConfig store.
18 | */
19 | constructor() {
20 | this.config = null;
21 | makeAutoObservable(this);
22 | this.set = this.set.bind(this);
23 | }
24 |
25 | get id() {
26 | return "server_conf";
27 | }
28 |
29 | toJSON() {
30 | return JSON.parse(JSON.stringify(this.config));
31 | }
32 |
33 | @action hydrate(data: API.RevoltConfig) {
34 | this.config = data ?? null;
35 | }
36 |
37 | /**
38 | * Create a new Revolt client.
39 | * @returns Revolt client
40 | */
41 | createClient() {
42 | const client = new Client({
43 | unreads: true,
44 | autoReconnect: true,
45 | apiURL: import.meta.env.VITE_API_URL,
46 | debug: isDebug(),
47 | onPongTimeout: "RECONNECT",
48 | });
49 |
50 | if (this.config !== null) {
51 | client.configuration = this.config;
52 | }
53 |
54 | return client;
55 | }
56 |
57 | /**
58 | * Get server configuration.
59 | * @returns Server configuration
60 | */
61 | @computed get() {
62 | return this.config;
63 | }
64 |
65 | /**
66 | * Set server configuration.
67 | * @param config Server configuration
68 | */
69 | @action set(config: API.RevoltConfig) {
70 | this.config = config;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/controllers/modals/components/CreateServer.tsx:
--------------------------------------------------------------------------------
1 | import { useHistory } from "react-router-dom";
2 |
3 | import { Text } from "preact-i18n";
4 |
5 | import { ModalForm } from "@revoltchat/ui";
6 |
7 | import { useClient } from "../../client/ClientController";
8 | import { mapError } from "../../client/jsx/error";
9 | import { ModalProps } from "../types";
10 |
11 | /**
12 | * Server creation modal
13 | */
14 | export default function CreateServer({
15 | ...props
16 | }: ModalProps<"create_server">) {
17 | const history = useHistory();
18 | const client = useClient();
19 |
20 | return (
21 | }
24 | description={
25 |
34 | }
35 | schema={{
36 | name: "text",
37 | }}
38 | data={{
39 | name: {
40 | field: (
41 |
42 | ) as React.ReactChild,
43 | },
44 | }}
45 | callback={async ({ name }) => {
46 | const server = await client.servers
47 | .createServer({
48 | name,
49 | })
50 | .catch(mapError);
51 |
52 | history.push(`/server/${server._id}`);
53 | }}
54 | submit={{
55 | children: ,
56 | }}
57 | />
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/pages/settings/panes/Sync.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from "mobx-react-lite";
2 |
3 | import styles from "./Panes.module.scss";
4 | import { Text } from "preact-i18n";
5 |
6 | import { Checkbox, Column } from "@revoltchat/ui";
7 |
8 | import { useApplicationState } from "../../../mobx/State";
9 | import { SyncKeys } from "../../../mobx/stores/Sync";
10 |
11 | export const Sync = observer(() => {
12 | const sync = useApplicationState().sync;
13 |
14 | return (
15 |
16 | {/*
17 |
18 |
19 | Sync items automatically
*/}
20 |
21 |
22 |
23 |
24 | {(
25 | [
26 | ["appearance", "appearance.title"],
27 | ["theme", "appearance.theme"],
28 | ["locale", "language.title"],
29 | // notifications sync is always-on
30 | ] as [SyncKeys, string][]
31 | ).map(([key, title]) => (
32 | }
36 | description={
37 |
40 | }
41 | onChange={() => sync.toggle(key)}
42 | />
43 | ))}
44 |
45 | {/*
46 | Last sync at 12:00
47 |
*/}
48 |
49 | );
50 | });
51 |
--------------------------------------------------------------------------------
/src/components/navigation/right/ChannelDebugInfo.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/rules-of-hooks */
2 | import { observer } from "mobx-react-lite";
3 | import { Channel } from "revolt.js";
4 |
5 | import { getRenderer } from "../../../lib/renderer/Singleton";
6 |
7 | interface Props {
8 | channel: Channel;
9 | }
10 |
11 | export const ChannelDebugInfo = observer(({ channel }: Props) => {
12 | if (process.env.NODE_ENV !== "development") return null;
13 | const renderer = getRenderer(channel);
14 |
15 | return (
16 |
17 |
24 | Channel Info
25 |
26 |
27 | State: {renderer.state}
28 | Stale: {renderer.stale ? "Yes" : "No"}
29 | Fetching: {renderer.fetching ? "Yes" : "No"}
30 |
31 | {renderer.state === "RENDER" && renderer.messages.length > 0 && (
32 | <>
33 | Start: {renderer.messages[0]._id}
34 | End:{" "}
35 |
36 | {
37 | renderer.messages[renderer.messages.length - 1]
38 | ._id
39 | }
40 | {" "}
41 |
42 | At Top: {renderer.atTop ? "Yes" : "No"}
43 | At Bottom: {renderer.atBottom ? "Yes" : "No"}
44 | >
45 | )}
46 |
47 |
48 | );
49 | });
50 |
--------------------------------------------------------------------------------
/src/controllers/modals/components/ServerInfo.tsx:
--------------------------------------------------------------------------------
1 | import { X } from "@styled-icons/boxicons-regular";
2 |
3 | import { Text } from "preact-i18n";
4 |
5 | import { Column, H1, IconButton, Modal, Row } from "@revoltchat/ui";
6 |
7 | import Markdown from "../../../components/markdown/Markdown";
8 | import { report } from "../../safety";
9 | import { modalController } from "../ModalController";
10 | import { ModalProps } from "../types";
11 |
12 | export default function ServerInfo({
13 | server,
14 | ...props
15 | }: ModalProps<"server_info">) {
16 | return (
17 |
21 |
22 | {server.name}
23 |
24 |
25 |
26 |
27 |
28 | }
29 | actions={[
30 | {
31 | onClick: () => {
32 | modalController.push({
33 | type: "server_identity",
34 | member: server.member!,
35 | });
36 | return true;
37 | },
38 | children: "Edit Identity",
39 | palette: "primary",
40 | },
41 | {
42 | onClick: () => {
43 | modalController.push({
44 | type: "report",
45 | target: server,
46 | });
47 | return true;
48 | },
49 | children: ,
50 | palette: "error",
51 | },
52 | ]}>
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/lib/i18n.tsx:
--------------------------------------------------------------------------------
1 | import { IntlContext, translate } from "preact-i18n";
2 | import { useContext } from "preact/hooks";
3 |
4 | import { Dictionary } from "../context/Locale";
5 |
6 | interface Fields {
7 | [key: string]: Children;
8 | }
9 |
10 | interface Props {
11 | id: string;
12 | fields: Fields;
13 | }
14 |
15 | export interface IntlType {
16 | intl: {
17 | dictionary: Dictionary;
18 | };
19 | }
20 |
21 | // This will exhibit O(2^n) behaviour.
22 | function recursiveReplaceFields(input: string, fields: Fields) {
23 | const key = Object.keys(fields)[0];
24 | if (key) {
25 | const { [key]: field, ...restOfFields } = fields;
26 | if (typeof field === "undefined") return [input];
27 |
28 | const values: (Children | string[])[] = input
29 | .split(`{{${key}}}`)
30 | .map((v) => recursiveReplaceFields(v, restOfFields));
31 |
32 | for (let i = values.length - 1; i > 0; i -= 2) {
33 | values.splice(i, 0, field);
34 | }
35 |
36 | return values.flat();
37 | }
38 | // base case
39 | return [input];
40 | }
41 |
42 | export function TextReact({ id, fields }: Props) {
43 | const { intl } = useContext(IntlContext) as unknown as IntlType;
44 |
45 | const path = id.split(".");
46 | let entry = intl.dictionary[path.shift()!];
47 | for (const key of path) {
48 | // @ts-expect-error TODO: lazy
49 | entry = entry[key];
50 | }
51 |
52 | return <>{recursiveReplaceFields(entry as string, fields)}>;
53 | }
54 |
55 | export function useTranslation() {
56 | const { intl } = useContext(IntlContext) as unknown as IntlType;
57 | return (
58 | id: string,
59 | fields?: Record,
60 | plural?: number,
61 | fallback?: string,
62 | ) => translate(id, "", intl.dictionary, fields, plural, fallback);
63 | }
64 |
65 | export function useDictionary() {
66 | const { intl } = useContext(IntlContext) as unknown as IntlType;
67 | return intl.dictionary;
68 | }
69 |
--------------------------------------------------------------------------------
/.github/workflows/triage_issue.yml:
--------------------------------------------------------------------------------
1 | name: Add Issue to Board
2 |
3 | on:
4 | issues:
5 | types: [opened]
6 |
7 | jobs:
8 | track_issue:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Get project data
12 | env:
13 | GITHUB_TOKEN: ${{ secrets.PAT }}
14 | run: |
15 | gh api graphql -f query='
16 | query {
17 | organization(login: "revoltchat"){
18 | projectV2(number: 3) {
19 | id
20 | fields(first:20) {
21 | nodes {
22 | ... on ProjectV2SingleSelectField {
23 | id
24 | name
25 | options {
26 | id
27 | name
28 | }
29 | }
30 | }
31 | }
32 | }
33 | }
34 | }' > project_data.json
35 |
36 | echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
37 | echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
38 | echo 'TODO_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
39 |
40 | - name: Add issue to project
41 | env:
42 | GITHUB_TOKEN: ${{ secrets.PAT }}
43 | ISSUE_ID: ${{ github.event.issue.node_id }}
44 | run: |
45 | item_id="$( gh api graphql -f query='
46 | mutation($project:ID!, $issue:ID!) {
47 | addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) {
48 | item {
49 | id
50 | }
51 | }
52 | }' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
53 |
54 | echo 'ITEM_ID='$item_id >> $GITHUB_ENV
55 |
--------------------------------------------------------------------------------
/src/components/markdown/plugins/emoji.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | import { useState } from "preact/hooks";
4 |
5 | import { emojiDictionary } from "../../../assets/emojis";
6 | import { clientController } from "../../../controllers/client/ClientController";
7 | import { parseEmoji } from "../../common/Emoji";
8 | import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
9 |
10 | const Emoji = styled.img`
11 | object-fit: contain;
12 |
13 | height: var(--emoji-size);
14 | width: var(--emoji-size);
15 | margin: 0 0.05em 0 0.1em;
16 | vertical-align: -0.2em;
17 |
18 | img:before {
19 | content: " ";
20 | display: block;
21 | position: absolute;
22 | height: 50px;
23 | width: 50px;
24 | background-image: url(ishere.jpg);
25 | }
26 | `;
27 |
28 | const RE_EMOJI = /:([a-zA-Z0-9\-_]+):/g;
29 | const RE_ULID = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/;
30 |
31 | export function RenderEmoji({ match }: CustomComponentProps) {
32 | const [fail, setFail] = useState(false);
33 | const url = RE_ULID.test(match)
34 | ? `${
35 | clientController.getAvailableClient().configuration?.features
36 | .autumn.url
37 | }/emojis/${match}`
38 | : parseEmoji(
39 | match in emojiDictionary
40 | ? emojiDictionary[match as keyof typeof emojiDictionary]
41 | : match,
42 | );
43 |
44 | if (fail) return {`:${match}:`};
45 |
46 | return (
47 | setFail(true)}
54 | />
55 | );
56 | }
57 |
58 | export const remarkEmoji = createComponent(
59 | "emoji",
60 | RE_EMOJI,
61 | (match) => match in emojiDictionary || RE_ULID.test(match),
62 | );
63 |
64 | export function isOnlyEmoji(text: string) {
65 | return text.replaceAll(RE_EMOJI, "").trim().length === 0;
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/settings/appearance/AdvancedOptions.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from "mobx-react-lite";
2 |
3 | import { Text } from "preact-i18n";
4 |
5 | import { ObservedInputElement } from "@revoltchat/ui";
6 |
7 | import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
8 |
9 | import { useApplicationState } from "../../../mobx/State";
10 |
11 | import {
12 | MonospaceFonts,
13 | MONOSPACE_FONTS,
14 | MONOSPACE_FONT_KEYS,
15 | } from "../../../context/Theme";
16 |
17 | /**
18 | * ! LEGACY
19 | * Component providing a way to edit custom CSS.
20 | */
21 | export const ShimThemeCustomCSS = observer(() => {
22 | const theme = useApplicationState().settings.theme;
23 | return (
24 | <>
25 |
26 |
27 |
28 | theme.setCSS(ev.currentTarget.value)}
34 | />
35 | >
36 | );
37 | });
38 |
39 | export default function AdvancedOptions() {
40 | const settings = useApplicationState().settings;
41 | return (
42 | <>
43 | {/** Combo box of available monospaced fonts */}
44 |
45 |
46 |
47 | settings.theme.getMonospaceFont()}
50 | onChange={(value) =>
51 | settings.theme.setMonospaceFont(value as MonospaceFonts)
52 | }
53 | options={MONOSPACE_FONT_KEYS.map((value) => ({
54 | value,
55 | name: MONOSPACE_FONTS[value as keyof typeof MONOSPACE_FONTS]
56 | .name,
57 | }))}
58 | />
59 | {/** Custom CSS */}
60 |
61 | >
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/lib/debounce.ts:
--------------------------------------------------------------------------------
1 | import isEqual from "lodash.isequal";
2 |
3 | import { Inputs, useCallback, useEffect, useRef } from "preact/hooks";
4 |
5 | export function debounce(cb: (...args: unknown[]) => void, duration: number) {
6 | // Store the timer variable.
7 | let timer: NodeJS.Timeout;
8 | // This function is given to React.
9 | return (...args: unknown[]) => {
10 | // Get rid of the old timer.
11 | clearTimeout(timer);
12 | // Set a new timer.
13 | timer = setTimeout(() => {
14 | // Instead calling the new function.
15 | // (with the newer data)
16 | cb(...args);
17 | }, duration);
18 | };
19 | }
20 |
21 | export function useDebounceCallback(
22 | cb: (...args: unknown[]) => void,
23 | inputs: Inputs,
24 | duration = 1000,
25 | ) {
26 | // eslint-disable-next-line
27 | return useCallback(
28 | debounce(cb as (...args: unknown[]) => void, duration),
29 | inputs,
30 | );
31 | }
32 |
33 | export function useAutosaveCallback(
34 | cb: (...args: unknown[]) => void,
35 | inputs: Inputs,
36 | duration = 1000,
37 | ) {
38 | const ref = useRef(cb);
39 |
40 | // eslint-disable-next-line
41 | const callback = useCallback(
42 | debounce(() => ref.current(), duration),
43 | [],
44 | );
45 |
46 | useEffect(() => {
47 | ref.current = cb;
48 | callback();
49 | // eslint-disable-next-line
50 | }, [cb, callback, ...inputs]);
51 | }
52 |
53 | export function useAutosave(
54 | cb: () => void,
55 | dependency: T,
56 | initialValue: T,
57 | onBeginChange?: () => void,
58 | duration?: number,
59 | ) {
60 | if (onBeginChange) {
61 | // eslint-disable-next-line
62 | useEffect(
63 | () => {
64 | !isEqual(dependency, initialValue) && onBeginChange();
65 | },
66 | // eslint-disable-next-line
67 | [dependency],
68 | );
69 | }
70 |
71 | return useAutosaveCallback(
72 | () => !isEqual(dependency, initialValue) && cb(),
73 | [dependency],
74 | duration,
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/markdown/plugins/Codeblock.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | import { useCallback, useRef } from "preact/hooks";
4 |
5 | import { Tooltip } from "@revoltchat/ui";
6 |
7 | import { modalController } from "../../../controllers/modals/ModalController";
8 |
9 | /**
10 | * Base codeblock styles
11 | */
12 | const Base = styled.pre`
13 | padding: 1em;
14 | overflow-x: scroll;
15 | background: var(--block);
16 | border-radius: var(--border-radius);
17 | `;
18 |
19 | /**
20 | * Copy codeblock contents button styles
21 | */
22 | const Lang = styled.div`
23 | font-family: var(--monospace-font);
24 | width: fit-content;
25 | padding-bottom: 8px;
26 |
27 | a {
28 | color: #111;
29 | cursor: pointer;
30 | padding: 2px 6px;
31 | font-weight: 600;
32 | user-select: none;
33 | display: inline-block;
34 | background: var(--accent);
35 |
36 | font-size: 10px;
37 | text-transform: uppercase;
38 | box-shadow: 0 2px #787676;
39 | border-radius: calc(var(--border-radius) / 3);
40 |
41 | &:active {
42 | transform: translateY(1px);
43 | box-shadow: 0 1px #787676;
44 | }
45 | }
46 | `;
47 |
48 | /**
49 | * Render a codeblock with copy text button
50 | */
51 | export const RenderCodeblock: React.FC<{ class: string }> = ({
52 | children,
53 | ...props
54 | }) => {
55 | const ref = useRef(null);
56 |
57 | let text = "text";
58 | if (props.class) {
59 | text = props.class.split("-")[1];
60 | }
61 |
62 | const onCopy = useCallback(() => {
63 | const text = ref.current?.querySelector("code")?.innerText;
64 | text && modalController.writeText(text);
65 | }, [ref]);
66 |
67 | return (
68 |
69 |
70 |
71 | {/**
72 | // @ts-expect-error Preact-React */}
73 | {text}
74 |
75 |
76 | {children}
77 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/src/controllers/client/jsx/legacy/FileUploads.module.scss:
--------------------------------------------------------------------------------
1 | .uploader {
2 | display: flex;
3 | flex-direction: column;
4 |
5 | &.icon {
6 | .image {
7 | border-radius: var(--border-radius-half);
8 | }
9 | }
10 |
11 | &.banner {
12 | .image {
13 | border-radius: var(--border-radius);
14 | }
15 |
16 | .modify {
17 | gap: 4px;
18 | flex-direction: row;
19 | }
20 | }
21 |
22 | .image {
23 | cursor: pointer;
24 | overflow: hidden;
25 | background-size: cover;
26 | background-position: center;
27 | background-color: var(--secondary-background);
28 |
29 | .uploading {
30 | width: 100%;
31 | height: 100%;
32 | display: grid;
33 | place-items: center;
34 | background: rgba(0, 0, 0, 0.5);
35 | }
36 |
37 | &:hover .edit {
38 | opacity: 1;
39 | }
40 |
41 | &:active .edit {
42 | filter: brightness(0.8);
43 | }
44 |
45 | &.desaturate {
46 | filter: brightness(0.7) sepia(50%) grayscale(90%);
47 | }
48 |
49 | .edit {
50 | opacity: 0;
51 | width: 100%;
52 | height: 100%;
53 | display: grid;
54 | color: white;
55 | place-items: center;
56 | background: rgba(95, 95, 95, 0.5);
57 | transition: 0.2s ease-in-out opacity;
58 | }
59 | }
60 |
61 | .modify {
62 | display: flex;
63 | margin-top: 5px;
64 | font-size: 12px;
65 | align-items: center;
66 | flex-direction: column;
67 | justify-content: center;
68 |
69 | :first-child {
70 | cursor: pointer;
71 | }
72 |
73 | .small {
74 | display: flex;
75 | font-size: 10px;
76 | flex-direction: column;
77 | color: var(--tertiary-foreground);
78 | }
79 | }
80 |
81 | &[data-uploading="true"] {
82 | .image,
83 | .modify:first-child {
84 | cursor: not-allowed !important;
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/components/common/messaging/attachments/Grid.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components/macro";
2 |
3 | import { Ref } from "preact";
4 |
5 | const Grid = styled.div<{ width: number; height: number }>`
6 | --width: ${(props) => props.width}px;
7 | --height: ${(props) => props.height}px;
8 |
9 | display: grid;
10 | overflow: hidden;
11 | aspect-ratio: ${(props) => props.width} / ${(props) => props.height};
12 |
13 | max-width: min(var(--width), var(--attachment-max-width));
14 | max-height: min(var(--height), var(--attachment-max-height));
15 |
16 | // This is a hack for browsers not supporting aspect-ratio.
17 | // Stolen from https://codepen.io/una/pen/BazyaOM.
18 | @supports not (
19 | aspect-ratio: ${(props) => props.width} / ${(props) => props.height}
20 | ) {
21 | div::before {
22 | float: left;
23 | padding-top: ${(props) => (props.height / props.width) * 100}%;
24 | content: "";
25 | }
26 |
27 | div::after {
28 | display: block;
29 | content: "";
30 | clear: both;
31 | }
32 | }
33 |
34 | img,
35 | video {
36 | grid-area: 1 / 1;
37 |
38 | display: block;
39 |
40 | max-width: 100%;
41 | max-height: 100%;
42 |
43 | overflow: hidden;
44 |
45 | object-fit: contain;
46 |
47 | // It's something
48 | object-position: left;
49 | }
50 |
51 | video {
52 | width: 100%;
53 | height: 100%;
54 | }
55 |
56 | &.spoiler {
57 | img,
58 | video {
59 | filter: blur(44px);
60 | }
61 |
62 | border-radius: var(--border-radius);
63 | }
64 | `;
65 |
66 | export default Grid;
67 |
68 | type Props = Omit<
69 | JSX.HTMLAttributes,
70 | "children" | "as" | "style"
71 | > & {
72 | children?: Children;
73 | width: number;
74 | height: number;
75 | innerRef?: Ref;
76 | };
77 |
78 | export function SizedGrid(props: Props) {
79 | const { width, height, children, innerRef, ...divProps } = props;
80 |
81 | return (
82 |
83 | {children}
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/src/components/settings/account/AccountManagement.tsx:
--------------------------------------------------------------------------------
1 | import { Block } from "@styled-icons/boxicons-regular";
2 | import { Trash } from "@styled-icons/boxicons-solid";
3 |
4 | import { Text } from "preact-i18n";
5 |
6 | import { CategoryButton } from "@revoltchat/ui";
7 |
8 | import {
9 | clientController,
10 | useClient,
11 | } from "../../../controllers/client/ClientController";
12 | import { modalController } from "../../../controllers/modals/ModalController";
13 |
14 | export default function AccountManagement() {
15 | const client = useClient();
16 |
17 | const callback = (route: "disable" | "delete") => () =>
18 | modalController.mfaFlow(client).then(
19 | (ticket) =>
20 | ticket &&
21 | client.api
22 | .post(`/auth/account/${route}`, undefined, {
23 | headers: {
24 | "X-MFA-Ticket": ticket.token,
25 | },
26 | })
27 | .then(clientController.logoutCurrent),
28 | );
29 |
30 | return (
31 | <>
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | }
41 | description={
42 | "Disable your account. You won't be able to access it unless you contact support."
43 | }
44 | action="chevron"
45 | onClick={callback("disable")}>
46 |
47 |
48 |
49 | }
51 | description={
52 | "Your account will be queued for deletion, a confirmation email will be sent."
53 | }
54 | action="chevron"
55 | onClick={callback("delete")}>
56 |
57 |
58 | >
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | description: File a bug report
3 | title: "bug: "
4 | labels: ["bug"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to fill out this bug report!
10 | - type: textarea
11 | id: what-happened
12 | attributes:
13 | label: What happened?
14 | description: What did you expect to happen?
15 | validations:
16 | required: true
17 | - type: dropdown
18 | id: branch
19 | attributes:
20 | label: Branch
21 | description: What branch of Revolt are you using?
22 | options:
23 | - Production (app.revolt.chat)
24 | - Nightly (nightly.revolt.chat)
25 | validations:
26 | required: true
27 | - type: textarea
28 | id: commit-hash
29 | attributes:
30 | label: Commit hash
31 | description: What is your commit hash? You can find this at the bottom of Settings, next to the branch name.
32 | validations:
33 | required: true
34 | - type: dropdown
35 | id: browsers
36 | attributes:
37 | label: What browsers are you seeing the problem on?
38 | multiple: true
39 | options:
40 | - Firefox
41 | - Chrome
42 | - Safari
43 | - Microsoft Edge
44 | - Other (please specify in the "What happened" form)
45 | - type: textarea
46 | id: logs
47 | attributes:
48 | label: Relevant log output
49 | description: Please copy and paste any relevant log output. (To get this, press `CTRL`- or `CMD`-`SHIFT`-`I` and navigate to the "Console" tab.)
50 | render: shell
51 | - type: checkboxes
52 | id: desktop
53 | attributes:
54 | label: Desktop
55 | description: Is this bug specific to [the desktop client](https://github.com/revoltchat/desktop)? (If not, leave this unchecked.)
56 | options:
57 | - label: Yes, this bug is specific to Revolt Desktop and is *not* an issue with Revolt Desktop itself.
58 | required: false
59 | - type: checkboxes
60 | id: pwa
61 | attributes:
62 | label: PWA
63 | description: Is this bug specific to the PWA (i.e. "installing" the web app on iOS or Android)? (If not, leave this unchecked.)
64 | options:
65 | - label: Yes, this bug is specific to the PWA.
66 | required: false
67 |
--------------------------------------------------------------------------------
/src/controllers/modals/components/ReportSuccess.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from "preact-i18n";
2 |
3 | import { Modal } from "@revoltchat/ui";
4 |
5 | import { noopTrue } from "../../../lib/js";
6 |
7 | import { ModalProps } from "../types";
8 |
9 | /**
10 | * Report success modal
11 | */
12 | export default function ReportSuccess({
13 | user,
14 | ...props
15 | }: ModalProps<"report_success">) {
16 | return (
17 | }
20 | description={
21 | <>
22 |
23 | {user && (
24 | <>
25 |
26 |
27 |
28 | >
29 | )}
30 | >
31 | }
32 | actions={
33 | user
34 | ? [
35 | {
36 | palette: "plain",
37 | onClick: async () => {
38 | user.blockUser();
39 | return true;
40 | },
41 | children: (
42 |
43 | ),
44 | },
45 | {
46 | palette: "plain-secondary",
47 | onClick: noopTrue,
48 | children: (
49 |
50 | ),
51 | },
52 | ]
53 | : [
54 | {
55 | palette: "plain",
56 | onClick: noopTrue,
57 | children: (
58 |
59 | ),
60 | },
61 | ]
62 | }
63 | />
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/common/ServerIcon.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from "mobx-react-lite";
2 | import { Server } from "revolt.js";
3 | import styled from "styled-components/macro";
4 |
5 | import { useContext } from "preact/hooks";
6 |
7 | import { useClient } from "../../controllers/client/ClientController";
8 | import { IconBaseProps, ImageIconBase } from "./IconBase";
9 |
10 | interface Props extends IconBaseProps {
11 | server_name?: string;
12 | }
13 |
14 | const ServerText = styled.div`
15 | display: flex;
16 | align-items: center;
17 | justify-content: center;
18 | padding: 0.2em;
19 | font-size: 0.75rem;
20 | font-weight: 600;
21 | overflow: hidden;
22 | color: var(--foreground);
23 | background: var(--primary-background);
24 | border-radius: var(--border-radius-half);
25 | `;
26 |
27 | // const fallback = "/assets/group.png";
28 | export default observer(
29 | (
30 | props: Props &
31 | Omit<
32 | JSX.HTMLAttributes,
33 | keyof Props | "children" | "as"
34 | >,
35 | ) => {
36 | const client = useClient();
37 |
38 | const { target, attachment, size, animate, server_name, ...imgProps } =
39 | props;
40 | const iconURL = client.generateFileURL(
41 | target?.icon ?? attachment ?? undefined,
42 | { max_side: 256 },
43 | animate,
44 | );
45 |
46 | if (typeof iconURL === "undefined") {
47 | const name = target?.name ?? server_name ?? "";
48 |
49 | return (
50 |
51 | {name
52 | .split(" ")
53 | .map((x) => x[0])
54 | .filter((x) => typeof x !== "undefined")
55 | .join("")
56 | .substring(0, 3)}
57 |
58 | );
59 | }
60 |
61 | return (
62 |
71 | );
72 | },
73 | );
74 |
--------------------------------------------------------------------------------
/src/mobx/stores/Changelog.ts:
--------------------------------------------------------------------------------
1 | import { action, makeAutoObservable, runInAction } from "mobx";
2 |
3 | import { changelogEntries, latestChangelog } from "../../assets/changelogs";
4 | import { modalController } from "../../controllers/modals/ModalController";
5 | import Persistent from "../interfaces/Persistent";
6 | import Store from "../interfaces/Store";
7 | import Syncable from "../interfaces/Syncable";
8 |
9 | export interface Data {
10 | viewed?: number;
11 | }
12 |
13 | /**
14 | * Keeps track of viewed changelog items
15 | */
16 | export default class Changelog implements Store, Persistent, Syncable {
17 | /**
18 | * Last viewed changelog ID
19 | */
20 | private viewed: number;
21 |
22 | /**
23 | * Construct new Layout store.
24 | */
25 | constructor() {
26 | this.viewed = 0;
27 | makeAutoObservable(this);
28 | }
29 |
30 | get id() {
31 | return "changelog";
32 | }
33 |
34 | toJSON() {
35 | return {
36 | viewed: this.viewed,
37 | };
38 | }
39 |
40 | @action hydrate(data: Data) {
41 | if (data.viewed) {
42 | this.viewed = data.viewed;
43 | }
44 | }
45 |
46 | apply(_key: string, data: unknown, _revision: number): void {
47 | this.hydrate(data as Data);
48 | }
49 |
50 | toSyncable(): { [key: string]: object } {
51 | return {
52 | changelog: this.toJSON(),
53 | };
54 | }
55 |
56 | /**
57 | * Check whether there are new updates
58 | */
59 | checkForUpdates() {
60 | if (this.viewed < latestChangelog) {
61 | const expires = new Date(+changelogEntries[latestChangelog].date);
62 | expires.setDate(expires.getDate() + 7);
63 |
64 | if (+new Date() < +expires) {
65 | if (latestChangelog === 3) {
66 | modalController.push({
67 | type: "changelog_usernames",
68 | });
69 | } else {
70 | modalController.push({
71 | type: "changelog",
72 | initial: latestChangelog,
73 | });
74 | }
75 | }
76 |
77 | runInAction(() => {
78 | this.viewed = latestChangelog;
79 | });
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/settings/appearance/ThemeSelection.tsx:
--------------------------------------------------------------------------------
1 | import { Brush } from "@styled-icons/boxicons-solid";
2 | import { observer } from "mobx-react-lite";
3 | import { Link } from "react-router-dom";
4 | // @ts-expect-error shade-blend-color does not have typings.
5 | import pSBC from "shade-blend-color";
6 |
7 | import { Text } from "preact-i18n";
8 |
9 | import { CategoryButton, ObservedInputElement } from "@revoltchat/ui";
10 |
11 | import { useApplicationState } from "../../../mobx/State";
12 |
13 | import { ThemeBaseSelector } from "./legacy/ThemeBaseSelector";
14 |
15 | /**
16 | * ! LEGACY
17 | * Component providing a way to switch the base theme being used.
18 | */
19 | export const ShimThemeBaseSelector = observer(() => {
20 | const theme = useApplicationState().settings.theme;
21 | return (
22 | {
25 | theme.setBase(base);
26 | theme.reset();
27 | }}
28 | />
29 | );
30 | });
31 |
32 | export default function ThemeSelection() {
33 | const theme = useApplicationState().settings.theme;
34 |
35 | return (
36 | <>
37 | {/** Allow users to change base theme */}
38 |
39 | {/** Provide a link to the theme shop */}
40 |
41 | }
43 | action="chevron"
44 | description={
45 |
46 | }>
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | {
58 | theme.setVariable("accent", colour as string);
59 | theme.setVariable("scrollbar-thumb", pSBC(-0.2, colour));
60 | }}
61 | />
62 | >
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/common/Emoji.tsx:
--------------------------------------------------------------------------------
1 | import { emojiDictionary } from "../../assets/emojis";
2 |
3 | export type EmojiPack = "mutant" | "twemoji" | "noto" | "openmoji";
4 |
5 | let EMOJI_PACK: EmojiPack = "mutant";
6 | const REVISION = 3;
7 |
8 | export function setGlobalEmojiPack(pack: EmojiPack) {
9 | EMOJI_PACK = pack;
10 | }
11 |
12 | // Originally taken from Twemoji source code,
13 | // re-written by bree to be more readable.
14 | function codePoints(rune: string) {
15 | const pairs = [];
16 | let low = 0;
17 | let i = 0;
18 |
19 | while (i < rune.length) {
20 | const charCode = rune.charCodeAt(i++);
21 | if (low) {
22 | pairs.push(0x10000 + ((low - 0xd800) << 10) + (charCode - 0xdc00));
23 | low = 0;
24 | } else if (0xd800 <= charCode && charCode <= 0xdbff) {
25 | low = charCode;
26 | } else {
27 | pairs.push(charCode);
28 | }
29 | }
30 |
31 | return pairs;
32 | }
33 |
34 | // Taken from Twemoji source code.
35 | // scripts/build.js#344
36 | // grabTheRightIcon(rawText);
37 | const UFE0Fg = /\uFE0F/g;
38 | const U200D = String.fromCharCode(0x200d);
39 | function toCodePoint(rune: string) {
40 | return codePoints(rune.indexOf(U200D) < 0 ? rune.replace(UFE0Fg, "") : rune)
41 | .map((val) => val.toString(16))
42 | .join("-");
43 | }
44 |
45 | export function parseEmoji(emoji: string) {
46 | if (emoji.startsWith("custom:")) {
47 | return `https://dl.insrt.uk/projects/revolt/emotes/${emoji.substring(
48 | 7,
49 | )}`;
50 | }
51 |
52 | const codepoint = toCodePoint(emoji);
53 | return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
54 | }
55 |
56 | export default function Emoji({
57 | emoji,
58 | size,
59 | }: {
60 | emoji: string;
61 | size?: number;
62 | }) {
63 | return (
64 |
74 | );
75 | }
76 |
77 | export function generateEmoji(emoji: string) {
78 | return `
`;
81 | }
82 |
--------------------------------------------------------------------------------
/src/components/common/ChannelIcon.tsx:
--------------------------------------------------------------------------------
1 | import { Hash, VolumeFull } from "@styled-icons/boxicons-regular";
2 | import { observer } from "mobx-react-lite";
3 | import { Channel } from "revolt.js";
4 |
5 | import fallback from "./assets/group.png";
6 |
7 | import { useClient } from "../../controllers/client/ClientController";
8 | import { ImageIconBase, IconBaseProps } from "./IconBase";
9 |
10 | interface Props extends IconBaseProps {
11 | isServerChannel?: boolean;
12 | }
13 |
14 | export default observer(
15 | (
16 | props: Props &
17 | Omit<
18 | JSX.HTMLAttributes,
19 | keyof Props | "children" | "as"
20 | >,
21 | ) => {
22 | const client = useClient();
23 |
24 | const {
25 | size,
26 | target,
27 | attachment,
28 | isServerChannel: server,
29 | animate,
30 | ...imgProps
31 | } = props;
32 | const iconURL = client.generateFileURL(
33 | target?.icon ?? attachment ?? undefined,
34 | { max_side: 256 },
35 | animate,
36 | );
37 | const isServerChannel =
38 | server ||
39 | (target &&
40 | (target.channel_type === "TextChannel" ||
41 | target.channel_type === "VoiceChannel"));
42 |
43 | if (typeof iconURL === "undefined") {
44 | if (isServerChannel) {
45 | if (target?.channel_type === "VoiceChannel") {
46 | return ;
47 | }
48 | return ;
49 | }
50 | }
51 |
52 | // The border radius of the channel icon, if it's a server-channel it should be square (undefined).
53 | let borderRadius: string | undefined = "--border-radius-channel-icon";
54 | if (isServerChannel) {
55 | borderRadius = undefined;
56 | }
57 |
58 | return (
59 | // ! TODO: replace fallback with +
60 |
69 | );
70 | },
71 | );
72 |
--------------------------------------------------------------------------------
/src/controllers/modals/components/UserPicker.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | import { Text } from "preact-i18n";
4 | import { useMemo, useState } from "preact/hooks";
5 |
6 | import { Modal } from "@revoltchat/ui";
7 |
8 | import UserCheckbox from "../../../components/common/user/UserCheckbox";
9 | import { useClient } from "../../client/ClientController";
10 | import { ModalProps } from "../types";
11 |
12 | const List = styled.div`
13 | max-width: 100%;
14 | max-height: 360px;
15 | overflow-y: scroll;
16 | `;
17 |
18 | export default function UserPicker({
19 | callback,
20 | omit,
21 | ...props
22 | }: ModalProps<"user_picker">) {
23 | const [selected, setSelected] = useState([]);
24 | const omitted = useMemo(
25 | () => new Set([...(omit || []), "00000000000000000000000000"]),
26 | [omit],
27 | );
28 |
29 | const client = useClient();
30 |
31 | return (
32 | }
35 | actions={[
36 | {
37 | children: ,
38 | onClick: () => callback(selected).then(() => true),
39 | },
40 | ]}>
41 |
42 | {[...client.users.values()]
43 | .filter(
44 | (x) =>
45 | x &&
46 | x.relationship === "Friend" &&
47 | !omitted.has(x._id),
48 | )
49 | .map((x) => (
50 | {
55 | if (v) {
56 | setSelected([...selected, x._id]);
57 | } else {
58 | setSelected(
59 | selected.filter((y) => y !== x._id),
60 | );
61 | }
62 | }}
63 | />
64 | ))}
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/controllers/modals/components/CreateChannel.tsx:
--------------------------------------------------------------------------------
1 | import { useHistory } from "react-router-dom";
2 |
3 | import { Text } from "preact-i18n";
4 |
5 | import { ModalForm } from "@revoltchat/ui";
6 |
7 | import { ModalProps } from "../types";
8 |
9 | /**
10 | * Channel creation modal
11 | */
12 | export default function CreateChannel({
13 | cb,
14 | target,
15 | ...props
16 | }: ModalProps<"create_channel">) {
17 | const history = useHistory();
18 |
19 | return (
20 | }
23 | schema={{
24 | name: "text",
25 | type: "radio",
26 | }}
27 | data={{
28 | name: {
29 | field: (
30 |
31 | ) as React.ReactChild,
32 | },
33 | type: {
34 | field: (
35 |
36 | ) as React.ReactChild,
37 | choices: [
38 | {
39 | name: (
40 |
41 | ) as React.ReactChild,
42 | value: "Text",
43 | },
44 | {
45 | name: (
46 |
47 | ) as React.ReactChild,
48 | value: "Voice",
49 | },
50 | ],
51 | },
52 | }}
53 | defaults={{
54 | type: "Text",
55 | }}
56 | callback={async ({ name, type }) => {
57 | const channel = await target.createChannel({
58 | type: type as "Text" | "Voice",
59 | name,
60 | });
61 |
62 | if (cb) {
63 | cb(channel as any);
64 | } else {
65 | history.push(
66 | `/server/${target._id}/channel/${channel._id}`,
67 | );
68 | }
69 | }}
70 | submit={{
71 | children: ,
72 | }}
73 | />
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/src/controllers/modals/components/ImageViewer.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | import { Modal } from "@revoltchat/ui";
4 |
5 | import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions";
6 | import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
7 | import { useClient } from "../../client/ClientController";
8 | import { ModalProps } from "../types";
9 |
10 | const Viewer = styled.div`
11 | display: flex;
12 | overflow: hidden;
13 | flex-direction: column;
14 | border-end-end-radius: 4px;
15 | border-end-start-radius: 4px;
16 |
17 | max-width: 100vw;
18 |
19 | img {
20 | width: auto;
21 | height: auto;
22 | max-width: 90vw;
23 | max-height: 75vh;
24 | object-fit: contain;
25 | border-bottom: thin solid var(--tertiary-foreground);
26 |
27 | -webkit-touch-callout: default;
28 | }
29 | `;
30 |
31 | export default function ImageViewer({
32 | embed,
33 | attachment,
34 | ...props
35 | }: ModalProps<"image_viewer">) {
36 | const client = useClient();
37 |
38 | if (attachment && attachment.metadata.type !== "Image") {
39 | console.warn(
40 | `Attempted to use a non valid attatchment type in the image viewer: ${attachment.metadata.type}`,
41 | );
42 | return null;
43 | }
44 |
45 | return (
46 |
47 |
48 | {attachment && (
49 | <>
50 |
56 |
57 | >
58 | )}
59 | {embed && (
60 | <>
61 |
67 |
68 | >
69 | )}
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/common/messaging/attachments/Attachment.module.scss:
--------------------------------------------------------------------------------
1 | .attachment {
2 | display: grid;
3 | grid-auto-flow: row dense;
4 | grid-auto-columns: min(100%, var(--attachment-max-width));
5 |
6 | margin: 0.125rem 0 0.125rem;
7 | width: max-content;
8 | max-width: 100%;
9 |
10 | &[data-spoiler="true"] {
11 | filter: blur(30px);
12 | pointer-events: none;
13 | }
14 |
15 | &.audio {
16 | gap: 4px;
17 | padding: 6px;
18 | display: flex;
19 | max-width: 100%;
20 | flex-direction: column;
21 | width: var(--attachment-default-width);
22 | background: var(--secondary-background);
23 |
24 | > audio {
25 | width: 100%;
26 | }
27 | }
28 |
29 | &.file {
30 | > div {
31 | padding: 12px;
32 | max-width: 100%;
33 | user-select: none;
34 | width: fit-content;
35 | border-radius: var(--border-radius);
36 | width: var(--attachment-default-width);
37 | }
38 | }
39 |
40 | &.text {
41 | width: 100%;
42 | overflow: hidden;
43 | grid-auto-columns: unset;
44 | max-width: var(--attachment-max-text-width);
45 |
46 | .textContent {
47 | height: 140px;
48 | padding: 12px;
49 | overflow-x: auto;
50 | overflow-y: auto;
51 | border-radius: 0 !important;
52 | background: var(--secondary-header);
53 |
54 | pre {
55 | margin: 0;
56 | }
57 |
58 | pre code {
59 | font-family: var(--monospace-font), sans-serif;
60 | }
61 |
62 | &[data-loading="true"] {
63 | display: flex;
64 |
65 | > * {
66 | flex-grow: 1;
67 | }
68 | }
69 | }
70 | }
71 | }
72 |
73 | .margin {
74 | margin-top: 4px;
75 | }
76 |
77 | .container {
78 | max-width: 100%;
79 | overflow: hidden;
80 | width: fit-content;
81 |
82 | > :first-child {
83 | width: min(var(--attachment-max-width), 100%, var(--width));
84 | }
85 | }
86 |
87 | .container,
88 | .attachment,
89 | .image {
90 | border-radius: var(--border-radius);
91 | }
92 |
93 | .image {
94 | cursor: pointer;
95 | width: 100%;
96 | height: 100%;
97 |
98 | &.loading {
99 | background: var(--background);
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/mobx/stores/Ordering.ts:
--------------------------------------------------------------------------------
1 | import { action, computed, makeAutoObservable } from "mobx";
2 |
3 | import { reorder } from "@revoltchat/ui";
4 |
5 | import { clientController } from "../../controllers/client/ClientController";
6 | import State from "../State";
7 | import Persistent from "../interfaces/Persistent";
8 | import Store from "../interfaces/Store";
9 | import Syncable from "../interfaces/Syncable";
10 |
11 | export interface Data {
12 | servers?: string[];
13 | }
14 |
15 | /**
16 | * Keeps track of ordering of various elements
17 | */
18 | export default class Ordering implements Store, Persistent, Syncable {
19 | private state: State;
20 |
21 | /**
22 | * Ordered list of server IDs
23 | */
24 | private servers: string[];
25 |
26 | /**
27 | * Construct new Layout store.
28 | */
29 | constructor(state: State) {
30 | this.servers = [];
31 | makeAutoObservable(this);
32 |
33 | this.state = state;
34 | this.reorderServer = this.reorderServer.bind(this);
35 | }
36 |
37 | get id() {
38 | return "ordering";
39 | }
40 |
41 | toJSON() {
42 | return {
43 | servers: this.servers,
44 | };
45 | }
46 |
47 | @action hydrate(data: Data) {
48 | if (data.servers) {
49 | this.servers = data.servers;
50 | }
51 | }
52 |
53 | apply(_key: string, data: unknown, _revision: number): void {
54 | this.hydrate(data as Data);
55 | }
56 |
57 | toSyncable(): { [key: string]: object } {
58 | return {
59 | ordering: this.toJSON(),
60 | };
61 | }
62 |
63 | /**
64 | * All known servers with ordering applied
65 | */
66 | @computed get orderedServers() {
67 | const client = clientController.getReadyClient();
68 | const known = new Set(client?.servers.keys() ?? []);
69 | const ordered = [...this.servers];
70 |
71 | const out = [];
72 | for (const id of ordered) {
73 | if (known.delete(id)) {
74 | out.push(client!.servers.get(id)!);
75 | }
76 | }
77 |
78 | for (const id of known) {
79 | out.push(client!.servers.get(id)!);
80 | }
81 |
82 | return out;
83 | }
84 |
85 | /**
86 | * Re-order a server
87 | */
88 | @action reorderServer(source: number, dest: number) {
89 | this.servers = reorder(
90 | this.orderedServers.map((x) => x._id),
91 | source,
92 | dest,
93 | );
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/components/settings/appearance/legacy/ThemeBaseSelector.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components/macro";
2 |
3 | import { Text } from "preact-i18n";
4 |
5 | import darkSVG from "./assets/dark.svg";
6 | import lightSVG from "./assets/light.svg";
7 |
8 | const List = styled.div`
9 | gap: 8px;
10 | width: 100%;
11 | display: flex;
12 | margin-bottom: 15px;
13 |
14 | > div {
15 | min-width: 0;
16 | display: flex;
17 | flex-direction: column;
18 | }
19 |
20 | img {
21 | cursor: pointer;
22 | border-radius: var(--border-radius);
23 | transition: border 0.3s;
24 | border: 3px solid transparent;
25 | width: 100%;
26 |
27 | &[data-active="true"] {
28 | cursor: default;
29 | border: 3px solid var(--accent);
30 |
31 | &:hover {
32 | border: 3px solid var(--accent);
33 | }
34 | }
35 |
36 | &:hover {
37 | border: 3px solid var(--tertiary-background);
38 | }
39 | }
40 | `;
41 |
42 | interface Props {
43 | value?: "light" | "dark";
44 | setValue: (base: "light" | "dark") => void;
45 | }
46 |
47 | export function ThemeBaseSelector({ value, setValue }: Props) {
48 | return (
49 | <>
50 |
51 |
52 |
53 |
54 |
55 |

setValue("light")}
61 | onContextMenu={(e) => e.preventDefault()}
62 | />
63 |
64 |
65 |
66 |
67 |
68 |

setValue("dark")}
74 | onContextMenu={(e) => e.preventDefault()}
75 | />
76 |
77 |
78 |
79 |
80 |
81 | >
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/pages/settings/server/Panes.module.scss:
--------------------------------------------------------------------------------
1 | .overview {
2 | .row {
3 | gap: 20px;
4 | display: flex;
5 |
6 | .name {
7 | flex-grow: 1;
8 |
9 | h3 {
10 | margin-top: 0;
11 | }
12 |
13 | input {
14 | width: 100%;
15 | }
16 | }
17 | }
18 |
19 | .markdown {
20 | display: flex;
21 | align-items: center;
22 | margin-top: 8px;
23 | gap: 4px;
24 |
25 | svg {
26 | flex-shrink: 0;
27 | }
28 |
29 | a:hover {
30 | text-decoration: underline;
31 | }
32 |
33 | h5 {
34 | margin: 0;
35 | }
36 | }
37 | }
38 |
39 | .userList {
40 | gap: 8px;
41 | flex-grow: 1;
42 | display: flex;
43 | flex-direction: column;
44 |
45 | .subtitle {
46 | gap: 8px;
47 | display: flex;
48 | justify-content: space-between;
49 | font-size: 13px;
50 | text-transform: uppercase;
51 | color: var(--secondary-foreground);
52 | font-weight: 700;
53 |
54 | .reason {
55 | text-align: center;
56 | }
57 | }
58 |
59 | .reason {
60 | flex: 2;
61 | }
62 |
63 | .invite,
64 | .ban,
65 | .member {
66 | gap: 8px;
67 | padding: 10px;
68 | display: flex;
69 | align-items: center;
70 | flex-direction: row;
71 | background: var(--secondary-background);
72 |
73 | span,
74 | code {
75 | flex: 1;
76 |
77 | overflow: hidden;
78 | white-space: nowrap;
79 | text-overflow: ellipsis;
80 | }
81 |
82 | code {
83 | font-family: var(--monospace-font);
84 | user-select: all;
85 | }
86 |
87 | span {
88 | gap: 8px;
89 | display: flex;
90 | color: var(--secondary-foreground);
91 | }
92 |
93 | &[data-deleting="true"] {
94 | opacity: 0.5;
95 | }
96 | }
97 |
98 | .member {
99 | cursor: pointer;
100 |
101 | .chevron {
102 | transition: 0.2s ease all;
103 | }
104 |
105 | &:not([data-open="true"]) .chevron {
106 | transform: rotateZ(90deg);
107 | }
108 | }
109 |
110 | .memberView {
111 | padding: 10px;
112 | background: var(--background);
113 | }
114 |
115 | .virtual {
116 | flex-grow: 1;
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/components/settings/appearance/ChatOptions.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from "mobx-react-lite";
2 |
3 | import { Text } from "preact-i18n";
4 |
5 | import { Column, ObservedInputElement } from "@revoltchat/ui";
6 |
7 | import { useApplicationState } from "../../../mobx/State";
8 |
9 | import { FONTS, Fonts, FONT_KEYS } from "../../../context/Theme";
10 |
11 | import { EmojiSelector } from "./legacy/EmojiSelector";
12 |
13 | /**
14 | * ! LEGACY
15 | * Component providing a way to change emoji pack.
16 | */
17 | export const ShimDisplayEmoji = observer(() => {
18 | const settings = useApplicationState().settings;
19 | return (
20 | settings.set("appearance:emoji", v)}
23 | />
24 | );
25 | });
26 |
27 | export default observer(() => {
28 | const settings = useApplicationState().settings;
29 |
30 | return (
31 | <>
32 |
33 | {/* Combo box of available fonts. */}
34 |
35 |
36 |
37 | settings.theme.getFont()}
40 | onChange={(value) => settings.theme.setFont(value as Fonts)}
41 | options={FONT_KEYS.map((value) => ({
42 | value,
43 | name: FONTS[value as keyof typeof FONTS].name,
44 | }))}
45 | />
46 | {/* Option to toggle liagures for supported fonts. */}
47 | {settings.theme.getFont() === "Inter" && (
48 |
51 | settings.get("appearance:ligatures") ?? true
52 | }
53 | onChange={(v) =>
54 | settings.set("appearance:ligatures", v)
55 | }
56 | title={
57 |
58 | }
59 | description={
60 |
61 | }
62 | />
63 | )}
64 |
65 |
66 | {/* Emoji pack selector */}
67 |
68 | >
69 | );
70 | });
71 |
--------------------------------------------------------------------------------