├── .prettierignore ├── src ├── types │ ├── vite-env.d.ts │ ├── env.d.ts │ ├── revolt-api.d.ts │ ├── preact.d.ts │ └── native.d.ts ├── mobx │ ├── interfaces │ │ ├── Store.ts │ │ ├── Syncable.ts │ │ └── Persistent.ts │ └── stores │ │ ├── helpers │ │ └── SSecurity.ts │ │ ├── ServerConfig.ts │ │ ├── Changelog.ts │ │ └── Ordering.ts ├── assets │ └── sounds │ │ ├── call_join.mp3 │ │ ├── call_join.ogg │ │ ├── inbound.mp3 │ │ ├── inbound.ogg │ │ ├── message.mp3 │ │ ├── message.ogg │ │ ├── outbound.mp3 │ │ ├── outbound.ogg │ │ ├── call_leave.mp3 │ │ └── call_leave.ogg ├── components │ ├── settings │ │ ├── appearance │ │ │ ├── legacy │ │ │ │ ├── README.md │ │ │ │ └── ThemeBaseSelector.tsx │ │ │ ├── ThemeOverrides.tsx │ │ │ ├── AdvancedOptions.tsx │ │ │ ├── ThemeSelection.tsx │ │ │ └── ChatOptions.tsx │ │ ├── roles │ │ │ ├── RoleSelection.ts │ │ │ └── PermissionList.tsx │ │ └── account │ │ │ └── AccountManagement.tsx │ ├── common │ │ ├── assets │ │ │ ├── group.png │ │ │ └── user.png │ │ ├── user │ │ │ ├── UserCheckbox.tsx │ │ │ ├── UserStatus.tsx │ │ │ └── UserHover.tsx │ │ ├── messaging │ │ │ ├── attachments │ │ │ │ ├── Spoiler.tsx │ │ │ │ ├── AttachmentActions.module.scss │ │ │ │ ├── ImageFile.tsx │ │ │ │ ├── Grid.tsx │ │ │ │ └── Attachment.module.scss │ │ │ └── embed │ │ │ │ └── EmbedMediaActions.tsx │ │ ├── LocaleSelector.tsx │ │ ├── CollapsibleSection.tsx │ │ ├── Tooltip.tsx │ │ ├── IconBase.tsx │ │ ├── UpdateIndicator.tsx │ │ ├── ServerIcon.tsx │ │ ├── Emoji.tsx │ │ └── ChannelIcon.tsx │ ├── markdown │ │ ├── hast.ts │ │ ├── plugins │ │ │ ├── htmlToText.ts │ │ │ ├── channels.tsx │ │ │ ├── anchors.tsx │ │ │ ├── timestamps.ts │ │ │ ├── spoiler.tsx │ │ │ ├── mentions.tsx │ │ │ ├── emoji.tsx │ │ │ └── Codeblock.tsx │ │ └── Markdown.tsx │ ├── README.md │ └── navigation │ │ ├── items │ │ ├── placeholder.svg │ │ └── ConnectionStatus.tsx │ │ ├── RightSidebar.tsx │ │ ├── left │ │ └── ServerListSidebar.tsx │ │ ├── SidebarBase.tsx │ │ ├── LeftSidebar.tsx │ │ └── right │ │ └── ChannelDebugInfo.tsx ├── pages │ ├── login │ │ ├── background.jpg │ │ ├── forms │ │ │ ├── FormLogin.tsx │ │ │ ├── FormCreate.tsx │ │ │ ├── FormReset.tsx │ │ │ ├── FormVerify.tsx │ │ │ └── CaptchaBlock.tsx │ │ └── ConfirmDelete.tsx │ ├── settings │ │ ├── assets │ │ │ ├── flags │ │ │ │ ├── tamil_nadu.png │ │ │ │ ├── enchanting_table.webp │ │ │ │ ├── esperanto.svg │ │ │ │ ├── kurdistan.svg │ │ │ │ └── sources.txt │ │ │ ├── revolt_r.svg │ │ │ └── opus_logo.svg │ │ ├── panes │ │ │ ├── Account.tsx │ │ │ ├── Experiments.tsx │ │ │ ├── Appearance.tsx │ │ │ └── Sync.tsx │ │ └── server │ │ │ └── Panes.module.scss │ ├── channels │ │ └── messaging │ │ │ └── ConversationStart.tsx │ ├── home │ │ └── Home.module.scss │ └── invite │ │ └── Invite.module.scss ├── lib │ ├── js.ts │ ├── stopPropagation.ts │ ├── fileSize.ts │ ├── isTouchscreenDevice.ts │ ├── defer.ts │ ├── ConditionalLink.tsx │ ├── modifiers.ts │ ├── window.ts │ ├── conversion.ts │ ├── windowSize.ts │ ├── PaintCounter.tsx │ ├── eventEmitter.ts │ ├── renderer │ │ └── types.ts │ ├── links.ts │ ├── dnd.ts │ ├── i18n.tsx │ └── debounce.ts ├── version.ts ├── main.tsx ├── revision.ts ├── controllers │ ├── safety │ │ └── index.ts │ ├── client │ │ └── jsx │ │ │ ├── Binder.tsx │ │ │ ├── error.tsx │ │ │ ├── ChannelName.tsx │ │ │ ├── CheckAuth.tsx │ │ │ ├── RequiresOnline.tsx │ │ │ └── legacy │ │ │ └── FileUploads.module.scss │ └── modals │ │ ├── components │ │ ├── PendingFriendRequests.tsx │ │ ├── legacy │ │ │ ├── assets │ │ │ │ └── onboarding_background.svg │ │ │ └── Onboarding.module.scss │ │ ├── SignedOut.tsx │ │ ├── ChannelInfo.tsx │ │ ├── Error.tsx │ │ ├── ShowToken.tsx │ │ ├── AddFriend.tsx │ │ ├── ImportTheme.tsx │ │ ├── CreateRole.tsx │ │ ├── Clipboard.tsx │ │ ├── DeleteMessage.tsx │ │ ├── CreateBot.tsx │ │ ├── KickMember.tsx │ │ ├── SignOutSessions.tsx │ │ ├── CreateCategory.tsx │ │ ├── CustomStatus.tsx │ │ ├── CreateGroup.tsx │ │ ├── ModifyDisplayname.tsx │ │ ├── ConfirmLeave.tsx │ │ ├── OutOfDate.tsx │ │ ├── BanMember.tsx │ │ ├── LinkWarning.tsx │ │ ├── CreateServer.tsx │ │ ├── ServerInfo.tsx │ │ ├── ReportSuccess.tsx │ │ ├── UserPicker.tsx │ │ ├── CreateChannel.tsx │ │ └── ImageViewer.tsx │ │ └── ModalRenderer.tsx ├── context │ ├── history.ts │ └── index.tsx └── styles │ ├── index.scss │ ├── _variables.scss │ ├── _page.scss │ └── _elements.scss ├── .env.build ├── .babelrc ├── public ├── assets_default │ ├── logo.png │ ├── wide.webp │ ├── logo_round.png │ ├── icons │ │ ├── apple-touch.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── masking-512x512.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ └── monochrome.svg │ ├── splashscreens │ │ ├── ipad_splash.png │ │ ├── ipadpro1_splash.png │ │ ├── ipadpro2_splash.png │ │ ├── ipadpro3_splash.png │ │ ├── iphone5_splash.png │ │ ├── iphone6_splash.png │ │ ├── iphonex_splash.png │ │ ├── iphonexr_splash.png │ │ ├── iphoneplus_splash.png │ │ └── iphonexsmax_splash.png │ └── badges │ │ ├── amog.svg │ │ ├── paw.svg │ │ ├── developer.svg │ │ ├── founder.svg │ │ ├── raccoon.svg │ │ ├── revolt_r.svg │ │ ├── supporter.svg │ │ ├── verified.svg │ │ ├── early_adopter.svg │ │ ├── moderation.svg │ │ └── translator.svg └── .well-known │ └── assetlinks.json ├── .vscode ├── settings.json ├── extensions.json └── launch.json ├── .dockerignore ├── .env ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature.yml │ └── bug.yml ├── workflows │ ├── mirroring.yml │ └── triage_issue.yml ├── pull_request_template.md └── actions │ └── build │ └── action.yml ├── .gitmodules ├── .prettierrc.js ├── .yarnrc.yml ├── Dockerfile ├── scripts ├── locale.js ├── publish.sh ├── setup_assets.js └── inject.js ├── tsconfig.json └── disabled-js.svg /.prettierignore: -------------------------------------------------------------------------------- 1 | src/components/markdown/prism.ts -------------------------------------------------------------------------------- /src/types/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.env.build: -------------------------------------------------------------------------------- 1 | VITE_API_URL=__API_URL__ 2 | VITE_THEMES_URL=https://themes.revolt.chat -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { "plugins": [["@babel/plugin-proposal-decorators", { "legacy": true }]] } 2 | -------------------------------------------------------------------------------- /src/mobx/interfaces/Store.ts: -------------------------------------------------------------------------------- 1 | export default interface Store { 2 | get id(): string; 3 | } 4 | -------------------------------------------------------------------------------- /public/assets_default/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/logo.png -------------------------------------------------------------------------------- /public/assets_default/wide.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/wide.webp -------------------------------------------------------------------------------- /src/assets/sounds/call_join.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/assets/sounds/call_join.mp3 -------------------------------------------------------------------------------- /src/assets/sounds/call_join.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/assets/sounds/call_join.ogg -------------------------------------------------------------------------------- /src/assets/sounds/inbound.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/assets/sounds/inbound.mp3 -------------------------------------------------------------------------------- /src/assets/sounds/inbound.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/assets/sounds/inbound.ogg -------------------------------------------------------------------------------- /src/assets/sounds/message.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/assets/sounds/message.mp3 -------------------------------------------------------------------------------- /src/assets/sounds/message.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/assets/sounds/message.ogg -------------------------------------------------------------------------------- /src/assets/sounds/outbound.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/assets/sounds/outbound.mp3 -------------------------------------------------------------------------------- /src/assets/sounds/outbound.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/assets/sounds/outbound.ogg -------------------------------------------------------------------------------- /src/components/settings/appearance/legacy/README.md: -------------------------------------------------------------------------------- 1 | These components need to be ported to @revoltchat/ui. 2 | -------------------------------------------------------------------------------- /src/pages/login/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/pages/login/background.jpg -------------------------------------------------------------------------------- /src/assets/sounds/call_leave.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/assets/sounds/call_leave.mp3 -------------------------------------------------------------------------------- /src/assets/sounds/call_leave.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/assets/sounds/call_leave.ogg -------------------------------------------------------------------------------- /public/assets_default/logo_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/logo_round.png -------------------------------------------------------------------------------- /src/components/common/assets/group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/components/common/assets/group.png -------------------------------------------------------------------------------- /src/components/common/assets/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/components/common/assets/user.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true 4 | } 5 | -------------------------------------------------------------------------------- /public/assets_default/icons/apple-touch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/icons/apple-touch.png -------------------------------------------------------------------------------- /public/assets_default/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/assets_default/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/assets_default/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/icons/mstile-150x150.png -------------------------------------------------------------------------------- /src/pages/settings/assets/flags/tamil_nadu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/pages/settings/assets/flags/tamil_nadu.png -------------------------------------------------------------------------------- /public/assets_default/icons/masking-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/icons/masking-512x512.png -------------------------------------------------------------------------------- /public/assets_default/splashscreens/ipad_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/splashscreens/ipad_splash.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .vscode 3 | dist 4 | dist_injected 5 | node_modules 6 | .env 7 | .env.local 8 | 9 | Dockerfile 10 | .dockerignore 11 | -------------------------------------------------------------------------------- /src/pages/settings/assets/flags/enchanting_table.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/src/pages/settings/assets/flags/enchanting_table.webp -------------------------------------------------------------------------------- /public/assets_default/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/assets_default/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/assets_default/splashscreens/ipadpro1_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/splashscreens/ipadpro1_splash.png -------------------------------------------------------------------------------- /public/assets_default/splashscreens/ipadpro2_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/splashscreens/ipadpro2_splash.png -------------------------------------------------------------------------------- /public/assets_default/splashscreens/ipadpro3_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/splashscreens/ipadpro3_splash.png -------------------------------------------------------------------------------- /public/assets_default/splashscreens/iphone5_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/splashscreens/iphone5_splash.png -------------------------------------------------------------------------------- /public/assets_default/splashscreens/iphone6_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/splashscreens/iphone6_splash.png -------------------------------------------------------------------------------- /public/assets_default/splashscreens/iphonex_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/splashscreens/iphonex_splash.png -------------------------------------------------------------------------------- /public/assets_default/splashscreens/iphonexr_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/splashscreens/iphonexr_splash.png -------------------------------------------------------------------------------- /public/assets_default/splashscreens/iphoneplus_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/splashscreens/iphoneplus_splash.png -------------------------------------------------------------------------------- /public/assets_default/splashscreens/iphonexsmax_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmfunc/revite/master/public/assets_default/splashscreens/iphonexsmax_splash.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "kol.commit-lint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # VITE_API_URL=https://api.revolt.chat 2 | VITE_API_URL=http://46.101.250.208/api 3 | # VITE_API_URL=http://local.revolt.chat:8000 4 | # VITE_API_URL=https://revolt.chat/api 5 | VITE_THEMES_URL=https://themes.revolt.chat 6 | -------------------------------------------------------------------------------- /src/types/env.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMetaEnv { 2 | DEV: boolean; 3 | VITE_API_URL: string; 4 | VITE_THEMES_URL: string; 5 | BASE_URL: string; 6 | } 7 | 8 | interface ImportMeta { 9 | env: ImportMetaEnv; 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/js.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | export const noop = () => {}; 3 | export const noopAsync = async () => {}; 4 | export const noopTrue = () => true; 5 | /* eslint-enable @typescript-eslint/no-empty-function */ 6 | -------------------------------------------------------------------------------- /src/types/revolt-api.d.ts: -------------------------------------------------------------------------------- 1 | // TODO: re-export from revolt-api in some way 2 | declare type Session = { 3 | _id?: string; 4 | token: string; 5 | name: string; 6 | user_id: string; 7 | }; 8 | 9 | declare type SessionPrivate = Session; 10 | -------------------------------------------------------------------------------- /src/pages/login/forms/FormLogin.tsx: -------------------------------------------------------------------------------- 1 | import { clientController } from "../../../controllers/client/ClientController"; 2 | import { Form } from "./Form"; 3 | 4 | export function FormLogin() { 5 | return
; 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist_injected 5 | dist-ssr 6 | *.local 7 | *.log 8 | /.idea 9 | 10 | .yarn/cache 11 | .yarn/install-state.gz 12 | 13 | public/assets 14 | public/assets_* 15 | !public/assets_default 16 | 17 | .vscode/chrome_data 18 | -------------------------------------------------------------------------------- /src/lib/stopPropagation.ts: -------------------------------------------------------------------------------- 1 | export const stopPropagation = ( 2 | ev: JSX.TargetedMouseEvent, 3 | // eslint-disable-next-line 4 | _consume?: unknown, 5 | ) => { 6 | ev.preventDefault(); 7 | ev.stopPropagation(); 8 | return true; 9 | }; 10 | -------------------------------------------------------------------------------- /src/pages/settings/assets/flags/esperanto.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/fileSize.ts: -------------------------------------------------------------------------------- 1 | export function determineFileSize(size: number) { 2 | if (size > 1e6) { 3 | return `${(size / 1e6).toFixed(2)} MB`; 4 | } else if (size > 1e3) { 5 | return `${(size / 1e3).toFixed(2)} KB`; 6 | } 7 | 8 | return `${size} B`; 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/login/forms/FormCreate.tsx: -------------------------------------------------------------------------------- 1 | import { useClient } from "../../../controllers/client/ClientController"; 2 | import { Form } from "./Form"; 3 | 4 | export function FormCreate() { 5 | const client = useClient(); 6 | return client.register(data)} />; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/preact.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import JSX = preact.JSX; 3 | 4 | declare type Child = 5 | | JSX.Element 6 | | preact.VNode 7 | | string 8 | | number 9 | | boolean 10 | | undefined 11 | | null; 12 | 13 | declare type Children = Child | Child[] | Children[]; 14 | -------------------------------------------------------------------------------- /src/components/markdown/hast.ts: -------------------------------------------------------------------------------- 1 | import { passThroughComponents } from "./plugins/remarkRegexComponent"; 2 | import { timestampHandler } from "./plugins/timestamps"; 3 | 4 | export const handlers = { 5 | ...passThroughComponents("emoji", "spoiler", "mention", "channel"), 6 | timestamp: timestampHandler, 7 | }; 8 | -------------------------------------------------------------------------------- /src/mobx/interfaces/Syncable.ts: -------------------------------------------------------------------------------- 1 | import Store from "./Store"; 2 | 3 | /** 4 | * A data store which syncs data to Revolt. 5 | */ 6 | export default interface Syncable extends Store { 7 | apply(key: string, data: unknown, revision: number): void; 8 | toSyncable(): { [key: string]: object }; 9 | } 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Lounge Chat 3 | url: https://rvlt.gg/Testers 4 | about: Ask questions and discuss with others. 5 | - name: Discussions 6 | url: https://github.com/orgs/revoltchat/discussions 7 | about: For larger feature requests and general question & answer. 8 | -------------------------------------------------------------------------------- /src/lib/isTouchscreenDevice.ts: -------------------------------------------------------------------------------- 1 | import { isDesktop, isMobile, isTablet } from "react-device-detect"; 2 | 3 | export const isTouchscreenDevice = 4 | isDesktop || isTablet 5 | ? false 6 | : (typeof window !== "undefined" 7 | ? navigator.maxTouchPoints > 0 8 | : false) || isMobile; 9 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | export const APP_VERSION = "__APP_VERSION__"; 2 | export const IS_REVOLT = 3 | import.meta.env.VITE_API_URL === "https://api.revolt.chat" || 4 | // future proofing 5 | import.meta.env.VITE_API_URL === "https://app.revolt.chat/api" || 6 | import.meta.env.VITE_API_URL === "https://revolt.chat/api"; 7 | -------------------------------------------------------------------------------- /src/components/settings/appearance/ThemeOverrides.tsx: -------------------------------------------------------------------------------- 1 | import Overrides from "./legacy/ThemeOverrides"; 2 | import ThemeTools from "./legacy/ThemeTools"; 3 | 4 | export default function ThemeOverrides() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import "./styles/index.scss"; 2 | import { render } from "preact"; 3 | 4 | import "../external/lang/Languages.patch"; 5 | import { App } from "./pages/app"; 6 | import "./updateWorker"; 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 9 | render(, document.getElementById("app")!); 10 | -------------------------------------------------------------------------------- /src/components/settings/roles/RoleSelection.ts: -------------------------------------------------------------------------------- 1 | import { API } from "revolt.js"; 2 | 3 | export type RoleOrDefault = ( 4 | | API.Role 5 | | { 6 | name: string; 7 | permissions: number; 8 | colour?: string; 9 | hoist?: boolean; 10 | rank?: number; 11 | } 12 | ) & { id: string }; 13 | -------------------------------------------------------------------------------- /src/lib/defer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Schedule a task at the end of the Event Loop 3 | * @param cb Callback 4 | */ 5 | export const defer = (cb: () => void) => setTimeout(cb, 0); 6 | 7 | /** 8 | * Schedule a task at the end of the second Event Loop 9 | * @param cb Callback 10 | */ 11 | export const chainedDefer = (cb: () => void) => defer(() => defer(cb)); 12 | -------------------------------------------------------------------------------- /src/components/README.md: -------------------------------------------------------------------------------- 1 | The following folders should not be added to or modified: 2 | 3 | - `common` 4 | - `markdown` 5 | - `native` 6 | - `ui` 7 | 8 | The following are part-legacy, will remain in place and will be rewritten to some degree still: 9 | 10 | - `navigation` 11 | 12 | The following are mostly good to go: 13 | 14 | - `settings` 15 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "external/lang"] 2 | path = external/lang 3 | url = https://github.com/revoltchat/translations 4 | [submodule "external/components"] 5 | path = external/components 6 | url = https://github.com/revoltchat/components 7 | [submodule "external/revolt.js"] 8 | path = external/revolt.js 9 | url = https://github.com/revoltchat/revolt.js 10 | -------------------------------------------------------------------------------- /src/components/markdown/plugins/htmlToText.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "unified"; 2 | import { visit } from "unist-util-visit"; 3 | 4 | export const remarkHtmlToText: Plugin = () => { 5 | return (tree) => { 6 | visit(tree, "html", (node: { type: string; value: string }) => { 7 | node.type = "text"; 8 | }); 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 4, 3 | trailingComma: "all", 4 | jsxBracketSameLine: true, 5 | importOrder: [ 6 | "preact|classnames|.scss$", 7 | "^@revoltchat", 8 | "/(lib)", 9 | "/(redux|mobx)", 10 | "/(context)", 11 | "/(ui|common)$", 12 | ".svg|.webp|.png|.jpg$", 13 | "^[./]", 14 | ], 15 | importOrderSeparation: true, 16 | }; 17 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs 7 | spec: "@yarnpkg/plugin-typescript" 8 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 9 | spec: "@yarnpkg/plugin-workspace-tools" 10 | 11 | yarnPath: .yarn/releases/yarn-3.2.0.cjs 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-buster AS builder 2 | 3 | WORKDIR /usr/src/app 4 | COPY . . 5 | COPY .env.build .env 6 | 7 | RUN yarn install --frozen-lockfile 8 | RUN yarn build:deps 9 | # RUN yarn typecheck # lol no 10 | RUN yarn build:highmem 11 | RUN yarn workspaces focus --production --all 12 | 13 | FROM node:16-alpine 14 | WORKDIR /usr/src/app 15 | COPY --from=builder /usr/src/app . 16 | 17 | EXPOSE 5000 18 | CMD [ "yarn", "start:inject" ] 19 | -------------------------------------------------------------------------------- /src/revision.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // Strings needs to be explictly stated here as they can cause type issues elsewhere. 3 | 4 | export const REPO_URL: string = "https://github.com/revoltchat/revite/commit"; 5 | export const GIT_REVISION: string = "__GIT_REVISION__"; 6 | export const GIT_BRANCH: string = "__GIT_BRANCH__"; 7 | 8 | export function isDebug() { 9 | return import.meta.env.FORCE_DEBUG === "1" || import.meta.env.DEV; 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/ConditionalLink.tsx: -------------------------------------------------------------------------------- 1 | import { Link, LinkProps } from "react-router-dom"; 2 | 3 | type Props = LinkProps & 4 | JSX.HTMLAttributes & { 5 | active: boolean; 6 | }; 7 | 8 | export default function ConditionalLink(props: Props) { 9 | const { active, ...linkProps } = props; 10 | 11 | if (active) { 12 | return {props.children}; 13 | } 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /src/mobx/interfaces/Persistent.ts: -------------------------------------------------------------------------------- 1 | import Store from "./Store"; 2 | 3 | /** 4 | * A data store which is persistent and should cache its data locally. 5 | */ 6 | export default interface Persistent extends Store { 7 | /** 8 | * Serialise this data store. 9 | */ 10 | toJSON(): unknown; 11 | 12 | /** 13 | * Hydrate this data store using given data. 14 | * @param data Given data 15 | */ 16 | hydrate(data: T, revision: number): void; 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/mirroring.yml: -------------------------------------------------------------------------------- 1 | name: Mirroring 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | 8 | jobs: 9 | to_gitlab: 10 | runs-on: ubuntu-18.04 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: pixta-dev/repository-mirroring-action@v1 14 | with: 15 | target_repo_url: git@gitlab.com:insert/revolt-vite.git 16 | ssh_private_key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} 17 | -------------------------------------------------------------------------------- /src/controllers/safety/index.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "revolt.js"; 2 | 3 | export function report(object: Server) { 4 | let type; 5 | if (object instanceof Server) { 6 | type = "Server"; 7 | } 8 | 9 | window.open( 10 | `mailto:abuse@revolt.chat?subject=${encodeURIComponent( 11 | `${type} Report`, 12 | )}&body=${encodeURIComponent( 13 | `${type} ID: ${object._id}\nWrite more information here!`, 14 | )}`, 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/context/history.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from "history"; 2 | 3 | export const history = createBrowserHistory({ 4 | basename: import.meta.env.BASE_URL, 5 | }); 6 | 7 | export const routeInformation = { 8 | getServer: () => 9 | /server\/([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26})/.exec( 10 | history.location.pathname, 11 | )?.[1], 12 | getChannel: () => 13 | /channel\/([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26})/.exec( 14 | history.location.pathname, 15 | )?.[1], 16 | }; 17 | -------------------------------------------------------------------------------- /src/lib/modifiers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility file for detecting whether the 3 | * shift key is currently pressed or not. 4 | */ 5 | 6 | export let shiftKeyPressed = false; 7 | 8 | if (typeof window !== "undefined") { 9 | document.addEventListener("keydown", (ev) => { 10 | if (ev.shiftKey) shiftKeyPressed = true; 11 | else shiftKeyPressed = false; 12 | }); 13 | 14 | document.addEventListener("keyup", (ev) => { 15 | if (ev.shiftKey) shiftKeyPressed = true; 16 | else shiftKeyPressed = false; 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/window.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Inject a key into the window's globals. 3 | * @param key Key 4 | * @param value Value 5 | */ 6 | export function injectWindow(key: string, value: any) { 7 | (window as any)[key] = value; 8 | } 9 | 10 | /** 11 | * Inject a controller into the global controllers object. 12 | * @param key Key 13 | * @param value Value 14 | */ 15 | export function injectController(key: string, value: any) { 16 | (window as any).controllers = { 17 | ...((window as any).controllers ?? {}), 18 | [key]: value, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/markdown/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, lazy } from "preact/compat"; 2 | 3 | const Renderer = lazy(() => import("./RemarkRenderer")); 4 | 5 | export interface MarkdownProps { 6 | content: string; 7 | disallowBigEmoji?: boolean; 8 | } 9 | 10 | export default function Markdown(props: MarkdownProps) { 11 | if (!props.content) return null; 12 | 13 | return ( 14 | // @ts-expect-error Typings mis-match. 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/controllers/client/jsx/Binder.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | 3 | import { useEffect } from "preact/hooks"; 4 | 5 | import { state } from "../../../mobx/State"; 6 | 7 | import { clientController } from "../ClientController"; 8 | 9 | /** 10 | * Also binds listeners from state to the current client. 11 | */ 12 | const Binder: React.FC = () => { 13 | const client = clientController.getReadyClient(); 14 | useEffect(() => state.registerListeners(client!), [client]); 15 | return null; 16 | }; 17 | 18 | export default observer(Binder); 19 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Please make sure to check the following tasks before opening and submitting a PR 2 | 3 | * [ ] I understand and have followed the [contribution guide](https://github.com/revoltchat/revolt/discussions/282) 4 | * [ ] I have tested my changes locally and they are working as intended 5 | * [ ] These changes do not have any notable side effects on other Revolt projects 6 | * [ ] (optional) I have opened a pull request on [the translation repository](https://github.com/revoltchat/translations) 7 | * [ ] I have included screenshots to demonstrate my changes 8 | -------------------------------------------------------------------------------- /src/components/navigation/items/placeholder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "context-menu"; 3 | @import "elements"; 4 | @import "page"; 5 | 6 | @import "react-overlapping-panels/dist"; 7 | @import "tippy.js/dist/tippy.css"; 8 | @import "tippy.js/animations/shift-away.css"; 9 | 10 | .tippy-box { 11 | color: var(--foreground); 12 | background: var(--tooltip, var(--background)); 13 | } 14 | 15 | .tippy-content { 16 | padding: 6px 10px; 17 | font-size: 13px; 18 | font-weight: 600; 19 | max-width: 200px; 20 | } 21 | 22 | .tippy-arrow { 23 | color: var(--tooltip); 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/settings/assets/revolt_r.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/lib/conversion.ts: -------------------------------------------------------------------------------- 1 | export function urlBase64ToUint8Array(base64String: string) { 2 | const padding = "=".repeat((4 - (base64String.length % 4)) % 4); 3 | const base64 = (base64String + padding) 4 | .replace(/-/g, "+") 5 | .replace(/_/g, "/"); 6 | const rawData = window.atob(base64); 7 | 8 | return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); 9 | } 10 | 11 | export function mapToRecord( 12 | map: Map, 13 | ) { 14 | const record = {} as Record; 15 | map.forEach((v, k) => (record[k] = v)); 16 | return record; 17 | } 18 | -------------------------------------------------------------------------------- /public/assets_default/badges/amog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets_default/badges/paw.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets_default/icons/monochrome.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets_default/badges/developer.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets_default/badges/founder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets_default/badges/raccoon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets_default/badges/revolt_r.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets_default/badges/supporter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets_default/badges/verified.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets_default/badges/early_adopter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets_default/badges/moderation.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets_default/badges/translator.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://local.revolt.chat:3000", 12 | "webRoot": "${workspaceFolder}", 13 | "runtimeExecutable": "/usr/bin/chromium", 14 | "userDataDir": "${workspaceFolder}/.vscode/chrome_data" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/controllers/modals/components/PendingFriendRequests.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { Column, Modal } from "@revoltchat/ui"; 4 | 5 | import { Friend } from "../../../pages/friends/Friend"; 6 | import { ModalProps } from "../types"; 7 | 8 | export default function PendingFriendRequests({ 9 | users, 10 | ...props 11 | }: ModalProps<"pending_friend_requests">) { 12 | return ( 13 | }> 14 | 15 | {users.map((x) => ( 16 | 17 | ))} 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/windowSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "preact/hooks"; 2 | 3 | export function useWindowSize() { 4 | const [windowSize, setWindowSize] = useState({ 5 | width: window.innerWidth, 6 | height: window.innerHeight, 7 | }); 8 | 9 | useEffect(() => { 10 | function handleResize() { 11 | setWindowSize({ 12 | width: window.innerWidth, 13 | height: window.innerHeight, 14 | }); 15 | } 16 | 17 | window.addEventListener("resize", handleResize); 18 | handleResize(); 19 | 20 | return () => window.removeEventListener("resize", handleResize); 21 | }, []); 22 | 23 | return windowSize; 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/PaintCounter.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | import { useState } from "preact/hooks"; 3 | 4 | const counts: { [key: string]: number } = {}; 5 | 6 | export default function PaintCounter({ 7 | small, 8 | always, 9 | }: { 10 | small?: boolean; 11 | always?: boolean; 12 | }) { 13 | if (import.meta.env.PROD && !always) return null; 14 | 15 | const [uniqueId] = useState(`${Math.random()}`); 16 | const count = counts[uniqueId] ?? 0; 17 | counts[uniqueId] = count + 1; 18 | return ( 19 |
20 | {small ? <>P: {count + 1} : <>Painted {count + 1} time(s).} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /scripts/locale.js: -------------------------------------------------------------------------------- 1 | const { readdirSync } = require("fs"); 2 | 3 | console.log( 4 | "var locale_keys = " + 5 | JSON.stringify([ 6 | ...readdirSync("node_modules/dayjs/locale") 7 | .filter((x) => x.endsWith(".js")) 8 | .map((x) => { 9 | v = x.split("."); 10 | v.pop(); 11 | return v.join("."); 12 | }), 13 | ...readdirSync("external/lang") 14 | .filter((x) => x.endsWith(".json")) 15 | .map((x) => { 16 | v = x.split("."); 17 | v.pop(); 18 | return v.join("."); 19 | }), 20 | ]) + 21 | ";", 22 | ); 23 | -------------------------------------------------------------------------------- /src/controllers/modals/components/legacy/assets/onboarding_background.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/components/common/user/UserCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "revolt.js"; 2 | 3 | import { Checkbox, Row, Column } from "@revoltchat/ui"; 4 | 5 | import UserIcon from "./UserIcon"; 6 | import { Username } from "./UserShort"; 7 | 8 | type UserProps = { value: boolean; onChange: (v: boolean) => void; user: User }; 9 | 10 | export default function UserCheckbox({ user, ...props }: UserProps) { 11 | return ( 12 | 16 | 17 | 18 | 19 | 20 | 21 | } 22 | /> 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/types/native.d.ts: -------------------------------------------------------------------------------- 1 | type Build = "stable" | "nightly" | "dev"; 2 | 3 | type NativeConfig = { 4 | frame: boolean; 5 | build: Build; 6 | discordRPC: boolean; 7 | minimiseToTray: boolean; 8 | hardwareAcceleration: boolean; 9 | }; 10 | 11 | declare interface Window { 12 | isNative?: boolean; 13 | nativeVersion: string; 14 | native: { 15 | min(); 16 | max(); 17 | close(); 18 | reload(); 19 | relaunch(); 20 | 21 | getConfig(): NativeConfig; 22 | set(key: keyof NativeConfig, value: unknown); 23 | 24 | getAutoStart(): Promise; 25 | enableAutoStart(): Promise; 26 | disableAutoStart(): Promise; 27 | }; 28 | } 29 | 30 | declare const Fragment = preact.Fragment; 31 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Build and publish release to production server 3 | 4 | # Remote Server 5 | if [ -z "$REMOTE" ]; then 6 | echo "Please set REMOTE!" 7 | exit 8 | fi 9 | 10 | # Remote Directory 11 | REMOTE_DIR=/root/revite 12 | 13 | # Post-install script 14 | POST_INSTALL="pm2 restart revite" 15 | 16 | # Assets 17 | export REVOLT_SAAS=https://github.com/revoltchat/assets 18 | 19 | 20 | # Exit when any command fails 21 | set -e 22 | 23 | # 1. Build Revite 24 | yarn build:highmem 25 | 26 | # 2. Archive built files 27 | tar -czvf build.tar.gz dist 28 | 29 | # 3. Upload built files 30 | scp build.tar.gz $REMOTE:$REMOTE_DIR/build.tar.gz 31 | rm build.tar.gz 32 | 33 | # 4. Apply changes 34 | ssh $REMOTE "cd $REMOTE_DIR; tar -xvzf build.tar.gz; rm build.tar.gz; $POST_INSTALL" 35 | 36 | -------------------------------------------------------------------------------- /src/controllers/modals/components/SignedOut.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 | * Indicate that the user has been signed out of their account 11 | */ 12 | export default function SignedOut(props: ModalProps<"signed_out">) { 13 | return ( 14 | } 17 | actions={[ 18 | { 19 | onClick: noopTrue, 20 | confirmation: true, 21 | children: , 22 | }, 23 | ]} 24 | /> 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "preserve", 17 | "jsxFactory": "h", 18 | "jsxFragmentFactory": "Fragment", 19 | "types": ["vite-plugin-pwa/client"], 20 | "experimentalDecorators": true 21 | }, 22 | "include": ["src", "external/lang/Languages.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /src/components/markdown/plugins/channels.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import { clientController } from "../../../controllers/client/ClientController"; 4 | import { createComponent, CustomComponentProps } from "./remarkRegexComponent"; 5 | 6 | export function RenderChannel({ match }: CustomComponentProps) { 7 | const channel = clientController.getAvailableClient().channels.get(match)!; 8 | 9 | return ( 10 | {`#${channel.name}`} 14 | ); 15 | } 16 | 17 | export const remarkChannels = createComponent( 18 | "channel", 19 | /<#([A-z0-9]{26})>/g, 20 | (match) => clientController.getAvailableClient().channels.has(match), 21 | ); 22 | -------------------------------------------------------------------------------- /disabled-js.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/pages/settings/assets/flags/kurdistan.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/settings/assets/flags/sources.txt: -------------------------------------------------------------------------------- 1 | Flag of Brittany 2 | CC BY-SA 4.0 3 | https://commons.wikimedia.org/wiki/File:Flag_of_Brittany.svg 4 | 5 | Enchanting Table 6 | Minecraft game render 7 | https://minecraft.fandom.com/wiki/Enchanting_Table?file=Enchanting_Table.gif 8 | 9 | Flag of Esperanto 10 | Public Domain 11 | https://commons.wikimedia.org/wiki/File:Flag_of_Esperanto.svg 12 | 13 | Flag of Kurdistan 14 | Public Domain 15 | https://commons.wikimedia.org/wiki/File:Flag_of_Kurdistan.svg 16 | 17 | Tamil Nadu Flag 18 | CC BY-SA 3.0 19 | https://commons.wikimedia.org/wiki/File:..Tamil_Nadu_Flag(INDIA).png 20 | 21 | Toki Pona Flag 22 | Free for any use 23 | https://www.reddit.com/r/tokipona/comments/mevzbn/a_flag_for_toki_pona/gsk3euc/ 24 | 25 | Flag of Veneto 26 | CC BY-SA 3.0 27 | https://commons.wikimedia.org/wiki/File:Flag_of_Veneto.svg 28 | -------------------------------------------------------------------------------- /public/.well-known/assetlinks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "relation": ["delegate_permission/common.handle_all_urls"], 4 | "target": { 5 | "namespace": "android_app", 6 | "package_name": "chat.revolt.app.twa", 7 | "sha256_cert_fingerprints": [ 8 | "6E:62:C1:BF:5A:2D:11:31:A3:22:91:8D:22:2B:2C:49:D3:70:F3:A1:45:DF:11:6A:97:DC:4C:A9:3B:C3:AA:FB" 9 | ] 10 | } 11 | }, 12 | { 13 | "relation": ["delegate_permission/common.handle_all_urls"], 14 | "target": { 15 | "namespace": "android_app", 16 | "package_name": "chat.revolt.app.twa", 17 | "sha256_cert_fingerprints": [ 18 | "2B:C7:89:87:BD:62:88:38:7B:C0:D7:5F:D1:10:F4:91:D5:24:A6:B3:25:3A:75:C2:3A:91:07:1B:63:C0:98:67" 19 | ] 20 | } 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /src/controllers/modals/components/legacy/Onboarding.module.scss: -------------------------------------------------------------------------------- 1 | .onboarding { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | 8 | background: var(--background); 9 | 10 | display: flex; 11 | align-items: center; 12 | flex-direction: column; 13 | 14 | div { 15 | &.container { 16 | max-width: 750px; 17 | flex-grow: 1; 18 | align-items: left; 19 | } 20 | 21 | &.header { 22 | gap: 8px; 23 | padding-top: 5em; 24 | display: flex; 25 | } 26 | 27 | &.form { 28 | input { 29 | width: 100%; 30 | } 31 | 32 | button { 33 | display: block; 34 | margin: 24px 0; 35 | margin-left: auto; 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/controllers/client/jsx/error.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | export function takeError(error: any): string { 3 | if (error.response) { 4 | const type = error.response.data?.type; 5 | if (type) { 6 | return type; 7 | } 8 | 9 | switch (error.response.status) { 10 | case 429: 11 | return "TooManyRequests"; 12 | case 401: 13 | case 403: 14 | return "Unauthorized"; 15 | default: 16 | return "UnknownError"; 17 | } 18 | } else if (error.request) { 19 | return "NetworkError"; 20 | } 21 | 22 | console.error(error); 23 | return "UnknownError"; 24 | } 25 | 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | export function mapError(error: any): never { 28 | throw takeError(error); 29 | } 30 | -------------------------------------------------------------------------------- /src/controllers/client/jsx/ChannelName.tsx: -------------------------------------------------------------------------------- 1 | // ! This should be moved into @revoltchat/ui 2 | import { Channel } from "revolt.js"; 3 | 4 | import { Text } from "preact-i18n"; 5 | 6 | interface Props { 7 | channel?: Channel; 8 | prefix?: boolean; 9 | } 10 | 11 | /** 12 | * Channel display name 13 | */ 14 | export function ChannelName({ channel, prefix }: Props) { 15 | if (!channel) return <>; 16 | 17 | if (channel.channel_type === "SavedMessages") 18 | return ; 19 | 20 | if (channel.channel_type === "DirectMessage") { 21 | return ( 22 | <> 23 | {prefix && "@"} 24 | {channel.recipient!.username} 25 | 26 | ); 27 | } 28 | 29 | if (channel.channel_type === "TextChannel" && prefix) { 30 | return <>{`#${channel.name}`}; 31 | } 32 | 33 | return <>{channel.name}; 34 | } 35 | -------------------------------------------------------------------------------- /src/components/common/messaging/attachments/Spoiler.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components/macro"; 2 | 3 | import { Text } from "preact-i18n"; 4 | 5 | const Base = styled.div` 6 | display: grid; 7 | place-items: center; 8 | 9 | z-index: 1; 10 | grid-area: 1 / 1; 11 | 12 | cursor: pointer; 13 | user-select: none; 14 | text-transform: uppercase; 15 | 16 | span { 17 | padding: 8px; 18 | color: var(--foreground); 19 | background: var(--primary-background); 20 | border-radius: calc(var(--border-radius) * 4); 21 | } 22 | `; 23 | 24 | interface Props { 25 | set: (v: boolean) => void; 26 | } 27 | 28 | export default function Spoiler({ set }: Props) { 29 | return ( 30 | set(false)}> 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/eventEmitter.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | 3 | export const InternalEvent = new EventEmitter(); 4 | 5 | export function internalSubscribe( 6 | ns: string, 7 | event: string, 8 | fn: (...args: unknown[]) => void, 9 | ) { 10 | InternalEvent.addListener(`${ns}/${event}`, fn); 11 | return () => InternalEvent.removeListener(`${ns}/${event}`, fn); 12 | } 13 | 14 | export function internalEmit(ns: string, event: string, ...args: unknown[]) { 15 | InternalEvent.emit(`${ns}/${event}`, ...args); 16 | } 17 | 18 | // Event structure: namespace/event 19 | 20 | /// Event List 21 | // - RightSidebar/open 22 | // - MessageArea/jump_to_bottom 23 | // - MessageRenderer/edit_last 24 | // - MessageRenderer/edit_message 25 | // - Intermediate/open_profile 26 | // - Intermediate/navigate 27 | // - MessageBox/append 28 | // - TextArea/focus 29 | // - ReplyBar/add 30 | // - Modal/close 31 | // - PWA/update 32 | // - NewMessages/hide 33 | // - NewMessages/mark 34 | // - System/alert 35 | -------------------------------------------------------------------------------- /src/controllers/modals/components/ChannelInfo.tsx: -------------------------------------------------------------------------------- 1 | import { X } from "@styled-icons/boxicons-regular"; 2 | 3 | import { Column, H1, IconButton, Modal, Row } from "@revoltchat/ui"; 4 | 5 | import Markdown from "../../../components/markdown/Markdown"; 6 | import { modalController } from "../ModalController"; 7 | import { ModalProps } from "../types"; 8 | 9 | export default function ChannelInfo({ 10 | channel, 11 | ...props 12 | }: ModalProps<"channel_info">) { 13 | return ( 14 | 18 | 19 |

{`#${channel.name}`}

20 |
21 | 22 | 23 | 24 | 25 | }> 26 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/channels/messaging/ConversationStart.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { Channel } from "revolt.js"; 3 | import styled from "styled-components/macro"; 4 | 5 | import { Text } from "preact-i18n"; 6 | 7 | import { ChannelName } from "../../../controllers/client/jsx/ChannelName"; 8 | 9 | const StartBase = styled.div` 10 | margin: 18px 16px 10px 16px; 11 | 12 | h1 { 13 | font-size: 23px; 14 | margin: 0 0 8px 0; 15 | } 16 | 17 | h4 { 18 | font-weight: 400; 19 | margin: 0; 20 | font-size: 14px; 21 | } 22 | `; 23 | 24 | interface Props { 25 | channel: Channel; 26 | } 27 | 28 | export default observer(({ channel }: Props) => { 29 | return ( 30 | 31 |

32 | 33 |

34 |

35 | 36 |

37 |
38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /.github/actions/build/action.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | description: Builds a project instance, assuming all the correct project files are in the build folder 3 | 4 | inputs: 5 | base: 6 | name: Base path 7 | description: The path to use as a base for linking 8 | required: true 9 | default: / 10 | folder: 11 | name: Build Folder 12 | description: The folder to try to build from 13 | required: true 14 | default: . 15 | 16 | runs: 17 | using: composite 18 | steps: 19 | - name: Setup Node 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 16 23 | cache: "yarn" 24 | 25 | - name: Install Dependencies and Build 26 | shell: bash -l {0} 27 | env: 28 | BUILD_FOLDER: ${{ inputs.folder }} 29 | BASE: ${{ inputs.base }} 30 | run: | 31 | cd "$BUILD_FOLDER" 32 | yarn install 33 | yarn build --base "$BASE" 34 | -------------------------------------------------------------------------------- /src/controllers/modals/components/Error.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 | export default function Error({ error, ...props }: ModalProps<"error">) { 10 | return ( 11 | } 14 | actions={[ 15 | { 16 | onClick: noopTrue, 17 | confirmation: true, 18 | children: , 19 | }, 20 | { 21 | palette: "plain-secondary", 22 | onClick: () => location.reload(), 23 | children: , 24 | }, 25 | ]}> 26 | {error} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/controllers/modals/components/ShowToken.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 | export default function ShowToken({ 10 | name, 11 | token, 12 | ...props 13 | }: ModalProps<"show_token">) { 14 | return ( 15 | 22 | } 23 | actions={[ 24 | { 25 | onClick: noopTrue, 26 | confirmation: true, 27 | children: , 28 | }, 29 | ]}> 30 | 31 | {token} 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/login/forms/FormReset.tsx: -------------------------------------------------------------------------------- 1 | import { useHistory, useParams } from "react-router-dom"; 2 | 3 | import { useApi } from "../../../controllers/client/ClientController"; 4 | import { Form } from "./Form"; 5 | 6 | export function FormSendReset() { 7 | const api = useApi(); 8 | 9 | return ( 10 | { 13 | await api.post("/auth/account/reset_password", data); 14 | }} 15 | /> 16 | ); 17 | } 18 | 19 | export function FormReset() { 20 | const { token } = useParams<{ token: string }>(); 21 | const history = useHistory(); 22 | const api = useApi(); 23 | 24 | return ( 25 | { 28 | await api.patch("/auth/account/reset_password", { 29 | token, 30 | ...data, 31 | }); 32 | history.push("/login"); 33 | }} 34 | /> 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/common/messaging/embed/EmbedMediaActions.tsx: -------------------------------------------------------------------------------- 1 | import { LinkExternal } from "@styled-icons/boxicons-regular"; 2 | import { API } from "revolt.js"; 3 | 4 | import styles from "./Embed.module.scss"; 5 | 6 | import { IconButton } from "@revoltchat/ui"; 7 | 8 | interface Props { 9 | embed: API.Image; 10 | } 11 | 12 | export default function EmbedMediaActions({ embed }: Props) { 13 | const filename = embed.url.split("/").pop(); 14 | 15 | return ( 16 |
17 | {filename} 18 | 19 | {`${embed.width}x${embed.height}`} 20 | 21 | 26 | 27 | 28 | 29 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Make a feature request 3 | title: "feature request: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 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). 10 | - type: textarea 11 | id: your-idea 12 | attributes: 13 | label: What do you want to see? 14 | description: Describe your idea in as much detail as possible - if applicable, screenshots/mockups are really useful. 15 | validations: 16 | required: true 17 | - type: checkboxes 18 | id: pwa 19 | attributes: 20 | label: PWA 21 | description: Is this feature request specific to the PWA (i.e. "installing" the web app on iOS or Android)? (If not, leave this unchecked.) 22 | options: 23 | - label: Yes, this feature request is specific to the PWA. 24 | required: false 25 | -------------------------------------------------------------------------------- /src/components/common/LocaleSelector.tsx: -------------------------------------------------------------------------------- 1 | import { ComboBox } from "@revoltchat/ui"; 2 | 3 | import { useApplicationState } from "../../mobx/State"; 4 | 5 | import { Language, Languages } from "../../../external/lang/Languages"; 6 | 7 | /** 8 | * Component providing a language selector combobox. 9 | * Note: this is not an observer but this is fine as we are just using a combobox. 10 | */ 11 | export default function LocaleSelector() { 12 | const locale = useApplicationState().locale; 13 | 14 | return ( 15 | 18 | locale.setLanguage(e.currentTarget.value as Language) 19 | }> 20 | {Object.keys(Languages).map((x) => { 21 | const l = Languages[x as keyof typeof Languages]; 22 | return ( 23 | 26 | ); 27 | })} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/controllers/modals/components/AddFriend.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { ModalForm } from "@revoltchat/ui"; 4 | 5 | import { noop } from "../../../lib/js"; 6 | 7 | import { useClient } from "../../client/ClientController"; 8 | import { ModalProps } from "../types"; 9 | 10 | /** 11 | * Add friend modal 12 | */ 13 | export default function AddFriend({ ...props }: ModalProps<"add_friend">) { 14 | const client = useClient(); 15 | 16 | return ( 17 | 30 | client.api.post(`/users/friend`, { username }).then(noop) 31 | } 32 | submit={{ 33 | children: , 34 | }} 35 | /> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/controllers/modals/components/ImportTheme.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { ModalForm } from "@revoltchat/ui"; 4 | 5 | import { state } from "../../../mobx/State"; 6 | 7 | import { ModalProps } from "../types"; 8 | 9 | /** 10 | * Import theme modal 11 | */ 12 | export default function ImportTheme({ ...props }: ModalProps<"import_theme">) { 13 | return ( 14 | } 17 | schema={{ 18 | data: "text", 19 | }} 20 | data={{ 21 | data: { 22 | field: ( 23 | 24 | ) as React.ReactChild, 25 | }, 26 | }} 27 | callback={async ({ data }) => 28 | state.settings.theme.hydrate(JSON.parse(data)) 29 | } 30 | submit={{ 31 | children: , 32 | }} 33 | /> 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/settings/panes/Account.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import styles from "./Panes.module.scss"; 4 | import { Text } from "preact-i18n"; 5 | 6 | import { Tip } from "@revoltchat/ui"; 7 | 8 | import AccountManagement from "../../../components/settings/account/AccountManagement"; 9 | import EditAccount from "../../../components/settings/account/EditAccount"; 10 | import MultiFactorAuthentication from "../../../components/settings/account/MultiFactorAuthentication"; 11 | 12 | export function Account() { 13 | return ( 14 |
15 | 16 |
17 | 18 | 19 |
20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | {" "} 28 | 29 | 30 | 31 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/controllers/modals/components/CreateRole.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { ModalForm } from "@revoltchat/ui"; 4 | 5 | import { ModalProps } from "../types"; 6 | 7 | /** 8 | * Role creation modal 9 | */ 10 | export default function CreateRole({ 11 | server, 12 | callback, 13 | ...props 14 | }: ModalProps<"create_role">) { 15 | return ( 16 | } 19 | schema={{ 20 | name: "text", 21 | }} 22 | data={{ 23 | name: { 24 | field: ( 25 | 26 | ) as React.ReactChild, 27 | }, 28 | }} 29 | callback={async ({ name }) => { 30 | const role = await server.createRole(name); 31 | callback(role.id); 32 | }} 33 | submit={{ 34 | children: , 35 | }} 36 | /> 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/controllers/modals/components/Clipboard.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 | export default function Clipboard({ text, ...props }: ModalProps<"clipboard">) { 10 | return ( 11 | } 14 | description={ 15 | location.protocol !== "https:" ? ( 16 | 17 | ) : undefined 18 | } 19 | actions={[ 20 | { 21 | onClick: noopTrue, 22 | confirmation: true, 23 | children: , 24 | }, 25 | ]}> 26 | {" "} 27 | 28 | {text} 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/common/CollapsibleSection.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronDown } from "@styled-icons/boxicons-regular"; 2 | 3 | import { Details } from "@revoltchat/ui"; 4 | 5 | import { useApplicationState } from "../../mobx/State"; 6 | 7 | interface Props { 8 | id: string; 9 | defaultValue: boolean; 10 | 11 | sticky?: boolean; 12 | large?: boolean; 13 | 14 | summary: Children; 15 | children: Children; 16 | } 17 | 18 | export default function CollapsibleSection({ 19 | id, 20 | defaultValue, 21 | summary, 22 | children, 23 | ...detailsProps 24 | }: Props) { 25 | const layout = useApplicationState().layout; 26 | 27 | return ( 28 |
31 | layout.setSectionState(id, e.currentTarget.open, defaultValue) 32 | } 33 | {...detailsProps}> 34 | 35 |
36 | 37 | {summary} 38 |
39 |
40 | {children} 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | /** 3 | * Appearance 4 | */ 5 | --ligatures: none; 6 | --text-size: 14px; 7 | --font: "Open Sans"; 8 | --sidebar-active: var(--secondary-background); 9 | 10 | /** 11 | * Native 12 | */ 13 | --titlebar-height: 29px; 14 | --titlebar-action-padding: 8px; 15 | --titlebar-logo-color: var(--secondary-foreground); 16 | 17 | /** 18 | * Layout 19 | */ 20 | --app-height: 100vh; 21 | --scrollbar-thickness: 3px; 22 | --scrollbar-thickness-ff: thin; 23 | --border-radius: 6px; 24 | --border-radius-half: 50%; 25 | 26 | --border-radius-user-icon: var(--border-radius-half); 27 | --border-radius-channel-icon: var(--border-radius-half); 28 | --border-radius-server-icon: var(--border-radius-half); 29 | 30 | --input-border-width: 2px; 31 | --textarea-padding: 16px; 32 | --textarea-line-height: 20px; 33 | --message-box-padding: 14px 14px 14px 0; 34 | 35 | --attachment-max-width: 400px; 36 | --attachment-max-height: 300px; 37 | --attachment-default-width: 400px; 38 | --attachment-max-text-width: 800px; 39 | 40 | --bottom-navigation-height: 50px; 41 | } 42 | -------------------------------------------------------------------------------- /src/mobx/stores/helpers/SSecurity.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, computed, action } from "mobx"; 2 | 3 | import Settings from "../Settings"; 4 | 5 | const TRUSTED_DOMAINS = ["revolt.chat", "revolt.wtf", "gifbox.me", "rvlt.gg"]; 6 | 7 | /** 8 | * Helper class for changing security options. 9 | */ 10 | export default class SSecurity { 11 | private settings: Settings; 12 | 13 | /** 14 | * Construct a new security helper. 15 | * @param settings Settings parent class 16 | */ 17 | constructor(settings: Settings) { 18 | this.settings = settings; 19 | makeAutoObservable(this); 20 | } 21 | 22 | @action addTrustedOrigin(origin: string) { 23 | this.settings.set("security:trustedOrigins", [ 24 | ...(this.settings.get("security:trustedOrigins") ?? []).filter( 25 | (x) => x !== origin, 26 | ), 27 | origin, 28 | ]); 29 | } 30 | 31 | @computed isTrustedOrigin(origin: string) { 32 | if (TRUSTED_DOMAINS.find((x) => origin.endsWith(x))) { 33 | return true; 34 | } 35 | 36 | return this.settings.get("security:trustedOrigins")?.includes(origin); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/common/messaging/attachments/AttachmentActions.module.scss: -------------------------------------------------------------------------------- 1 | .actions.imageAction { 2 | grid-template: 3 | "name icon external download" auto 4 | "size icon external download" auto 5 | / minmax(20px, 1fr) min-content min-content; 6 | } 7 | 8 | .actions { 9 | display: grid; 10 | grid-template: 11 | "icon name external download" auto 12 | "icon size external download" auto 13 | / min-content minmax(20px, 1fr) min-content; 14 | 15 | align-items: center; 16 | column-gap: 12px; 17 | 18 | width: 100%; 19 | padding: 8px; 20 | overflow: none; 21 | 22 | color: var(--foreground); 23 | background: var(--secondary-background); 24 | 25 | span { 26 | text-overflow: ellipsis; 27 | white-space: nowrap; 28 | overflow: hidden; 29 | } 30 | 31 | .filesize { 32 | grid-area: size; 33 | 34 | font-size: 10px; 35 | color: var(--secondary-foreground); 36 | } 37 | 38 | .downloadIcon { 39 | grid-area: download; 40 | } 41 | 42 | .externalType { 43 | grid-area: external; 44 | } 45 | 46 | .iconType { 47 | grid-area: icon; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/styles/_page.scss: -------------------------------------------------------------------------------- 1 | * { 2 | text-rendering: optimizeLegibility !important; 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | 6 | box-sizing: border-box; 7 | scrollbar-width: var(--scrollbar-thickness-ff); 8 | 9 | -webkit-touch-callout: none; 10 | } 11 | 12 | html { 13 | // contain: content; 14 | background-size: cover !important; 15 | background-repeat: no-repeat !important; 16 | background-color: var(--background) !important; 17 | } 18 | 19 | html, 20 | body { 21 | margin: 0; 22 | height: 100%; 23 | font-family: var(--font), sans-serif; 24 | font-variant-ligatures: var(--ligatures); 25 | 26 | -webkit-font-smoothing: antialiased; 27 | -moz-osx-font-smoothing: grayscale; 28 | caret-color: var(--accent); 29 | color: var(--foreground); 30 | 31 | -webkit-tap-highlight-color: transparent; 32 | -webkit-overflow-scrolling: touch; 33 | -webkit-text-size-adjust: 100%; 34 | overscroll-behavior: none; 35 | 36 | scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); 37 | } 38 | 39 | #app { 40 | position: fixed; 41 | top: 0; 42 | left: 0; 43 | width: 100%; 44 | height: 100%; 45 | } 46 | -------------------------------------------------------------------------------- /scripts/setup_assets.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const { copy, remove, access } = require("fs-extra"); 3 | const { exec: cexec } = require("child_process"); 4 | const { resolve } = require("path"); 5 | 6 | let target = process.env.REVOLT_SAAS; 7 | let branch = process.env.REVOLT_SAAS_BRANCH; 8 | let DEFAULT_DIRECTORY = "public/assets_default"; 9 | let OUT_DIRECTORY = "public/assets"; 10 | 11 | function exec(command) { 12 | return new Promise((fulfil, reject) => { 13 | cexec(command, (err, stdout, stderr) => { 14 | if (err) { 15 | reject(err); 16 | return; 17 | } 18 | 19 | fulfil({ stdout, stderr }); 20 | }); 21 | }); 22 | } 23 | 24 | (async () => { 25 | try { 26 | await access(OUT_DIRECTORY); 27 | if (process.argv[2] === "--check") return; 28 | 29 | await remove(OUT_DIRECTORY); 30 | } catch (err) {} 31 | 32 | if (target) { 33 | let arg = branch ? `-b ${branch} ` : ""; 34 | await exec(`git clone ${arg}${target} ${OUT_DIRECTORY}`); 35 | await exec(`rm -rf ${resolve(OUT_DIRECTORY, ".git")}`); 36 | } else { 37 | await copy(DEFAULT_DIRECTORY, OUT_DIRECTORY); 38 | } 39 | })(); 40 | -------------------------------------------------------------------------------- /src/components/markdown/plugins/anchors.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import { determineLink } from "../../../lib/links"; 4 | 5 | import { modalController } from "../../../controllers/modals/ModalController"; 6 | 7 | export function RenderAnchor({ 8 | href, 9 | ...props 10 | }: JSX.HTMLAttributes) { 11 | // Pass-through no href or if anchor 12 | if (!href || href.startsWith("#")) return ; 13 | 14 | // Determine type of link 15 | const link = determineLink(href); 16 | if (link.type === "none") return ; 17 | 18 | // Render direct link if internal 19 | if (link.type === "navigate") { 20 | return ; 21 | } 22 | 23 | return ( 24 | 30 | modalController.openLink( 31 | href, 32 | undefined, 33 | ev.currentTarget.innerText !== href, 34 | ) && ev.preventDefault() 35 | } 36 | /> 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/markdown/plugins/timestamps.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from "mdast-util-to-hast"; 2 | 3 | import { dayjs } from "../../../context/Locale"; 4 | 5 | import { createComponent } from "./remarkRegexComponent"; 6 | 7 | export const timestampHandler: Handler = (h, { match, arg1 }) => { 8 | if (isNaN(match)) return { type: "text", value: match }; 9 | const date = dayjs.unix(match); 10 | 11 | let value = ""; 12 | switch (arg1) { 13 | case "t": 14 | value = date.format("hh:mm"); 15 | break; 16 | case "T": 17 | value = date.format("hh:mm:ss"); 18 | break; 19 | case "R": 20 | value = date.fromNow(); 21 | break; 22 | case "D": 23 | value = date.format("DD MMMM YYYY"); 24 | break; 25 | case "F": 26 | value = date.format("dddd, DD MMMM YYYY hh:mm"); 27 | break; 28 | default: 29 | value = date.format("DD MMMM YYYY hh:mm"); 30 | break; 31 | } 32 | 33 | return h(null, "code", {}, [{ type: "text", value }]); 34 | }; 35 | 36 | export const remarkTimestamps = createComponent( 37 | "timestamp", 38 | //g, 39 | ); 40 | -------------------------------------------------------------------------------- /src/controllers/modals/components/DeleteMessage.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { ModalForm } from "@revoltchat/ui"; 4 | 5 | import Message from "../../../components/common/messaging/Message"; 6 | import { ModalProps } from "../types"; 7 | 8 | /** 9 | * Delete message modal 10 | */ 11 | export default function DeleteMessage({ 12 | target, 13 | ...props 14 | }: ModalProps<"delete_message">) { 15 | return ( 16 | } 19 | description={ 20 | 23 | } 24 | schema={{ 25 | message: "custom", 26 | }} 27 | data={{ 28 | message: { 29 | element: , 30 | }, 31 | }} 32 | callback={() => target.delete()} 33 | submit={{ 34 | palette: "error", 35 | children: , 36 | }} 37 | /> 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/navigation/RightSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Switch } from "react-router"; 2 | 3 | import { useEffect, useState } from "preact/hooks"; 4 | 5 | import { internalSubscribe } from "../../lib/eventEmitter"; 6 | 7 | import SidebarBase from "./SidebarBase"; 8 | import MemberSidebar from "./right/MemberSidebar"; 9 | import { SearchSidebar } from "./right/Search"; 10 | 11 | export default function RightSidebar() { 12 | const [sidebar, setSidebar] = useState<"search" | undefined>(); 13 | const close = () => setSidebar(undefined); 14 | 15 | useEffect( 16 | () => 17 | internalSubscribe( 18 | "RightSidebar", 19 | "open", 20 | setSidebar as (...args: unknown[]) => void, 21 | ), 22 | [setSidebar], 23 | ); 24 | 25 | const content = 26 | sidebar === "search" ? ( 27 | 28 | ) : ( 29 | 30 | ); 31 | 32 | return ( 33 | 34 | 35 | {content} 36 | {content} 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/markdown/plugins/spoiler.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | import { useState } from "preact/hooks"; 4 | 5 | import { createComponent, CustomComponentProps } from "./remarkRegexComponent"; 6 | 7 | const Spoiler = styled.span<{ shown: boolean }>` 8 | padding: 0 2px; 9 | cursor: pointer; 10 | user-select: none; 11 | color: transparent; 12 | background: #151515; 13 | border-radius: var(--border-radius); 14 | 15 | > * { 16 | opacity: 0; 17 | pointer-events: none; 18 | } 19 | 20 | ${(props) => 21 | props.shown && 22 | css` 23 | cursor: auto; 24 | user-select: all; 25 | color: var(--foreground); 26 | background: var(--secondary-background); 27 | 28 | > * { 29 | opacity: 1; 30 | pointer-events: unset; 31 | } 32 | `} 33 | `; 34 | 35 | export function RenderSpoiler({ match }: CustomComponentProps) { 36 | const [shown, setShown] = useState(false); 37 | 38 | return ( 39 | setShown(true)}> 40 | {match} 41 | 42 | ); 43 | } 44 | 45 | export const remarkSpoiler = createComponent("spoiler", /!!([^!]+)!!/g); 46 | -------------------------------------------------------------------------------- /src/controllers/modals/components/CreateBot.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { ModalForm } from "@revoltchat/ui"; 4 | 5 | import { useClient } from "../../client/ClientController"; 6 | import { mapError } from "../../client/jsx/error"; 7 | import { ModalProps } from "../types"; 8 | 9 | /** 10 | * Bot creation modal 11 | */ 12 | export default function CreateBot({ 13 | onCreate, 14 | ...props 15 | }: ModalProps<"create_bot">) { 16 | const client = useClient(); 17 | 18 | return ( 19 | } 22 | schema={{ 23 | name: "text", 24 | }} 25 | data={{ 26 | name: { 27 | field: () as React.ReactChild, 28 | }, 29 | }} 30 | callback={async ({ name }) => { 31 | const { bot } = await client.bots 32 | .create({ name }) 33 | .catch(mapError); 34 | 35 | onCreate(bot); 36 | }} 37 | submit={{ 38 | children: , 39 | }} 40 | /> 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/controllers/client/jsx/CheckAuth.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { Redirect } from "react-router-dom"; 3 | 4 | import { Preloader } from "@revoltchat/ui"; 5 | 6 | import { clientController } from "../ClientController"; 7 | 8 | interface Props { 9 | auth?: boolean; 10 | blockRender?: boolean; 11 | 12 | children: Children; 13 | } 14 | 15 | /** 16 | * Check that we are logged in or out and redirect accordingly. 17 | * Also prevent render until the client is ready to display. 18 | */ 19 | export const CheckAuth = observer((props: Props) => { 20 | const loggedIn = clientController.isLoggedIn(); 21 | 22 | // Redirect if logged out on authenticated page or vice-versa. 23 | if (props.auth && !loggedIn) { 24 | if (props.blockRender) return null; 25 | return ; 26 | } else if (!props.auth && loggedIn) { 27 | if (props.blockRender) return null; 28 | return ; 29 | } 30 | 31 | // Block render if client is getting ready to work. 32 | if ( 33 | props.auth && 34 | clientController.isLoggedIn() && 35 | !clientController.isReady() 36 | ) { 37 | return ; 38 | } 39 | 40 | return <>{props.children}; 41 | }); 42 | -------------------------------------------------------------------------------- /src/pages/home/Home.module.scss: -------------------------------------------------------------------------------- 1 | .home { 2 | height: 100%; 3 | user-select: none; 4 | position: relative; 5 | 6 | .homeScreen { 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: center; 10 | align-items: center; 11 | height: 100%; 12 | padding: 12px; 13 | 14 | h3 { 15 | margin: 20px 0; 16 | font-size: 48px; 17 | text-align: center; 18 | 19 | img { 20 | height: 36px; 21 | } 22 | } 23 | 24 | a { 25 | font-size: 13px; 26 | } 27 | 28 | .actions { 29 | display: grid; 30 | grid-template-columns: repeat(2, 1fr); 31 | gap: 16px; 32 | max-width: 650px; 33 | margin-bottom: 30px; 34 | 35 | a { 36 | width: 100%; 37 | 38 | div { 39 | margin: 0; 40 | } 41 | } 42 | 43 | @media (max-width: 600px) { 44 | grid-template-columns: repeat(1, 1fr); 45 | } 46 | 47 | > * > * { 48 | height: 100%; 49 | } 50 | } 51 | } 52 | } 53 | 54 | [data-light="true"] .home svg { 55 | filter: invert(100%); 56 | } 57 | -------------------------------------------------------------------------------- /src/controllers/client/jsx/RequiresOnline.tsx: -------------------------------------------------------------------------------- 1 | import { WifiOff } from "@styled-icons/boxicons-regular"; 2 | import styled from "styled-components/macro"; 3 | 4 | import { Text } from "preact-i18n"; 5 | 6 | import { Preloader } from "@revoltchat/ui"; 7 | 8 | import { useSession } from "../ClientController"; 9 | 10 | interface Props { 11 | children: Children; 12 | } 13 | 14 | const Base = styled.div` 15 | gap: 16px; 16 | padding: 1em; 17 | display: flex; 18 | user-select: none; 19 | align-items: center; 20 | flex-direction: row; 21 | justify-content: center; 22 | color: var(--tertiary-foreground); 23 | background: var(--secondary-header); 24 | 25 | > div { 26 | font-size: 18px; 27 | } 28 | `; 29 | 30 | export default function RequiresOnline(props: Props) { 31 | const session = useSession(); 32 | 33 | if (!session || session.state === "Connecting") 34 | return ; 35 | 36 | if (!(session.state === "Online" || session.state === "Ready")) 37 | return ( 38 | 39 | 40 |
41 | 42 |
43 | 44 | ); 45 | 46 | return <>{props.children}; 47 | } 48 | -------------------------------------------------------------------------------- /src/components/common/user/UserStatus.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { User, API } from "revolt.js"; 3 | 4 | import { Text } from "preact-i18n"; 5 | 6 | import Tooltip from "../Tooltip"; 7 | 8 | interface Props { 9 | user?: User; 10 | tooltip?: boolean; 11 | } 12 | 13 | export default observer(({ user, tooltip }: Props) => { 14 | if (user?.online) { 15 | if (user.status?.text) { 16 | if (tooltip) { 17 | return ( 18 | 19 | {user.status.text} 20 | 21 | ); 22 | } 23 | 24 | return <>{user.status.text}; 25 | } 26 | 27 | if (user.status?.presence === "Busy") { 28 | return ; 29 | } 30 | 31 | if (user.status?.presence === "Idle") { 32 | return ; 33 | } 34 | 35 | if (user.status?.presence === "Focus") { 36 | return ; 37 | } 38 | 39 | if (user.status?.presence === "Invisible") { 40 | return ; 41 | } 42 | 43 | return ; 44 | } 45 | 46 | return ; 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/navigation/left/ServerListSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { useParams } from "react-router-dom"; 3 | 4 | import { useCallback } from "preact/hooks"; 5 | 6 | import { ServerList } from "@revoltchat/ui"; 7 | 8 | import { useApplicationState } from "../../../mobx/State"; 9 | 10 | import { useClient } from "../../../controllers/client/ClientController"; 11 | import { modalController } from "../../../controllers/modals/ModalController"; 12 | import { IS_REVOLT } from "../../../version"; 13 | 14 | /** 15 | * Server list sidebar shim component 16 | */ 17 | export default observer(() => { 18 | const client = useClient(); 19 | const state = useApplicationState(); 20 | const { server: server_id } = useParams<{ server?: string }>(); 21 | 22 | const createServer = useCallback( 23 | () => 24 | modalController.push({ 25 | type: "create_server", 26 | }), 27 | [], 28 | ); 29 | 30 | return ( 31 | 41 | ); 42 | }); 43 | -------------------------------------------------------------------------------- /src/controllers/modals/components/KickMember.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { Column, ModalForm } from "@revoltchat/ui"; 4 | 5 | import UserIcon from "../../../components/common/user/UserIcon"; 6 | import { ModalProps } from "../types"; 7 | 8 | /** 9 | * Kick member modal 10 | */ 11 | export default function KickMember({ 12 | member, 13 | ...props 14 | }: ModalProps<"kick_member">) { 15 | return ( 16 | } 19 | schema={{ 20 | member: "custom", 21 | }} 22 | data={{ 23 | member: { 24 | element: ( 25 | 26 | 27 | 31 | 32 | ), 33 | }, 34 | }} 35 | callback={() => member.kick()} 36 | submit={{ 37 | palette: "error", 38 | children: , 39 | }} 40 | /> 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/navigation/SidebarBase.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components/macro"; 2 | 3 | import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; 4 | 5 | export default styled.div` 6 | height: 100%; 7 | display: flex; 8 | user-select: none; 9 | flex-direction: row; 10 | align-items: stretch; 11 | /*background: var(--background);*/ 12 | 13 | background-color: rgba( 14 | var(--background-rgb), 15 | max(var(--min-opacity), 0.75) 16 | ); 17 | backdrop-filter: blur(20px); 18 | `; 19 | 20 | export const GenericSidebarBase = styled.div<{ 21 | mobilePadding?: boolean; 22 | }>` 23 | height: 100%; 24 | width: 232px; 25 | display: flex; 26 | flex-shrink: 0; 27 | flex-direction: column; 28 | /*border-end-start-radius: 8px;*/ 29 | background: var(--secondary-background); 30 | 31 | /*> :nth-child(1) { 32 | //border-end-start-radius: 8px; 33 | } 34 | 35 | > :nth-child(2) { 36 | margin-top: 48px; 37 | background: red; 38 | }*/ 39 | 40 | ${(props) => 41 | props.mobilePadding && 42 | isTouchscreenDevice && 43 | css` 44 | padding-bottom: 50px; 45 | `} 46 | `; 47 | 48 | export const GenericSidebarList = styled.div` 49 | padding: 6px; 50 | flex-grow: 1; 51 | overflow-y: scroll; 52 | 53 | > img { 54 | width: 100%; 55 | } 56 | `; 57 | -------------------------------------------------------------------------------- /src/controllers/modals/components/SignOutSessions.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | import { useCallback } from "preact/hooks"; 3 | 4 | import { Modal } from "@revoltchat/ui"; 5 | 6 | import { noopTrue } from "../../../lib/js"; 7 | 8 | import { ModalProps } from "../types"; 9 | 10 | /** 11 | * Confirm whether a user wants to sign out of all other sessions 12 | */ 13 | export default function SignOutSessions( 14 | props: ModalProps<"sign_out_sessions">, 15 | ) { 16 | const onClick = useCallback(() => { 17 | props.onDeleting(); 18 | props.client.api.delete("/auth/session/all").then(props.onDelete); 19 | return true; 20 | }, []); 21 | 22 | return ( 23 | } 26 | actions={[ 27 | { 28 | onClick: noopTrue, 29 | palette: "accent", 30 | confirmation: true, 31 | children: , 32 | }, 33 | { 34 | onClick, 35 | confirmation: true, 36 | children: , 37 | }, 38 | ]}> 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/controllers/modals/ModalRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { Prompt, useHistory } from "react-router-dom"; 3 | 4 | import { useEffect } from "preact/hooks"; 5 | 6 | import { modalController } from "./ModalController"; 7 | 8 | export default observer(() => { 9 | const history = useHistory(); 10 | 11 | useEffect(() => { 12 | function keyDown(event: KeyboardEvent) { 13 | if (event.key === "Escape") { 14 | modalController.pop("close"); 15 | } else if (event.key === "Enter") { 16 | if (event.target instanceof HTMLSelectElement) return; 17 | modalController.pop("confirm"); 18 | } 19 | } 20 | 21 | document.addEventListener("keydown", keyDown); 22 | return () => document.removeEventListener("keydown", keyDown); 23 | }, []); 24 | 25 | return ( 26 | <> 27 | {modalController.rendered} 28 | { 31 | if (action === "POP") { 32 | modalController.pop("close"); 33 | setTimeout(() => history.push(history.location), 0); 34 | 35 | return false; 36 | } 37 | 38 | return true; 39 | }} 40 | /> 41 | 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /src/pages/login/forms/FormVerify.tsx: -------------------------------------------------------------------------------- 1 | import { useHistory, useParams } from "react-router-dom"; 2 | 3 | import { useEffect, useState } from "preact/hooks"; 4 | 5 | import { Category, Preloader } from "@revoltchat/ui"; 6 | 7 | import { I18nError } from "../../../context/Locale"; 8 | 9 | import { useApi } from "../../../controllers/client/ClientController"; 10 | import { takeError } from "../../../controllers/client/jsx/error"; 11 | import { Form } from "./Form"; 12 | 13 | export function FormResend() { 14 | const api = useApi(); 15 | 16 | return ( 17 | { 20 | await api.post("/auth/account/reverify", data); 21 | }} 22 | /> 23 | ); 24 | } 25 | 26 | export function FormVerify() { 27 | const [error, setError] = useState(undefined); 28 | const { token } = useParams<{ token: string }>(); 29 | const history = useHistory(); 30 | const api = useApi(); 31 | 32 | useEffect(() => { 33 | api.post(`/auth/account/verify/${token as ""}`) 34 | .then(() => history.push("/login")) 35 | .catch((err) => setError(takeError(err))); 36 | // eslint-disable-next-line 37 | }, []); 38 | 39 | return error ? ( 40 | 41 | 42 | 43 | ) : ( 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/controllers/modals/components/CreateCategory.tsx: -------------------------------------------------------------------------------- 1 | import { ulid } from "ulid"; 2 | 3 | import { Text } from "preact-i18n"; 4 | 5 | import { ModalForm } from "@revoltchat/ui"; 6 | 7 | import { ModalProps } from "../types"; 8 | 9 | /** 10 | * Category creation modal 11 | */ 12 | export default function CreateCategory({ 13 | target, 14 | ...props 15 | }: ModalProps<"create_category">) { 16 | return ( 17 | } 20 | schema={{ 21 | name: "text", 22 | }} 23 | data={{ 24 | name: { 25 | field: ( 26 | 27 | ) as React.ReactChild, 28 | }, 29 | }} 30 | callback={async ({ name }) => { 31 | await target.edit({ 32 | categories: [ 33 | ...(target.categories ?? []), 34 | { 35 | id: ulid(), 36 | title: name, 37 | channels: [], 38 | }, 39 | ], 40 | }); 41 | }} 42 | submit={{ 43 | children: , 44 | }} 45 | /> 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/controllers/modals/components/CustomStatus.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { ModalForm } from "@revoltchat/ui"; 4 | 5 | import { useClient } from "../../client/ClientController"; 6 | import { ModalProps } from "../types"; 7 | 8 | /** 9 | * Custom status modal 10 | */ 11 | export default function CustomStatus({ 12 | ...props 13 | }: ModalProps<"custom_status">) { 14 | const client = useClient(); 15 | 16 | return ( 17 | } 20 | schema={{ 21 | text: "text", 22 | }} 23 | defaults={{ 24 | text: client.user?.status?.text as string, 25 | }} 26 | data={{ 27 | text: { 28 | field: ( 29 | 30 | ) as React.ReactChild, 31 | }, 32 | }} 33 | callback={({ text }) => 34 | client.users.edit({ 35 | status: { 36 | ...client.user?.status, 37 | text: text.trim().length > 0 ? text : undefined, 38 | }, 39 | }) 40 | } 41 | submit={{ 42 | children: , 43 | }} 44 | /> 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/common/messaging/attachments/ImageFile.tsx: -------------------------------------------------------------------------------- 1 | import { API } from "revolt.js"; 2 | 3 | import styles from "./Attachment.module.scss"; 4 | import classNames from "classnames"; 5 | import { useState } from "preact/hooks"; 6 | 7 | import { useClient } from "../../../../controllers/client/ClientController"; 8 | import { modalController } from "../../../../controllers/modals/ModalController"; 9 | 10 | enum ImageLoadingState { 11 | Loading, 12 | Loaded, 13 | Error, 14 | } 15 | 16 | type Props = JSX.HTMLAttributes & { 17 | attachment: API.File; 18 | }; 19 | 20 | export default function ImageFile({ attachment, ...props }: Props) { 21 | const [loading, setLoading] = useState(ImageLoadingState.Loading); 22 | const client = useClient(); 23 | const url = client.generateFileURL(attachment)!; 24 | 25 | return ( 26 | {attachment.filename} 35 | modalController.push({ type: "image_viewer", attachment }) 36 | } 37 | onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")} 38 | onLoad={() => setLoading(ImageLoadingState.Loaded)} 39 | onError={() => setLoading(ImageLoadingState.Error)} 40 | /> 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/renderer/types.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "revolt.js"; 2 | 3 | import { ChannelRenderer } from "./Singleton"; 4 | 5 | export type ScrollState = 6 | | { type: "Free" } 7 | | { type: "Bottom"; scrollingUntil?: number } 8 | | { type: "ScrollToBottom" | "StayAtBottom"; smooth?: boolean } 9 | | { type: "ScrollToView"; id: string } 10 | | { type: "OffsetTop"; previousHeight: number } 11 | | { type: "ScrollTop"; y: number }; 12 | 13 | export type RenderState = 14 | | { 15 | type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY"; 16 | } 17 | | { 18 | type: "RENDER"; 19 | atTop: boolean; 20 | atBottom: boolean; 21 | messages: Message[]; 22 | }; 23 | 24 | export interface RendererRoutines { 25 | init: ( 26 | renderer: ChannelRenderer, 27 | message?: string, 28 | smooth?: boolean, 29 | ) => Promise; 30 | 31 | receive: (renderer: ChannelRenderer, message: Message) => Promise; 32 | updated: ( 33 | renderer: ChannelRenderer, 34 | id: string, 35 | message: Message, 36 | ) => Promise; 37 | delete: (renderer: ChannelRenderer, id: string) => Promise; 38 | 39 | loadTop: ( 40 | renderer: ChannelRenderer, 41 | generateScroll: (end: string) => ScrollState, 42 | ) => Promise; 43 | loadBottom: ( 44 | renderer: ChannelRenderer, 45 | generateScroll: (start: string) => ScrollState, 46 | ) => Promise; 47 | } 48 | -------------------------------------------------------------------------------- /src/components/common/user/UserHover.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "revolt.js"; 2 | import styled from "styled-components/macro"; 3 | 4 | import Tooltip from "../Tooltip"; 5 | import { Username } from "./UserShort"; 6 | import UserStatus from "./UserStatus"; 7 | 8 | interface Props { 9 | user?: User; 10 | children: Children; 11 | } 12 | 13 | const Base = styled.div` 14 | display: flex; 15 | flex-direction: column; 16 | 17 | .username { 18 | font-size: 13px; 19 | font-weight: 600; 20 | } 21 | 22 | .status { 23 | font-size: 11px; 24 | overflow: hidden; 25 | white-space: nowrap; 26 | text-overflow: ellipsis; 27 | } 28 | 29 | .tip { 30 | display: flex; 31 | align-items: center; 32 | gap: 4px; 33 | margin-top: 2px; 34 | color: var(--secondary-foreground); 35 | } 36 | `; 37 | 38 | export default function UserHover({ user, children }: Props) { 39 | return ( 40 | 44 | 45 | 46 | 47 | 48 | {/*
Right-click on the avatar to access the quick menu
*/} 49 | 50 | }> 51 | {children} 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/controllers/modals/components/CreateGroup.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 | * Group creation modal 13 | */ 14 | export default function CreateGroup({ ...props }: ModalProps<"create_group">) { 15 | const history = useHistory(); 16 | const client = useClient(); 17 | 18 | return ( 19 | } 22 | schema={{ 23 | name: "text", 24 | }} 25 | data={{ 26 | name: { 27 | field: ( 28 | 29 | ) as React.ReactChild, 30 | }, 31 | }} 32 | callback={async ({ name }) => { 33 | const group = await client.channels 34 | .createGroup({ 35 | name, 36 | users: [], 37 | }) 38 | .catch(mapError); 39 | 40 | history.push(`/channel/${group._id}`); 41 | }} 42 | submit={{ 43 | children: , 44 | }} 45 | /> 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/context/index.tsx: -------------------------------------------------------------------------------- 1 | import { Router, Link } from "react-router-dom"; 2 | 3 | import { ContextMenuTrigger } from "preact-context-menu"; 4 | import { Text } from "preact-i18n"; 5 | import { useEffect, useState } from "preact/hooks"; 6 | 7 | import { Preloader, UIProvider } from "@revoltchat/ui"; 8 | 9 | import { state } from "../mobx/State"; 10 | 11 | import Binder from "../controllers/client/jsx/Binder"; 12 | import ModalRenderer from "../controllers/modals/ModalRenderer"; 13 | import Locale from "./Locale"; 14 | import Theme from "./Theme"; 15 | import { history } from "./history"; 16 | 17 | const uiContext = { 18 | Link, 19 | Text: Text as any, 20 | Trigger: ContextMenuTrigger, 21 | emitAction: () => void {}, 22 | }; 23 | 24 | /** 25 | * This component provides all of the application's context layers. 26 | * @param param0 Provided children 27 | */ 28 | export default function Context({ children }: { children: Children }) { 29 | const [ready, setReady] = useState(false); 30 | 31 | useEffect(() => { 32 | state.hydrate().then(() => setReady(true)); 33 | }, []); 34 | 35 | if (!ready) return ; 36 | 37 | return ( 38 | 39 | 40 | 41 | <>{children} 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/markdown/plugins/mentions.tsx: -------------------------------------------------------------------------------- 1 | import { RE_MENTIONS } from "revolt.js"; 2 | import styled from "styled-components"; 3 | 4 | import { clientController } from "../../../controllers/client/ClientController"; 5 | import UserShort from "../../common/user/UserShort"; 6 | import { createComponent, CustomComponentProps } from "./remarkRegexComponent"; 7 | 8 | const Mention = styled.a` 9 | gap: 4px; 10 | flex-shrink: 0; 11 | padding-left: 2px; 12 | padding-right: 6px; 13 | align-items: center; 14 | display: inline-flex; 15 | vertical-align: middle; 16 | 17 | cursor: pointer; 18 | 19 | font-weight: 600; 20 | text-decoration: none !important; 21 | background: var(--secondary-background); 22 | border-radius: calc(var(--border-radius) * 2); 23 | 24 | transition: 0.1s ease filter; 25 | 26 | &:hover { 27 | filter: brightness(0.75); 28 | } 29 | 30 | &:active { 31 | filter: brightness(0.65); 32 | } 33 | 34 | svg { 35 | width: 1em; 36 | height: 1em; 37 | } 38 | `; 39 | 40 | export function RenderMention({ match }: CustomComponentProps) { 41 | return ( 42 | 43 | 47 | 48 | ); 49 | } 50 | 51 | export const remarkMention = createComponent("mention", RE_MENTIONS, (match) => 52 | clientController.getAvailableClient().users.has(match), 53 | ); 54 | -------------------------------------------------------------------------------- /src/components/navigation/items/ConnectionStatus.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | 3 | import { Text } from "preact-i18n"; 4 | 5 | import { Banner, Button, Column } from "@revoltchat/ui"; 6 | 7 | import { useSession } from "../../../controllers/client/ClientController"; 8 | 9 | function ConnectionStatus() { 10 | const session = useSession()!; 11 | 12 | if (session.state === "Offline") { 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } else if (session.state === "Disconnected") { 19 | return ( 20 | 21 | 22 | 23 | 33 | 34 | 35 | ); 36 | } else if (session.state === "Connecting") { 37 | return ( 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | return null; 45 | } 46 | 47 | export default observer(ConnectionStatus); 48 | -------------------------------------------------------------------------------- /src/pages/login/forms/CaptchaBlock.tsx: -------------------------------------------------------------------------------- 1 | import HCaptcha from "@hcaptcha/react-hcaptcha"; 2 | import { observer } from "mobx-react-lite"; 3 | 4 | import styles from "../Login.module.scss"; 5 | import { Text } from "preact-i18n"; 6 | import { useEffect } from "preact/hooks"; 7 | 8 | import { Preloader } from "@revoltchat/ui"; 9 | 10 | import { clientController } from "../../../controllers/client/ClientController"; 11 | 12 | export interface CaptchaProps { 13 | onSuccess: (token?: string) => void; 14 | onCancel: () => void; 15 | } 16 | 17 | export const CaptchaBlock = observer((props: CaptchaProps) => { 18 | const configuration = clientController.getServerConfig(); 19 | 20 | useEffect(() => { 21 | if (!configuration?.features.captcha.enabled) { 22 | props.onSuccess(); 23 | } 24 | }, [configuration?.features.captcha.enabled, props]); 25 | 26 | if (!configuration?.features.captcha.enabled) 27 | return ; 28 | 29 | return ( 30 |
43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /src/pages/settings/panes/Experiments.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 { 10 | AVAILABLE_EXPERIMENTS, 11 | EXPERIMENTS, 12 | } from "../../../mobx/stores/Experiments"; 13 | 14 | export const ExperimentsPage = observer(() => { 15 | const experiments = useApplicationState().experiments; 16 | 17 | return ( 18 |
19 |

20 | 21 |

22 | 23 | {AVAILABLE_EXPERIMENTS.map((key) => ( 24 | 28 | experiments.setEnabled(key, enabled) 29 | } 30 | description={EXPERIMENTS[key].description} 31 | title={EXPERIMENTS[key].title} 32 | /> 33 | ))} 34 | 35 | {AVAILABLE_EXPERIMENTS.length === 0 && ( 36 |
37 | 38 |
39 | )} 40 |
41 | ); 42 | }); 43 | -------------------------------------------------------------------------------- /src/controllers/modals/components/ModifyDisplayname.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { ModalForm } from "@revoltchat/ui"; 4 | 5 | import { useClient } from "../../client/ClientController"; 6 | import { ModalProps } from "../types"; 7 | 8 | /** 9 | * Modify display name modal 10 | */ 11 | export default function ModifyDisplayname({ 12 | ...props 13 | }: ModalProps<"modify_displayname">) { 14 | const client = useClient(); 15 | 16 | return ( 17 | 34 | display_name && display_name !== client.user!.username 35 | ? client.users.edit({ 36 | display_name, 37 | } as never) 38 | : client.users.edit({ 39 | remove: ["DisplayName"], 40 | } as never) 41 | } 42 | submit={{ 43 | children: , 44 | }} 45 | /> 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/settings/roles/PermissionList.tsx: -------------------------------------------------------------------------------- 1 | import { API, Channel, Permission, Server } from "revolt.js"; 2 | 3 | import { PermissionSelect } from "./PermissionSelect"; 4 | 5 | interface Props { 6 | value: API.OverrideField | number; 7 | onChange: (v: API.OverrideField | number) => void; 8 | 9 | target?: Channel | Server; 10 | filter?: (keyof typeof Permission)[]; 11 | } 12 | 13 | export function PermissionList({ value, onChange, filter, target }: Props) { 14 | return ( 15 | <> 16 | {(Object.keys(Permission) as (keyof typeof Permission)[]) 17 | .filter( 18 | (key) => 19 | ![ 20 | "GrantAllSafe", 21 | "ReadMessageHistory", 22 | "Speak", 23 | "Video", 24 | "MuteMembers", 25 | "DeafenMembers", 26 | "MoveMembers", 27 | "ManageWebhooks", 28 | ].includes(key) && 29 | (!filter || filter.includes(key)), 30 | ) 31 | .map((x) => ( 32 | 40 | ))} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /scripts/inject.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const { copy, remove, access, readFile, writeFile } = require("fs-extra"); 3 | const klaw = require("klaw"); 4 | 5 | let target = /__API_URL__/g; 6 | let replacement = process.env.REVOLT_PUBLIC_URL; 7 | let BUILD_DIRECTORY = "dist"; 8 | let OUT_DIRECTORY = "dist_injected"; 9 | 10 | if (typeof replacement === "undefined") { 11 | console.error("No REVOLT_PUBLIC_URL specified in environment variables."); 12 | process.exit(1); 13 | } 14 | 15 | (async () => { 16 | console.log("Ensuring project has been built at least once."); 17 | try { 18 | await access(BUILD_DIRECTORY); 19 | } catch (err) { 20 | console.error("Build project at least once!"); 21 | return process.exit(1); 22 | } 23 | 24 | console.log("Determining if injected build already exists..."); 25 | try { 26 | await access(OUT_DIRECTORY); 27 | 28 | console.log("Deleting existing build..."); 29 | await remove(OUT_DIRECTORY); 30 | } catch (err) {} 31 | 32 | await copy(BUILD_DIRECTORY, OUT_DIRECTORY); 33 | 34 | console.log("Processing bundles..."); 35 | for await (const file of klaw(OUT_DIRECTORY)) { 36 | let path = file.path; 37 | if (path.endsWith(".js")) { 38 | let data = await readFile(path); 39 | if (target.test(data)) { 40 | console.log("Matched file", path); 41 | 42 | let processed = data.toString().replace(target, replacement); 43 | await writeFile(path, processed); 44 | } 45 | } 46 | } 47 | 48 | console.log("Complete."); 49 | })(); 50 | -------------------------------------------------------------------------------- /src/pages/settings/panes/Appearance.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 CollapsibleSection from "../../../components/common/CollapsibleSection"; 7 | import AdvancedOptions from "../../../components/settings/appearance/AdvancedOptions"; 8 | import AppearanceOptions from "../../../components/settings/appearance/AppearanceOptions"; 9 | import ChatOptions from "../../../components/settings/appearance/ChatOptions"; 10 | import ThemeOverrides from "../../../components/settings/appearance/ThemeOverrides"; 11 | import ThemeSelection from "../../../components/settings/appearance/ThemeSelection"; 12 | 13 | export const Appearance = observer(() => { 14 | return ( 15 |
16 | 17 |
18 | 19 |
20 | 21 |
22 | }> 26 | 27 | 28 | }> 32 | 33 | 34 |
35 | ); 36 | }); 37 | 38 | // 39 | -------------------------------------------------------------------------------- /src/pages/settings/assets/opus_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/common/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import Tippy, { TippyProps } from "@tippyjs/react"; 2 | import styled from "styled-components/macro"; 3 | 4 | import { Text } from "preact-i18n"; 5 | 6 | type Props = Omit & { 7 | children: Children; 8 | content: Children; 9 | }; 10 | 11 | export default function Tooltip(props: Props) { 12 | const { children, content, ...tippyProps } = props; 13 | 14 | return ( 15 | 16 | {/* 17 | // @ts-expect-error Type mis-match. */} 18 |
{children}
19 |
20 | ); 21 | } 22 | 23 | const PermissionTooltipBase = styled.div` 24 | display: flex; 25 | align-items: center; 26 | flex-direction: column; 27 | 28 | span { 29 | font-size: 11px; 30 | font-weight: 700; 31 | text-transform: uppercase; 32 | color: var(--secondary-foreground); 33 | } 34 | 35 | code { 36 | font-family: var(--monospace-font); 37 | } 38 | `; 39 | 40 | export function PermissionTooltip( 41 | props: Omit & { permission: string }, 42 | ) { 43 | const { permission, ...tooltipProps } = props; 44 | 45 | return ( 46 | 49 | 50 | 51 | 52 | {permission} 53 | 54 | } 55 | {...tooltipProps} 56 | /> 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/controllers/modals/components/ConfirmLeave.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { ModalForm } from "@revoltchat/ui"; 4 | 5 | import { TextReact } from "../../../lib/i18n"; 6 | 7 | import { ModalProps } from "../types"; 8 | 9 | /** 10 | * Confirmation modal 11 | */ 12 | export default function ConfirmLeave( 13 | props: ModalProps<"leave_group" | "leave_server">, 14 | ) { 15 | const name = props.target.name; 16 | 17 | return ( 18 | 25 | } 26 | description={ 27 | {name} }} 30 | /> 31 | } 32 | data={{ 33 | silently_leave: { 34 | title: , 35 | description: ( 36 | 37 | ), 38 | }, 39 | }} 40 | schema={{ 41 | silently_leave: "checkbox", 42 | }} 43 | callback={({ silently_leave }) => 44 | props.target.delete(silently_leave) 45 | } 46 | submit={{ 47 | palette: "error", 48 | children: , 49 | }} 50 | /> 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/invite/Invite.module.scss: -------------------------------------------------------------------------------- 1 | .invite { 2 | height: 100%; 3 | display: flex; 4 | color: white; 5 | user-select: none; 6 | align-items: center; 7 | flex-direction: column; 8 | background-size: cover; 9 | justify-content: center; 10 | background-position: center; 11 | 12 | * { 13 | overflow: visible; 14 | } 15 | 16 | .icon { 17 | width: 64px; 18 | z-index: 100; 19 | text-align: left; 20 | position: relative; 21 | 22 | > * { 23 | top: -32px; 24 | position: absolute; 25 | } 26 | } 27 | 28 | .leave { 29 | top: 8px; 30 | left: 8px; 31 | cursor: pointer; 32 | position: fixed; 33 | } 34 | 35 | .details { 36 | text-align: center; 37 | align-self: center; 38 | padding: 32px 16px 16px 16px; 39 | background: rgba(0, 0, 0, 0.6); 40 | border-radius: var(--border-radius); 41 | 42 | h1 { 43 | margin: 0; 44 | font-weight: 500; 45 | } 46 | 47 | h2 { 48 | margin: 4px; 49 | opacity: 0.7; 50 | font-size: 0.8em; 51 | font-weight: 400; 52 | } 53 | 54 | h3 { 55 | gap: 8px; 56 | display: flex; 57 | font-size: 1em; 58 | font-weight: 400; 59 | flex-direction: row; 60 | justify-content: center; 61 | } 62 | 63 | button { 64 | margin: auto; 65 | display: block; 66 | } 67 | } 68 | } 69 | 70 | .preloader { 71 | height: 100%; 72 | display: grid; 73 | place-items: center; 74 | } 75 | -------------------------------------------------------------------------------- /src/components/common/IconBase.tsx: -------------------------------------------------------------------------------- 1 | import { API } from "revolt.js"; 2 | import { Nullable } from "revolt.js"; 3 | import styled, { css } from "styled-components/macro"; 4 | 5 | import { Ref } from "preact"; 6 | 7 | export interface IconBaseProps { 8 | target?: T; 9 | url?: string; 10 | attachment?: Nullable; 11 | 12 | size: number; 13 | hover?: boolean; 14 | animate?: boolean; 15 | 16 | innerRef?: Ref; 17 | } 18 | 19 | interface IconModifiers { 20 | /** 21 | * If this is undefined or null then the icon defaults to square, else uses the CSS variable given. 22 | */ 23 | borderRadius?: string; 24 | hover?: boolean; 25 | } 26 | 27 | export default styled.svg` 28 | flex-shrink: 0; 29 | cursor: pointer; 30 | img { 31 | width: 100%; 32 | height: 100%; 33 | object-fit: cover; 34 | 35 | ${(props) => 36 | props.borderRadius && 37 | css` 38 | border-radius: var(${props.borderRadius}); 39 | `} 40 | } 41 | 42 | ${(props) => 43 | props.hover && 44 | css` 45 | &:hover .icon { 46 | filter: brightness(0.8); 47 | } 48 | `} 49 | `; 50 | 51 | export const ImageIconBase = styled.img` 52 | flex-shrink: 0; 53 | object-fit: cover; 54 | 55 | ${(props) => 56 | props.borderRadius && 57 | css` 58 | border-radius: var(${props.borderRadius}); 59 | `} 60 | 61 | ${(props) => 62 | props.hover && 63 | css` 64 | &:hover img { 65 | filter: brightness(0.8); 66 | } 67 | `} 68 | `; 69 | -------------------------------------------------------------------------------- /src/styles/_elements.scss: -------------------------------------------------------------------------------- 1 | :disabled { 2 | opacity: 0.5; 3 | pointer-events: none; 4 | } 5 | 6 | ::-webkit-scrollbar { 7 | width: var(--scrollbar-thickness); 8 | height: var(--scrollbar-thickness); 9 | } 10 | 11 | ::-webkit-scrollbar-track { 12 | background: var(--scrollbar-track); 13 | } 14 | 15 | ::-webkit-scrollbar-thumb { 16 | min-width: 30px; 17 | min-height: 30px; 18 | 19 | background-clip: content-box; 20 | background: var(--scrollbar-thumb); 21 | } 22 | 23 | [data-scroll-offset] { 24 | overflow-y: scroll; 25 | } 26 | 27 | [data-scroll-offset="with-padding"], 28 | [data-scroll-offset] .with-padding { 29 | padding-top: var(--header-height); 30 | } 31 | 32 | [data-scroll-offset]::-webkit-scrollbar-thumb { 33 | background-clip: content-box; 34 | border-top: var(--header-height) solid transparent; 35 | } 36 | 37 | [data-avoids-navigation]::-webkit-scrollbar-thumb { 38 | background-clip: content-box; 39 | border-bottom: var(--effective-bottom-offset) solid transparent; 40 | } 41 | 42 | ::-webkit-scrollbar-corner { 43 | background: transparent; 44 | } 45 | 46 | ::selection { 47 | background: var(--foreground); 48 | color: var(--background); 49 | } 50 | 51 | ::-moz-selection { 52 | background: var(--foreground); 53 | color: var(--background); 54 | } 55 | 56 | ::-webkit-selection { 57 | background: var(--foreground); 58 | color: var(--background); 59 | } 60 | 61 | a, 62 | a:link, 63 | a:visited, 64 | a:hover { 65 | text-decoration: none; 66 | color: var(--accent); 67 | } 68 | 69 | hr { 70 | border: 0; 71 | height: 1px; 72 | flex-grow: 1; 73 | } 74 | 75 | foreignObject > svg { 76 | vertical-align: top !important; 77 | } 78 | -------------------------------------------------------------------------------- /src/controllers/modals/components/OutOfDate.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { Modal } from "@revoltchat/ui"; 4 | 5 | import { noop, noopTrue } from "../../../lib/js"; 6 | 7 | import { APP_VERSION } from "../../../version"; 8 | import { ModalProps } from "../types"; 9 | 10 | /** 11 | * Out-of-date indicator which instructs users 12 | * that their client needs to be updated 13 | */ 14 | export default function OutOfDate({ 15 | onClose, 16 | version, 17 | }: ModalProps<"out_of_date">) { 18 | return ( 19 | } 21 | description={ 22 | <> 23 | 24 |
25 | 29 | 30 | } 31 | actions={[ 32 | { 33 | palette: "plain", 34 | onClick: noop, 35 | children: ( 36 | 37 | ), 38 | }, 39 | { 40 | palette: "plain-secondary", 41 | onClick: noopTrue, 42 | children: ( 43 | 44 | ), 45 | }, 46 | ]} 47 | onClose={onClose} 48 | nonDismissable 49 | /> 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/controllers/modals/components/BanMember.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { Column, ModalForm } from "@revoltchat/ui"; 4 | 5 | import UserIcon from "../../../components/common/user/UserIcon"; 6 | import { ModalProps } from "../types"; 7 | 8 | /** 9 | * Ban member modal 10 | */ 11 | export default function BanMember({ 12 | member, 13 | ...props 14 | }: ModalProps<"ban_member">) { 15 | return ( 16 | } 19 | schema={{ 20 | member: "custom", 21 | reason: "text", 22 | }} 23 | data={{ 24 | member: { 25 | element: ( 26 | 27 | 28 | 32 | 33 | ), 34 | }, 35 | reason: { 36 | field: ( 37 | 38 | ) as React.ReactChild, 39 | }, 40 | }} 41 | callback={async ({ reason }) => 42 | void (await member.server!.banUser(member._id.user, { reason })) 43 | } 44 | submit={{ 45 | palette: "error", 46 | children: , 47 | }} 48 | /> 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/links.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type of link 3 | */ 4 | type LinkType = 5 | | { 6 | type: "navigate"; 7 | path: string; 8 | } 9 | | { type: "external"; href: string; url: URL } 10 | | { type: "none" }; 11 | 12 | /** 13 | * Allowed origins for relative navigation 14 | */ 15 | const ALLOWED_ORIGINS = [ 16 | location.hostname, 17 | "app.revolt.chat", 18 | "nightly.revolt.chat", 19 | "local.revolt.chat", 20 | "rolt.chat", 21 | ]; 22 | 23 | /** 24 | * Permissible protocols in URLs 25 | */ 26 | const PROTOCOL_WHITELIST = [ 27 | "http:", 28 | "https:", 29 | "ftp:", 30 | "ftps:", 31 | "mailto:", 32 | "news:", 33 | "irc:", 34 | "gopher:", 35 | "nntp:", 36 | "feed:", 37 | "telnet:", 38 | "mms:", 39 | "rtsp:", 40 | "svn:", 41 | "git:", 42 | "tel:", 43 | "fax:", 44 | "xmpp:", 45 | "magnet:", 46 | ]; 47 | 48 | /** 49 | * Determine what kind of link we are dealing with and sanitise any malicious input 50 | * @param href Input URL 51 | * @returns Link Type 52 | */ 53 | export function determineLink(href?: string): LinkType { 54 | let internal, 55 | url: URL | null = null; 56 | 57 | if (href) { 58 | try { 59 | url = new URL(href, location.href); 60 | 61 | if (ALLOWED_ORIGINS.includes(url.hostname)) { 62 | const path = url.pathname.replace(/[^A-z0-9/]/g, ""); 63 | return { type: "navigate", path }; 64 | } 65 | } catch (err) {} 66 | 67 | if (!internal && url) { 68 | if (PROTOCOL_WHITELIST.includes(url.protocol)) { 69 | return { type: "external", href, url }; 70 | } 71 | } 72 | } 73 | 74 | return { type: "none" }; 75 | } 76 | -------------------------------------------------------------------------------- /src/controllers/modals/components/LinkWarning.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "preact-i18n"; 2 | 3 | import { Modal } from "@revoltchat/ui"; 4 | 5 | import { noopTrue } from "../../../lib/js"; 6 | 7 | import { useApplicationState } from "../../../mobx/State"; 8 | 9 | import { ModalProps } from "../types"; 10 | 11 | export default function LinkWarning({ 12 | link, 13 | callback, 14 | ...props 15 | }: ModalProps<"link_warning">) { 16 | const settings = useApplicationState().settings; 17 | 18 | return ( 19 | } 22 | actions={[ 23 | { 24 | onClick: callback, 25 | confirmation: true, 26 | palette: "accent", 27 | children: "Continue", 28 | }, 29 | { 30 | onClick: noopTrue, 31 | confirmation: false, 32 | children: "Cancel", 33 | }, 34 | { 35 | onClick: () => { 36 | try { 37 | const url = new URL(link); 38 | settings.security.addTrustedOrigin(url.hostname); 39 | } catch (e) {} 40 | 41 | return callback(); 42 | }, 43 | palette: "plain", 44 | children: ( 45 | 46 | ), 47 | }, 48 | ]}> 49 |
50 | {link} 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/pages/login/ConfirmDelete.tsx: -------------------------------------------------------------------------------- 1 | import { Check } from "@styled-icons/boxicons-regular"; 2 | import { useParams } from "react-router-dom"; 3 | import styled from "styled-components"; 4 | 5 | import { useEffect, useState } from "preact/hooks"; 6 | 7 | import { Modal, Preloader } from "@revoltchat/ui"; 8 | 9 | import { useApi } from "../../controllers/client/ClientController"; 10 | 11 | const Centre = styled.div` 12 | display: flex; 13 | justify-content: center; 14 | `; 15 | 16 | export default function ConfirmDelete() { 17 | const api = useApi(); 18 | const [deleted, setDeleted] = useState(true); 19 | const { token } = useParams<{ token: string }>(); 20 | 21 | useEffect(() => { 22 | api.put("/auth/account/delete", { token }).then(() => setDeleted(true)); 23 | }, []); 24 | 25 | return ( 26 | 31 | Your account will be deleted in 7 days. 32 |
33 | You may contact{" "} 34 | 35 | Revolt support 36 | {" "} 37 | to cancel the request if you wish. 38 | 39 | ) : ( 40 | "Contacting the server." 41 | ) 42 | } 43 | nonDismissable> 44 | {deleted ? ( 45 | 46 | 47 | 48 | ) : ( 49 | 50 | )} 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/navigation/LeftSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { Route, Switch } from "react-router"; 3 | import { useLocation } from "react-router-dom"; 4 | 5 | import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; 6 | 7 | import { useApplicationState } from "../../mobx/State"; 8 | import { SIDEBAR_CHANNELS } from "../../mobx/stores/Layout"; 9 | 10 | import SidebarBase from "./SidebarBase"; 11 | import HomeSidebar from "./left/HomeSidebar"; 12 | import ServerListSidebar from "./left/ServerListSidebar"; 13 | import ServerSidebar from "./left/ServerSidebar"; 14 | 15 | export default observer(() => { 16 | const layout = useApplicationState().layout; 17 | const { pathname } = useLocation(); 18 | const isOpen = 19 | !pathname.startsWith("/discover") && 20 | (isTouchscreenDevice || layout.getSectionState(SIDEBAR_CHANNELS, true)); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | {isOpen && } 29 | 30 | 31 | 32 | {isOpen && } 33 | 34 | 35 | 36 | {isOpen && } 37 | 38 | 39 | 40 | {isOpen && } 41 | 42 | 43 | 44 | ); 45 | }); 46 | -------------------------------------------------------------------------------- /src/lib/dnd.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Draggable as rbdDraggable, 3 | DraggableProps as rbdDraggableProps, 4 | DraggableProvided as rbdDraggableProvided, 5 | DraggableProvidedDraggableProps as rbdDraggableProvidedDraggableProps, 6 | DraggableProvidedDragHandleProps as rbdDraggableProvidedDragHandleProps, 7 | DraggableRubric, 8 | DraggableStateSnapshot, 9 | Droppable as rbdDroppable, 10 | DroppableProps, 11 | DroppableProvided, 12 | DroppableStateSnapshot, 13 | } from "react-beautiful-dnd"; 14 | 15 | export type DraggableProvidedDraggableProps = Omit< 16 | rbdDraggableProvidedDraggableProps, 17 | "style" | "onTransitionEnd" 18 | > & { 19 | style?: string; 20 | onTransitionEnd?: JSX.TransitionEventHandler; 21 | }; 22 | 23 | export type DraggableProvidedDragHandleProps = Omit< 24 | rbdDraggableProvidedDragHandleProps, 25 | "onDragStart" 26 | > & { 27 | onDragStart?: JSX.DragEventHandler; 28 | }; 29 | 30 | export type DraggableProvided = rbdDraggableProvided & { 31 | draggableProps: DraggableProvidedDraggableProps; 32 | dragHandleProps?: DraggableProvidedDragHandleProps | undefined; 33 | }; 34 | 35 | export type DraggableChildrenFn = ( 36 | provided: DraggableProvided, 37 | snapshot: DraggableStateSnapshot, 38 | rubric: DraggableRubric, 39 | ) => JSX.Element; 40 | 41 | export type DraggableProps = Omit & { 42 | children: DraggableChildrenFn; 43 | }; 44 | 45 | export const Draggable = rbdDraggable as unknown as ( 46 | props: DraggableProps, 47 | ) => JSX.Element; 48 | 49 | export const Droppable = rbdDroppable as unknown as ( 50 | props: Omit & { 51 | children( 52 | provided: DroppableProvided, 53 | snapshot: DroppableStateSnapshot, 54 | ): JSX.Element; 55 | }, 56 | ) => JSX.Element; 57 | -------------------------------------------------------------------------------- /src/components/common/UpdateIndicator.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | import { Download, CloudDownload } from "@styled-icons/boxicons-regular"; 3 | 4 | import { useEffect, useState } from "preact/hooks"; 5 | 6 | import { IconButton } from "@revoltchat/ui"; 7 | 8 | import { internalSubscribe } from "../../lib/eventEmitter"; 9 | 10 | import { useApplicationState } from "../../mobx/State"; 11 | 12 | import { updateSW } from "../../updateWorker"; 13 | import Tooltip from "./Tooltip"; 14 | 15 | let pendingUpdate = false; 16 | internalSubscribe("PWA", "update", () => (pendingUpdate = true)); 17 | 18 | interface Props { 19 | style: "titlebar" | "channel"; 20 | } 21 | 22 | export default function UpdateIndicator({ style }: Props) { 23 | const [pending, setPending] = useState(pendingUpdate); 24 | 25 | useEffect(() => { 26 | return internalSubscribe("PWA", "update", () => setPending(true)); 27 | }); 28 | 29 | if (!pending) return null; 30 | const theme = useApplicationState().settings.theme; 31 | 32 | if (style === "titlebar") { 33 | return ( 34 |
35 | 38 |
updateSW(true)}> 39 | 43 |
44 |
45 |
46 | ); 47 | } 48 | 49 | if (window.isNative && window.native.getConfig().frame) return null; 50 | 51 | return ( 52 | updateSW(true)}> 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/mobx/stores/ServerConfig.ts: -------------------------------------------------------------------------------- 1 | import { action, computed, makeAutoObservable } from "mobx"; 2 | import { API, Client, Nullable } from "revolt.js"; 3 | 4 | import { isDebug } from "../../revision"; 5 | import Persistent from "../interfaces/Persistent"; 6 | import Store from "../interfaces/Store"; 7 | 8 | /** 9 | * Stores server configuration data. 10 | */ 11 | export default class ServerConfig 12 | implements Store, Persistent 13 | { 14 | private config: Nullable; 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 |
26 | By creating this server, you agree to the{" "} 27 | 31 | Acceptable Use Policy. 32 | 33 |
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 |