mirror of
https://github.com/stoatchat/for-legacy-web.git
synced 2026-10-31 04:21:04 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d996deb1e | ||
|
|
a9e4839727 | ||
|
|
959c084727 |
No files matched your search
@@ -1,5 +1,2 @@
|
||||
# VITE_API_URL=https://api.revolt.chat
|
||||
VITE_API_URL=https://app.revolt.chat/api
|
||||
# VITE_API_URL=http://local.revolt.chat:8000
|
||||
# VITE_API_URL=https://revolt.chat/api
|
||||
VITE_API_URL=https://api.revolt.chat
|
||||
VITE_THEMES_URL=https://themes.revolt.chat
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- "master"
|
||||
tags:
|
||||
- "*"
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- ".github/**"
|
||||
- "!.github/workflows/docker.yml"
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "production"
|
||||
|
||||
jobs:
|
||||
to_gitlab:
|
||||
|
||||
@@ -15,27 +15,22 @@ jobs:
|
||||
gh api graphql -f query='
|
||||
query {
|
||||
organization(login: "revoltchat"){
|
||||
projectV2(number: 3) {
|
||||
projectNext(number: 3) {
|
||||
id
|
||||
fields(first:20) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
options {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
id
|
||||
name
|
||||
settings
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}' > project_data.json
|
||||
|
||||
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
|
||||
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
|
||||
echo 'TODO_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
|
||||
echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV
|
||||
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
|
||||
echo 'TODO_OPTION_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") |.settings | fromjson.options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
|
||||
|
||||
- name: Add issue to project
|
||||
env:
|
||||
@@ -44,11 +39,11 @@ jobs:
|
||||
run: |
|
||||
item_id="$( gh api graphql -f query='
|
||||
mutation($project:ID!, $issue:ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) {
|
||||
item {
|
||||
addProjectNextItem(input: {projectId: $project, contentId: $issue}) {
|
||||
projectNextItem {
|
||||
id
|
||||
}
|
||||
}
|
||||
}' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
|
||||
|
||||
}' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectNextItem.projectNextItem.id')"
|
||||
|
||||
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
|
||||
@@ -15,27 +15,22 @@ jobs:
|
||||
gh api graphql -f query='
|
||||
query {
|
||||
organization(login: "revoltchat"){
|
||||
projectV2(number: 3) {
|
||||
projectNext(number: 3) {
|
||||
id
|
||||
fields(first:20) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
options {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
id
|
||||
name
|
||||
settings
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}' > project_data.json
|
||||
|
||||
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
|
||||
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
|
||||
echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Incoming PRs") |.id' project_data.json) >> $GITHUB_ENV
|
||||
echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV
|
||||
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
|
||||
echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") |.settings | fromjson.options[] | select(.name=="Incoming PRs") |.id' project_data.json) >> $GITHUB_ENV
|
||||
|
||||
- name: Add PR to project
|
||||
env:
|
||||
@@ -44,13 +39,13 @@ jobs:
|
||||
run: |
|
||||
item_id="$( gh api graphql -f query='
|
||||
mutation($project:ID!, $pr:ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
|
||||
item {
|
||||
addProjectNextItem(input: {projectId: $project, contentId: $pr}) {
|
||||
projectNextItem {
|
||||
id
|
||||
}
|
||||
}
|
||||
}' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectV2ItemById.item.id')"
|
||||
|
||||
}' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectNextItem.projectNextItem.id')"
|
||||
|
||||
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
|
||||
|
||||
- name: Set fields
|
||||
@@ -64,16 +59,14 @@ jobs:
|
||||
$status_field: ID!
|
||||
$status_value: String!
|
||||
) {
|
||||
set_status: updateProjectV2ItemFieldValue(input: {
|
||||
set_status: updateProjectNextItemField(input: {
|
||||
projectId: $project
|
||||
itemId: $item
|
||||
fieldId: $status_field
|
||||
value: {
|
||||
singleSelectOptionId: $status_value
|
||||
}
|
||||
value: $status_value
|
||||
}) {
|
||||
projectV2Item {
|
||||
projectNextItem {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent
|
||||
@@ -0,0 +1,40 @@
|
||||
image: node:16-buster
|
||||
|
||||
variables:
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- node_modules
|
||||
|
||||
# Fetch dependencies and setup project for compilation.
|
||||
install:
|
||||
stage: prepare
|
||||
script:
|
||||
- yarn
|
||||
|
||||
# Type check the project
|
||||
typecheck:
|
||||
stage: test
|
||||
needs:
|
||||
- install
|
||||
dependencies:
|
||||
- install
|
||||
script:
|
||||
- yarn typecheck
|
||||
|
||||
# Lint the project and check prettier output.
|
||||
lint:
|
||||
stage: test
|
||||
allow_failure: true
|
||||
needs:
|
||||
- install
|
||||
dependencies:
|
||||
- install
|
||||
script:
|
||||
- yarn lint
|
||||
- yarn --check 'src/**/*.{js,jsx,ts,tsx}'
|
||||
|
||||
stages:
|
||||
- prepare
|
||||
- test
|
||||
@@ -1,9 +1,3 @@
|
||||
[submodule "external/lang"]
|
||||
path = external/lang
|
||||
url = https://github.com/revoltchat/translations
|
||||
[submodule "external/components"]
|
||||
path = external/components
|
||||
url = https://github.com/revoltchat/components
|
||||
[submodule "external/revolt.js"]
|
||||
path = external/revolt.js
|
||||
url = https://github.com/revoltchat/revolt.js
|
||||
+1
-2
@@ -5,8 +5,7 @@ COPY . .
|
||||
COPY .env.build .env
|
||||
|
||||
RUN yarn install --frozen-lockfile
|
||||
RUN yarn build:deps
|
||||
# RUN yarn typecheck # lol no
|
||||
RUN yarn typecheck
|
||||
RUN yarn build:highmem
|
||||
RUN yarn workspaces focus --production --all
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
|
||||
This is the web client for Revolt, which is also available live at [app.revolt.chat](https://app.revolt.chat).
|
||||
|
||||
## Pending Rewrite
|
||||
|
||||
The following code is pending a partial or full rewrite:
|
||||
|
||||
- `src/components`: components are being migrated to [revoltchat/components](https://github.com/revoltchat/components)
|
||||
- `src/styles`: needs to be migrated to [revoltchat/components](https://github.com/revoltchat/components)
|
||||
- `src/lib`: this needs to be organised
|
||||
|
||||
## Stack
|
||||
|
||||
- [Preact](https://preactjs.com/)
|
||||
@@ -43,7 +35,6 @@ Get revite up and running locally.
|
||||
git clone --recursive https://github.com/revoltchat/revite
|
||||
cd revite
|
||||
yarn
|
||||
yarn build:deps
|
||||
yarn dev
|
||||
```
|
||||
|
||||
@@ -51,19 +42,17 @@ You can now access the client at http://local.revolt.chat:3000.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
| --------------------------------------- | -------------------------------------------- |
|
||||
| `yarn pull` | Setup assets required for Revite. |
|
||||
| `yarn dev` | Start the Revolt client in development mode. |
|
||||
| `yarn build` | Build the Revolt client. |
|
||||
| `yarn build:deps` | Build external dependencies. |
|
||||
| `yarn preview` | Start a local server with the built client. |
|
||||
| `yarn lint` | Run ESLint on the client. |
|
||||
| `yarn fmt` | Run Prettier on the client. |
|
||||
| `yarn typecheck` | Run TypeScript type checking on the client. |
|
||||
| `yarn start` | Start a local sirv server with built client. |
|
||||
| `yarn start:inject` | Inject a given API URL and start server. |
|
||||
| `yarn lint \| egrep "no-literals" -B 1` | Scan for untranslated strings. |
|
||||
| Command | Description |
|
||||
| ------------------- | -------------------------------------------- |
|
||||
| `yarn pull` | Setup assets required for Revite. |
|
||||
| `yarn dev` | Start the Revolt client in development mode. |
|
||||
| `yarn build` | Build the Revolt client. |
|
||||
| `yarn preview` | Start a local server with the built client. |
|
||||
| `yarn lint` | Run ESLint on the client. |
|
||||
| `yarn fmt` | Run Prettier on the client. |
|
||||
| `yarn typecheck` | Run TypeScript type checking on the client. |
|
||||
| `yarn start` | Start a local sirv server with built client. |
|
||||
| `yarn start:inject` | Inject a given API URL and start server. |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Vendored
-1
Submodule external/components deleted from 4be02430c7.
Vendored
+1
-1
Submodule external/lang updated: 7e6c475f69...e010e46ee9.
Vendored
-1
Submodule external/revolt.js deleted from 783c8d1a3e.
+34
-44
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"version": "1.0.1",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "node scripts/setup_assets.js --check && vite",
|
||||
"pull": "node scripts/setup_assets.js",
|
||||
"build:deps": "cd external && cd components && yarn && yarn build:esm && cd .. && cd revolt.js && yarn && yarn build",
|
||||
"build": "yarn && rimraf build && node scripts/setup_assets.js --check && yarn build:deps && vite build",
|
||||
"build": "rimraf build && node scripts/setup_assets.js --check && vite build",
|
||||
"build:highmem": "NODE_OPTIONS='--max-old-space-size=4096' yarn build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src/**/*.{js,jsx,ts,tsx}",
|
||||
@@ -40,21 +39,34 @@
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"react/jsx-no-literals": "warn"
|
||||
"require-jsdoc": [
|
||||
"error",
|
||||
{
|
||||
"require": {
|
||||
"FunctionDeclaration": true,
|
||||
"MethodDefinition": true,
|
||||
"ClassDeclaration": true,
|
||||
"ArrowFunctionExpression": false,
|
||||
"FunctionExpression": false
|
||||
},
|
||||
"ignore": {
|
||||
"MethodDefinition": [
|
||||
"toJSON",
|
||||
"hydrate"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@revoltchat/rehype-katex": "6.0.3-patch.1",
|
||||
"fs-extra": "^10.0.0",
|
||||
"klaw": "^3.0.0",
|
||||
"lottie-react": "^2.4.0",
|
||||
"sirv-cli": "^1.0.14",
|
||||
"vite": "^3.0.5"
|
||||
"vite": "^2.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-decorators": "^7.17.9",
|
||||
"@floating-ui/react-dom": "^1.0.0",
|
||||
"@floating-ui/react-dom-interactions": "^0.9.1",
|
||||
"@fontsource/atkinson-hyperlegible": "^4.4.5",
|
||||
"@fontsource/bitter": "^4.5.7",
|
||||
"@fontsource/comic-neue": "^4.4.5",
|
||||
@@ -75,30 +87,29 @@
|
||||
"@fontsource/space-mono": "^4.4.5",
|
||||
"@fontsource/ubuntu": "^4.4.5",
|
||||
"@fontsource/ubuntu-mono": "^4.4.5",
|
||||
"@hcaptcha/react-hcaptcha": "^1.4.4",
|
||||
"@hcaptcha/react-hcaptcha": "^0.3.6",
|
||||
"@insertish/vite-plugin-babel-macros": "^1.0.5",
|
||||
"@preact/preset-vite": "^2.0.0",
|
||||
"@revoltchat/ui": "^1.0.77",
|
||||
"@revoltchat/ui": "1.0.33",
|
||||
"@rollup/plugin-replace": "^2.4.2",
|
||||
"@styled-icons/boxicons-logos": "^10.38.0",
|
||||
"@styled-icons/boxicons-regular": "^10.38.0",
|
||||
"@styled-icons/boxicons-solid": "^10.38.0",
|
||||
"@styled-icons/simple-icons": "^10.45.0",
|
||||
"@styled-icons/simple-icons": "^10.33.0",
|
||||
"@tippyjs/react": "4.2.6",
|
||||
"@traptitech/markdown-it-katex": "^3.4.3",
|
||||
"@traptitech/markdown-it-spoiler": "^1.1.6",
|
||||
"@trivago/prettier-plugin-sort-imports": "^2.0.2",
|
||||
"@types/lodash": "^4",
|
||||
"@types/lodash.defaultsdeep": "^4.6.6",
|
||||
"@types/lodash.isequal": "^4.5.5",
|
||||
"@types/node": "^15.14.9",
|
||||
"@types/markdown-it": "^12.0.2",
|
||||
"@types/node": "^15.12.4",
|
||||
"@types/preact-i18n": "^2.3.0",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/prismjs": "^1.16.5",
|
||||
"@types/react-beautiful-dnd": "^13",
|
||||
"@types/react-helmet": "^6.1.1",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/react-scroll": "^1.8.2",
|
||||
"@types/semver": "^7",
|
||||
"@types/styled-components": "^5.1.10",
|
||||
"@types/twemoji": "^12.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
||||
@@ -110,26 +121,22 @@
|
||||
"detect-browser": "^5.2.0",
|
||||
"eslint": "^7.28.0",
|
||||
"eslint-config-preact": "^1.1.4",
|
||||
"eslint-plugin-jsdoc": "^39.3.2",
|
||||
"eslint-plugin-mobx": "^0.0.8",
|
||||
"eventemitter3": "^4.0.7",
|
||||
"history": "4",
|
||||
"json-stringify-deterministic": "^1.0.2",
|
||||
"localforage": "^1.9.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash.defaultsdeep": "^4.6.1",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"long": "^5.2.0",
|
||||
"mdast-util-to-hast": "^12.1.2",
|
||||
"markdown-it": "^12.0.6",
|
||||
"markdown-it-emoji": "^2.0.0",
|
||||
"mediasoup-client": "npm:@insertish/mediasoup-client@3.6.36-esnext",
|
||||
"mobx": "^6.6.0",
|
||||
"mobx-react-lite": "3.4.0",
|
||||
"preact": "^10.5.14",
|
||||
"preact-context-menu": "0.4.1",
|
||||
"preact-context-menu": "0.4.0-patch.0",
|
||||
"preact-i18n": "^2.4.0-preactx",
|
||||
"prettier": "^2.3.1",
|
||||
"prismjs": "^1.28.0",
|
||||
"qrcode.react": "^3.0.2",
|
||||
"prismjs": "^1.23.0",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-device-detect": "2.2.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
@@ -138,29 +145,16 @@
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scroll": "^1.8.2",
|
||||
"react-virtuoso": "^2.12.0",
|
||||
"rehype-prism": "^2.1.3",
|
||||
"rehype-react": "^7.1.1",
|
||||
"remark-breaks": "^3.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"remark-parse": "^10.0.1",
|
||||
"remark-rehype": "^10.1.0",
|
||||
"revolt.js": "6.0.17",
|
||||
"revolt.js": "6.0.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"sass": "^1.35.1",
|
||||
"semver": "^7.3.7",
|
||||
"shade-blend-color": "^1.0.0",
|
||||
"slate": "^0.81.1",
|
||||
"slate-history": "^0.66.0",
|
||||
"slate-react": "^0.81.0",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"styled-components": "^5.3.0",
|
||||
"typescript": "^4.4.2",
|
||||
"ulid": "^2.3.0",
|
||||
"unified": "^10.1.2",
|
||||
"unist-util-visit": "^4.1.0",
|
||||
"use-resize-observer": "^7.0.0",
|
||||
"vite-plugin-pwa": "^0.12.3",
|
||||
"vite-plugin-pwa": "^0.11.13",
|
||||
"workbox-precaching": "^6.1.5"
|
||||
},
|
||||
"name": "client",
|
||||
@@ -168,9 +162,5 @@
|
||||
"repository": "https://github.com/revoltchat/revite.git",
|
||||
"author": "Paul <paulmakles@gmail.com>",
|
||||
"license": "MIT",
|
||||
"packageManager": "yarn@3.2.0",
|
||||
"resolutions": {
|
||||
"@revoltchat/ui": "portal:external/components",
|
||||
"revolt.js": "portal:external/revolt.js"
|
||||
}
|
||||
"packageManager": "yarn@3.2.0"
|
||||
}
|
||||
+3
-8
@@ -2,10 +2,7 @@
|
||||
# Build and publish release to production server
|
||||
|
||||
# Remote Server
|
||||
if [ -z "$REMOTE" ]; then
|
||||
echo "Please set REMOTE!"
|
||||
exit
|
||||
fi
|
||||
REMOTE=revolt-de-nrb-1
|
||||
|
||||
# Remote Directory
|
||||
REMOTE_DIR=/root/revite
|
||||
@@ -17,11 +14,9 @@ POST_INSTALL="pm2 restart revite"
|
||||
export REVOLT_SAAS=https://github.com/revoltchat/assets
|
||||
|
||||
|
||||
# Exit when any command fails
|
||||
set -e
|
||||
|
||||
# 1. Build Revite
|
||||
yarn build:highmem
|
||||
yarn
|
||||
yarn build
|
||||
|
||||
# 2. Archive built files
|
||||
tar -czvf build.tar.gz dist
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import Lottie, { LottieRefCurrentProps } from "lottie-react";
|
||||
|
||||
import { JSX } from "preact";
|
||||
|
||||
import usernameAnim from "../controllers/modals/components/legacy/usernameUpdateLottie.json";
|
||||
|
||||
type Element =
|
||||
| string
|
||||
| {
|
||||
type: "image";
|
||||
src: string;
|
||||
shadow?: boolean;
|
||||
}
|
||||
| { type: "element"; element: JSX.Element };
|
||||
|
||||
export interface ChangelogPost {
|
||||
date: Date;
|
||||
title: string;
|
||||
content: Element[];
|
||||
}
|
||||
|
||||
export const changelogEntries: Record<number, ChangelogPost> = {
|
||||
1: {
|
||||
date: new Date("2022-06-12T20:39:16.674Z"),
|
||||
title: "Secure your account with 2FA",
|
||||
content: [
|
||||
"Two-factor authentication is now available to all users, you can now head over to settings to enable recovery codes and an authenticator app.",
|
||||
{
|
||||
type: "image",
|
||||
src: "https://autumn.revolt.chat/attachments/E21kwmuJGcASgkVLiSIW0wV3ggcaOWjW0TQF7cdFNY/image.png",
|
||||
},
|
||||
"Once enabled, you will be prompted on login.",
|
||||
{
|
||||
type: "image",
|
||||
src: "https://autumn.revolt.chat/attachments/LWRYoKR2tE1ggW_Lzm547P1pnrkNgmBaoCAfWvHE74/image.png",
|
||||
},
|
||||
"Other authentication methods coming later, stay tuned!",
|
||||
],
|
||||
},
|
||||
2: {
|
||||
date: new Date("2023-02-23T20:00:00.000Z"),
|
||||
title: "In-App Reporting Is Here",
|
||||
content: [
|
||||
"You can now report any user, server, or message directly from the app.",
|
||||
{
|
||||
type: "image",
|
||||
src: "https://autumn.revolt.chat/attachments/ZuDVIjGiCl61Pk9XGk5qfc8-idN9EnFAk55DUQp713/the.png",
|
||||
shadow: true,
|
||||
},
|
||||
"If you want to learn more about how we're making Revolt safer for you, check out our new blog post :point_right: [https://revolt.chat/posts/improving-user-safety](https://revolt.chat/posts/improving-user-safety)",
|
||||
],
|
||||
},
|
||||
3: {
|
||||
date: new Date("2023-06-11T15:00:00.000Z"),
|
||||
title: "Usernames are Changing",
|
||||
content: [
|
||||
{
|
||||
type: "element",
|
||||
element: (
|
||||
<Lottie
|
||||
animationData={usernameAnim}
|
||||
style={{
|
||||
background: "var(--secondary-background)",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
"Revolt has undergone a significant change to its username system, transitioning from unique username handles to a new system of display names and usernames with four-digit number tags called discriminators. The four-digit number tags serve as identifiers to differentiate users with the same username, allowing individuals to select desired usernames that reflect their identity.",
|
||||
{
|
||||
type: "element",
|
||||
element: (
|
||||
<a href="https://revolt.chat/posts/evolving-usernames">
|
||||
Read more on our blog!
|
||||
</a>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const changelogEntryArray = Object.keys(changelogEntries).map(
|
||||
(index) => changelogEntries[index as unknown as number],
|
||||
);
|
||||
|
||||
export const latestChangelog = changelogEntryArray.length;
|
||||
@@ -151,6 +151,7 @@ export const emojiDictionary = {
|
||||
hole: "🕳️",
|
||||
bomb: "💣",
|
||||
speech_balloon: "💬",
|
||||
eye_speech_bubble: "👁️🗨️",
|
||||
left_speech_bubble: "🗨️",
|
||||
right_anger_bubble: "🗯️",
|
||||
thought_balloon: "💭",
|
||||
@@ -1849,7 +1850,7 @@ export const emojiDictionary = {
|
||||
scotland: "🏴",
|
||||
wales: "🏴",
|
||||
...{
|
||||
1984: "custom:1984.gif",
|
||||
"1984": "custom:1984.gif",
|
||||
KekW: "custom:KekW.png",
|
||||
amogus: "custom:amogus.gif",
|
||||
awaa: "custom:awaa.png",
|
||||
@@ -1941,6 +1942,8 @@ export const emojiDictionary = {
|
||||
charle: "custom:charle.gif",
|
||||
sadge: "custom:sadge.webp",
|
||||
sus: "custom:sus.webp",
|
||||
chade: "custom:chade.gif",
|
||||
gigachad: "custom:gigachad.webp",
|
||||
sippy: "custom:sippy.webp",
|
||||
ayame_heart: "custom:ayame_heart.png",
|
||||
catgirl_peek: "custom:catgirl_peek.png",
|
||||
@@ -1949,6 +1952,5 @@ export const emojiDictionary = {
|
||||
huggies: "custom:huggies.png",
|
||||
noted: "custom:noted.gif",
|
||||
waving: "custom:waving.png",
|
||||
mogusvented: "custom:mogusvented.png",
|
||||
},
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
The following folders should not be added to or modified:
|
||||
|
||||
- `common`
|
||||
- `markdown`
|
||||
- `native`
|
||||
- `ui`
|
||||
|
||||
The following are part-legacy, will remain in place and will be rewritten to some degree still:
|
||||
|
||||
- `navigation`
|
||||
|
||||
The following are mostly good to go:
|
||||
|
||||
- `settings`
|
||||
@@ -6,11 +6,15 @@ import styled from "styled-components/macro";
|
||||
import { Text } from "preact-i18n";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import { Button, Checkbox } from "@revoltchat/ui";
|
||||
import { Button } from "@revoltchat/ui";
|
||||
|
||||
import { useApplicationState } from "../../mobx/State";
|
||||
import { SECTION_NSFW } from "../../mobx/stores/Layout";
|
||||
|
||||
import Checkbox from "../ui/Checkbox";
|
||||
|
||||
import { Children } from "../../types/Preact";
|
||||
|
||||
const Base = styled.div`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
@@ -77,10 +81,10 @@ export default observer((props: Props) => {
|
||||
</span>
|
||||
|
||||
<Checkbox
|
||||
title={<Text id="app.main.channel.nsfw.confirm" />}
|
||||
value={layout.getSectionState(SECTION_NSFW, false)}
|
||||
onChange={() => layout.toggleSectionState(SECTION_NSFW, false)}
|
||||
/>
|
||||
checked={layout.getSectionState(SECTION_NSFW, false)}
|
||||
onChange={() => layout.toggleSectionState(SECTION_NSFW, false)}>
|
||||
<Text id="app.main.channel.nsfw.confirm" />
|
||||
</Checkbox>
|
||||
<div className="actions">
|
||||
<Button palette="secondary" onClick={() => history.goBack()}>
|
||||
<Text id="app.special.modals.actions.back" />
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import { Link } from "react-router-dom";
|
||||
import { Channel, User } from "revolt.js";
|
||||
import { Emoji as CustomEmoji } from "revolt.js/esm/maps/Emojis";
|
||||
import styled, { css } from "styled-components/macro";
|
||||
|
||||
import { StateUpdater, useState } from "preact/hooks";
|
||||
|
||||
import { useClient } from "../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { emojiDictionary } from "../../assets/emojis";
|
||||
import { useClient } from "../../controllers/client/ClientController";
|
||||
import ChannelIcon from "./ChannelIcon";
|
||||
import Emoji from "./Emoji";
|
||||
import ServerIcon from "./ServerIcon";
|
||||
import Tooltip from "./Tooltip";
|
||||
import UserIcon from "./user/UserIcon";
|
||||
|
||||
export type AutoCompleteState =
|
||||
@@ -19,7 +15,7 @@ export type AutoCompleteState =
|
||||
| ({ selected: number; within: boolean } & (
|
||||
| {
|
||||
type: "emoji";
|
||||
matches: (string | CustomEmoji)[];
|
||||
matches: string[];
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
@@ -64,7 +60,7 @@ export function useAutoComplete(
|
||||
const cursor = el.selectionStart;
|
||||
const content = el.value.slice(0, cursor);
|
||||
|
||||
const valid = /[\w\-]/;
|
||||
const valid = /\w/;
|
||||
|
||||
let j = content.length - 1;
|
||||
if (content[j] === "@") {
|
||||
@@ -92,7 +88,7 @@ export function useAutoComplete(
|
||||
? "emoji"
|
||||
: "user",
|
||||
search.toLowerCase(),
|
||||
current === ":" ? j + 1 : j,
|
||||
j + 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -109,23 +105,16 @@ export function useAutoComplete(
|
||||
|
||||
if (type === "emoji") {
|
||||
// ! TODO: we should convert it to a Binary Search Tree and use that
|
||||
const matches = [
|
||||
...Object.keys(emojiDictionary).filter((emoji: string) =>
|
||||
emoji.match(regex),
|
||||
),
|
||||
...Array.from(client.emojis.values()).filter((emoji) =>
|
||||
emoji.name.match(regex),
|
||||
),
|
||||
].splice(0, 5);
|
||||
const matches = Object.keys(emojiDictionary)
|
||||
.filter((emoji: string) => emoji.match(regex))
|
||||
.splice(0, 5);
|
||||
|
||||
if (matches.length > 0) {
|
||||
const currentPosition =
|
||||
state.type !== "none" ? state.selected : 0;
|
||||
|
||||
setState({
|
||||
// @ts-ignore-next-line are you high
|
||||
type: "emoji",
|
||||
// @ts-ignore-next-line
|
||||
matches,
|
||||
selected: Math.min(currentPosition, matches.length - 1),
|
||||
within: false,
|
||||
@@ -245,18 +234,15 @@ export function useAutoComplete(
|
||||
|
||||
const content = el.value.split("");
|
||||
if (state.type === "emoji") {
|
||||
const selected = state.matches[state.selected];
|
||||
content.splice(
|
||||
index,
|
||||
search.length,
|
||||
selected instanceof CustomEmoji
|
||||
? selected._id
|
||||
: selected,
|
||||
state.matches[state.selected],
|
||||
": ",
|
||||
);
|
||||
} else if (state.type === "user") {
|
||||
content.splice(
|
||||
index,
|
||||
index - 1,
|
||||
search.length + 1,
|
||||
"<@",
|
||||
state.matches[state.selected]._id,
|
||||
@@ -264,7 +250,7 @@ export function useAutoComplete(
|
||||
);
|
||||
} else {
|
||||
content.splice(
|
||||
index,
|
||||
index - 1,
|
||||
search.length + 1,
|
||||
"<#",
|
||||
state.matches[state.selected]._id,
|
||||
@@ -403,17 +389,12 @@ export default function AutoComplete({
|
||||
setState,
|
||||
onClick,
|
||||
}: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) {
|
||||
const client = useClient();
|
||||
return (
|
||||
<Base detached={detached}>
|
||||
<div>
|
||||
{state.type === "emoji" &&
|
||||
state.matches.map((match, i) => (
|
||||
<button
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
key={match}
|
||||
className={i === state.selected ? "active" : ""}
|
||||
onMouseEnter={() =>
|
||||
@@ -432,61 +413,15 @@ export default function AutoComplete({
|
||||
})
|
||||
}
|
||||
onClick={onClick}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
{match instanceof CustomEmoji ? (
|
||||
<img
|
||||
loading="lazy"
|
||||
src={match.imageURL}
|
||||
style={{
|
||||
width: `20px`,
|
||||
height: `20px`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Emoji
|
||||
emoji={
|
||||
(
|
||||
emojiDictionary as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
)[match]
|
||||
}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
<span style={{ paddingLeft: "4px" }}>{`:${
|
||||
match instanceof CustomEmoji
|
||||
? match.name
|
||||
: match
|
||||
}:`}</span>
|
||||
</div>
|
||||
{match instanceof CustomEmoji &&
|
||||
match.parent.type == "Server" && (
|
||||
<>
|
||||
<Tooltip
|
||||
content={
|
||||
client.servers.get(
|
||||
match.parent.id,
|
||||
)?.name
|
||||
}>
|
||||
<Link
|
||||
to={`/server/${match.parent.id}`}>
|
||||
<ServerIcon
|
||||
target={client.servers.get(
|
||||
match.parent.id,
|
||||
)}
|
||||
size={20}
|
||||
/>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Emoji
|
||||
emoji={
|
||||
(emojiDictionary as Record<string, string>)[
|
||||
match
|
||||
]
|
||||
}
|
||||
size={20}
|
||||
/>
|
||||
:{match}:
|
||||
</button>
|
||||
))}
|
||||
{state.type === "user" &&
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Hash, VolumeFull } from "@styled-icons/boxicons-regular";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Channel } from "revolt.js";
|
||||
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||
|
||||
import fallback from "./assets/group.png";
|
||||
|
||||
import { useClient } from "../../controllers/client/ClientController";
|
||||
import { ImageIconBase, IconBaseProps } from "./IconBase";
|
||||
|
||||
interface Props extends IconBaseProps<Channel> {
|
||||
@@ -19,7 +22,7 @@ export default observer(
|
||||
keyof Props | "children" | "as"
|
||||
>,
|
||||
) => {
|
||||
const client = useClient();
|
||||
const client = useContext(AppContext);
|
||||
|
||||
const {
|
||||
size,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ChevronDown } from "@styled-icons/boxicons-regular";
|
||||
|
||||
import { Details } from "@revoltchat/ui";
|
||||
|
||||
import { useApplicationState } from "../../mobx/State";
|
||||
|
||||
import Details from "../ui/Details";
|
||||
|
||||
import { Children } from "../../types/Preact";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
defaultValue: boolean;
|
||||
@@ -32,7 +34,7 @@ export default function CollapsibleSection({
|
||||
}
|
||||
{...detailsProps}>
|
||||
<summary>
|
||||
<div className="padding">
|
||||
<div class="padding">
|
||||
<ChevronDown size={20} />
|
||||
{summary}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { emojiDictionary } from "../../assets/emojis";
|
||||
|
||||
export type EmojiPack = "mutant" | "twemoji" | "noto" | "openmoji";
|
||||
|
||||
let EMOJI_PACK: EmojiPack = "mutant";
|
||||
@@ -42,7 +40,7 @@ function toCodePoint(rune: string) {
|
||||
.join("-");
|
||||
}
|
||||
|
||||
export function parseEmoji(emoji: string) {
|
||||
function parseEmoji(emoji: string) {
|
||||
if (emoji.startsWith("custom:")) {
|
||||
return `https://dl.insrt.uk/projects/revolt/emotes/${emoji.substring(
|
||||
7,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ComboBox } from "@revoltchat/ui";
|
||||
|
||||
import { useApplicationState } from "../../mobx/State";
|
||||
|
||||
import ComboBox from "../ui/ComboBox";
|
||||
|
||||
import { Language, Languages } from "../../../external/lang/Languages";
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,9 +7,8 @@ import styled, { css } from "styled-components/macro";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import { IconButton } from "@revoltchat/ui";
|
||||
import IconButton from "../ui/IconButton";
|
||||
|
||||
import { modalController } from "../../controllers/modals/ModalController";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
interface Props {
|
||||
@@ -61,9 +60,6 @@ const ServerBanner = styled.div<Omit<Props, "server">>`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-grow: 1;
|
||||
|
||||
cursor: pointer;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -125,13 +121,7 @@ export default observer(({ server }: Props) => {
|
||||
</svg>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
<a
|
||||
className="title"
|
||||
onClick={() =>
|
||||
modalController.push({ type: "server_info", server })
|
||||
}>
|
||||
{server.name}
|
||||
</a>
|
||||
<div className="title">{server.name}</div>
|
||||
{server.havePermission("ManageServer") && (
|
||||
<Link to={`/server/${server._id}/settings`}>
|
||||
<IconButton>
|
||||
|
||||
@@ -4,7 +4,8 @@ import styled from "styled-components/macro";
|
||||
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
import { useClient } from "../../controllers/client/ClientController";
|
||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { IconBaseProps, ImageIconBase } from "./IconBase";
|
||||
|
||||
interface Props extends IconBaseProps<Server> {
|
||||
@@ -33,7 +34,7 @@ export default observer(
|
||||
keyof Props | "children" | "as"
|
||||
>,
|
||||
) => {
|
||||
const client = useClient();
|
||||
const client = useContext(AppContext);
|
||||
|
||||
const { target, attachment, size, animate, server_name, ...imgProps } =
|
||||
props;
|
||||
|
||||
@@ -3,6 +3,8 @@ import styled from "styled-components/macro";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import { Children } from "../../types/Preact";
|
||||
|
||||
type Props = Omit<TippyProps, "children"> & {
|
||||
children: Children;
|
||||
content: Children;
|
||||
|
||||
@@ -3,13 +3,13 @@ import { Download, CloudDownload } from "@styled-icons/boxicons-regular";
|
||||
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { IconButton } from "@revoltchat/ui";
|
||||
|
||||
import { internalSubscribe } from "../../lib/eventEmitter";
|
||||
|
||||
import { useApplicationState } from "../../mobx/State";
|
||||
|
||||
import { updateSW } from "../../updateWorker";
|
||||
import IconButton from "../ui/IconButton";
|
||||
|
||||
import { updateSW } from "../../main";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
let pendingUpdate = false;
|
||||
@@ -31,7 +31,7 @@ export default function UpdateIndicator({ style }: Props) {
|
||||
|
||||
if (style === "titlebar") {
|
||||
return (
|
||||
<div className="actions">
|
||||
<div class="actions">
|
||||
<Tooltip
|
||||
content="A new update is available!"
|
||||
placement="bottom">
|
||||
@@ -46,7 +46,7 @@ export default function UpdateIndicator({ style }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (window.isNative && window.native.getConfig().frame) return null;
|
||||
if (window.isNative) return null;
|
||||
|
||||
return (
|
||||
<IconButton onClick={() => updateSW(true)}>
|
||||
|
||||
@@ -5,16 +5,17 @@ import { useTriggerEvents } from "preact-context-menu";
|
||||
import { memo } from "preact/compat";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { Category } from "@revoltchat/ui";
|
||||
|
||||
import { internalEmit } from "../../../lib/eventEmitter";
|
||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||
|
||||
import { QueuedMessage } from "../../../mobx/stores/MessageQueue";
|
||||
|
||||
import { I18nError } from "../../../context/Locale";
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
import Overline from "../../ui/Overline";
|
||||
|
||||
import { Children } from "../../../types/Preact";
|
||||
import Markdown from "../../markdown/Markdown";
|
||||
import UserIcon from "../user/UserIcon";
|
||||
import { Username } from "../user/UserShort";
|
||||
@@ -25,7 +26,6 @@ import MessageBase, {
|
||||
} from "./MessageBase";
|
||||
import Attachment from "./attachments/Attachment";
|
||||
import { MessageReply } from "./attachments/MessageReply";
|
||||
import { Reactions } from "./attachments/Reactions";
|
||||
import { MessageOverlayBar } from "./bars/MessageOverlayBar";
|
||||
import Embed from "./embed/Embed";
|
||||
import InviteList from "./embed/EmbedInvite";
|
||||
@@ -33,7 +33,7 @@ import InviteList from "./embed/EmbedInvite";
|
||||
interface Props {
|
||||
attachContext?: boolean;
|
||||
queued?: QueuedMessage;
|
||||
message: MessageObject & { webhook: { name: string; avatar?: string } };
|
||||
message: MessageObject;
|
||||
highlight?: boolean;
|
||||
contrast?: boolean;
|
||||
content?: Children;
|
||||
@@ -52,9 +52,11 @@ const Message = observer(
|
||||
queued,
|
||||
hideReply,
|
||||
}: Props) => {
|
||||
const client = message.client;
|
||||
const client = useClient();
|
||||
const user = message.author;
|
||||
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
const content = message.content;
|
||||
const head =
|
||||
preferHead || (message.reply_ids && message.reply_ids.length > 0);
|
||||
@@ -63,16 +65,12 @@ const Message = observer(
|
||||
? useTriggerEvents("Menu", {
|
||||
user: message.author_id,
|
||||
contextualChannel: message.channel_id,
|
||||
contextualMessage: message._id,
|
||||
// eslint-disable-next-line
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const openProfile = () =>
|
||||
modalController.push({
|
||||
type: "user_profile",
|
||||
user_id: message.author_id,
|
||||
});
|
||||
openScreen({ id: "profile", user_id: message.author_id });
|
||||
|
||||
const handleUserClick = (e: MouseEvent) => {
|
||||
if (e.shiftKey && user?._id) {
|
||||
@@ -89,7 +87,6 @@ const Message = observer(
|
||||
|
||||
// ! FIXME(?): animate on hover
|
||||
const [mouseHovering, setAnimate] = useState(false);
|
||||
const [reactionsOpen, setReactionsOpen] = useState(false);
|
||||
useEffect(() => setAnimate(false), [replacement]);
|
||||
|
||||
return (
|
||||
@@ -118,11 +115,7 @@ const Message = observer(
|
||||
}
|
||||
contrast={contrast}
|
||||
sending={typeof queued !== "undefined"}
|
||||
mention={
|
||||
message.mention_ids && client.user
|
||||
? message.mention_ids.includes(client.user._id)
|
||||
: undefined
|
||||
}
|
||||
mention={message.mention_ids?.includes(client.user!._id)}
|
||||
failed={typeof queued?.error !== "undefined"}
|
||||
{...(attachContext
|
||||
? useTriggerEvents("Menu", {
|
||||
@@ -138,11 +131,6 @@ const Message = observer(
|
||||
<UserIcon
|
||||
className="avatar"
|
||||
url={message.generateMasqAvatarURL()}
|
||||
override={
|
||||
message.webhook?.avatar
|
||||
? `https://autumn.revolt.chat/avatars/${message.webhook.avatar}`
|
||||
: undefined
|
||||
}
|
||||
target={user}
|
||||
size={36}
|
||||
onClick={handleUserClick}
|
||||
@@ -163,7 +151,6 @@ const Message = observer(
|
||||
showServerIdentity
|
||||
onClick={handleUserClick}
|
||||
masquerade={message.masquerade!}
|
||||
override={message.webhook?.name}
|
||||
{...userContext}
|
||||
/>
|
||||
<MessageDetail
|
||||
@@ -172,13 +159,10 @@ const Message = observer(
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{replacement ??
|
||||
(content && <Markdown content={content} />)}
|
||||
{replacement ?? <Markdown content={content} />}
|
||||
{!queued && <InviteList message={message} />}
|
||||
{queued?.error && (
|
||||
<Category>
|
||||
<I18nError error={queued.error} />
|
||||
</Category>
|
||||
<Overline type="error" error={queued.error} />
|
||||
)}
|
||||
{message.attachments?.map((attachment, index) => (
|
||||
<Attachment
|
||||
@@ -193,13 +177,10 @@ const Message = observer(
|
||||
{message.embeds?.map((embed, index) => (
|
||||
<Embed key={index} embed={embed} />
|
||||
))}
|
||||
<Reactions message={message} />
|
||||
{(mouseHovering || reactionsOpen) &&
|
||||
{mouseHovering &&
|
||||
!replacement &&
|
||||
!isTouchscreenDevice && (
|
||||
<MessageOverlayBar
|
||||
reactionsOpen={reactionsOpen}
|
||||
setReactionsOpen={setReactionsOpen}
|
||||
message={message}
|
||||
queued={queued}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { HappyBeaming, Send, ShieldX } from "@styled-icons/boxicons-solid";
|
||||
import { Send, ShieldX } from "@styled-icons/boxicons-solid";
|
||||
import Axios, { CancelTokenSource } from "axios";
|
||||
import Long from "long";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Channel } from "revolt.js";
|
||||
import {
|
||||
Channel,
|
||||
DEFAULT_PERMISSION_DIRECT_MESSAGE,
|
||||
DEFAULT_PERMISSION_VIEW_ONLY,
|
||||
Permission,
|
||||
Server,
|
||||
U32_MAX,
|
||||
UserPermission,
|
||||
} from "revolt.js";
|
||||
import styled, { css } from "styled-components/macro";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
import { memo } from "preact/compat";
|
||||
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
||||
|
||||
import { IconButton, Picker } from "@revoltchat/ui";
|
||||
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
|
||||
|
||||
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
|
||||
import { debounce } from "../../../lib/debounce";
|
||||
@@ -22,25 +28,20 @@ import {
|
||||
SMOOTH_SCROLL_ON_RECEIVE,
|
||||
} from "../../../lib/renderer/Singleton";
|
||||
|
||||
import { state, useApplicationState } from "../../../mobx/State";
|
||||
import { DraftObject } from "../../../mobx/stores/Draft";
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
import { Reply } from "../../../mobx/stores/MessageQueue";
|
||||
|
||||
import { dayjs } from "../../../context/Locale";
|
||||
|
||||
import { emojiDictionary } from "../../../assets/emojis";
|
||||
import {
|
||||
clientController,
|
||||
useClient,
|
||||
} from "../../../controllers/client/ClientController";
|
||||
import { takeError } from "../../../controllers/client/jsx/error";
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
import {
|
||||
FileUploader,
|
||||
grabFiles,
|
||||
uploadFile,
|
||||
} from "../../../controllers/client/jsx/legacy/FileUploads";
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
import { RenderEmoji } from "../../markdown/plugins/emoji";
|
||||
} from "../../../context/revoltjs/FileUploads";
|
||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||
import { takeError } from "../../../context/revoltjs/util";
|
||||
|
||||
import IconButton from "../../ui/IconButton";
|
||||
|
||||
import AutoComplete, { useAutoComplete } from "../AutoComplete";
|
||||
import { PermissionTooltip } from "../Tooltip";
|
||||
import FilePreview from "./bars/FilePreview";
|
||||
@@ -103,7 +104,7 @@ const Blocked = styled.div`
|
||||
`;
|
||||
|
||||
const Action = styled.div`
|
||||
> a {
|
||||
> div {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
display: flex;
|
||||
@@ -126,7 +127,7 @@ const Action = styled.div`
|
||||
`;
|
||||
|
||||
const FileAction = styled.div`
|
||||
> a {
|
||||
> div {
|
||||
height: 48px;
|
||||
width: 62px;
|
||||
display: flex;
|
||||
@@ -135,10 +136,6 @@ const FileAction = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const FloatingLayer = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const ThisCodeWillBeReplacedAnywaysSoIMightAsWellJustDoItThisWay__Padding = styled.div`
|
||||
width: 16px;
|
||||
`;
|
||||
@@ -149,67 +146,6 @@ const RE_SED = new RegExp("^s/([^])*/([^])*$");
|
||||
// Tests for code block delimiters (``` at start of line)
|
||||
const RE_CODE_DELIMITER = new RegExp("^```", "gm");
|
||||
|
||||
export const HackAlertThisFileWillBeReplaced = observer(
|
||||
({
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const renderEmoji = useMemo(
|
||||
() =>
|
||||
memo(({ emoji }: { emoji: string }) => (
|
||||
<RenderEmoji match={emoji} {...({} as any)} />
|
||||
)),
|
||||
[],
|
||||
);
|
||||
|
||||
const emojis: Record<string, any> = {
|
||||
default: Object.keys(emojiDictionary).map((id) => ({ id })),
|
||||
};
|
||||
|
||||
// ! FIXME: also expose typing from component
|
||||
const categories: any[] = [];
|
||||
|
||||
for (const server of state.ordering.orderedServers) {
|
||||
// ! FIXME: add a separate map on each server for emoji
|
||||
const list = [...clientController.getReadyClient()!.emojis.values()]
|
||||
.filter(
|
||||
(emoji) =>
|
||||
emoji.parent.type !== "Detached" &&
|
||||
emoji.parent.id === server._id,
|
||||
)
|
||||
.map(({ _id, name }) => ({ id: _id, name }));
|
||||
|
||||
if (list.length > 0) {
|
||||
emojis[server._id] = list;
|
||||
categories.push({
|
||||
id: server._id,
|
||||
name: server.name,
|
||||
iconURL: server.generateIconURL({ max_side: 256 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
categories.push({
|
||||
id: "default",
|
||||
name: "Default",
|
||||
emoji: "smiley",
|
||||
});
|
||||
|
||||
return (
|
||||
<Picker
|
||||
emojis={emojis}
|
||||
categories={categories}
|
||||
renderEmoji={renderEmoji}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ! FIXME: add to app config and load from app config
|
||||
export const CAN_UPLOAD_AT_ONCE = 5;
|
||||
|
||||
@@ -221,42 +157,12 @@ export default observer(({ channel }: Props) => {
|
||||
});
|
||||
const [typing, setTyping] = useState<boolean | number>(false);
|
||||
const [replies, setReplies] = useState<Reply[]>([]);
|
||||
const [picker, setPicker] = useState(false);
|
||||
const client = useClient();
|
||||
const { openScreen } = useIntermediate();
|
||||
const client = useContext(AppContext);
|
||||
const translate = useTranslation();
|
||||
|
||||
const closePicker = useCallback(() => setPicker(false), []);
|
||||
|
||||
const renderer = getRenderer(channel);
|
||||
|
||||
if (channel.server?.member?.timeout) {
|
||||
return (
|
||||
<Base>
|
||||
<Blocked>
|
||||
<Action>
|
||||
<PermissionTooltip
|
||||
permission="SendMessages"
|
||||
placement="top">
|
||||
<ShieldX size={22} />
|
||||
</PermissionTooltip>
|
||||
</Action>
|
||||
<div className="text">
|
||||
<Text
|
||||
id="app.main.channel.misc.timed_out"
|
||||
fields={{
|
||||
// TODO: make this reactive
|
||||
time: dayjs().to(
|
||||
channel.server.member.timeout,
|
||||
true,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Blocked>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
if (!channel.havePermission("SendMessage")) {
|
||||
return (
|
||||
<Base>
|
||||
@@ -278,12 +184,7 @@ export default observer(({ channel }: Props) => {
|
||||
|
||||
// Push message content to draft.
|
||||
const setMessage = useCallback(
|
||||
(content?: string) => {
|
||||
const dobj: DraftObject = {
|
||||
content,
|
||||
};
|
||||
state.draft.set(channel._id, dobj);
|
||||
},
|
||||
(content?: string) => state.draft.set(channel._id, content),
|
||||
[state.draft, channel._id],
|
||||
);
|
||||
|
||||
@@ -323,7 +224,7 @@ export default observer(({ channel }: Props) => {
|
||||
if (uploadState.type === "uploading" || uploadState.type === "sending")
|
||||
return;
|
||||
|
||||
const content = state.draft.get(channel._id)?.content?.trim() ?? "";
|
||||
const content = state.draft.get(channel._id)?.trim() ?? "";
|
||||
if (uploadState.type === "attached") return sendFile(content);
|
||||
if (content.length === 0) return;
|
||||
|
||||
@@ -408,7 +309,6 @@ export default observer(({ channel }: Props) => {
|
||||
async function sendFile(content: string) {
|
||||
if (uploadState.type !== "attached") return;
|
||||
const attachments: string[] = [];
|
||||
setMessage;
|
||||
|
||||
const cancel = Axios.CancelToken.source();
|
||||
const files = uploadState.files;
|
||||
@@ -532,7 +432,7 @@ export default observer(({ channel }: Props) => {
|
||||
}
|
||||
|
||||
function isInCodeBlock(cursor: number): boolean {
|
||||
const content = state.draft.get(channel._id)?.content || "";
|
||||
const content = state.draft.get(channel._id) || "";
|
||||
const contentBeforeCursor = content.substring(0, cursor);
|
||||
|
||||
let delimiterCount = 0;
|
||||
@@ -582,10 +482,7 @@ export default observer(({ channel }: Props) => {
|
||||
files: [...uploadState.files, ...files],
|
||||
}),
|
||||
() =>
|
||||
modalController.push({
|
||||
type: "error",
|
||||
error: "FileTooLarge",
|
||||
}),
|
||||
openScreen({ id: "error", error: "FileTooLarge" }),
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -608,22 +505,6 @@ export default observer(({ channel }: Props) => {
|
||||
replies={replies}
|
||||
setReplies={setReplies}
|
||||
/>
|
||||
<FloatingLayer>
|
||||
{picker && (
|
||||
<HackAlertThisFileWillBeReplaced
|
||||
onSelect={(emoji) => {
|
||||
const v = state.draft.get(channel._id);
|
||||
const cnt: DraftObject = {
|
||||
content:
|
||||
(v?.content ? `${v.content} ` : "") +
|
||||
`:${emoji}:`,
|
||||
};
|
||||
state.draft.set(channel._id, cnt);
|
||||
}}
|
||||
onClose={closePicker}
|
||||
/>
|
||||
)}
|
||||
</FloatingLayer>
|
||||
<Base>
|
||||
{channel.havePermission("UploadFiles") ? (
|
||||
<FileAction>
|
||||
@@ -672,7 +553,7 @@ export default observer(({ channel }: Props) => {
|
||||
id="message"
|
||||
maxLength={2000}
|
||||
onKeyUp={onKeyUp}
|
||||
value={state.draft.get(channel._id)?.content ?? ""}
|
||||
value={state.draft.get(channel._id) ?? ""}
|
||||
padding="var(--message-box-padding)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.ctrlKey && e.key === "Enter") {
|
||||
@@ -744,11 +625,16 @@ export default observer(({ channel }: Props) => {
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<Action>
|
||||
<IconButton onClick={() => setPicker(!picker)}>
|
||||
<HappyBeaming size={24} />
|
||||
{/*<Action>
|
||||
<IconButton>
|
||||
<Box size={24} />
|
||||
</IconButton>
|
||||
</Action>
|
||||
<Action>
|
||||
<IconButton>
|
||||
<HappyBeaming size={24} />
|
||||
</IconButton>
|
||||
</Action>*/}
|
||||
<Action>
|
||||
<IconButton
|
||||
className={
|
||||
|
||||
@@ -9,26 +9,15 @@ import {
|
||||
EditAlt,
|
||||
Edit,
|
||||
MessageSquareEdit,
|
||||
Key,
|
||||
} from "@styled-icons/boxicons-solid";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Message, API } from "revolt.js";
|
||||
import styled from "styled-components/macro";
|
||||
import { decodeTime } from "ulid";
|
||||
|
||||
import { useTriggerEvents } from "preact-context-menu";
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import { Row } from "@revoltchat/ui";
|
||||
|
||||
import { TextReact } from "../../../lib/i18n";
|
||||
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
import { dayjs } from "../../../context/Locale";
|
||||
|
||||
import Markdown from "../../markdown/Markdown";
|
||||
import Tooltip from "../Tooltip";
|
||||
import UserShort from "../user/UserShort";
|
||||
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
|
||||
|
||||
@@ -78,17 +67,12 @@ const iconDictionary = {
|
||||
channel_renamed: EditAlt,
|
||||
channel_description_changed: Edit,
|
||||
channel_icon_changed: MessageSquareEdit,
|
||||
channel_ownership_changed: Key,
|
||||
text: InfoCircle,
|
||||
};
|
||||
|
||||
export const SystemMessage = observer(
|
||||
({ attachContext, message, highlight, hideInfo }: Props) => {
|
||||
const data = message.asSystemMessage;
|
||||
if (!data) return null;
|
||||
|
||||
const settings = useApplicationState().settings;
|
||||
|
||||
const SystemMessageIcon =
|
||||
iconDictionary[data.type as API.SystemMessage["type"]] ??
|
||||
InfoCircle;
|
||||
@@ -114,39 +98,16 @@ export const SystemMessage = observer(
|
||||
case "user_joined":
|
||||
case "user_left":
|
||||
case "user_kicked":
|
||||
case "user_banned": {
|
||||
const createdAt = data.user ? decodeTime(data.user._id) : null;
|
||||
case "user_banned":
|
||||
children = (
|
||||
<Row centred>
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${data.type}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.user} />,
|
||||
}}
|
||||
/>
|
||||
{data.type == "user_joined" &&
|
||||
createdAt &&
|
||||
(settings.get("appearance:show_account_age") ||
|
||||
Date.now() - createdAt <
|
||||
1000 * 60 * 60 * 24 * 7) && (
|
||||
<Tooltip
|
||||
content={
|
||||
<Text
|
||||
id="app.main.channel.system.registered_at"
|
||||
fields={{
|
||||
time: dayjs(
|
||||
createdAt,
|
||||
).fromNow(),
|
||||
}}
|
||||
/>
|
||||
}>
|
||||
<InfoCircle size={16} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Row>
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${data.type}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.user} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "channel_renamed":
|
||||
children = (
|
||||
<TextReact
|
||||
@@ -169,22 +130,6 @@ export const SystemMessage = observer(
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "channel_ownership_changed":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.channel_ownership_changed`}
|
||||
fields={{
|
||||
from: <UserShort user={data.from} />,
|
||||
to: <UserShort user={data.to} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "text":
|
||||
if (message.system?.type === "text") {
|
||||
children = <Markdown content={message.system?.content} />;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,9 +3,10 @@ import { API } from "revolt.js";
|
||||
import styles from "./Attachment.module.scss";
|
||||
import classNames from "classnames";
|
||||
import { useTriggerEvents } from "preact-context-menu";
|
||||
import { useState } from "preact/hooks";
|
||||
import { useContext, useState } from "preact/hooks";
|
||||
|
||||
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { useClient } from "../../../../controllers/client/ClientController";
|
||||
import AttachmentActions from "./AttachmentActions";
|
||||
import { SizedGrid } from "./Grid";
|
||||
import ImageFile from "./ImageFile";
|
||||
@@ -20,7 +21,7 @@ interface Props {
|
||||
const MAX_ATTACHMENT_WIDTH = 480;
|
||||
|
||||
export default function Attachment({ attachment, hasContent }: Props) {
|
||||
const client = useClient();
|
||||
const client = useContext(AppContext);
|
||||
const { filename, metadata } = attachment;
|
||||
const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_"));
|
||||
|
||||
|
||||
@@ -11,18 +11,18 @@ import styles from "./AttachmentActions.module.scss";
|
||||
import classNames from "classnames";
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
import { IconButton } from "@revoltchat/ui";
|
||||
|
||||
import { determineFileSize } from "../../../../lib/fileSize";
|
||||
|
||||
import { useClient } from "../../../../controllers/client/ClientController";
|
||||
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import IconButton from "../../../ui/IconButton";
|
||||
|
||||
interface Props {
|
||||
attachment: API.File;
|
||||
}
|
||||
|
||||
export default function AttachmentActions({ attachment }: Props) {
|
||||
const client = useClient();
|
||||
const client = useContext(AppContext);
|
||||
const { filename, metadata, size } = attachment;
|
||||
|
||||
const url = client.generateFileURL(attachment);
|
||||
|
||||
@@ -2,6 +2,8 @@ import styled from "styled-components/macro";
|
||||
|
||||
import { Ref } from "preact";
|
||||
|
||||
import { Children } from "../../../../types/Preact";
|
||||
|
||||
const Grid = styled.div<{ width: number; height: number }>`
|
||||
--width: ${(props) => props.width}px;
|
||||
--height: ${(props) => props.height}px;
|
||||
|
||||
@@ -2,10 +2,10 @@ import { API } from "revolt.js";
|
||||
|
||||
import styles from "./Attachment.module.scss";
|
||||
import classNames from "classnames";
|
||||
import { useState } from "preact/hooks";
|
||||
import { useContext, useState } from "preact/hooks";
|
||||
|
||||
import { useClient } from "../../../../controllers/client/ClientController";
|
||||
import { modalController } from "../../../../controllers/modals/ModalController";
|
||||
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
|
||||
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
||||
|
||||
enum ImageLoadingState {
|
||||
Loading,
|
||||
@@ -19,7 +19,8 @@ type Props = JSX.HTMLAttributes<HTMLImageElement> & {
|
||||
|
||||
export default function ImageFile({ attachment, ...props }: Props) {
|
||||
const [loading, setLoading] = useState(ImageLoadingState.Loading);
|
||||
const client = useClient();
|
||||
const client = useContext(AppContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
const url = client.generateFileURL(attachment)!;
|
||||
|
||||
return (
|
||||
@@ -31,9 +32,7 @@ export default function ImageFile({ attachment, ...props }: Props) {
|
||||
className={classNames(styles.image, {
|
||||
[styles.loading]: loading !== ImageLoadingState.Loaded,
|
||||
})}
|
||||
onClick={() =>
|
||||
modalController.push({ type: "image_viewer", attachment })
|
||||
}
|
||||
onClick={() => openScreen({ id: "image_viewer", attachment })}
|
||||
onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")}
|
||||
onLoad={() => setLoading(ImageLoadingState.Loaded)}
|
||||
onError={() => setLoading(ImageLoadingState.Error)}
|
||||
|
||||
@@ -221,15 +221,13 @@ export const MessageReply = observer(
|
||||
</em>
|
||||
</>
|
||||
)}
|
||||
{message.content && (
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={message.content.replace(
|
||||
/\n/g,
|
||||
" ",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={message.content?.replace(
|
||||
/\n/g,
|
||||
" ",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import {
|
||||
autoPlacement,
|
||||
offset,
|
||||
shift,
|
||||
useFloating,
|
||||
} from "@floating-ui/react-dom-interactions";
|
||||
import { Plus } from "@styled-icons/boxicons-regular";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Message } from "revolt.js";
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
import { createPortal } from "preact/compat";
|
||||
import { useCallback, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { IconButton } from "@revoltchat/ui";
|
||||
|
||||
import { emojiDictionary } from "../../../../assets/emojis";
|
||||
import { useClient } from "../../../../controllers/client/ClientController";
|
||||
import { RenderEmoji } from "../../../markdown/plugins/emoji";
|
||||
import { HackAlertThisFileWillBeReplaced } from "../MessageBox";
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaction list element
|
||||
*/
|
||||
const List = styled.div`
|
||||
gap: 0.4em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.2em;
|
||||
align-items: center;
|
||||
|
||||
.add {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover .add {
|
||||
display: grid;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* List divider
|
||||
*/
|
||||
const Divider = styled.div`
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--tertiary-foreground);
|
||||
`;
|
||||
|
||||
/**
|
||||
* Reaction styling
|
||||
*/
|
||||
const Reaction = styled.div<{ active: boolean }>`
|
||||
padding: 0.4em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
border: 1px solid transparent;
|
||||
color: var(--secondary-foreground);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--secondary-background);
|
||||
|
||||
img {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
&:active {
|
||||
filter: brightness(0.75);
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.active &&
|
||||
css`
|
||||
border-color: var(--accent);
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Render reactions on a message
|
||||
*/
|
||||
export const Reactions = observer(({ message }: Props) => {
|
||||
const client = useClient();
|
||||
const [showPicker, setPicker] = useState(false);
|
||||
|
||||
/**
|
||||
* Render individual reaction entries
|
||||
*/
|
||||
const Entry = useCallback(
|
||||
observer(({ id, user_ids }: { id: string; user_ids?: Set<string> }) => {
|
||||
const active = user_ids?.has(client.user!._id) || false;
|
||||
|
||||
return (
|
||||
<Reaction
|
||||
active={active}
|
||||
onClick={() =>
|
||||
active ? message.unreact(id) : message.react(id)
|
||||
}>
|
||||
<RenderEmoji match={id} /> {user_ids?.size || 0}
|
||||
</Reaction>
|
||||
);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Determine two lists of 'required' and 'optional' reactions
|
||||
*/
|
||||
const { required, optional } = (() => {
|
||||
const required = new Set<string>();
|
||||
const optional = new Set<string>();
|
||||
|
||||
if (message.interactions?.reactions) {
|
||||
for (const reaction of message.interactions.reactions) {
|
||||
required.add(reaction);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of message.reactions.keys()) {
|
||||
if (!required.has(key)) {
|
||||
optional.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
required,
|
||||
optional,
|
||||
};
|
||||
})();
|
||||
|
||||
// Don't render list if nothing is going to show anyways
|
||||
if (required.size === 0 && optional.size === 0) return null;
|
||||
|
||||
return (
|
||||
<List>
|
||||
{Array.from(required, (id) => (
|
||||
<Entry key={id} id={id} user_ids={message.reactions.get(id)} />
|
||||
))}
|
||||
{required.size !== 0 && optional.size !== 0 && <Divider />}
|
||||
{Array.from(optional, (id) => (
|
||||
<Entry key={id} id={id} user_ids={message.reactions.get(id)} />
|
||||
))}
|
||||
{message.channel?.havePermission("React") && (
|
||||
<ReactionWrapper
|
||||
message={message}
|
||||
open={showPicker}
|
||||
setOpen={setPicker}>
|
||||
<IconButton className={showPicker ? "" : "add"}>
|
||||
<Plus size={20} />
|
||||
</IconButton>
|
||||
</ReactionWrapper>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
});
|
||||
|
||||
const Base = styled.div`
|
||||
> div {
|
||||
position: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* ! FIXME: rewrite
|
||||
*/
|
||||
export const ReactionWrapper: React.FC<{
|
||||
message: Message;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
}> = ({ open, setOpen, message, children }) => {
|
||||
const { x, y, reference, floating, strategy } = useFloating({
|
||||
open,
|
||||
middleware: [
|
||||
offset(4),
|
||||
shift({ mainAxis: true, crossAxis: true, padding: 4 }),
|
||||
autoPlacement(),
|
||||
],
|
||||
});
|
||||
|
||||
const skip = useRef();
|
||||
const toggle = () => {
|
||||
if (skip.current) {
|
||||
skip.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(!open);
|
||||
|
||||
if (!open) {
|
||||
skip.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={reference}
|
||||
onClick={toggle}
|
||||
style={{ width: "fit-content" }}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{createPortal(
|
||||
<div id="reaction">
|
||||
{open && (
|
||||
<Base
|
||||
ref={floating}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
}}>
|
||||
<HackAlertThisFileWillBeReplaced
|
||||
onSelect={(emoji) =>
|
||||
message.react(
|
||||
emojiDictionary[
|
||||
emoji as keyof typeof emojiDictionary
|
||||
] ?? emoji,
|
||||
)
|
||||
}
|
||||
onClose={toggle}
|
||||
/>
|
||||
</Base>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -3,12 +3,16 @@ import { API } from "revolt.js";
|
||||
|
||||
import styles from "./Attachment.module.scss";
|
||||
import { Text } from "preact-i18n";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useContext, useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { Button, Preloader } from "@revoltchat/ui";
|
||||
import RequiresOnline from "../../../../context/revoltjs/RequiresOnline";
|
||||
import {
|
||||
AppContext,
|
||||
StatusContext,
|
||||
} from "../../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { useClient } from "../../../../controllers/client/ClientController";
|
||||
import RequiresOnline from "../../../../controllers/client/jsx/RequiresOnline";
|
||||
import Preloader from "../../../ui/Preloader";
|
||||
import { Button } from "@revoltchat/ui";
|
||||
|
||||
interface Props {
|
||||
attachment: API.File;
|
||||
@@ -20,8 +24,9 @@ export default function TextFile({ attachment }: Props) {
|
||||
const [gated, setGated] = useState(attachment.size > 100_000);
|
||||
const [content, setContent] = useState<undefined | string>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const status = useContext(StatusContext);
|
||||
const client = useContext(AppContext);
|
||||
|
||||
const client = useClient();
|
||||
const url = client.generateFileURL(attachment)!;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,7 +57,7 @@ export default function TextFile({ attachment }: Props) {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [content, loading, gated, attachment._id, attachment.size, url]);
|
||||
}, [content, loading, gated, status, attachment._id, attachment.size, url]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -149,12 +149,12 @@ function FileEntry({
|
||||
<EmptyEntry className="icon">
|
||||
<File size={36} />
|
||||
</EmptyEntry>
|
||||
<div className="overlay">
|
||||
<div class="overlay">
|
||||
<XCircle size={36} />
|
||||
</div>
|
||||
</PreviewBox>
|
||||
<span className="fn">{file.name}</span>
|
||||
<span className="size">{determineFileSize(file.size)}</span>
|
||||
<span class="fn">{file.name}</span>
|
||||
<span class="size">{determineFileSize(file.size)}</span>
|
||||
</Entry>
|
||||
);
|
||||
|
||||
@@ -169,18 +169,13 @@ function FileEntry({
|
||||
return (
|
||||
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
|
||||
<PreviewBox onClick={remove}>
|
||||
<img
|
||||
className="icon"
|
||||
src={url}
|
||||
alt={file.name}
|
||||
loading="eager"
|
||||
/>
|
||||
<div className="overlay">
|
||||
<img class="icon" src={url} alt={file.name} loading="eager" />
|
||||
<div class="overlay">
|
||||
<XCircle size={36} />
|
||||
</div>
|
||||
</PreviewBox>
|
||||
<span className="fn">{file.name}</span>
|
||||
<span className="size">{determineFileSize(file.size)}</span>
|
||||
<span class="fn">{file.name}</span>
|
||||
<span class="size">{determineFileSize(file.size)}</span>
|
||||
</Entry>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
Share,
|
||||
InfoSquare,
|
||||
Notification,
|
||||
HappyBeaming,
|
||||
} from "@styled-icons/boxicons-solid";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Permission } from "revolt.js";
|
||||
import { Message as MessageObject } from "revolt.js";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -18,16 +18,17 @@ import { internalEmit } from "../../../../lib/eventEmitter";
|
||||
import { shiftKeyPressed } from "../../../../lib/modifiers";
|
||||
import { getRenderer } from "../../../../lib/renderer/Singleton";
|
||||
|
||||
import { state } from "../../../../mobx/State";
|
||||
import { QueuedMessage } from "../../../../mobx/stores/MessageQueue";
|
||||
|
||||
import { modalController } from "../../../../controllers/modals/ModalController";
|
||||
import {
|
||||
Screen,
|
||||
useIntermediate,
|
||||
} from "../../../../context/intermediate/Intermediate";
|
||||
import { useClient } from "../../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import Tooltip from "../../../common/Tooltip";
|
||||
import { ReactionWrapper } from "../attachments/Reactions";
|
||||
|
||||
interface Props {
|
||||
reactionsOpen: boolean;
|
||||
setReactionsOpen: (v: boolean) => void;
|
||||
message: MessageObject;
|
||||
queued?: QueuedMessage;
|
||||
}
|
||||
@@ -86,151 +87,127 @@ const Divider = styled.div`
|
||||
background: var(--tertiary-background);
|
||||
`;
|
||||
|
||||
export const MessageOverlayBar = observer(
|
||||
({ reactionsOpen, setReactionsOpen, message, queued }: Props) => {
|
||||
const client = message.client;
|
||||
const isAuthor = message.author_id === client.user!._id;
|
||||
export const MessageOverlayBar = observer(({ message, queued }: Props) => {
|
||||
const client = useClient();
|
||||
const { openScreen, writeClipboard } = useIntermediate();
|
||||
const isAuthor = message.author_id === client.user!._id;
|
||||
|
||||
const [copied, setCopied] = useState<"link" | "id">(null!);
|
||||
const [extraActions, setExtra] = useState(shiftKeyPressed);
|
||||
const [copied, setCopied] = useState<"link" | "id">(null!);
|
||||
const [extraActions, setExtra] = useState(shiftKeyPressed);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: KeyboardEvent) => setExtra(ev.shiftKey);
|
||||
useEffect(() => {
|
||||
const handler = (ev: KeyboardEvent) => setExtra(ev.shiftKey);
|
||||
|
||||
document.addEventListener("keyup", handler);
|
||||
document.addEventListener("keydown", handler);
|
||||
document.addEventListener("keyup", handler);
|
||||
document.addEventListener("keydown", handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keyup", handler);
|
||||
document.removeEventListener("keydown", handler);
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener("keyup", handler);
|
||||
document.removeEventListener("keydown", handler);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<OverlayBar>
|
||||
{message.channel?.havePermission("SendMessage") && (
|
||||
<Tooltip content="Reply">
|
||||
<Entry
|
||||
onClick={() =>
|
||||
internalEmit("ReplyBar", "add", message)
|
||||
}>
|
||||
<Share size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
)}
|
||||
return (
|
||||
<OverlayBar>
|
||||
<Tooltip content="Reply">
|
||||
<Entry onClick={() => internalEmit("ReplyBar", "add", message)}>
|
||||
<Share size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
|
||||
{message.channel?.havePermission("React") && (
|
||||
<ReactionWrapper
|
||||
open={reactionsOpen}
|
||||
setOpen={setReactionsOpen}
|
||||
message={message}>
|
||||
<Tooltip content="React">
|
||||
<Entry>
|
||||
<HappyBeaming size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
</ReactionWrapper>
|
||||
)}
|
||||
|
||||
{isAuthor && (
|
||||
<Tooltip content="Edit">
|
||||
<Entry
|
||||
onClick={() =>
|
||||
internalEmit(
|
||||
"MessageRenderer",
|
||||
"edit_message",
|
||||
message._id,
|
||||
)
|
||||
}>
|
||||
<Pencil size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAuthor ||
|
||||
(message.channel &&
|
||||
message.channel.havePermission("ManageMessages")) ? (
|
||||
<Tooltip content="Delete">
|
||||
<Entry
|
||||
onClick={(e) =>
|
||||
e.shiftKey
|
||||
? message.delete()
|
||||
: modalController.push({
|
||||
type: "delete_message",
|
||||
target: message,
|
||||
})
|
||||
}>
|
||||
<Trash size={18} color={"var(--error)"} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
<Tooltip content="More">
|
||||
{isAuthor && (
|
||||
<Tooltip content="Edit">
|
||||
<Entry
|
||||
onClick={() =>
|
||||
openContextMenu("Menu", {
|
||||
message,
|
||||
contextualChannel: message.channel_id,
|
||||
queued,
|
||||
})
|
||||
internalEmit(
|
||||
"MessageRenderer",
|
||||
"edit_message",
|
||||
message._id,
|
||||
)
|
||||
}>
|
||||
<DotsVerticalRounded size={18} />
|
||||
<Pencil size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
{extraActions && (
|
||||
<>
|
||||
<Divider />
|
||||
<Tooltip content="Mark as Unread">
|
||||
<Entry
|
||||
onClick={() => {
|
||||
// ! FIXME: deduplicate this code with ctx menu
|
||||
const messages = getRenderer(
|
||||
message.channel!,
|
||||
).messages;
|
||||
const index = messages.findIndex(
|
||||
(x) => x._id === message._id,
|
||||
);
|
||||
)}
|
||||
{isAuthor ||
|
||||
(message.channel &&
|
||||
message.channel.havePermission("ManageMessages")) ? (
|
||||
<Tooltip content="Delete">
|
||||
<Entry
|
||||
onClick={(e) =>
|
||||
e.shiftKey
|
||||
? message.delete()
|
||||
: openScreen({
|
||||
id: "special_prompt",
|
||||
type: "delete_message",
|
||||
target: message,
|
||||
} as unknown as Screen)
|
||||
}>
|
||||
<Trash size={18} color={"var(--error)"} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
<Tooltip content="More">
|
||||
<Entry
|
||||
onClick={() =>
|
||||
openContextMenu("Menu", {
|
||||
message,
|
||||
contextualChannel: message.channel_id,
|
||||
queued,
|
||||
})
|
||||
}>
|
||||
<DotsVerticalRounded size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
{extraActions && (
|
||||
<>
|
||||
<Divider />
|
||||
<Tooltip content="Mark as Unread">
|
||||
<Entry
|
||||
onClick={() => {
|
||||
// ! FIXME: deduplicate this code with ctx menu
|
||||
const messages = getRenderer(
|
||||
message.channel!,
|
||||
).messages;
|
||||
const index = messages.findIndex(
|
||||
(x) => x._id === message._id,
|
||||
);
|
||||
|
||||
let unread_id = message._id;
|
||||
if (index > 0) {
|
||||
unread_id = messages[index - 1]._id;
|
||||
}
|
||||
let unread_id = message._id;
|
||||
if (index > 0) {
|
||||
unread_id = messages[index - 1]._id;
|
||||
}
|
||||
|
||||
internalEmit(
|
||||
"NewMessages",
|
||||
"mark",
|
||||
unread_id,
|
||||
);
|
||||
message.channel?.ack(unread_id, true);
|
||||
}}>
|
||||
<Notification size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
copied === "link" ? "Copied!" : "Copy Link"
|
||||
}
|
||||
hideOnClick={false}>
|
||||
<Entry
|
||||
onClick={() => {
|
||||
setCopied("link");
|
||||
modalController.writeText(message.url);
|
||||
}}>
|
||||
<LinkAlt size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={copied === "id" ? "Copied!" : "Copy ID"}
|
||||
hideOnClick={false}>
|
||||
<Entry
|
||||
onClick={() => {
|
||||
setCopied("id");
|
||||
modalController.writeText(message._id);
|
||||
}}>
|
||||
<InfoSquare size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</OverlayBar>
|
||||
);
|
||||
},
|
||||
);
|
||||
internalEmit("NewMessages", "mark", unread_id);
|
||||
message.channel?.ack(unread_id, true);
|
||||
}}>
|
||||
<Notification size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={copied === "link" ? "Copied!" : "Copy Link"}
|
||||
hideOnClick={false}>
|
||||
<Entry
|
||||
onClick={() => {
|
||||
setCopied("link");
|
||||
writeClipboard(message.url);
|
||||
}}>
|
||||
<LinkAlt size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={copied === "id" ? "Copied!" : "Copy ID"}
|
||||
hideOnClick={false}>
|
||||
<Entry
|
||||
onClick={() => {
|
||||
setCopied("id");
|
||||
writeClipboard(message._id);
|
||||
}}>
|
||||
<InfoSquare size={18} />
|
||||
</Entry>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</OverlayBar>
|
||||
);
|
||||
});
|
||||
@@ -17,7 +17,6 @@ import { Bar } from "./JumpToBottom";
|
||||
export default observer(
|
||||
({ channel, last_id }: { channel: Channel; last_id?: string }) => {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [timeAgo, setTimeAgo] = useState("");
|
||||
const hide = () => setHidden(true);
|
||||
|
||||
useEffect(() => setHidden(false), [last_id]);
|
||||
@@ -30,14 +29,6 @@ export default observer(
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (last_id) {
|
||||
try {
|
||||
setTimeAgo(dayjs(decodeTime(last_id)).fromNow());
|
||||
} catch (err) {}
|
||||
}
|
||||
}, [last_id]);
|
||||
|
||||
const renderer = getRenderer(channel);
|
||||
const history = useHistory();
|
||||
if (renderer.state !== "RENDER") return null;
|
||||
@@ -61,7 +52,7 @@ export default observer(
|
||||
<Text
|
||||
id="app.main.channel.misc.new_messages"
|
||||
fields={{
|
||||
time_ago: timeAgo,
|
||||
time_ago: dayjs(decodeTime(last_id)).fromNow(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,6 @@ import styled from "styled-components/macro";
|
||||
import { Text } from "preact-i18n";
|
||||
import { StateUpdater, useEffect } from "preact/hooks";
|
||||
|
||||
import { IconButton } from "@revoltchat/ui";
|
||||
|
||||
import { internalSubscribe } from "../../../../lib/eventEmitter";
|
||||
|
||||
import { useApplicationState } from "../../../../mobx/State";
|
||||
@@ -16,6 +14,8 @@ import { SECTION_MENTION } from "../../../../mobx/stores/Layout";
|
||||
import { Reply } from "../../../../mobx/stores/MessageQueue";
|
||||
|
||||
import Tooltip from "../../../common/Tooltip";
|
||||
import IconButton from "../../../ui/IconButton";
|
||||
|
||||
import Markdown from "../../../markdown/Markdown";
|
||||
import UserShort from "../../user/UserShort";
|
||||
import { SystemMessage } from "../SystemMessage";
|
||||
@@ -152,11 +152,11 @@ export default observer(({ channel, replies, setReplies }: Props) => {
|
||||
return (
|
||||
<Base key={reply.id}>
|
||||
<ReplyBase preview>
|
||||
<div className="replyto">
|
||||
<div class="replyto">
|
||||
<Text id="app.main.channel.reply.replying" />
|
||||
</div>
|
||||
<div className="content">
|
||||
<div className="username">
|
||||
<div class="content">
|
||||
<div class="username">
|
||||
<UserShort
|
||||
size={16}
|
||||
showServerIdentity
|
||||
@@ -164,7 +164,7 @@ export default observer(({ channel, replies, setReplies }: Props) => {
|
||||
masquerade={message.masquerade!}
|
||||
/>
|
||||
</div>
|
||||
<div className="message">
|
||||
<div class="message">
|
||||
{message.attachments && (
|
||||
<>
|
||||
<File size={16} />
|
||||
@@ -185,20 +185,18 @@ export default observer(({ channel, replies, setReplies }: Props) => {
|
||||
hideInfo
|
||||
/>
|
||||
) : (
|
||||
message.content && (
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={message.content.replace(
|
||||
/\n/g,
|
||||
" ",
|
||||
)}
|
||||
/>
|
||||
)
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={message.content?.replace(
|
||||
/\n/g,
|
||||
" ",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ReplyBase>
|
||||
<span className="actions">
|
||||
<span class="actions">
|
||||
{message.author_id !== client.user!._id && (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
@@ -227,7 +225,7 @@ export default observer(({ channel, replies, setReplies }: Props) => {
|
||||
content={
|
||||
<Text id="app.main.channel.reply.toggle" />
|
||||
}>
|
||||
<span className="toggle">
|
||||
<span class="toggle">
|
||||
<At size={15} />
|
||||
<Text
|
||||
id={
|
||||
|
||||
@@ -76,9 +76,7 @@ export default observer(({ channel }: Props) => {
|
||||
if (users.length >= 5) {
|
||||
text = <Text id="app.main.channel.typing.several" />;
|
||||
} else if (users.length > 1) {
|
||||
const userlist = [...users].map(
|
||||
(x) => x!.display_name ?? x!.username,
|
||||
);
|
||||
const userlist = [...users].map((x) => x!.username);
|
||||
const user = userlist.pop();
|
||||
|
||||
text = (
|
||||
@@ -94,9 +92,7 @@ export default observer(({ channel }: Props) => {
|
||||
text = (
|
||||
<Text
|
||||
id="app.main.channel.typing.single"
|
||||
fields={{
|
||||
user: users[0]!.display_name ?? users[0]!.username,
|
||||
}}
|
||||
fields={{ user: users[0]!.username }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
&.website {
|
||||
gap: 6px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
> div:nth-child(1) {
|
||||
|
||||
@@ -4,8 +4,9 @@ import styles from "./Embed.module.scss";
|
||||
import classNames from "classnames";
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
import { useClient } from "../../../../controllers/client/ClientController";
|
||||
import { modalController } from "../../../../controllers/modals/ModalController";
|
||||
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
|
||||
import { useClient } from "../../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea";
|
||||
import Markdown from "../../../markdown/Markdown";
|
||||
import Attachment from "../attachments/Attachment";
|
||||
@@ -23,6 +24,7 @@ const MAX_PREVIEW_SIZE = 150;
|
||||
export default function Embed({ embed }: Props) {
|
||||
const client = useClient();
|
||||
|
||||
const { openScreen, openLink } = useIntermediate();
|
||||
const maxWidth = Math.min(
|
||||
useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
|
||||
MAX_EMBED_WIDTH,
|
||||
@@ -67,8 +69,7 @@ export default function Embed({ embed }: Props) {
|
||||
break;
|
||||
}
|
||||
case "Twitch":
|
||||
case "Lightspeed":
|
||||
case "Streamable": {
|
||||
case "Lightspeed": {
|
||||
mw = 1280;
|
||||
mh = 720;
|
||||
break;
|
||||
@@ -142,11 +143,7 @@ export default function Embed({ embed }: Props) {
|
||||
<a
|
||||
onMouseDown={(ev) =>
|
||||
(ev.button === 0 || ev.button === 1) &&
|
||||
modalController.openLink(
|
||||
embed.url!,
|
||||
undefined,
|
||||
true,
|
||||
)
|
||||
openLink(embed.url!)
|
||||
}
|
||||
className={styles.title}>
|
||||
{embed.title}
|
||||
@@ -194,13 +191,8 @@ export default function Embed({ embed }: Props) {
|
||||
type="text/html"
|
||||
frameBorder="0"
|
||||
loading="lazy"
|
||||
onClick={() =>
|
||||
modalController.push({ type: "image_viewer", embed })
|
||||
}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 &&
|
||||
modalController.openLink(embed.url, undefined, true)
|
||||
}
|
||||
onClick={() => openScreen({ id: "image_viewer", embed })}
|
||||
onMouseDown={(ev) => ev.button === 1 && openLink(embed.url)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Group } from "@styled-icons/boxicons-solid";
|
||||
import { reaction } from "mobx";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Message, API } from "revolt.js";
|
||||
@@ -6,18 +7,20 @@ import styled, { css } from "styled-components/macro";
|
||||
|
||||
import { useContext, useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { Button, Category, Preloader } from "@revoltchat/ui";
|
||||
import { Button } from "@revoltchat/ui";
|
||||
|
||||
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
|
||||
|
||||
import { I18nError } from "../../../../context/Locale";
|
||||
import {
|
||||
AppContext,
|
||||
ClientStatus,
|
||||
StatusContext,
|
||||
} from "../../../../context/revoltjs/RevoltClient";
|
||||
import { takeError } from "../../../../context/revoltjs/util";
|
||||
|
||||
import ServerIcon from "../../../../components/common/ServerIcon";
|
||||
import {
|
||||
useClient,
|
||||
useSession,
|
||||
} from "../../../../controllers/client/ClientController";
|
||||
import { takeError } from "../../../../controllers/client/jsx/error";
|
||||
import Overline from "../../../ui/Overline";
|
||||
import Preloader from "../../../ui/Preloader";
|
||||
|
||||
const EmbedInviteBase = styled.div`
|
||||
width: 400px;
|
||||
@@ -76,8 +79,8 @@ type Props = {
|
||||
|
||||
export function EmbedInvite({ code }: Props) {
|
||||
const history = useHistory();
|
||||
const session = useSession()!;
|
||||
const client = session.client!;
|
||||
const client = useContext(AppContext);
|
||||
const status = useContext(StatusContext);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [joinError, setJoinError] = useState<string | undefined>(undefined);
|
||||
@@ -88,7 +91,7 @@ export function EmbedInvite({ code }: Props) {
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof invite === "undefined" &&
|
||||
(session.state === "Online" || session.state === "Ready")
|
||||
(status === ClientStatus.ONLINE || status === ClientStatus.READY)
|
||||
) {
|
||||
client
|
||||
.fetchInvite(code)
|
||||
@@ -97,7 +100,7 @@ export function EmbedInvite({ code }: Props) {
|
||||
)
|
||||
.catch((err) => setError(takeError(err)));
|
||||
}
|
||||
}, [client, code, invite, session.state]);
|
||||
}, [client, code, invite, status]);
|
||||
|
||||
if (typeof invite === "undefined") {
|
||||
return error ? (
|
||||
@@ -157,11 +160,7 @@ export function EmbedInvite({ code }: Props) {
|
||||
</Button>
|
||||
)}
|
||||
</EmbedInviteBase>
|
||||
{joinError && (
|
||||
<Category>
|
||||
<I18nError error={joinError} />
|
||||
</Category>
|
||||
)}
|
||||
{joinError && <Overline type="error" error={joinError} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { API } from "revolt.js";
|
||||
|
||||
import styles from "./Embed.module.scss";
|
||||
|
||||
import { useClient } from "../../../../controllers/client/ClientController";
|
||||
import { modalController } from "../../../../controllers/modals/ModalController";
|
||||
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
|
||||
import { useClient } from "../../../../context/revoltjs/RevoltClient";
|
||||
|
||||
interface Props {
|
||||
embed: API.Embed;
|
||||
@@ -14,6 +14,7 @@ interface Props {
|
||||
|
||||
export default function EmbedMedia({ embed, width, height }: Props) {
|
||||
if (embed.type !== "Website") return null;
|
||||
const { openScreen } = useIntermediate();
|
||||
const client = useClient();
|
||||
|
||||
switch (embed.special?.type) {
|
||||
@@ -49,7 +50,7 @@ export default function EmbedMedia({ embed, width, height }: Props) {
|
||||
case "Lightspeed":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://new.lightspeed.tv/embed/${embed.special.id}/stream`}
|
||||
src={`https://next.lightspeed.tv/embed/${embed.special.id}`}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
scrolling="no"
|
||||
@@ -92,16 +93,6 @@ export default function EmbedMedia({ embed, width, height }: Props) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "Streamable": {
|
||||
return (
|
||||
<iframe
|
||||
src={`https://streamable.com/e/${embed.special.id}?loop=0`}
|
||||
seamless
|
||||
loading="lazy"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
if (embed.video) {
|
||||
const url = embed.video.url;
|
||||
@@ -124,10 +115,10 @@ export default function EmbedMedia({ embed, width, height }: Props) {
|
||||
className={styles.image}
|
||||
src={client.proxyFile(url)}
|
||||
loading="lazy"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
style={{ width, height }}
|
||||
onClick={() =>
|
||||
modalController.push({
|
||||
type: "image_viewer",
|
||||
openScreen({
|
||||
id: "image_viewer",
|
||||
embed: embed.image!,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { API } from "revolt.js";
|
||||
|
||||
import styles from "./Embed.module.scss";
|
||||
|
||||
import { IconButton } from "@revoltchat/ui";
|
||||
import IconButton from "../../../ui/IconButton";
|
||||
|
||||
interface Props {
|
||||
embed: API.Image;
|
||||
@@ -20,7 +20,7 @@ export default function EmbedMediaActions({ embed }: Props) {
|
||||
</span>
|
||||
<a
|
||||
href={embed.url}
|
||||
className={styles.openIcon}
|
||||
class={styles.openIcon}
|
||||
target="_blank"
|
||||
rel="noreferrer">
|
||||
<IconButton>
|
||||
|
||||
@@ -16,17 +16,17 @@ enum Badges {
|
||||
Paw = 128,
|
||||
EarlyAdopter = 256,
|
||||
ReservedRelevantJokeBadge1 = 512,
|
||||
ReservedRelevantJokeBadge2 = 1024,
|
||||
}
|
||||
|
||||
const BadgesBase = styled.div`
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
margin-top: 4px;
|
||||
flex-direction: row;
|
||||
|
||||
img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function UserBadges({ badges, uid }: Props) {
|
||||
content={
|
||||
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
|
||||
}>
|
||||
<Shield size={24} color="gray" />
|
||||
<Shield size={32} color="gray" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<></>
|
||||
@@ -135,13 +135,6 @@ export default function UserBadges({ badges, uid }: Props) {
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{badges & Badges.ReservedRelevantJokeBadge2 ? (
|
||||
<Tooltip content="It's Morbin Time">
|
||||
<img src="/assets/badges/amorbus.svg" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{badges & Badges.Paw ? (
|
||||
<Tooltip content="🦊">
|
||||
<img src="/assets/badges/paw.svg" />
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { User } from "revolt.js";
|
||||
|
||||
import { Checkbox, Row, Column } from "@revoltchat/ui";
|
||||
import Checkbox, { CheckboxProps } from "../../ui/Checkbox";
|
||||
|
||||
import UserIcon from "./UserIcon";
|
||||
import { Username } from "./UserShort";
|
||||
|
||||
type UserProps = { value: boolean; onChange: (v: boolean) => void; user: User };
|
||||
type UserProps = Omit<CheckboxProps, "children"> & { user: User };
|
||||
|
||||
export default function UserCheckbox({ user, ...props }: UserProps) {
|
||||
return (
|
||||
<Checkbox
|
||||
{...props}
|
||||
title={
|
||||
<Row centred>
|
||||
<UserIcon target={user} size={32} />
|
||||
<Column centred>
|
||||
<Username user={user} />
|
||||
</Column>
|
||||
</Row>
|
||||
}
|
||||
/>
|
||||
<Checkbox {...props}>
|
||||
<UserIcon target={user} size={32} />
|
||||
<Username user={user} />
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import styled from "styled-components/macro";
|
||||
import { openContextMenu } from "preact-context-menu";
|
||||
import { Text, Localizer } from "preact-i18n";
|
||||
|
||||
import { Header, IconButton } from "@revoltchat/ui";
|
||||
|
||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
|
||||
import Header from "../../ui/Header";
|
||||
import IconButton from "../../ui/IconButton";
|
||||
|
||||
import Tooltip from "../Tooltip";
|
||||
import UserStatus from "./UserStatus";
|
||||
|
||||
@@ -29,14 +31,9 @@ const HeaderBase = styled.div`
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.new-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.username {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -52,22 +49,17 @@ interface Props {
|
||||
}
|
||||
|
||||
export default observer(({ user }: Props) => {
|
||||
const { writeClipboard } = useIntermediate();
|
||||
|
||||
return (
|
||||
<Header topBorder palette="secondary">
|
||||
<Header topBorder placement="secondary">
|
||||
<HeaderBase>
|
||||
<div className="new-name">
|
||||
{user.display_name ?? user.username}
|
||||
</div>
|
||||
<Localizer>
|
||||
<Tooltip content={<Text id="app.special.copy_username" />}>
|
||||
<span
|
||||
className="username"
|
||||
onClick={() =>
|
||||
modalController.writeText(user.username)
|
||||
}>
|
||||
{user.username}
|
||||
{"#"}
|
||||
{user.discriminator}
|
||||
onClick={() => writeClipboard(user.username)}>
|
||||
@{user.username}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Localizer>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { User } from "revolt.js";
|
||||
import styled from "styled-components/macro";
|
||||
|
||||
import { Children } from "../../../types/Preact";
|
||||
import Tooltip from "../Tooltip";
|
||||
import { Username } from "./UserShort";
|
||||
import UserStatus from "./UserStatus";
|
||||
|
||||
@@ -6,15 +6,15 @@ import styled, { css } from "styled-components/macro";
|
||||
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import fallback from "../assets/user.png";
|
||||
|
||||
import { useClient } from "../../../controllers/client/ClientController";
|
||||
import IconBase, { IconBaseProps } from "../IconBase";
|
||||
|
||||
type VoiceStatus = "muted" | "deaf";
|
||||
interface Props extends IconBaseProps<User> {
|
||||
status?: boolean;
|
||||
override?: string;
|
||||
voice?: VoiceStatus;
|
||||
masquerade?: API.Masquerade;
|
||||
showServerIdentity?: boolean;
|
||||
@@ -26,8 +26,6 @@ export function useStatusColour(user?: User) {
|
||||
return user?.online && user?.status?.presence !== "Invisible"
|
||||
? user?.status?.presence === "Idle"
|
||||
? theme.getVariable("status-away")
|
||||
: user?.status?.presence === "Focus"
|
||||
? theme.getVariable("status-focus")
|
||||
: user?.status?.presence === "Busy"
|
||||
? theme.getVariable("status-busy")
|
||||
: theme.getVariable("status-online")
|
||||
@@ -71,15 +69,12 @@ export default observer(
|
||||
showServerIdentity,
|
||||
masquerade,
|
||||
innerRef,
|
||||
override,
|
||||
...svgProps
|
||||
} = props;
|
||||
|
||||
let { url } = props;
|
||||
if (masquerade?.avatar) {
|
||||
url = client.proxyFile(masquerade.avatar);
|
||||
} else if (override) {
|
||||
url = override;
|
||||
} else if (!url) {
|
||||
let override;
|
||||
if (target && showServerIdentity) {
|
||||
@@ -119,7 +114,7 @@ export default observer(
|
||||
y="0"
|
||||
width="32"
|
||||
height="32"
|
||||
className="icon"
|
||||
class="icon"
|
||||
mask={mask ?? (status ? "url(#user)" : undefined)}>
|
||||
{<img src={url} draggable={false} loading="lazy" />}
|
||||
</foreignObject>
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { TimeFive } from "@styled-icons/boxicons-regular";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { User, API } from "revolt.js";
|
||||
import styled, { css } from "styled-components/macro";
|
||||
import styled from "styled-components/macro";
|
||||
|
||||
import { Ref } from "preact";
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import { internalEmit } from "../../../lib/eventEmitter";
|
||||
|
||||
import { dayjs } from "../../../context/Locale";
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { useClient } from "../../../controllers/client/ClientController";
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
import Tooltip from "../Tooltip";
|
||||
import UserIcon from "./UserIcon";
|
||||
|
||||
const BotBadge = styled.div`
|
||||
@@ -30,34 +27,15 @@ const BotBadge = styled.div`
|
||||
border-radius: calc(var(--border-radius) / 2);
|
||||
`;
|
||||
|
||||
type UsernameProps = Omit<
|
||||
JSX.HTMLAttributes<HTMLElement>,
|
||||
"children" | "as"
|
||||
> & {
|
||||
type UsernameProps = JSX.HTMLAttributes<HTMLElement> & {
|
||||
user?: User;
|
||||
prefixAt?: boolean;
|
||||
masquerade?: API.Masquerade;
|
||||
showServerIdentity?: boolean | "both";
|
||||
|
||||
override?: string;
|
||||
innerRef?: Ref<any>;
|
||||
};
|
||||
|
||||
const Name = styled.span<{ colour?: string | null }>`
|
||||
${(props) =>
|
||||
props.colour &&
|
||||
(props.colour.includes("gradient")
|
||||
? css`
|
||||
background: ${props.colour};
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
`
|
||||
: css`
|
||||
color: ${props.colour};
|
||||
`)}
|
||||
`;
|
||||
|
||||
export const Username = observer(
|
||||
({
|
||||
user,
|
||||
@@ -65,18 +43,12 @@ export const Username = observer(
|
||||
masquerade,
|
||||
showServerIdentity,
|
||||
innerRef,
|
||||
override,
|
||||
...otherProps
|
||||
}: UsernameProps) => {
|
||||
let username =
|
||||
(user as unknown as { display_name: string })?.display_name ??
|
||||
user?.username;
|
||||
let color = masquerade?.colour;
|
||||
let timed_out: Date | undefined;
|
||||
let username = user?.username;
|
||||
let color;
|
||||
|
||||
if (override) {
|
||||
username = override;
|
||||
} else if (user && showServerIdentity) {
|
||||
if (user && showServerIdentity) {
|
||||
const { server } = useParams<{ server?: string }>();
|
||||
if (server) {
|
||||
const client = useClient();
|
||||
@@ -94,14 +66,15 @@ export const Username = observer(
|
||||
}
|
||||
}
|
||||
|
||||
if (member.timeout) {
|
||||
timed_out = member.timeout;
|
||||
}
|
||||
|
||||
if (!color) {
|
||||
for (const [_, { colour }] of member.orderedRoles) {
|
||||
if (colour) {
|
||||
color = colour;
|
||||
if (member.roles && member.roles.length > 0) {
|
||||
const srv = client.servers.get(member._id.server);
|
||||
if (srv?.roles) {
|
||||
for (const role of member.roles) {
|
||||
const c = srv.roles[role]?.colour;
|
||||
if (c) {
|
||||
color = c;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,38 +82,14 @@ export const Username = observer(
|
||||
}
|
||||
}
|
||||
|
||||
const el = (
|
||||
<>
|
||||
<Name {...otherProps} ref={innerRef} colour={color}>
|
||||
{prefixAt ? "@" : undefined}
|
||||
{masquerade?.name ?? username ?? (
|
||||
<Text id="app.main.channel.unknown_user" />
|
||||
)}
|
||||
</Name>
|
||||
|
||||
{timed_out && (
|
||||
<Tooltip
|
||||
content={
|
||||
<Text
|
||||
id="app.main.channel.user_timed_out"
|
||||
fields={{
|
||||
time: dayjs(timed_out).fromNow(true),
|
||||
}}
|
||||
/>
|
||||
}>
|
||||
<TimeFive
|
||||
size={16}
|
||||
color="var(--secondary-foreground)"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (user?.bot) {
|
||||
return (
|
||||
<>
|
||||
{el}
|
||||
<span {...otherProps} ref={innerRef} style={{ color }}>
|
||||
{masquerade?.name ?? username ?? (
|
||||
<Text id="app.main.channel.unknown_user" />
|
||||
)}
|
||||
</span>
|
||||
<BotBadge>
|
||||
{masquerade ? (
|
||||
<Text id="app.main.channel.bridge" />
|
||||
@@ -152,18 +101,14 @@ export const Username = observer(
|
||||
);
|
||||
}
|
||||
|
||||
if (override) {
|
||||
return (
|
||||
<>
|
||||
{el}
|
||||
<BotBadge>
|
||||
<Text id="app.main.channel.bot" />
|
||||
</BotBadge>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return el;
|
||||
return (
|
||||
<span {...otherProps} ref={innerRef} style={{ color }}>
|
||||
{prefixAt ? "@" : undefined}
|
||||
{masquerade?.name ?? username ?? (
|
||||
<Text id="app.main.channel.unknown_user" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -180,9 +125,9 @@ export default function UserShort({
|
||||
masquerade?: API.Masquerade;
|
||||
showServerIdentity?: boolean;
|
||||
}) {
|
||||
const { openScreen } = useIntermediate();
|
||||
const openProfile = () =>
|
||||
user &&
|
||||
modalController.push({ type: "user_profile", user_id: user._id });
|
||||
user && openScreen({ id: "profile", user_id: user._id });
|
||||
|
||||
const handleUserClick = (e: MouseEvent) => {
|
||||
if (e.shiftKey && user?._id) {
|
||||
|
||||
@@ -32,10 +32,6 @@ export default observer(({ user, tooltip }: Props) => {
|
||||
return <Text id="app.status.idle" />;
|
||||
}
|
||||
|
||||
if (user.status?.presence === "Focus") {
|
||||
return <Text id="app.status.focus" />;
|
||||
}
|
||||
|
||||
if (user.status?.presence === "Invisible") {
|
||||
return <Text id="app.status.offline" />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
.markdown {
|
||||
:global(.emoji) {
|
||||
object-fit: contain;
|
||||
|
||||
height: 1.25em;
|
||||
width: 1.25em;
|
||||
margin: 0 0.05em 0 0.1em;
|
||||
vertical-align: -0.2em;
|
||||
}
|
||||
|
||||
&[data-large-emojis="true"] :global(.emoji) {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-bottom: 0;
|
||||
margin-top: 1px;
|
||||
margin-right: 2px;
|
||||
vertical-align: -0.3em;
|
||||
}
|
||||
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
&[data-type="mention"] {
|
||||
padding: 0 6px;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
background: var(--secondary-background);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
&:not(:first-child) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style-position: inside;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 2px 0;
|
||||
padding: 2px 0;
|
||||
background: var(--hover);
|
||||
border-radius: var(--border-radius);
|
||||
border-inline-start: 4px solid var(--tertiary-background);
|
||||
|
||||
> * {
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 1em;
|
||||
overflow-x: scroll;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--block) !important;
|
||||
}
|
||||
|
||||
p > code {
|
||||
padding: 1px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
color: white;
|
||||
font-size: 90%;
|
||||
background: var(--block);
|
||||
border-radius: var(--border-radius);
|
||||
font-family: var(--monospace-font), monospace;
|
||||
border-radius: 3px;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin-right: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 6px;
|
||||
border: 1px solid var(--tertiary-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.katex-block) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:global(.spoiler) {
|
||||
padding: 0 2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: transparent;
|
||||
background: #151515;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
> * {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:global(.shown) {
|
||||
cursor: auto;
|
||||
user-select: all;
|
||||
color: var(--foreground);
|
||||
background: var(--secondary-background);
|
||||
|
||||
> * {
|
||||
opacity: 1;
|
||||
pointer-events: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.code) {
|
||||
font-family: var(--monospace-font), monospace;
|
||||
|
||||
:global(.lang) {
|
||||
width: fit-content;
|
||||
padding-bottom: 8px;
|
||||
|
||||
div {
|
||||
color: #111;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 2px #787676;
|
||||
border-radius: calc(var(--border-radius) / 3);
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 1px #787676;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
label {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input[type="checkbox"] + label:before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
content: "a";
|
||||
font-size: 10px;
|
||||
margin-right: 6px;
|
||||
line-height: 12px;
|
||||
background: white;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
input[type="checkbox"][checked="true"] + label:before {
|
||||
content: "✓";
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
input[type="checkbox"] + label {
|
||||
line-height: 12px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Suspense, lazy } from "preact/compat";
|
||||
|
||||
const Renderer = lazy(() => import("./RemarkRenderer"));
|
||||
const Renderer = lazy(() => import("./Renderer"));
|
||||
|
||||
export interface MarkdownProps {
|
||||
content: string;
|
||||
content?: string | null;
|
||||
disallowBigEmoji?: boolean;
|
||||
}
|
||||
|
||||
export default function Markdown(props: MarkdownProps) {
|
||||
if (!props.content) return null;
|
||||
|
||||
return (
|
||||
// @ts-expect-error Typings mis-match.
|
||||
<Suspense fallback={props.content}>
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import "katex/dist/katex.min.css";
|
||||
import rehypePrism from "rehype-prism";
|
||||
import rehypeReact from "rehype-react";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import styled, { css } from "styled-components";
|
||||
import { unified } from "unified";
|
||||
|
||||
import { createElement } from "preact";
|
||||
import { memo } from "preact/compat";
|
||||
import { useLayoutEffect, useMemo, useState } from "preact/hooks";
|
||||
|
||||
// @ts-expect-error no typings
|
||||
import rehypeKatex from "@revoltchat/rehype-katex";
|
||||
|
||||
import { MarkdownProps } from "./Markdown";
|
||||
import { handlers } from "./hast";
|
||||
import { RenderCodeblock } from "./plugins/Codeblock";
|
||||
import { RenderAnchor } from "./plugins/anchors";
|
||||
import { remarkChannels, RenderChannel } from "./plugins/channels";
|
||||
import { isOnlyEmoji, remarkEmoji, RenderEmoji } from "./plugins/emoji";
|
||||
import { remarkHtmlToText } from "./plugins/htmlToText";
|
||||
import { remarkMention, RenderMention } from "./plugins/mentions";
|
||||
import { remarkSpoiler, RenderSpoiler } from "./plugins/spoiler";
|
||||
import { remarkTimestamps } from "./plugins/timestamps";
|
||||
import "./prism";
|
||||
|
||||
/**
|
||||
* Null element
|
||||
*/
|
||||
const Null: React.FC = () => null;
|
||||
|
||||
/**
|
||||
* Custom Markdown components
|
||||
*/
|
||||
const components = {
|
||||
emoji: RenderEmoji,
|
||||
mention: RenderMention,
|
||||
spoiler: RenderSpoiler,
|
||||
channel: RenderChannel,
|
||||
a: RenderAnchor,
|
||||
p: styled.p`
|
||||
margin: 0;
|
||||
|
||||
> code {
|
||||
padding: 1px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`,
|
||||
h1: styled.h1`
|
||||
margin: 0.2em 0;
|
||||
`,
|
||||
h2: styled.h2`
|
||||
margin: 0.2em 0;
|
||||
`,
|
||||
h3: styled.h3`
|
||||
margin: 0.2em 0;
|
||||
`,
|
||||
h4: styled.h4`
|
||||
margin: 0.2em 0;
|
||||
`,
|
||||
h5: styled.h5`
|
||||
margin: 0.2em 0;
|
||||
`,
|
||||
h6: styled.h6`
|
||||
margin: 0.2em 0;
|
||||
`,
|
||||
pre: RenderCodeblock,
|
||||
code: styled.code`
|
||||
color: white;
|
||||
background: var(--block);
|
||||
|
||||
font-size: 90%;
|
||||
font-family: var(--monospace-font), monospace;
|
||||
|
||||
border-radius: 3px;
|
||||
box-decoration-break: clone;
|
||||
`,
|
||||
table: styled.table`
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 6px;
|
||||
border: 1px solid var(--tertiary-foreground);
|
||||
}
|
||||
`,
|
||||
ul: styled.ul`
|
||||
list-style-position: inside;
|
||||
padding-left: 10px;
|
||||
margin: 0.2em 0;
|
||||
`,
|
||||
ol: styled.ol`
|
||||
list-style-position: inside;
|
||||
padding-left: 10px;
|
||||
margin: 0.2em 0;
|
||||
`,
|
||||
li: styled.li`
|
||||
${(props) =>
|
||||
props.class === "task-list-item" &&
|
||||
css`
|
||||
list-style-type: none;
|
||||
`}
|
||||
`,
|
||||
blockquote: styled.blockquote`
|
||||
margin: 2px 0;
|
||||
padding: 2px 0;
|
||||
background: var(--hover);
|
||||
border-radius: var(--border-radius);
|
||||
border-inline-start: 4px solid var(--tertiary-background);
|
||||
|
||||
> * {
|
||||
margin: 0 8px;
|
||||
}
|
||||
`,
|
||||
// Block image elements
|
||||
img: Null,
|
||||
// Catch literally everything else just in case
|
||||
video: Null,
|
||||
figure: Null,
|
||||
picture: Null,
|
||||
source: Null,
|
||||
audio: Null,
|
||||
script: Null,
|
||||
style: Null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified Markdown renderer
|
||||
*/
|
||||
const render = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkBreaks)
|
||||
.use(remarkGfm)
|
||||
.use(remarkMath)
|
||||
.use(remarkSpoiler)
|
||||
.use(remarkChannels)
|
||||
.use(remarkTimestamps)
|
||||
.use(remarkEmoji)
|
||||
.use(remarkMention)
|
||||
.use(remarkHtmlToText)
|
||||
.use(remarkRehype, {
|
||||
handlers,
|
||||
})
|
||||
.use(rehypeKatex, {
|
||||
maxSize: 10,
|
||||
maxExpand: 0,
|
||||
maxLength: 512,
|
||||
trust: false,
|
||||
strict: false,
|
||||
output: "html",
|
||||
throwOnError: false,
|
||||
errorColor: "var(--error)",
|
||||
})
|
||||
.use(rehypePrism)
|
||||
// @ts-expect-error typings do not
|
||||
// match between Preact and React
|
||||
.use(rehypeReact, {
|
||||
createElement,
|
||||
Fragment,
|
||||
components,
|
||||
});
|
||||
|
||||
/**
|
||||
* Markdown parent container
|
||||
*/
|
||||
const Container = styled.div<{ largeEmoji: boolean }>`
|
||||
// Allow scrolling block math
|
||||
.math-display {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
// Set emoji size
|
||||
--emoji-size: ${(props) => (props.largeEmoji ? "3em" : "1.25em")};
|
||||
|
||||
// Underline link hover
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Regex for matching execessive recursion of blockquotes and lists
|
||||
*/
|
||||
const RE_RECURSIVE = /(^(?:[>*+-][^\S\r\n]*){5})(?:[>*+-][^\S\r\n]*)+(.*$)/gm;
|
||||
|
||||
/**
|
||||
* Regex for matching multi-line blockquotes
|
||||
*/
|
||||
const RE_BLOCKQUOTE = /^([^\S\r\n]*>[^\n]+\n?)+/gm;
|
||||
|
||||
/**
|
||||
* Regex for matching HTML tags
|
||||
*/
|
||||
const RE_HTML_TAGS = /^(<\/?[a-zA-Z0-9]+>)(.*$)/gm;
|
||||
|
||||
/**
|
||||
* Regex for matching empty lines
|
||||
*/
|
||||
const RE_EMPTY_LINE = /^\s*?$/gm;
|
||||
|
||||
/**
|
||||
* Regex for matching line starting with plus
|
||||
*/
|
||||
const RE_PLUS = /^\s*\+(?:$|[^+])/gm;
|
||||
|
||||
/**
|
||||
* Sanitise Markdown input before rendering
|
||||
* @param content Input string
|
||||
* @returns Sanitised string
|
||||
*/
|
||||
function sanitise(content: string) {
|
||||
return (
|
||||
content
|
||||
// Strip excessive blockquote or list indentation
|
||||
.replace(RE_RECURSIVE, (_, m0, m1) => m0 + m1)
|
||||
|
||||
// Append empty character if string starts with html tag
|
||||
// This is to avoid inconsistencies in rendering Markdown inside/after HTML tags
|
||||
// https://github.com/revoltchat/revite/issues/733
|
||||
.replace(RE_HTML_TAGS, (match) => `\u200E${match}`)
|
||||
|
||||
// Append empty character if line starts with a plus
|
||||
// which would usually open a new list but we want
|
||||
// to avoid that behaviour in our case.
|
||||
.replace(RE_PLUS, (match) => `\u200E${match}`)
|
||||
|
||||
// Replace empty lines with non-breaking space
|
||||
// because remark renderer is collapsing empty
|
||||
// or otherwise whitespace-only lines of text
|
||||
.replace(RE_EMPTY_LINE, "")
|
||||
|
||||
// Ensure empty line after blockquotes for correct rendering
|
||||
.replace(RE_BLOCKQUOTE, (match) => `${match}\n`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remark renderer component
|
||||
*/
|
||||
export default memo(({ content, disallowBigEmoji }: MarkdownProps) => {
|
||||
const sanitisedContent = useMemo(() => sanitise(content), [content]);
|
||||
|
||||
const [Content, setContent] = useState<React.ReactElement>(null!);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
render
|
||||
.process(sanitisedContent)
|
||||
.then((file) => setContent(file.result));
|
||||
}, [sanitisedContent]);
|
||||
|
||||
const largeEmoji = useMemo(
|
||||
() => !disallowBigEmoji && isOnlyEmoji(content!),
|
||||
[content, disallowBigEmoji],
|
||||
);
|
||||
|
||||
return <Container largeEmoji={largeEmoji}>{Content}</Container>;
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import MarkdownKatex from "@traptitech/markdown-it-katex";
|
||||
import MarkdownSpoilers from "@traptitech/markdown-it-spoiler";
|
||||
import "katex/dist/katex.min.css";
|
||||
import MarkdownIt from "markdown-it";
|
||||
// @ts-expect-error No typings.
|
||||
import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare";
|
||||
import { RE_MENTIONS } from "revolt.js";
|
||||
|
||||
import styles from "./Markdown.module.scss";
|
||||
import { useCallback, useContext } from "preact/hooks";
|
||||
|
||||
import { internalEmit } from "../../lib/eventEmitter";
|
||||
import { determineLink } from "../../lib/links";
|
||||
|
||||
import { dayjs } from "../../context/Locale";
|
||||
import { useIntermediate } from "../../context/intermediate/Intermediate";
|
||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { generateEmoji } from "../common/Emoji";
|
||||
|
||||
import { emojiDictionary } from "../../assets/emojis";
|
||||
import { MarkdownProps } from "./Markdown";
|
||||
import Prism from "./prism";
|
||||
|
||||
// TODO: global.d.ts file for defining globals
|
||||
declare global {
|
||||
interface Window {
|
||||
copycode: (element: HTMLDivElement) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for code block copy.
|
||||
if (typeof window !== "undefined") {
|
||||
window.copycode = function (element: HTMLDivElement) {
|
||||
try {
|
||||
const code = element.parentElement?.parentElement?.children[1];
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code.textContent?.trim() ?? "");
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
}
|
||||
|
||||
export const md: MarkdownIt = MarkdownIt({
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
highlight: (str, lang) => {
|
||||
const v = Prism.languages[lang];
|
||||
if (v) {
|
||||
const out = Prism.highlight(str, v, lang);
|
||||
return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`;
|
||||
}
|
||||
|
||||
return `<pre class="code"><code>${md.utils.escapeHtml(
|
||||
str,
|
||||
)}</code></pre>`;
|
||||
},
|
||||
})
|
||||
.disable("image")
|
||||
.use(MarkdownEmoji, { defs: emojiDictionary })
|
||||
.use(MarkdownSpoilers)
|
||||
.use(MarkdownKatex, {
|
||||
throwOnError: false,
|
||||
maxExpand: 0,
|
||||
maxSize: 10,
|
||||
strict: false,
|
||||
errorColor: "var(--error)",
|
||||
});
|
||||
|
||||
md.linkify.set({ fuzzyLink: false });
|
||||
|
||||
// TODO: global.d.ts file for defining globals
|
||||
declare global {
|
||||
interface Window {
|
||||
internalHandleURL: (element: HTMLAnchorElement) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Include emojis.
|
||||
md.renderer.rules.emoji = function (token, idx) {
|
||||
return generateEmoji(token[idx].content);
|
||||
};
|
||||
|
||||
// Force line breaks.
|
||||
// https://github.com/markdown-it/markdown-it/issues/211#issuecomment-508380611
|
||||
const defaultParagraphRenderer =
|
||||
md.renderer.rules.paragraph_open ||
|
||||
((tokens, idx, options, env, self) =>
|
||||
self.renderToken(tokens, idx, options));
|
||||
|
||||
md.renderer.rules.paragraph_open = function (tokens, idx, options, env, self) {
|
||||
let result = "";
|
||||
if (idx > 1) {
|
||||
const inline = tokens[idx - 2];
|
||||
const paragraph = tokens[idx];
|
||||
if (
|
||||
inline.type === "inline" &&
|
||||
inline.map &&
|
||||
inline.map[1] &&
|
||||
paragraph.map &&
|
||||
paragraph.map[0]
|
||||
) {
|
||||
const diff = paragraph.map[0] - inline.map[1];
|
||||
if (diff > 0) {
|
||||
result = "<br>".repeat(diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result + defaultParagraphRenderer(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
const RE_TWEMOJI = /:(\w+):/g;
|
||||
|
||||
// ! FIXME: Move to library
|
||||
const RE_CHANNELS = /<#([A-z0-9]{26})>/g;
|
||||
|
||||
const RE_TIME = /<t:([0-9]+):(\w)>/g;
|
||||
|
||||
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
|
||||
const client = useContext(AppContext);
|
||||
const { openLink } = useIntermediate();
|
||||
|
||||
if (typeof content === "undefined") return null;
|
||||
if (!content || content.length === 0) return null;
|
||||
|
||||
// We replace the message with the mention at the time of render.
|
||||
// We don't care if the mention changes.
|
||||
const newContent = content
|
||||
.replace(RE_TIME, (sub: string, ...args: unknown[]) => {
|
||||
if (isNaN(args[0] as number)) return sub;
|
||||
const date = dayjs.unix(args[0] as number);
|
||||
const format = args[1] as string;
|
||||
let final = "";
|
||||
switch (format) {
|
||||
case "t":
|
||||
final = date.format("hh:mm");
|
||||
break;
|
||||
case "T":
|
||||
final = date.format("hh:mm:ss");
|
||||
break;
|
||||
case "R":
|
||||
final = date.fromNow();
|
||||
break;
|
||||
case "D":
|
||||
final = date.format("DD MMMM YYYY");
|
||||
break;
|
||||
case "F":
|
||||
final = date.format("dddd, DD MMMM YYYY hh:mm");
|
||||
break;
|
||||
default:
|
||||
final = date.format("DD MMMM YYYY hh:mm");
|
||||
break;
|
||||
}
|
||||
return `\`${final}\``;
|
||||
})
|
||||
.replace(RE_MENTIONS, (sub: string, ...args: unknown[]) => {
|
||||
const id = args[0] as string,
|
||||
user = client.users.get(id);
|
||||
|
||||
if (user) {
|
||||
return `[@${user.username}](/@${id})`;
|
||||
}
|
||||
|
||||
return sub;
|
||||
})
|
||||
.replace(RE_CHANNELS, (sub: string, ...args: unknown[]) => {
|
||||
const id = args[0] as string,
|
||||
channel = client.channels.get(id);
|
||||
|
||||
if (channel?.channel_type === "TextChannel") {
|
||||
return `[#${channel.name}](/server/${channel.server_id}/channel/${id})`;
|
||||
}
|
||||
|
||||
return sub;
|
||||
});
|
||||
|
||||
const useLargeEmojis = disallowBigEmoji
|
||||
? false
|
||||
: content.replace(RE_TWEMOJI, "").trim().length === 0;
|
||||
|
||||
const toggle = useCallback((ev: MouseEvent) => {
|
||||
if (ev.currentTarget) {
|
||||
const element = ev.currentTarget as HTMLDivElement;
|
||||
if (element.classList.contains("spoiler")) {
|
||||
element.classList.add("shown");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLink = useCallback(
|
||||
(ev: MouseEvent) => {
|
||||
if (ev.currentTarget) {
|
||||
const element = ev.currentTarget as HTMLAnchorElement;
|
||||
|
||||
if (ev.shiftKey) {
|
||||
switch (element.dataset.type) {
|
||||
case "mention": {
|
||||
internalEmit(
|
||||
"MessageBox",
|
||||
"append",
|
||||
`<@${element.dataset.mentionId}>`,
|
||||
"mention",
|
||||
);
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
case "channel_mention": {
|
||||
internalEmit(
|
||||
"MessageBox",
|
||||
"append",
|
||||
`<#${element.dataset.mentionId}>`,
|
||||
"channel_mention",
|
||||
);
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openLink(element.href)) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[openLink],
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
el.querySelectorAll<HTMLDivElement>(".spoiler").forEach(
|
||||
(element) => {
|
||||
element.removeEventListener("click", toggle);
|
||||
element.addEventListener("click", toggle);
|
||||
},
|
||||
);
|
||||
|
||||
el.querySelectorAll<HTMLAnchorElement>("a").forEach(
|
||||
(element) => {
|
||||
element.removeEventListener("click", handleLink);
|
||||
element.addEventListener("click", handleLink);
|
||||
element.removeAttribute("data-type");
|
||||
element.removeAttribute("data-mention-id");
|
||||
element.removeAttribute("target");
|
||||
|
||||
const link = determineLink(element.href);
|
||||
switch (link.type) {
|
||||
case "profile": {
|
||||
element.setAttribute(
|
||||
"data-type",
|
||||
"mention",
|
||||
);
|
||||
element.setAttribute(
|
||||
"data-mention-id",
|
||||
link.id,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "navigate": {
|
||||
if (link.navigation_type === "channel") {
|
||||
element.setAttribute(
|
||||
"data-type",
|
||||
"channel_mention",
|
||||
);
|
||||
element.setAttribute(
|
||||
"data-mention-id",
|
||||
link.channel_id,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "external": {
|
||||
element.setAttribute("target", "_blank");
|
||||
element.setAttribute("rel", "noreferrer");
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={styles.markdown}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: md.render(newContent),
|
||||
}}
|
||||
data-large-emojis={useLargeEmojis}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { passThroughComponents } from "./plugins/remarkRegexComponent";
|
||||
import { timestampHandler } from "./plugins/timestamps";
|
||||
|
||||
export const handlers = {
|
||||
...passThroughComponents("emoji", "spoiler", "mention", "channel"),
|
||||
timestamp: timestampHandler,
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
import { useCallback, useRef } from "preact/hooks";
|
||||
|
||||
import { Tooltip } from "@revoltchat/ui";
|
||||
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
|
||||
/**
|
||||
* Base codeblock styles
|
||||
*/
|
||||
const Base = styled.pre`
|
||||
padding: 1em;
|
||||
overflow-x: scroll;
|
||||
background: var(--block);
|
||||
border-radius: var(--border-radius);
|
||||
`;
|
||||
|
||||
/**
|
||||
* Copy codeblock contents button styles
|
||||
*/
|
||||
const Lang = styled.div`
|
||||
font-family: var(--monospace-font);
|
||||
width: fit-content;
|
||||
padding-bottom: 8px;
|
||||
|
||||
a {
|
||||
color: #111;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 2px #787676;
|
||||
border-radius: calc(var(--border-radius) / 3);
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 1px #787676;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Render a codeblock with copy text button
|
||||
*/
|
||||
export const RenderCodeblock: React.FC<{ class: string }> = ({
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
|
||||
let text = "text";
|
||||
if (props.class) {
|
||||
text = props.class.split("-")[1];
|
||||
}
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
const text = ref.current?.querySelector("code")?.innerText;
|
||||
text && modalController.writeText(text);
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<Base ref={ref}>
|
||||
<Lang>
|
||||
<Tooltip content="Copy to Clipboard" placement="top">
|
||||
{/**
|
||||
// @ts-expect-error Preact-React */}
|
||||
<a onClick={onCopy}>{text}</a>
|
||||
</Tooltip>
|
||||
</Lang>
|
||||
{children}
|
||||
</Base>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { determineLink } from "../../../lib/links";
|
||||
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
|
||||
export function RenderAnchor({
|
||||
href,
|
||||
...props
|
||||
}: JSX.HTMLAttributes<HTMLAnchorElement>) {
|
||||
// Pass-through no href or if anchor
|
||||
if (!href || href.startsWith("#")) return <a href={href} {...props} />;
|
||||
|
||||
// Determine type of link
|
||||
const link = determineLink(href);
|
||||
if (link.type === "none") return <a {...props} />;
|
||||
|
||||
// Render direct link if internal
|
||||
if (link.type === "navigate") {
|
||||
return <Link to={link.path} children={props.children} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(ev) =>
|
||||
modalController.openLink(
|
||||
href,
|
||||
undefined,
|
||||
ev.currentTarget.innerText !== href,
|
||||
) && ev.preventDefault()
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { clientController } from "../../../controllers/client/ClientController";
|
||||
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
|
||||
|
||||
export function RenderChannel({ match }: CustomComponentProps) {
|
||||
const channel = clientController.getAvailableClient().channels.get(match)!;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`${
|
||||
channel.server_id ? `/server/${channel.server_id}` : ""
|
||||
}/channel/${match}`}>{`#${channel.name}`}</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const remarkChannels = createComponent(
|
||||
"channel",
|
||||
/<#([A-z0-9]{26})>/g,
|
||||
(match) => clientController.getAvailableClient().channels.has(match),
|
||||
);
|
||||
@@ -1,66 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import { emojiDictionary } from "../../../assets/emojis";
|
||||
import { clientController } from "../../../controllers/client/ClientController";
|
||||
import { parseEmoji } from "../../common/Emoji";
|
||||
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
|
||||
|
||||
const Emoji = styled.img`
|
||||
object-fit: contain;
|
||||
|
||||
height: var(--emoji-size);
|
||||
width: var(--emoji-size);
|
||||
margin: 0 0.05em 0 0.1em;
|
||||
vertical-align: -0.2em;
|
||||
|
||||
img:before {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
background-image: url(ishere.jpg);
|
||||
}
|
||||
`;
|
||||
|
||||
const RE_EMOJI = /:([a-zA-Z0-9\-_]+):/g;
|
||||
const RE_ULID = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/;
|
||||
|
||||
export function RenderEmoji({ match }: CustomComponentProps) {
|
||||
const [fail, setFail] = useState(false);
|
||||
const url = RE_ULID.test(match)
|
||||
? `${
|
||||
clientController.getAvailableClient().configuration?.features
|
||||
.autumn.url
|
||||
}/emojis/${match}`
|
||||
: parseEmoji(
|
||||
match in emojiDictionary
|
||||
? emojiDictionary[match as keyof typeof emojiDictionary]
|
||||
: match,
|
||||
);
|
||||
|
||||
if (fail) return <span>{`:${match}:`}</span>;
|
||||
|
||||
return (
|
||||
<Emoji
|
||||
alt={`:${match}:`}
|
||||
loading="lazy"
|
||||
className="emoji"
|
||||
draggable={false}
|
||||
src={url}
|
||||
onError={() => setFail(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const remarkEmoji = createComponent(
|
||||
"emoji",
|
||||
RE_EMOJI,
|
||||
(match) => match in emojiDictionary || RE_ULID.test(match),
|
||||
);
|
||||
|
||||
export function isOnlyEmoji(text: string) {
|
||||
return text.replaceAll(RE_EMOJI, "").trim().length === 0;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Plugin } from "unified";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
export const remarkHtmlToText: Plugin = () => {
|
||||
return (tree) => {
|
||||
visit(tree, "html", (node: { type: string; value: string }) => {
|
||||
node.type = "text";
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import { RE_MENTIONS } from "revolt.js";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { clientController } from "../../../controllers/client/ClientController";
|
||||
import UserShort from "../../common/user/UserShort";
|
||||
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
|
||||
|
||||
const Mention = styled.a`
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
padding-left: 2px;
|
||||
padding-right: 6px;
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
font-weight: 600;
|
||||
text-decoration: none !important;
|
||||
background: var(--secondary-background);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
|
||||
transition: 0.1s ease filter;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.75);
|
||||
}
|
||||
|
||||
&:active {
|
||||
filter: brightness(0.65);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
`;
|
||||
|
||||
export function RenderMention({ match }: CustomComponentProps) {
|
||||
return (
|
||||
<Mention>
|
||||
<UserShort
|
||||
showServerIdentity
|
||||
user={clientController.getAvailableClient().users.get(match)}
|
||||
/>
|
||||
</Mention>
|
||||
);
|
||||
}
|
||||
|
||||
export const remarkMention = createComponent("mention", RE_MENTIONS, (match) =>
|
||||
clientController.getAvailableClient().users.has(match),
|
||||
);
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { Handler } from "mdast-util-to-hast";
|
||||
import type { Plugin } from "unified";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
/**
|
||||
* Props given to custom components
|
||||
*/
|
||||
export interface CustomComponentProps {
|
||||
type?: string;
|
||||
match: string;
|
||||
arg1?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new custom component matched by a given RegExp
|
||||
* @param type hast node type
|
||||
* @param regex Regex to match (must have one capture group)
|
||||
* @returns Unified Plugin
|
||||
*/
|
||||
export function createComponent(
|
||||
type: string,
|
||||
regex: RegExp,
|
||||
validator?: (match: string) => boolean,
|
||||
): Plugin {
|
||||
/**
|
||||
* Plugin which transforms a given RegExp into a custom component with given name.
|
||||
*/
|
||||
return () => {
|
||||
return (tree) => {
|
||||
visit(
|
||||
tree,
|
||||
"text",
|
||||
(
|
||||
node: { value: string },
|
||||
index: number,
|
||||
parent: { children: any[] },
|
||||
) => {
|
||||
const result = [];
|
||||
let start = 0;
|
||||
|
||||
regex.lastIndex = 0;
|
||||
|
||||
let match = regex.exec(node.value);
|
||||
|
||||
while (match) {
|
||||
if (!validator || validator(match[1])) {
|
||||
const position = match.index;
|
||||
|
||||
if (start !== position) {
|
||||
result.push({
|
||||
type: "text",
|
||||
value: node.value.slice(start, position),
|
||||
});
|
||||
}
|
||||
|
||||
result.push({
|
||||
type,
|
||||
match: match[1],
|
||||
arg1: match[2],
|
||||
});
|
||||
start = position + match[0].length;
|
||||
}
|
||||
|
||||
match = regex.exec(node.value);
|
||||
}
|
||||
|
||||
if (
|
||||
result.length > 0 &&
|
||||
parent &&
|
||||
typeof index === "number"
|
||||
) {
|
||||
if (start < node.value.length) {
|
||||
result.push({
|
||||
type: "text",
|
||||
value: node.value.slice(start),
|
||||
});
|
||||
}
|
||||
|
||||
parent.children.splice(index, 1, ...result);
|
||||
return index + result.length;
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through a component as-is from remark to rehype
|
||||
* @param name Tag name
|
||||
* @returns Handler
|
||||
*/
|
||||
export const passThroughRehype: (name: string) => Handler =
|
||||
(name: string) => (h, node) =>
|
||||
h(node, name, node);
|
||||
|
||||
/**
|
||||
* Pass-through multiple components at once
|
||||
* @param keys Tags
|
||||
* @returns Handlers
|
||||
*/
|
||||
export const passThroughComponents = (...keys: string[]) => {
|
||||
const obj: Record<string, Handler> = {};
|
||||
for (const key of keys) {
|
||||
obj[key] = passThroughRehype(key);
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
|
||||
|
||||
const Spoiler = styled.span<{ shown: boolean }>`
|
||||
padding: 0 2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: transparent;
|
||||
background: #151515;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
> * {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.shown &&
|
||||
css`
|
||||
cursor: auto;
|
||||
user-select: all;
|
||||
color: var(--foreground);
|
||||
background: var(--secondary-background);
|
||||
|
||||
> * {
|
||||
opacity: 1;
|
||||
pointer-events: unset;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export function RenderSpoiler({ match }: CustomComponentProps) {
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
return (
|
||||
<Spoiler shown={shown} onClick={() => setShown(true)}>
|
||||
{match}
|
||||
</Spoiler>
|
||||
);
|
||||
}
|
||||
|
||||
export const remarkSpoiler = createComponent("spoiler", /!!([^!]+)!!/g);
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { Handler } from "mdast-util-to-hast";
|
||||
|
||||
import { dayjs } from "../../../context/Locale";
|
||||
|
||||
import { createComponent } from "./remarkRegexComponent";
|
||||
|
||||
export const timestampHandler: Handler = (h, { match, arg1 }) => {
|
||||
if (isNaN(match)) return { type: "text", value: match };
|
||||
const date = dayjs.unix(match);
|
||||
|
||||
let value = "";
|
||||
switch (arg1) {
|
||||
case "t":
|
||||
value = date.format("hh:mm");
|
||||
break;
|
||||
case "T":
|
||||
value = date.format("hh:mm:ss");
|
||||
break;
|
||||
case "R":
|
||||
value = date.fromNow();
|
||||
break;
|
||||
case "D":
|
||||
value = date.format("DD MMMM YYYY");
|
||||
break;
|
||||
case "F":
|
||||
value = date.format("dddd, DD MMMM YYYY hh:mm");
|
||||
break;
|
||||
default:
|
||||
value = date.format("DD MMMM YYYY hh:mm");
|
||||
break;
|
||||
}
|
||||
|
||||
return h(null, "code", {}, [{ type: "text", value }]);
|
||||
};
|
||||
|
||||
export const remarkTimestamps = createComponent(
|
||||
"timestamp",
|
||||
/<t:([0-9]+)(?::(\w))?>/g,
|
||||
);
|
||||
@@ -34,6 +34,7 @@ import "prismjs/components/prism-r";
|
||||
import "prismjs/components/prism-sql";
|
||||
import "prismjs/components/prism-graphql";
|
||||
import "prismjs/components/prism-shell-session";
|
||||
import "prismjs/components/prism-java";
|
||||
import "prismjs/components/prism-powershell";
|
||||
import "prismjs/components/prism-swift";
|
||||
import "prismjs/components/prism-yaml";
|
||||
@@ -86,6 +87,7 @@ import "prismjs/components/prism-moonscript";
|
||||
import "prismjs/components/prism-qml";
|
||||
import "prismjs/components/prism-vim";
|
||||
import "prismjs/components/prism-nim";
|
||||
import "prismjs/components/prism-swift";
|
||||
import "prismjs/components/prism-haml";
|
||||
import "prismjs/components/prism-ada";
|
||||
import "prismjs/components/prism-arduino";
|
||||
|
||||
@@ -98,7 +98,7 @@ const TitlebarBase = styled.div<Props>`
|
||||
export function Titlebar(props: Props) {
|
||||
return (
|
||||
<TitlebarBase {...props}>
|
||||
<div className="title">
|
||||
<div class="title">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 193.733 37.438">
|
||||
@@ -114,7 +114,7 @@ export function Titlebar(props: Props) {
|
||||
<Wrench size="12.5" />
|
||||
)}
|
||||
</div>
|
||||
{/*<div className="actions quick">
|
||||
{/*<div class="actions quick">
|
||||
<Tooltip
|
||||
content="Mute"
|
||||
placement="bottom">
|
||||
@@ -130,9 +130,9 @@ export function Titlebar(props: Props) {
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>*/}
|
||||
<div className="drag" />
|
||||
<div class="drag" />
|
||||
<UpdateIndicator style="titlebar" />
|
||||
<div className="actions">
|
||||
<div class="actions">
|
||||
<div onClick={window.native.min}>
|
||||
<svg
|
||||
aria-hidden="false"
|
||||
@@ -164,7 +164,7 @@ export function Titlebar(props: Props) {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div onClick={window.native.close} className="error">
|
||||
<div onClick={window.native.close} class="error">
|
||||
<svg
|
||||
aria-hidden="false"
|
||||
width="12"
|
||||
|
||||
@@ -3,14 +3,14 @@ import { observer } from "mobx-react-lite";
|
||||
import { useHistory, useLocation } from "react-router";
|
||||
import styled, { css } from "styled-components/macro";
|
||||
|
||||
import { Centred, IconButton } from "@revoltchat/ui";
|
||||
|
||||
import ConditionalLink from "../../lib/ConditionalLink";
|
||||
|
||||
import { useApplicationState } from "../../mobx/State";
|
||||
|
||||
import { useClient } from "../../controllers/client/ClientController";
|
||||
import { useClient } from "../../context/revoltjs/RevoltClient";
|
||||
|
||||
import UserIcon from "../common/user/UserIcon";
|
||||
import IconButton from "../ui/IconButton";
|
||||
|
||||
const Base = styled.div`
|
||||
background: var(--secondary-background);
|
||||
@@ -24,19 +24,8 @@ const Navbar = styled.div`
|
||||
height: var(--bottom-navigation-height);
|
||||
`;
|
||||
|
||||
/**
|
||||
* I've decided that this whole component
|
||||
* needs to be re-written 👍👍👍👍👍👍
|
||||
*/
|
||||
|
||||
const Button = styled.a<{ active: boolean }>`
|
||||
flex: 1;
|
||||
color: var(--foreground);
|
||||
|
||||
// ok
|
||||
* {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
> a,
|
||||
> div,
|
||||
@@ -74,7 +63,7 @@ export default observer(() => {
|
||||
<Base>
|
||||
<Navbar>
|
||||
<Button active={homeActive}>
|
||||
<Centred
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (settingsActive) {
|
||||
if (history.length > 0) {
|
||||
@@ -91,14 +80,14 @@ export default observer(() => {
|
||||
}
|
||||
}}>
|
||||
<Message size={24} />
|
||||
</Centred>
|
||||
</IconButton>
|
||||
</Button>
|
||||
<Button active={friendsActive}>
|
||||
<IconButton>
|
||||
<ConditionalLink active={friendsActive} to="/friends">
|
||||
<ConditionalLink active={friendsActive} to="/friends">
|
||||
<IconButton>
|
||||
<Group size={25} />
|
||||
</ConditionalLink>
|
||||
</IconButton>
|
||||
</IconButton>
|
||||
</ConditionalLink>
|
||||
</Button>
|
||||
{/*<Button active={searchActive}>
|
||||
<ConditionalLink active={searchActive} to="/search">
|
||||
@@ -115,20 +104,20 @@ export default observer(() => {
|
||||
</ConditionalLink>
|
||||
</Button>*/}
|
||||
<Button active={discoverActive}>
|
||||
<IconButton>
|
||||
<ConditionalLink
|
||||
active={discoverActive}
|
||||
to="/discover/servers">
|
||||
<ConditionalLink
|
||||
active={discoverActive}
|
||||
to="/discover/servers">
|
||||
<IconButton>
|
||||
<Compass size={24} />
|
||||
</ConditionalLink>
|
||||
</IconButton>
|
||||
</IconButton>
|
||||
</ConditionalLink>
|
||||
</Button>
|
||||
<Button active={settingsActive}>
|
||||
<IconButton>
|
||||
<ConditionalLink active={settingsActive} to="/settings">
|
||||
<ConditionalLink active={settingsActive} to="/settings">
|
||||
<IconButton>
|
||||
<UserIcon target={user} size={26} status={true} />
|
||||
</ConditionalLink>
|
||||
</IconButton>
|
||||
</IconButton>
|
||||
</ConditionalLink>
|
||||
</Button>
|
||||
</Navbar>
|
||||
</Base>
|
||||
|
||||
@@ -5,20 +5,23 @@ import { User, Channel } from "revolt.js";
|
||||
|
||||
import styles from "./Item.module.scss";
|
||||
import classNames from "classnames";
|
||||
import { Ref } from "preact";
|
||||
import { useTriggerEvents } from "preact-context-menu";
|
||||
import { Localizer, Text } from "preact-i18n";
|
||||
|
||||
import { IconButton } from "@revoltchat/ui";
|
||||
|
||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||
import { stopPropagation } from "../../../lib/stopPropagation";
|
||||
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
|
||||
import ChannelIcon from "../../common/ChannelIcon";
|
||||
import Tooltip from "../../common/Tooltip";
|
||||
import UserIcon from "../../common/user/UserIcon";
|
||||
import { Username } from "../../common/user/UserShort";
|
||||
import UserStatus from "../../common/user/UserStatus";
|
||||
import IconButton from "../../ui/IconButton";
|
||||
|
||||
import { Children } from "../../../types/Preact";
|
||||
|
||||
type CommonProps = Omit<
|
||||
JSX.HTMLAttributes<HTMLDivElement>,
|
||||
@@ -49,6 +52,7 @@ export const UserButton = observer((props: UserProps) => {
|
||||
channel,
|
||||
...divProps
|
||||
} = props;
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -109,7 +113,8 @@ export const UserButton = observer((props: UserProps) => {
|
||||
className={styles.icon}
|
||||
onClick={(e) =>
|
||||
stopPropagation(e) &&
|
||||
modalController.push({
|
||||
openScreen({
|
||||
id: "special_prompt",
|
||||
type: "close_dm",
|
||||
target: channel,
|
||||
})
|
||||
@@ -146,6 +151,7 @@ export const ChannelButton = observer((props: ChannelProps) => {
|
||||
return <UserButton {...{ active, alert, channel, user }} />;
|
||||
}
|
||||
|
||||
const { openScreen } = useIntermediate();
|
||||
const alerting = alert && !muted && !active;
|
||||
|
||||
return (
|
||||
@@ -155,16 +161,16 @@ export const ChannelButton = observer((props: ChannelProps) => {
|
||||
data-alert={alerting}
|
||||
data-muted={muted}
|
||||
aria-label={channel.name}
|
||||
className={classNames(styles.item, {
|
||||
[styles.compact]: compact,
|
||||
})}
|
||||
className={classNames(styles.item, { [styles.compact]: compact })}
|
||||
{...useTriggerEvents("Menu", {
|
||||
channel: channel._id,
|
||||
unread: !!alert,
|
||||
})}>
|
||||
<div className={styles.avatar}>
|
||||
<ChannelIcon target={channel} size={compact ? 24 : 32} />
|
||||
</div>
|
||||
<ChannelIcon
|
||||
className={styles.avatar}
|
||||
target={channel}
|
||||
size={compact ? 24 : 32}
|
||||
/>
|
||||
<div className={styles.name}>
|
||||
<div>{channel.name}</div>
|
||||
{channel.channel_type === "Group" && (
|
||||
@@ -177,9 +183,7 @@ export const ChannelButton = observer((props: ChannelProps) => {
|
||||
<Text
|
||||
id="quantities.members"
|
||||
plural={channel.recipients!.length}
|
||||
fields={{
|
||||
count: channel.recipients!.length,
|
||||
}}
|
||||
fields={{ count: channel.recipients!.length }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -195,7 +199,8 @@ export const ChannelButton = observer((props: ChannelProps) => {
|
||||
<IconButton
|
||||
className={styles.icon}
|
||||
onClick={() =>
|
||||
modalController.push({
|
||||
openScreen({
|
||||
id: "special_prompt",
|
||||
type: "leave_group",
|
||||
target: channel,
|
||||
})
|
||||
|
||||
@@ -1,47 +1,45 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
import { Banner, Button, Column } from "@revoltchat/ui";
|
||||
import {
|
||||
ClientStatus,
|
||||
StatusContext,
|
||||
useClient,
|
||||
} from "../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { useSession } from "../../../controllers/client/ClientController";
|
||||
import Banner from "../../ui/Banner";
|
||||
|
||||
function ConnectionStatus() {
|
||||
const session = useSession()!;
|
||||
export default function ConnectionStatus() {
|
||||
const status = useContext(StatusContext);
|
||||
const client = useClient();
|
||||
|
||||
if (session.state === "Offline") {
|
||||
if (status === ClientStatus.OFFLINE) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.offline" />
|
||||
</Banner>
|
||||
);
|
||||
} else if (session.state === "Disconnected") {
|
||||
} else if (status === ClientStatus.DISCONNECTED) {
|
||||
return (
|
||||
<Banner>
|
||||
<Column centred>
|
||||
<Text id="app.special.status.disconnected" />
|
||||
<Button
|
||||
compact
|
||||
palette="secondary"
|
||||
onClick={() =>
|
||||
session.emit({
|
||||
action: "RETRY",
|
||||
})
|
||||
}>
|
||||
<Text id="app.status.reconnect" />
|
||||
</Button>
|
||||
</Column>
|
||||
<Text id="app.special.status.disconnected" /> <br />
|
||||
<a onClick={() => client.websocket.connect()}>
|
||||
<Text id="app.special.status.reconnect" />
|
||||
</a>
|
||||
</Banner>
|
||||
);
|
||||
} else if (session.state === "Connecting") {
|
||||
} else if (status === ClientStatus.CONNECTING) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.connecting" />
|
||||
</Banner>
|
||||
);
|
||||
} else if (status === ClientStatus.RECONNECTING) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.reconnecting" />
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(ConnectionStatus);
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Plus } from "@styled-icons/boxicons-regular";
|
||||
import {
|
||||
Home,
|
||||
UserDetail,
|
||||
@@ -12,18 +11,18 @@ import styled, { css } from "styled-components/macro";
|
||||
import { Text } from "preact-i18n";
|
||||
import { useContext, useEffect } from "preact/hooks";
|
||||
|
||||
import { Category, IconButton } from "@revoltchat/ui";
|
||||
|
||||
import ConditionalLink from "../../../lib/ConditionalLink";
|
||||
import PaintCounter from "../../../lib/PaintCounter";
|
||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import Category from "../../ui/Category";
|
||||
import placeholderSVG from "../items/placeholder.svg";
|
||||
|
||||
import { useClient } from "../../../controllers/client/ClientController";
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
|
||||
import ButtonItem, { ChannelButton } from "../items/ButtonItem";
|
||||
import ConnectionStatus from "../items/ConnectionStatus";
|
||||
@@ -45,9 +44,10 @@ const Navbar = styled.div`
|
||||
|
||||
export default observer(() => {
|
||||
const { pathname } = useLocation();
|
||||
const client = useClient();
|
||||
const client = useContext(AppContext);
|
||||
const state = useApplicationState();
|
||||
const { channel: channel_id } = useParams<{ channel: string }>();
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
const channels = [...client.channels.values()].filter(
|
||||
(x) =>
|
||||
@@ -125,17 +125,15 @@ export default observer(() => {
|
||||
</ButtonItem>
|
||||
</Link>
|
||||
)}
|
||||
<Category>
|
||||
<Text id="app.main.categories.conversations" />
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
modalController.push({
|
||||
type: "create_group",
|
||||
})
|
||||
}>
|
||||
<Plus size={16} />
|
||||
</IconButton>
|
||||
</Category>
|
||||
<Category
|
||||
text={<Text id="app.main.categories.conversations" />}
|
||||
action={() =>
|
||||
openScreen({
|
||||
id: "special_input",
|
||||
type: "create_group",
|
||||
})
|
||||
}
|
||||
/>
|
||||
{channels.length === 0 && (
|
||||
<img src={placeholderSVG} loading="eager" />
|
||||
)}
|
||||
|
||||
@@ -7,9 +7,8 @@ import { ServerList } from "@revoltchat/ui";
|
||||
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
import { useClient } from "../../../controllers/client/ClientController";
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
import { IS_REVOLT } from "../../../version";
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||
|
||||
/**
|
||||
* Server list sidebar shim component
|
||||
@@ -17,11 +16,13 @@ import { IS_REVOLT } from "../../../version";
|
||||
export default observer(() => {
|
||||
const client = useClient();
|
||||
const state = useApplicationState();
|
||||
const { openScreen } = useIntermediate();
|
||||
const { server: server_id } = useParams<{ server?: string }>();
|
||||
|
||||
const createServer = useCallback(
|
||||
() =>
|
||||
modalController.push({
|
||||
openScreen({
|
||||
id: "special_input",
|
||||
type: "create_server",
|
||||
}),
|
||||
[],
|
||||
@@ -36,7 +37,6 @@ export default observer(() => {
|
||||
home={state.layout.getLastHomePath}
|
||||
servers={state.ordering.orderedServers}
|
||||
reorder={state.ordering.reorderServer}
|
||||
showDiscovery={IS_REVOLT}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Redirect, useParams } from "react-router";
|
||||
import { Server } from "revolt.js";
|
||||
import styled, { css } from "styled-components/macro";
|
||||
|
||||
import { Ref } from "preact";
|
||||
import { useTriggerEvents } from "preact-context-menu";
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
import { Category } from "@revoltchat/ui";
|
||||
|
||||
import ConditionalLink from "../../../lib/ConditionalLink";
|
||||
import PaintCounter from "../../../lib/PaintCounter";
|
||||
import { internalEmit } from "../../../lib/eventEmitter";
|
||||
@@ -14,9 +14,12 @@ import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
import { useClient } from "../../../controllers/client/ClientController";
|
||||
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import CollapsibleSection from "../../common/CollapsibleSection";
|
||||
import ServerHeader from "../../common/ServerHeader";
|
||||
import Category from "../../ui/Category";
|
||||
|
||||
import { ChannelButton } from "../items/ButtonItem";
|
||||
import ConnectionStatus from "../items/ConnectionStatus";
|
||||
|
||||
@@ -123,7 +126,7 @@ export default observer(() => {
|
||||
<CollapsibleSection
|
||||
id={`category_${category.id}`}
|
||||
defaultValue
|
||||
summary={<Category>{category.title}</Category>}>
|
||||
summary={<Category text={category.title} />}>
|
||||
{channels}
|
||||
</CollapsibleSection>,
|
||||
);
|
||||
|
||||
@@ -8,7 +8,11 @@ import { memo } from "preact/compat";
|
||||
|
||||
import { internalEmit } from "../../../lib/eventEmitter";
|
||||
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
import {
|
||||
Screen,
|
||||
useIntermediate,
|
||||
} from "../../../context/intermediate/Intermediate";
|
||||
|
||||
import { UserButton } from "../items/ButtonItem";
|
||||
|
||||
export type MemberListGroup = {
|
||||
@@ -51,7 +55,15 @@ const NoOomfie = styled.div`
|
||||
`;
|
||||
|
||||
const ItemContent = memo(
|
||||
({ item, context }: { item: User; context: Channel }) => (
|
||||
({
|
||||
item,
|
||||
context,
|
||||
openScreen,
|
||||
}: {
|
||||
item: User;
|
||||
context: Channel;
|
||||
openScreen: (screen: Screen) => void;
|
||||
}) => (
|
||||
<UserButton
|
||||
key={item._id}
|
||||
user={item}
|
||||
@@ -65,12 +77,13 @@ const ItemContent = memo(
|
||||
`<@${item._id}>`,
|
||||
"mention",
|
||||
);
|
||||
} else {
|
||||
modalController.push({
|
||||
type: "user_profile",
|
||||
user_id: item._id,
|
||||
});
|
||||
}
|
||||
} else
|
||||
[
|
||||
openScreen({
|
||||
id: "profile",
|
||||
user_id: item._id,
|
||||
}),
|
||||
];
|
||||
}}
|
||||
/>
|
||||
),
|
||||
@@ -83,6 +96,8 @@ export default function MemberList({
|
||||
entries: MemberListGroup[];
|
||||
context: Channel;
|
||||
}) {
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
return (
|
||||
<GroupedVirtuoso
|
||||
groupCounts={entries.map((x) => x.users.length)}
|
||||
@@ -99,7 +114,7 @@ export default function MemberList({
|
||||
)}
|
||||
{entry.type !== "no_offline" && (
|
||||
<>
|
||||
{" – "}
|
||||
{" - "}
|
||||
{entry.users.length}
|
||||
</>
|
||||
)}
|
||||
@@ -118,20 +133,19 @@ export default function MemberList({
|
||||
return (
|
||||
<NoOomfie>
|
||||
<div>
|
||||
Offline users have temporarily been disabled for
|
||||
larger servers - see{" "}
|
||||
Offline users temporarily disabled for this
|
||||
server, see issue{" "}
|
||||
<a
|
||||
href="https://github.com/revoltchat/backend/issues/178"
|
||||
target="_blank"
|
||||
rel="noreferrer">
|
||||
issue #178
|
||||
href="https://github.com/revoltchat/delta/issues/128"
|
||||
target="_blank">
|
||||
#128
|
||||
</a>{" "}
|
||||
for when this will be resolved.
|
||||
</div>
|
||||
<div>
|
||||
You may re-enable them{" "}
|
||||
You may re-enable them in{" "}
|
||||
<Link to="/settings/experiments">
|
||||
<a>here</a>
|
||||
<a>experiments</a>
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
@@ -144,7 +158,11 @@ export default function MemberList({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ItemContent item={item} context={context} />
|
||||
<ItemContent
|
||||
item={item}
|
||||
context={context}
|
||||
openScreen={openScreen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -4,12 +4,14 @@ import { observer } from "mobx-react-lite";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Channel, Server, User, API } from "revolt.js";
|
||||
|
||||
import { useEffect, useLayoutEffect, useState } from "preact/hooks";
|
||||
import { useContext, useEffect, useState } from "preact/hooks";
|
||||
|
||||
import {
|
||||
useSession,
|
||||
ClientStatus,
|
||||
StatusContext,
|
||||
useClient,
|
||||
} from "../../../controllers/client/ClientController";
|
||||
} from "../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { GenericSidebarBase } from "../SidebarBase";
|
||||
import MemberList, { MemberListGroup } from "./MemberList";
|
||||
|
||||
@@ -180,7 +182,7 @@ export const GroupMemberSidebar = observer(
|
||||
);
|
||||
|
||||
// ! FIXME: this is temporary code until we get lazy guilds like subscriptions
|
||||
const FETCHED: Set<string> = new Set();
|
||||
const FETCHED: Set<String> = new Set();
|
||||
|
||||
export function resetMemberSidebarFetched() {
|
||||
FETCHED.clear();
|
||||
@@ -203,18 +205,18 @@ function shouldSkipOffline(id: string) {
|
||||
|
||||
export const ServerMemberSidebar = observer(
|
||||
({ channel }: { channel: Channel }) => {
|
||||
const session = useSession()!;
|
||||
const client = session.client!;
|
||||
const client = useClient();
|
||||
const status = useContext(StatusContext);
|
||||
|
||||
useEffect(() => {
|
||||
const server_id = channel.server_id!;
|
||||
if (session.state === "Online" && !FETCHED.has(server_id)) {
|
||||
if (status === ClientStatus.ONLINE && !FETCHED.has(server_id)) {
|
||||
FETCHED.add(server_id);
|
||||
channel
|
||||
.server!.syncMembers(shouldSkipOffline(server_id))
|
||||
.catch(() => FETCHED.delete(server_id));
|
||||
}
|
||||
}, [session.state, channel]);
|
||||
}, [status, channel]);
|
||||
|
||||
const entries = useEntries(
|
||||
channel,
|
||||
|
||||
@@ -5,10 +5,15 @@ import styled from "styled-components/macro";
|
||||
import { Text } from "preact-i18n";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { Button, Category, Error, InputBox, Preloader } from "@revoltchat/ui";
|
||||
import { Button } from "@revoltchat/ui";
|
||||
|
||||
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import { useClient } from "../../../controllers/client/ClientController";
|
||||
import Message from "../../common/messaging/Message";
|
||||
import InputBox from "../../ui/InputBox";
|
||||
import Overline from "../../ui/Overline";
|
||||
import Preloader from "../../ui/Preloader";
|
||||
|
||||
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
|
||||
|
||||
type SearchState =
|
||||
@@ -98,20 +103,18 @@ export function SearchSidebar({ close }: Props) {
|
||||
<GenericSidebarBase data-scroll-offset="with-padding">
|
||||
<GenericSidebarList>
|
||||
<SearchBase>
|
||||
<Category>
|
||||
<Error
|
||||
error={<a onClick={close}>« back to members</a>}
|
||||
/>
|
||||
</Category>
|
||||
<Category>
|
||||
<Overline type="accent" block hover>
|
||||
<a onClick={close}>« back to members</a>
|
||||
</Overline>
|
||||
<Overline type="subtle" block>
|
||||
<Text id="app.main.channel.search.title" />
|
||||
</Category>
|
||||
</Overline>
|
||||
<InputBox
|
||||
value={query}
|
||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
<div className="sort">
|
||||
<div class="sort">
|
||||
{["Latest", "Oldest", "Relevance"].map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
@@ -126,7 +129,7 @@ export function SearchSidebar({ close }: Props) {
|
||||
</div>
|
||||
{state.type === "loading" && <Preloader type="ring" />}
|
||||
{state.type === "results" && (
|
||||
<div className="list">
|
||||
<div class="list">
|
||||
{state.results.map((message) => {
|
||||
let href = "";
|
||||
if (channel?.channel_type === "TextChannel") {
|
||||
@@ -137,7 +140,7 @@ export function SearchSidebar({ close }: Props) {
|
||||
|
||||
return (
|
||||
<Link to={href} key={message._id}>
|
||||
<div className="message">
|
||||
<div class="message">
|
||||
<Message
|
||||
message={message}
|
||||
head
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { Brush } from "@styled-icons/boxicons-solid";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Link } from "react-router-dom";
|
||||
// @ts-expect-error shade-blend-color does not have typings.
|
||||
import pSBC from "shade-blend-color";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import TextAreaAutoSize from "../../lib/TextAreaAutoSize";
|
||||
|
||||
import { useApplicationState } from "../../mobx/State";
|
||||
|
||||
import {
|
||||
Fonts,
|
||||
FONTS,
|
||||
FONT_KEYS,
|
||||
MonospaceFonts,
|
||||
MONOSPACE_FONTS,
|
||||
MONOSPACE_FONT_KEYS,
|
||||
} from "../../context/Theme";
|
||||
|
||||
import Checkbox from "../ui/Checkbox";
|
||||
import ColourSwatches from "../ui/ColourSwatches";
|
||||
import ComboBox from "../ui/ComboBox";
|
||||
import Radio from "../ui/Radio";
|
||||
import CategoryButton from "../ui/fluent/CategoryButton";
|
||||
|
||||
import { EmojiSelector } from "./appearance/EmojiSelector";
|
||||
import { ThemeBaseSelector } from "./appearance/ThemeBaseSelector";
|
||||
|
||||
/**
|
||||
* Component providing a way to switch the base theme being used.
|
||||
*/
|
||||
export const ThemeBaseSelectorShim = observer(() => {
|
||||
const theme = useApplicationState().settings.theme;
|
||||
return (
|
||||
<ThemeBaseSelector
|
||||
value={theme.isModified() ? undefined : theme.getBase()}
|
||||
setValue={(base) => {
|
||||
theme.setBase(base);
|
||||
theme.reset();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Component providing a link to the theme shop.
|
||||
* Only appears if experiment is enabled.
|
||||
* TODO: stabilise
|
||||
*/
|
||||
export const ThemeShopShim = () => {
|
||||
return (
|
||||
<Link to="/discover/themes" replace>
|
||||
<CategoryButton
|
||||
icon={<Brush size={24} />}
|
||||
action="chevron"
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.discover.description" />
|
||||
}
|
||||
hover>
|
||||
<Text id="app.settings.pages.appearance.discover.title" />
|
||||
</CategoryButton>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Component providing a way to change current accent colour.
|
||||
*/
|
||||
export const ThemeAccentShim = observer(() => {
|
||||
const theme = useApplicationState().settings.theme;
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.accent_selector" />
|
||||
</h3>
|
||||
<ColourSwatches
|
||||
value={theme.getVariable("accent")}
|
||||
onChange={(colour) => {
|
||||
theme.setVariable("accent", colour as string);
|
||||
theme.setVariable("scrollbar-thumb", pSBC(-0.2, colour));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Component providing a way to edit custom CSS.
|
||||
*/
|
||||
export const ThemeCustomCSSShim = observer(() => {
|
||||
const theme = useApplicationState().settings.theme;
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.custom_css" />
|
||||
</h3>
|
||||
<TextAreaAutoSize
|
||||
maxRows={20}
|
||||
minHeight={480}
|
||||
code
|
||||
value={theme.getCSS() ?? ""}
|
||||
onChange={(ev) => theme.setCSS(ev.currentTarget.value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Component providing a way to switch between compact and normal message view.
|
||||
*/
|
||||
export const DisplayCompactShim = () => {
|
||||
// TODO: WIP feature
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.message_display" />
|
||||
</h3>
|
||||
<div /* className={styles.display} */>
|
||||
<Radio
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.display.default_description" />
|
||||
}
|
||||
checked>
|
||||
<Text id="app.settings.pages.appearance.display.default" />
|
||||
</Radio>
|
||||
<Radio
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.display.compact_description" />
|
||||
}
|
||||
disabled>
|
||||
<Text id="app.settings.pages.appearance.display.compact" />
|
||||
</Radio>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Component providing a way to change primary text font.
|
||||
*/
|
||||
export const DisplayFontShim = observer(() => {
|
||||
const theme = useApplicationState().settings.theme;
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.font" />
|
||||
</h3>
|
||||
<ComboBox
|
||||
value={theme.getFont()}
|
||||
onChange={(e) => theme.setFont(e.currentTarget.value as Fonts)}>
|
||||
{FONT_KEYS.map((key) => (
|
||||
<option value={key} key={key}>
|
||||
{FONTS[key as keyof typeof FONTS].name}
|
||||
</option>
|
||||
))}
|
||||
</ComboBox>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Component providing a way to change secondary, monospace text font.
|
||||
*/
|
||||
export const DisplayMonospaceFontShim = observer(() => {
|
||||
const theme = useApplicationState().settings.theme;
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.mono_font" />
|
||||
</h3>
|
||||
<ComboBox
|
||||
value={theme.getMonospaceFont()}
|
||||
onChange={(e) =>
|
||||
theme.setMonospaceFont(
|
||||
e.currentTarget.value as MonospaceFonts,
|
||||
)
|
||||
}>
|
||||
{MONOSPACE_FONT_KEYS.map((key) => (
|
||||
<option value={key} key={key}>
|
||||
{
|
||||
MONOSPACE_FONTS[key as keyof typeof MONOSPACE_FONTS]
|
||||
.name
|
||||
}
|
||||
</option>
|
||||
))}
|
||||
</ComboBox>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Component providing a way to toggle font ligatures.
|
||||
*/
|
||||
export const DisplayLigaturesShim = observer(() => {
|
||||
const settings = useApplicationState().settings;
|
||||
if (settings.theme.getFont() !== "Inter") return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={settings.get("appearance:ligatures") ?? false}
|
||||
onChange={(v) => settings.set("appearance:ligatures", v)}
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.ligatures_desc" />
|
||||
}>
|
||||
<Text id="app.settings.pages.appearance.ligatures" />
|
||||
</Checkbox>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Component providing a way to toggle showing the send button on desktop.
|
||||
*/
|
||||
export const ShowSendButtonShim = observer(() => {
|
||||
const settings = useApplicationState().settings;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={settings.get("appearance:show_send_button") ?? false}
|
||||
onChange={(v) => settings.set("appearance:show_send_button", v)}
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.appearance_options.show_send_desc" />
|
||||
}>
|
||||
<Text id="app.settings.pages.appearance.appearance_options.show_send" />
|
||||
</Checkbox>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Component providing a way to toggle seasonal themes.
|
||||
*/
|
||||
export const DisplaySeasonalShim = observer(() => {
|
||||
const settings = useApplicationState().settings;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={settings.get("appearance:seasonal") ?? true}
|
||||
onChange={(v) => settings.set("appearance:seasonal", v)}
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.theme_options.seasonal_desc" />
|
||||
}>
|
||||
<Text id="app.settings.pages.appearance.theme_options.seasonal" />
|
||||
</Checkbox>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Component providing a way to toggle transparency effects.
|
||||
*/
|
||||
export const DisplayTransparencyShim = observer(() => {
|
||||
const settings = useApplicationState().settings;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={settings.get("appearance:transparency") ?? true}
|
||||
onChange={(v) => settings.set("appearance:transparency", v)}
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.theme_options.transparency_desc" />
|
||||
}>
|
||||
<Text id="app.settings.pages.appearance.theme_options.transparency" />
|
||||
</Checkbox>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Component providing a way to change emoji pack.
|
||||
*/
|
||||
export const DisplayEmojiShim = observer(() => {
|
||||
const settings = useApplicationState().settings;
|
||||
return (
|
||||
<EmojiSelector
|
||||
value={settings.get("appearance:emoji")}
|
||||
setValue={(v) => settings.set("appearance:emoji", v)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Block } from "@styled-icons/boxicons-regular";
|
||||
import { Trash } from "@styled-icons/boxicons-solid";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import { CategoryButton } from "@revoltchat/ui";
|
||||
|
||||
import {
|
||||
clientController,
|
||||
useClient,
|
||||
} from "../../../controllers/client/ClientController";
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
|
||||
export default function AccountManagement() {
|
||||
const client = useClient();
|
||||
|
||||
const callback = (route: "disable" | "delete") => () =>
|
||||
modalController.mfaFlow(client).then(
|
||||
(ticket) =>
|
||||
ticket &&
|
||||
client.api
|
||||
.post(`/auth/account/${route}`, undefined, {
|
||||
headers: {
|
||||
"X-MFA-Ticket": ticket.token,
|
||||
},
|
||||
})
|
||||
.then(clientController.logoutCurrent),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<Text id="app.settings.pages.account.manage.title" />
|
||||
</h3>
|
||||
|
||||
<h5>
|
||||
<Text id="app.settings.pages.account.manage.description" />
|
||||
</h5>
|
||||
<CategoryButton
|
||||
icon={<Block size={24} color="var(--error)" />}
|
||||
description={
|
||||
"Disable your account. You won't be able to access it unless you contact support."
|
||||
}
|
||||
action="chevron"
|
||||
onClick={callback("disable")}>
|
||||
<Text id="app.settings.pages.account.manage.disable" />
|
||||
</CategoryButton>
|
||||
|
||||
<CategoryButton
|
||||
icon={<Trash size={24} color="var(--error)" />}
|
||||
description={
|
||||
"Your account will be queued for deletion, a confirmation email will be sent."
|
||||
}
|
||||
action="chevron"
|
||||
onClick={callback("delete")}>
|
||||
<Text id="app.settings.pages.account.manage.delete" />
|
||||
</CategoryButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { At } from "@styled-icons/boxicons-regular";
|
||||
import { Envelope, Key, Pencil } from "@styled-icons/boxicons-solid";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import {
|
||||
AccountDetail,
|
||||
CategoryButton,
|
||||
Column,
|
||||
HiddenValue,
|
||||
} from "@revoltchat/ui";
|
||||
|
||||
import { useSession } from "../../../controllers/client/ClientController";
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
|
||||
export default observer(() => {
|
||||
const session = useSession()!;
|
||||
const client = session.client!;
|
||||
|
||||
const [email, setEmail] = useState("...");
|
||||
|
||||
useEffect(() => {
|
||||
if (email === "..." && session.state === "Online") {
|
||||
client.api
|
||||
.get("/auth/account/")
|
||||
.then((account) => setEmail(account.email));
|
||||
}
|
||||
}, [client, email, session.state]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Column group>
|
||||
<AccountDetail user={client.user!} />
|
||||
</Column>
|
||||
|
||||
{(
|
||||
[
|
||||
[
|
||||
"username",
|
||||
client.user!.username +
|
||||
"#" +
|
||||
client.user!.discriminator,
|
||||
At,
|
||||
],
|
||||
["email", email, Envelope],
|
||||
["password", "•••••••••", Key],
|
||||
] as const
|
||||
).map(([field, value, Icon]) => (
|
||||
<CategoryButton
|
||||
key={field}
|
||||
icon={<Icon size={24} />}
|
||||
description={
|
||||
field === "email" ? (
|
||||
<HiddenValue
|
||||
value={value}
|
||||
placeholder={"•••••••••••@••••••.•••"}
|
||||
/>
|
||||
) : (
|
||||
value
|
||||
)
|
||||
}
|
||||
account
|
||||
action={<Pencil size={20} />}
|
||||
onClick={() =>
|
||||
modalController.push({
|
||||
type: "modify_account",
|
||||
client,
|
||||
field,
|
||||
})
|
||||
}>
|
||||
<Text id={`login.${field}`} />
|
||||
</CategoryButton>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
import { ListOl } from "@styled-icons/boxicons-regular";
|
||||
import { Lock } from "@styled-icons/boxicons-solid";
|
||||
import { API } from "revolt.js";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { Category, CategoryButton, Error, Tip } from "@revoltchat/ui";
|
||||
|
||||
import { useSession } from "../../../controllers/client/ClientController";
|
||||
import { takeError } from "../../../controllers/client/jsx/error";
|
||||
import { modalController } from "../../../controllers/modals/ModalController";
|
||||
|
||||
/**
|
||||
* Temporary helper function for Axios config
|
||||
* @param token Token
|
||||
* @returns Headers
|
||||
*/
|
||||
export function toConfig(token: string) {
|
||||
return {
|
||||
headers: {
|
||||
"X-MFA-Ticket": token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for configuring MFA on an account.
|
||||
*/
|
||||
export default function MultiFactorAuthentication() {
|
||||
// Pull in prerequisites
|
||||
const session = useSession()!;
|
||||
const client = session.client!;
|
||||
|
||||
// Keep track of MFA state
|
||||
const [mfa, setMFA] = useState<API.MultiFactorStatus>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
// Fetch the current MFA status on account
|
||||
useEffect(() => {
|
||||
if (!mfa && session.state === "Online") {
|
||||
client!.api
|
||||
.get("/auth/mfa/")
|
||||
.then(setMFA)
|
||||
.catch((err) => setError(takeError(err)));
|
||||
}
|
||||
}, [mfa, client, session.state]);
|
||||
|
||||
// Action called when recovery code button is pressed
|
||||
const recoveryAction = useCallback(async () => {
|
||||
// Perform MFA flow first
|
||||
const ticket = await modalController.mfaFlow(client);
|
||||
|
||||
// Check whether action was cancelled
|
||||
if (typeof ticket === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Decide whether to generate or fetch.
|
||||
let codes;
|
||||
if (mfa!.recovery_active) {
|
||||
// Fetch existing recovery codes
|
||||
codes = await client.api.post(
|
||||
"/auth/mfa/recovery",
|
||||
undefined,
|
||||
toConfig(ticket.token),
|
||||
);
|
||||
} else {
|
||||
// Generate new recovery codes
|
||||
codes = await client.api.patch(
|
||||
"/auth/mfa/recovery",
|
||||
undefined,
|
||||
toConfig(ticket.token),
|
||||
);
|
||||
|
||||
setMFA({
|
||||
...mfa!,
|
||||
recovery_active: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Display the codes to the user
|
||||
modalController.push({
|
||||
type: "mfa_recovery",
|
||||
client,
|
||||
codes,
|
||||
});
|
||||
}, [mfa]);
|
||||
|
||||
// Action called when TOTP button is pressed
|
||||
const totpAction = useCallback(async () => {
|
||||
// Perform MFA flow first
|
||||
const ticket = await modalController.mfaFlow(client);
|
||||
|
||||
// Check whether action was cancelled
|
||||
if (typeof ticket === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Decide whether to disable or enable.
|
||||
if (mfa!.totp_mfa) {
|
||||
// Disable TOTP authentication
|
||||
await client.api.delete(
|
||||
"/auth/mfa/totp",
|
||||
{},
|
||||
toConfig(ticket.token),
|
||||
);
|
||||
|
||||
setMFA({
|
||||
...mfa!,
|
||||
totp_mfa: false,
|
||||
});
|
||||
} else {
|
||||
// Generate a TOTP secret
|
||||
const { secret } = await client.api.post(
|
||||
"/auth/mfa/totp",
|
||||
undefined,
|
||||
toConfig(ticket.token),
|
||||
);
|
||||
|
||||
// Open secret modal
|
||||
let success;
|
||||
while (!success) {
|
||||
try {
|
||||
// Make the user generator a token
|
||||
const totp_code = await modalController.mfaEnableTOTP(
|
||||
secret,
|
||||
client.user!.username,
|
||||
);
|
||||
|
||||
if (totp_code) {
|
||||
// Check whether it is valid
|
||||
await client.api.put(
|
||||
"/auth/mfa/totp",
|
||||
{
|
||||
totp_code,
|
||||
},
|
||||
toConfig(ticket.token),
|
||||
);
|
||||
|
||||
// Mark as successful and activated
|
||||
success = true;
|
||||
|
||||
setMFA({
|
||||
...mfa!,
|
||||
totp_mfa: true,
|
||||
});
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
}, [mfa]);
|
||||
|
||||
const mfaActive = !!mfa?.totp_mfa;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<Text id="app.settings.pages.account.2fa.title" />
|
||||
</h3>
|
||||
<h5>
|
||||
<Text id="app.settings.pages.account.2fa.description" />
|
||||
</h5>
|
||||
|
||||
{error && (
|
||||
<Category compact>
|
||||
<Error error={error} />
|
||||
</Category>
|
||||
)}
|
||||
|
||||
<CategoryButton
|
||||
icon={<ListOl size={24} />}
|
||||
description={
|
||||
<Text
|
||||
id={`app.settings.pages.account.2fa.${
|
||||
mfa?.recovery_active
|
||||
? "view_recovery"
|
||||
: "generate_recovery"
|
||||
}_long`}
|
||||
/>
|
||||
}
|
||||
disabled={!mfa}
|
||||
onClick={recoveryAction}>
|
||||
<Text
|
||||
id={`app.settings.pages.account.2fa.${
|
||||
mfa?.recovery_active
|
||||
? "view_recovery"
|
||||
: "generate_recovery"
|
||||
}`}
|
||||
/>
|
||||
</CategoryButton>
|
||||
<CategoryButton
|
||||
icon={
|
||||
<Lock
|
||||
size={24}
|
||||
color={!mfa?.totp_mfa ? "var(--error)" : undefined}
|
||||
/>
|
||||
}
|
||||
description={"Set up time-based one-time password."}
|
||||
disabled={!mfa || (!mfa.recovery_active && !mfa.totp_mfa)}
|
||||
onClick={totpAction}>
|
||||
<Text
|
||||
id={`app.settings.pages.account.2fa.${
|
||||
mfa?.totp_mfa ? "remove" : "add"
|
||||
}_auth`}
|
||||
/>
|
||||
</CategoryButton>
|
||||
|
||||
{mfa && (
|
||||
<Tip palette={mfaActive ? "primary" : "error"}>
|
||||
<Text
|
||||
id={`app.settings.pages.account.2fa.two_factor_${
|
||||
mfaActive ? "on" : "off"
|
||||
}`}
|
||||
/>
|
||||
</Tip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import { ObservedInputElement } from "@revoltchat/ui";
|
||||
|
||||
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
|
||||
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
import {
|
||||
MonospaceFonts,
|
||||
MONOSPACE_FONTS,
|
||||
MONOSPACE_FONT_KEYS,
|
||||
} from "../../../context/Theme";
|
||||
|
||||
/**
|
||||
* ! LEGACY
|
||||
* Component providing a way to edit custom CSS.
|
||||
*/
|
||||
export const ShimThemeCustomCSS = observer(() => {
|
||||
const theme = useApplicationState().settings.theme;
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.custom_css" />
|
||||
</h3>
|
||||
<TextAreaAutoSize
|
||||
maxRows={20}
|
||||
minHeight={480}
|
||||
code
|
||||
value={theme.getCSS() ?? ""}
|
||||
onChange={(ev) => theme.setCSS(ev.currentTarget.value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default function AdvancedOptions() {
|
||||
const settings = useApplicationState().settings;
|
||||
return (
|
||||
<>
|
||||
{/** Combo box of available monospaced fonts */}
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.mono_font" />
|
||||
</h3>
|
||||
<ObservedInputElement
|
||||
type="combo"
|
||||
value={() => settings.theme.getMonospaceFont()}
|
||||
onChange={(value) =>
|
||||
settings.theme.setMonospaceFont(value as MonospaceFonts)
|
||||
}
|
||||
options={MONOSPACE_FONT_KEYS.map((value) => ({
|
||||
value,
|
||||
name: MONOSPACE_FONTS[value as keyof typeof MONOSPACE_FONTS]
|
||||
.name,
|
||||
}))}
|
||||
/>
|
||||
{/** Custom CSS */}
|
||||
<ShimThemeCustomCSS />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import { Column, ObservedInputElement } from "@revoltchat/ui";
|
||||
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
export default function AppearanceOptions() {
|
||||
const settings = useApplicationState().settings;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.appearance_options.title" />
|
||||
</h3>
|
||||
{/* Option to toggle "send message" button on desktop. */}
|
||||
<ObservedInputElement
|
||||
type="checkbox"
|
||||
value={() =>
|
||||
settings.get("appearance:show_send_button") ?? false
|
||||
}
|
||||
onChange={(v) => settings.set("appearance:show_send_button", v)}
|
||||
title={
|
||||
<Text id="app.settings.pages.appearance.appearance_options.show_send" />
|
||||
}
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.appearance_options.show_send_desc" />
|
||||
}
|
||||
/>
|
||||
{/* Option to always show the account creation age next to join system messages. */}
|
||||
<ObservedInputElement
|
||||
type="checkbox"
|
||||
value={() =>
|
||||
settings.get("appearance:show_account_age") ?? false
|
||||
}
|
||||
onChange={(v) => settings.set("appearance:show_account_age", v)}
|
||||
title={
|
||||
<Text id="app.settings.pages.appearance.appearance_options.show_account_age" />
|
||||
}
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.appearance_options.show_account_age_desc" />
|
||||
}
|
||||
/>
|
||||
<hr />
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.theme_options.title" />
|
||||
</h3>
|
||||
<Column>
|
||||
{/* Option to toggle transparency effects in-app. */}
|
||||
<ObservedInputElement
|
||||
type="checkbox"
|
||||
value={() =>
|
||||
settings.get("appearance:transparency") ?? true
|
||||
}
|
||||
onChange={(v) => settings.set("appearance:transparency", v)}
|
||||
title={
|
||||
<Text id="app.settings.pages.appearance.theme_options.transparency" />
|
||||
}
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.theme_options.transparency_desc" />
|
||||
}
|
||||
/>
|
||||
{/* Option to toggle seasonal effects. */}
|
||||
<ObservedInputElement
|
||||
type="checkbox"
|
||||
value={() => settings.get("appearance:seasonal") ?? true}
|
||||
onChange={(v) => settings.set("appearance:seasonal", v)}
|
||||
title={
|
||||
<Text id="app.settings.pages.appearance.theme_options.seasonal" />
|
||||
}
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.theme_options.seasonal_desc" />
|
||||
}
|
||||
/>
|
||||
</Column>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import { Column, ObservedInputElement } from "@revoltchat/ui";
|
||||
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
import { FONTS, Fonts, FONT_KEYS } from "../../../context/Theme";
|
||||
|
||||
import { EmojiSelector } from "./legacy/EmojiSelector";
|
||||
|
||||
/**
|
||||
* ! LEGACY
|
||||
* Component providing a way to change emoji pack.
|
||||
*/
|
||||
export const ShimDisplayEmoji = observer(() => {
|
||||
const settings = useApplicationState().settings;
|
||||
return (
|
||||
<EmojiSelector
|
||||
value={settings.get("appearance:emoji")}
|
||||
setValue={(v) => settings.set("appearance:emoji", v)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(() => {
|
||||
const settings = useApplicationState().settings;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Column>
|
||||
{/* Combo box of available fonts. */}
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.font" />
|
||||
</h3>
|
||||
<ObservedInputElement
|
||||
type="combo"
|
||||
value={() => settings.theme.getFont()}
|
||||
onChange={(value) => settings.theme.setFont(value as Fonts)}
|
||||
options={FONT_KEYS.map((value) => ({
|
||||
value,
|
||||
name: FONTS[value as keyof typeof FONTS].name,
|
||||
}))}
|
||||
/>
|
||||
{/* Option to toggle liagures for supported fonts. */}
|
||||
{settings.theme.getFont() === "Inter" && (
|
||||
<ObservedInputElement
|
||||
type="checkbox"
|
||||
value={() =>
|
||||
settings.get("appearance:ligatures") ?? true
|
||||
}
|
||||
onChange={(v) =>
|
||||
settings.set("appearance:ligatures", v)
|
||||
}
|
||||
title={
|
||||
<Text id="app.settings.pages.appearance.ligatures" />
|
||||
}
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.ligatures_desc" />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Column>
|
||||
<hr />
|
||||
{/* Emoji pack selector */}
|
||||
<ShimDisplayEmoji />
|
||||
</>
|
||||
);
|
||||
});
|
||||
+11
-12
@@ -2,12 +2,11 @@ import styled from "styled-components/macro";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import mutantSVG from "./assets/mutant_emoji.svg";
|
||||
import notoSVG from "./assets/noto_emoji.svg";
|
||||
import openmojiSVG from "./assets/openmoji_emoji.svg";
|
||||
import twemojiSVG from "./assets/twemoji_emoji.svg";
|
||||
|
||||
import { EmojiPack } from "../../../common/Emoji";
|
||||
import { EmojiPack } from "../../common/Emoji";
|
||||
import mutantSVG from "./mutant_emoji.svg";
|
||||
import notoSVG from "./noto_emoji.svg";
|
||||
import openmojiSVG from "./openmoji_emoji.svg";
|
||||
import twemojiSVG from "./twemoji_emoji.svg";
|
||||
|
||||
const Container = styled.div`
|
||||
gap: 12px;
|
||||
@@ -88,10 +87,10 @@ export function EmojiSelector({ value, setValue }: Props) {
|
||||
<Text id="app.settings.pages.appearance.emoji_pack" />
|
||||
</h3>
|
||||
<Container>
|
||||
<div className="row">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div
|
||||
className="button"
|
||||
class="button"
|
||||
onClick={() => setValue("mutant")}
|
||||
data-active={!value || value === "mutant"}>
|
||||
<img
|
||||
@@ -113,7 +112,7 @@ export function EmojiSelector({ value, setValue }: Props) {
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="button"
|
||||
class="button"
|
||||
onClick={() => setValue("twemoji")}
|
||||
data-active={value === "twemoji"}>
|
||||
<img
|
||||
@@ -126,10 +125,10 @@ export function EmojiSelector({ value, setValue }: Props) {
|
||||
<h4>Twemoji</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div
|
||||
className="button"
|
||||
class="button"
|
||||
onClick={() => setValue("openmoji")}
|
||||
data-active={value === "openmoji"}>
|
||||
<img
|
||||
@@ -143,7 +142,7 @@ export function EmojiSelector({ value, setValue }: Props) {
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="button"
|
||||
class="button"
|
||||
onClick={() => setValue("noto")}
|
||||
data-active={value === "noto"}>
|
||||
<img
|
||||
+2
-2
@@ -2,8 +2,8 @@ import styled from "styled-components/macro";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import darkSVG from "./assets/dark.svg";
|
||||
import lightSVG from "./assets/light.svg";
|
||||
import darkSVG from "./dark.svg";
|
||||
import lightSVG from "./light.svg";
|
||||
|
||||
const List = styled.div`
|
||||
gap: 8px;
|
||||
@@ -1,11 +1,170 @@
|
||||
import Overrides from "./legacy/ThemeOverrides";
|
||||
import ThemeTools from "./legacy/ThemeTools";
|
||||
import { Pencil } from "@styled-icons/boxicons-regular";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import styled from "styled-components/macro";
|
||||
|
||||
export default function ThemeOverrides() {
|
||||
return (
|
||||
<>
|
||||
<ThemeTools />
|
||||
<Overrides />
|
||||
</>
|
||||
import { useDebounceCallback } from "../../../lib/debounce";
|
||||
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
import { Variables } from "../../../context/Theme";
|
||||
|
||||
import InputBox from "../../ui/InputBox";
|
||||
|
||||
const Container = styled.div`
|
||||
row-gap: 8px;
|
||||
display: grid;
|
||||
column-gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
margin-bottom: 20px;
|
||||
|
||||
.entry {
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid black;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 8px;
|
||||
text-transform: capitalize;
|
||||
|
||||
background: inherit;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
.override {
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
|
||||
.picker {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--primary-background);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
border: none;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
top: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(() => {
|
||||
const theme = useApplicationState().settings.theme;
|
||||
const setVariable = useDebounceCallback(
|
||||
(data) => {
|
||||
const { key, value } = data as { key: Variables; value: string };
|
||||
theme.setVariable(key, value);
|
||||
},
|
||||
[theme],
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{(
|
||||
[
|
||||
"accent",
|
||||
"background",
|
||||
"foreground",
|
||||
"primary-background",
|
||||
"primary-header",
|
||||
"secondary-background",
|
||||
"secondary-foreground",
|
||||
"secondary-header",
|
||||
"tertiary-background",
|
||||
"tertiary-foreground",
|
||||
"block",
|
||||
"message-box",
|
||||
"mention",
|
||||
"scrollbar-thumb",
|
||||
"scrollbar-track",
|
||||
"status-online",
|
||||
"status-away",
|
||||
"status-busy",
|
||||
"status-streaming",
|
||||
"status-invisible",
|
||||
"success",
|
||||
"warning",
|
||||
"error",
|
||||
"hover",
|
||||
] as const
|
||||
).map((key) => (
|
||||
<div
|
||||
class="entry"
|
||||
key={key}
|
||||
style={{ backgroundColor: theme.getVariable(key) }}>
|
||||
<div class="input">
|
||||
<input
|
||||
type="color"
|
||||
value={theme.getVariable(key)}
|
||||
onChange={(el) =>
|
||||
setVariable({
|
||||
key,
|
||||
value: el.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
color: theme.getContrastingVariable(
|
||||
key,
|
||||
theme.getVariable("primary-background"),
|
||||
),
|
||||
}}>
|
||||
{key}
|
||||
</span>
|
||||
<div class="override">
|
||||
<div
|
||||
class="picker"
|
||||
onClick={(e) =>
|
||||
e.currentTarget.parentElement?.parentElement
|
||||
?.querySelector("input")
|
||||
?.click()
|
||||
}>
|
||||
<Pencil size={24} />
|
||||
</div>
|
||||
<InputBox
|
||||
type="text"
|
||||
class="text"
|
||||
value={theme.getVariable(key)}
|
||||
onChange={(el) =>
|
||||
setVariable({
|
||||
key,
|
||||
value: el.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Brush } from "@styled-icons/boxicons-solid";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Link } from "react-router-dom";
|
||||
// @ts-expect-error shade-blend-color does not have typings.
|
||||
import pSBC from "shade-blend-color";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import { CategoryButton, ObservedInputElement } from "@revoltchat/ui";
|
||||
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
import { ThemeBaseSelector } from "./legacy/ThemeBaseSelector";
|
||||
|
||||
/**
|
||||
* ! LEGACY
|
||||
* Component providing a way to switch the base theme being used.
|
||||
*/
|
||||
export const ShimThemeBaseSelector = observer(() => {
|
||||
const theme = useApplicationState().settings.theme;
|
||||
return (
|
||||
<ThemeBaseSelector
|
||||
value={theme.isModified() ? undefined : theme.getBase()}
|
||||
setValue={(base) => {
|
||||
theme.setBase(base);
|
||||
theme.reset();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default function ThemeSelection() {
|
||||
const theme = useApplicationState().settings.theme;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/** Allow users to change base theme */}
|
||||
<ShimThemeBaseSelector />
|
||||
{/** Provide a link to the theme shop */}
|
||||
<Link to="/discover/themes" replace>
|
||||
<CategoryButton
|
||||
icon={<Brush size={24} />}
|
||||
action="chevron"
|
||||
description={
|
||||
<Text id="app.settings.pages.appearance.discover.description" />
|
||||
}>
|
||||
<Text id="app.settings.pages.appearance.discover.title" />
|
||||
</CategoryButton>
|
||||
</Link>
|
||||
<hr />
|
||||
<h3>
|
||||
<Text id="app.settings.pages.appearance.accent_selector" />
|
||||
</h3>
|
||||
<ObservedInputElement
|
||||
type="colour"
|
||||
value={theme.getVariable("accent")}
|
||||
onChange={(colour) => {
|
||||
theme.setVariable("accent", colour as string);
|
||||
theme.setVariable("scrollbar-thumb", pSBC(-0.2, colour));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+17
-9
@@ -5,10 +5,11 @@ import { Text } from "preact-i18n";
|
||||
|
||||
import { Button } from "@revoltchat/ui";
|
||||
|
||||
import { useApplicationState } from "../../../../mobx/State";
|
||||
import { useApplicationState } from "../../../mobx/State";
|
||||
|
||||
import { modalController } from "../../../../controllers/modals/ModalController";
|
||||
import Tooltip from "../../../common/Tooltip";
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
|
||||
import Tooltip from "../../common/Tooltip";
|
||||
|
||||
const Actions = styled.div`
|
||||
gap: 8px;
|
||||
@@ -37,6 +38,7 @@ const Actions = styled.div`
|
||||
`;
|
||||
|
||||
export default function ThemeTools() {
|
||||
const { writeClipboard, openScreen } = useIntermediate();
|
||||
const theme = useApplicationState().settings.theme;
|
||||
|
||||
return (
|
||||
@@ -53,10 +55,8 @@ export default function ThemeTools() {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="code"
|
||||
onClick={() =>
|
||||
modalController.writeText(JSON.stringify(theme))
|
||||
}>
|
||||
class="code"
|
||||
onClick={() => writeClipboard(JSON.stringify(theme))}>
|
||||
<Tooltip content={<Text id="app.special.copy" />}>
|
||||
{" "}
|
||||
{JSON.stringify(theme)}
|
||||
@@ -72,8 +72,16 @@ export default function ThemeTools() {
|
||||
const text = await navigator.clipboard.readText();
|
||||
theme.hydrate(JSON.parse(text));
|
||||
} catch (err) {
|
||||
modalController.push({
|
||||
type: "import_theme",
|
||||
openScreen({
|
||||
id: "_input",
|
||||
question: (
|
||||
<Text id="app.settings.pages.appearance.import_theme" />
|
||||
),
|
||||
field: (
|
||||
<Text id="app.settings.pages.appearance.theme_data" />
|
||||
),
|
||||
callback: async (text) =>
|
||||
theme.hydrate(JSON.parse(text)),
|
||||
});
|
||||
}
|
||||
}}>
|
||||
File renamed without changes.
@@ -1 +0,0 @@
|
||||
These components need to be ported to @revoltchat/ui.
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Pencil } from "@styled-icons/boxicons-regular";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import styled from "styled-components/macro";
|
||||
|
||||
import { InputBox } from "@revoltchat/ui";
|
||||
|
||||
import { useDebounceCallback } from "../../../../lib/debounce";
|
||||
|
||||
import { useApplicationState } from "../../../../mobx/State";
|
||||
|
||||
import { Variables } from "../../../../context/Theme";
|
||||
|
||||
const Container = styled.div`
|
||||
row-gap: 8px;
|
||||
display: grid;
|
||||
column-gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
margin-bottom: 20px;
|
||||
|
||||
.entry {
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid black;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 8px;
|
||||
text-transform: capitalize;
|
||||
|
||||
background: inherit;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
.override {
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
|
||||
.picker {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--primary-background);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
border: none;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
top: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(() => {
|
||||
const theme = useApplicationState().settings.theme;
|
||||
const setVariable = useDebounceCallback(
|
||||
(data) => {
|
||||
const { key, value } = data as { key: Variables; value: string };
|
||||
theme.setVariable(key, value);
|
||||
},
|
||||
[theme],
|
||||
100,
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{(
|
||||
[
|
||||
"accent",
|
||||
"background",
|
||||
"foreground",
|
||||
"primary-background",
|
||||
"primary-header",
|
||||
"secondary-background",
|
||||
"secondary-foreground",
|
||||
"secondary-header",
|
||||
"tertiary-background",
|
||||
"tertiary-foreground",
|
||||
"block",
|
||||
"message-box",
|
||||
"mention",
|
||||
"scrollbar-thumb",
|
||||
"scrollbar-track",
|
||||
"status-online",
|
||||
"status-away",
|
||||
"status-busy",
|
||||
"status-streaming",
|
||||
"status-invisible",
|
||||
"success",
|
||||
"warning",
|
||||
"error",
|
||||
"hover",
|
||||
] as const
|
||||
).map((key) => (
|
||||
<div
|
||||
className="entry"
|
||||
key={key}
|
||||
style={{ backgroundColor: theme.getVariable(key) }}>
|
||||
<div className="input">
|
||||
<input
|
||||
type="color"
|
||||
value={theme.getVariable(key)}
|
||||
onChange={(el) =>
|
||||
setVariable({
|
||||
key,
|
||||
value: el.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
color: theme.getContrastingVariable(
|
||||
key,
|
||||
theme.getVariable("primary-background"),
|
||||
),
|
||||
}}>
|
||||
{key}
|
||||
</span>
|
||||
<div className="override">
|
||||
<div
|
||||
className="picker"
|
||||
onClick={(e) =>
|
||||
e.currentTarget.parentElement?.parentElement
|
||||
?.querySelector("input")
|
||||
?.click()
|
||||
}>
|
||||
<Pencil size={24} />
|
||||
</div>
|
||||
<InputBox
|
||||
type="text"
|
||||
className="text"
|
||||
value={theme.getVariable(key)}
|
||||
onChange={(el) =>
|
||||
setVariable({
|
||||
key,
|
||||
value: el.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user