1 Commits

Author SHA1 Message Date
trashtemp
4889830c06 feat(profile): added statistics to bot profile 2021-12-21 17:03:51 +01:00
409 changed files with 17412 additions and 30595 deletions

View File

@@ -1 +0,0 @@
{ "plugins": [["@babel/plugin-proposal-decorators", { "legacy": true }]] }

View File

@@ -5,6 +5,3 @@ dist_injected
node_modules node_modules
.env .env
.env.local .env.local
Dockerfile
.dockerignore

5
.env
View File

@@ -1,5 +1,2 @@
# VITE_API_URL=https://api.revolt.chat 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://app.revolt.chat/api
VITE_THEMES_URL=https://themes.revolt.chat VITE_THEMES_URL=https://themes.revolt.chat

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
ko_fi: insertish
custom: https://insrt.uk/donate

View File

@@ -1,66 +0,0 @@
name: Bug report
description: File a bug report
title: "bug: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What did you expect to happen?
validations:
required: true
- type: dropdown
id: branch
attributes:
label: Branch
description: What branch of Revolt are you using?
options:
- Production (app.revolt.chat)
- Nightly (nightly.revolt.chat)
validations:
required: true
- type: textarea
id: commit-hash
attributes:
label: Commit hash
description: What is your commit hash? You can find this at the bottom of Settings, next to the branch name.
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- Other (please specify in the "What happened" form)
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. (To get this, press `CTRL`- or `CMD`-`SHIFT`-`I` and navigate to the "Console" tab.)
render: shell
- type: checkboxes
id: desktop
attributes:
label: Desktop
description: Is this bug specific to [the desktop client](https://github.com/revoltchat/desktop)? (If not, leave this unchecked.)
options:
- label: Yes, this bug is specific to Revolt Desktop and is *not* an issue with Revolt Desktop itself.
required: false
- type: checkboxes
id: pwa
attributes:
label: PWA
description: Is this bug specific to the PWA (i.e. "installing" the web app on iOS or Android)? (If not, leave this unchecked.)
options:
- label: Yes, this bug is specific to the PWA.
required: false

View File

@@ -1,7 +0,0 @@
contact_links:
- name: Lounge Chat
url: https://rvlt.gg/Testers
about: Ask questions and discuss with others.
- name: Discussions
url: https://github.com/orgs/revoltchat/discussions
about: For larger feature requests and general question & answer.

View File

@@ -1,24 +0,0 @@
name: Feature request
description: Make a feature request
title: "feature request: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Before you start, a lot of bigger features may be better suited as [API issues](https://github.com/revoltchat/delta/issues/new) or [centralised discussions](https://github.com/revoltchat/revolt/discussions/new).
- type: textarea
id: your-idea
attributes:
label: What do you want to see?
description: Describe your idea in as much detail as possible - if applicable, screenshots/mockups are really useful.
validations:
required: true
- type: checkboxes
id: pwa
attributes:
label: PWA
description: Is this feature request specific to the PWA (i.e. "installing" the web app on iOS or Android)? (If not, leave this unchecked.)
options:
- label: Yes, this feature request is specific to the PWA.
required: false

24
.github/SECURITY.md vendored Normal file
View File

@@ -0,0 +1,24 @@
# Security
## Reporting a Vulnerability
If you would like to report a security vulnerability,
please email **[security@revolt.chat](mailto:security@revolt.chat)**,
this will open a new ticket in ticket system, you should receive a response
within the next couple of days, potentially within a few minutes if someone
is currently active.
To help us best triage the issue, please provide:
- The type of issue at hand
- The name of the relevant project affected
- Reproduction steps
- Reference to any relevant source file(s) that you may suspect are causing the issue
- Any extra information about your configuration.
- Description of potential ways this can be exploited, if you can list any
For revoltchat/revite in particular:
- Please include the commit hash of the client, it is visible in settings under the log out button.
Thank you for helping Revolt.

View File

@@ -19,7 +19,7 @@ runs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 16 node-version: 15
cache: "yarn" cache: "yarn"
- name: Install Dependencies and Build - name: Install Dependencies and Build

View File

@@ -1,7 +0,0 @@
## Please make sure to check the following tasks before opening and submitting a PR
* [ ] I understand and have followed the [contribution guide](https://developers.revolt.chat/contrib.html)
* [ ] I have tested my changes locally and they are working as intended
* [ ] These changes do not have any notable side effects on other Revolt projects
* [ ] (optional) I have opened a pull request on [the translation repository](https://github.com/revoltchat/translations)
* [ ] I have included screenshots to demonstrate my changes

View File

@@ -5,7 +5,7 @@ on:
branches: branches:
- "master" - "master"
tags: tags:
- "*" - "v*"
paths-ignore: paths-ignore:
- ".github/**" - ".github/**"
- "!.github/workflows/docker.yml" - "!.github/workflows/docker.yml"
@@ -30,44 +30,82 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
architecture: [linux/amd64]
steps:
- name: Checkout
uses: actions/checkout@v2
with:
submodules: "recursive"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache/${{ matrix.architecture }}
key: ${{ runner.os }}-buildx-${{ matrix.architecture }}-${{ github.sha }}
- name: Build
uses: docker/build-push-action@v2
with:
context: .
platforms: ${{ matrix.architecture }}
cache-from: type=local,src=/tmp/.buildx-cache/${{ matrix.architecture }}
cache-to: type=local,dest=/tmp/.buildx-cache-new/${{ matrix.architecture }},mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache/${{ matrix.architecture }}
mv /tmp/.buildx-cache-new/${{ matrix.architecture }} /tmp/.buildx-cache/${{ matrix.architecture }}
publish: publish:
needs: [test]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v2
with: with:
submodules: "recursive" submodules: "recursive"
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v1
- name: Cache amd64 Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache/linux/amd64
key: ${{ runner.os }}-buildx-linux/amd64-${{ github.sha }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v3
with: with:
images: revoltchat/client, ghcr.io/revoltchat/client images: revoltchat/client, ghcr.io/revoltchat/client
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
if: github.event_name != 'pull_request'
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Github Container Registry - name: Login to Github Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v1
if: github.event_name != 'pull_request'
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish - name: Build and publish
uses: docker/build-push-action@v6 uses: docker/build-push-action@v2
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache/linux/amd64
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

13
.github/workflows/mirroring.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Mirroring
on: [push, delete]
jobs:
to_gitlab:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v1
- uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url: git@gitlab.com:insert/revolt-vite.git
ssh_private_key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }}

39
.github/workflows/preview_cleanup.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Clean Preview
#! Safety:
#! this workflow should not execute any untrusted input at all
#! see the docs on `pull_request_target` for more
on:
pull_request_target:
types: [unlabeled]
jobs:
clean:
runs-on: ubuntu-latest
if: github.event.label.name == 'use-preview'
env:
BASE: refs/pull/${{ github.event.pull_request.number }}
steps:
- uses: actions/checkout@v2
with:
ref: build-previews
persist-credentials: false
- name: clean previews
run: rm -rf "$BASE"
- name: publish cleaned previews
uses: JamesIves/github-pages-deploy-action@4.1.5
with:
folder: .
branch: build-previews
commit-message: "Cleaning up build result for #${{ github.event.pull_request.number }}"
- name: send comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: Preview environment
message: |
## Preview environment
There is no longer a preview enviroment for this pull request due to the `use-preview` label being removed

View File

@@ -0,0 +1,52 @@
name: Preview Pull Request
#! Safety:
#! this workflow should not execute any untrusted input at all
#! see the docs on `pull_request_target` for more
on:
pull_request_target:
types: [synchronize, reopened, labeled]
jobs:
preview:
runs-on: ubuntu-latest
# make sure the pull request is labeled with 'use-preview'
if: github.event.label.name == 'use-preview' || contains(github.event.pull_request.labels.*.name, 'use-preview')
env:
BASE: refs/pull/${{ github.event.pull_request.number }}/merge
REPO: ${{ github.event.repository.name }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v2
with:
# Head commit of the pull request
ref: ${{ github.event.pull_request.head.sha }}
path: pull
submodules: recursive
- name: build
uses: ./.github/actions/build
with:
base: /${{ env.REPO }}/${{ env.BASE }}/
folder: pull
- name: publish preview
uses: JamesIves/github-pages-deploy-action@4.1.5
with:
folder: pull/dist
branch: build-previews
target-folder: ${{ env.BASE }}
single-commit: true
commit-message: "Publishing build result from #${{ github.event.pull_request.number }}"
- name: send comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: Preview environment
message: |
## Preview environment
https://${{ github.repository_owner }}.github.io/${{ env.REPO }}/${{ env.BASE }}/
This link will remain active until the `use-preview` label is removed.

View File

@@ -15,27 +15,22 @@ jobs:
gh api graphql -f query=' gh api graphql -f query='
query { query {
organization(login: "revoltchat"){ organization(login: "revoltchat"){
projectV2(number: 3) { projectNext(number: 3) {
id id
fields(first:20) { fields(first:20) {
nodes { nodes {
... on ProjectV2SingleSelectField { id
id name
name settings
options {
id
name
}
}
} }
} }
} }
} }
}' > project_data.json }' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.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.projectV2.fields.nodes[] | select(.name== "Status") | .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.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.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 - name: Add issue to project
env: env:
@@ -44,11 +39,11 @@ jobs:
run: | run: |
item_id="$( gh api graphql -f query=' item_id="$( gh api graphql -f query='
mutation($project:ID!, $issue:ID!) { mutation($project:ID!, $issue:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) { addProjectNextItem(input: {projectId: $project, contentId: $issue}) {
item { projectNextItem {
id 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 echo 'ITEM_ID='$item_id >> $GITHUB_ENV

View File

@@ -2,7 +2,7 @@ name: Add PR to Board
on: on:
pull_request_target: pull_request_target:
types: [opened, synchronize, ready_for_review, review_requested] types: [opened]
jobs: jobs:
track_pr: track_pr:
@@ -15,27 +15,22 @@ jobs:
gh api graphql -f query=' gh api graphql -f query='
query { query {
organization(login: "revoltchat"){ organization(login: "revoltchat"){
projectV2(number: 5) { projectNext(number: 3) {
id id
fields(first:20) { fields(first:20) {
nodes { nodes {
... on ProjectV2SingleSelectField { id
id name
name settings
options {
id
name
}
}
} }
} }
} }
} }
}' > project_data.json }' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.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.projectV2.fields.nodes[] | select(.name== "Status") | .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.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="🆕 Untriaged") |.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 - name: Add PR to project
env: env:
@@ -44,13 +39,13 @@ jobs:
run: | run: |
item_id="$( gh api graphql -f query=' item_id="$( gh api graphql -f query='
mutation($project:ID!, $pr:ID!) { mutation($project:ID!, $pr:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) { addProjectNextItem(input: {projectId: $project, contentId: $pr}) {
item { projectNextItem {
id 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 echo 'ITEM_ID='$item_id >> $GITHUB_ENV
- name: Set fields - name: Set fields
@@ -64,16 +59,14 @@ jobs:
$status_field: ID! $status_field: ID!
$status_value: String! $status_value: String!
) { ) {
set_status: updateProjectV2ItemFieldValue(input: { set_status: updateProjectNextItemField(input: {
projectId: $project projectId: $project
itemId: $item itemId: $item
fieldId: $status_field fieldId: $status_field
value: { value: $status_value
singleSelectOptionId: $status_value
}
}) { }) {
projectV2Item { projectNextItem {
id id
} }
} }
}' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent }' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent

5
.gitignore vendored
View File

@@ -7,11 +7,6 @@ dist-ssr
*.log *.log
/.idea /.idea
.yarn/cache
.yarn/install-state.gz
public/assets public/assets
public/assets_* public/assets_*
!public/assets_default !public/assets_default
.vscode/chrome_data

40
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,40 @@
image: node:14-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

6
.gitmodules vendored
View File

@@ -1,9 +1,3 @@
[submodule "external/lang"] [submodule "external/lang"]
path = external/lang path = external/lang
url = https://github.com/revoltchat/translations 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

View File

@@ -1 +0,0 @@
src/components/markdown/prism.ts

View File

@@ -4,12 +4,10 @@ module.exports = {
jsxBracketSameLine: true, jsxBracketSameLine: true,
importOrder: [ importOrder: [
"preact|classnames|.scss$", "preact|classnames|.scss$",
"^@revoltchat",
"/(lib)", "/(lib)",
"/(redux|mobx)", "/(redux|mobx)",
"/(context)", "/(context)",
"/(ui|common)$", "/(ui|common)|.svg|.webp|.png|.jpg$",
".svg|.webp|.png|.jpg$",
"^[./]", "^[./]",
], ],
importOrderSeparation: true, importOrderSeparation: true,

17
.vscode/launch.json vendored
View File

@@ -1,17 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://local.revolt.chat:3000",
"webRoot": "${workspaceFolder}",
"runtimeExecutable": "/usr/bin/chromium",
"userDataDir": "${workspaceFolder}/.vscode/chrome_data"
}
]
}

View File

@@ -1,4 +1,5 @@
{ {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true,
"compile-hero.disable-compile-files-on-did-save-code": true
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +0,0 @@
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
spec: "@yarnpkg/plugin-typescript"
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
yarnPath: .yarn/releases/yarn-3.2.0.cjs

View File

@@ -1,21 +1,20 @@
# syntax=docker.io/docker/dockerfile:1.7-labs FROM node:16-buster AS builder
FROM --platform=$BUILDPLATFORM node:16-buster AS builder
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package*.json ./
RUN yarn --no-cache
COPY . . COPY . .
COPY .env.build .env COPY .env.build .env
RUN yarn add --dev @babel/plugin-proposal-decorators
RUN yarn typecheck
RUN yarn build
RUN npm prune --production
RUN yarn install --frozen-lockfile FROM node:16-buster
RUN yarn build:deps
# RUN yarn typecheck # lol no
RUN yarn build:highmem
RUN yarn workspaces focus --production --all
FROM node:24-alpine
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY docker/package.json docker/yarn.lock . COPY --from=builder /usr/src/app .
RUN yarn install --frozen-lockfile
COPY --from=builder --exclude=package.json --exclude=yarn.lock --exclude=.yarn* --exclude=.git --exclude=external --exclude=node_modules /usr/src/app .
EXPOSE 5000 EXPOSE 5000
CMD [ "yarn", "start:inject" ] CMD [ "yarn", "start:inject" ]

View File

@@ -1,44 +1,9 @@
# Deprecation Notice
This project is deprecated, however it still may receive maintenance updates.
PRs for small fixes are more than welcome.
## Deploying a new release
Ensure `.env.local` points to `https://app.revolt.chat/api`.
```bash
cd ~/deployments/revite
git pull
git submodule update
# check:
git status
export REVOLT_SAAS_BRANCH=revite/main
export REMOTE=root@production
scripts/publish.sh
# SSH in and restart revite:
ssh $REMOTE
tmux a -t 4
```
# Revite # Revite
## Description ## Description
This is the web client for Revolt, which is also available live at [app.revolt.chat](https://app.revolt.chat). 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 ## Stack
- [Preact](https://preactjs.com/) - [Preact](https://preactjs.com/)
@@ -70,27 +35,22 @@ Get revite up and running locally.
git clone --recursive https://github.com/revoltchat/revite git clone --recursive https://github.com/revoltchat/revite
cd revite cd revite
yarn yarn
yarn build:deps
yarn dev yarn dev
``` ```
You can now access the client at http://local.revolt.chat:3000.
## CLI Commands ## CLI Commands
| Command | Description | | Command | Description |
| --------------------------------------- | -------------------------------------------- | | ------------------- | -------------------------------------------- |
| `yarn pull` | Setup assets required for Revite. | | `yarn pull` | Setup assets required for Revite. |
| `yarn dev` | Start the Revolt client in development mode. | | `yarn dev` | Start the Revolt client in development mode. |
| `yarn build` | Build the Revolt client. | | `yarn build` | Build the Revolt client. |
| `yarn build:deps` | Build external dependencies. | | `yarn preview` | Start a local server with the built client. |
| `yarn preview` | Start a local server with the built client. | | `yarn lint` | Run ESLint on the client. |
| `yarn lint` | Run ESLint on the client. | | `yarn fmt` | Run Prettier on the client. |
| `yarn fmt` | Run Prettier on the client. | | `yarn typecheck` | Run TypeScript type checking on the client. |
| `yarn typecheck` | Run TypeScript type checking on the client. | | `yarn start` | Start a local sirv server with built client. |
| `yarn start` | Start a local sirv server with built client. | | `yarn start:inject` | Inject a given API URL and start server. |
| `yarn start:inject` | Inject a given API URL and start server. |
| `yarn lint \| egrep "no-literals" -B 1` | Scan for untranslated strings. |
## License ## License

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.5.3-1

View File

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

View File

@@ -1,16 +0,0 @@
{
"name": "inject-and-sirv",
"version": "0.0.1",
"scripts": {
"start:inject": "node scripts/inject.js && sirv dist_injected --port 5000 --cors --single --host"
},
"private": true,
"dependencies": {
"fs-extra": "^11.3.0",
"klaw": "^4.1.0",
"sirv-cli": "^3.0.1"
},
"engines": {
"node": ">=18"
}
}

View File

@@ -1,116 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@polka/url@^1.0.0-next.24":
version "1.0.0-next.29"
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==
console-clear@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/console-clear/-/console-clear-1.1.1.tgz#995e20cbfbf14dd792b672cde387bd128d674bf7"
integrity sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==
fs-extra@^11.3.0:
version "11.3.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d"
integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^6.0.1"
universalify "^2.0.0"
get-port@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193"
integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
graceful-fs@^4.1.6, graceful-fs@^4.2.0:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
jsonfile@^6.0.1:
version "6.1.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==
dependencies:
universalify "^2.0.0"
optionalDependencies:
graceful-fs "^4.1.6"
klaw@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/klaw/-/klaw-4.1.0.tgz#5df608067d8cb62bbfb24374f8e5d956323338f3"
integrity sha512-1zGZ9MF9H22UnkpVeuaGKOjfA2t6WrfdrJmGjy16ykcjnKQDmHVX+KI477rpbGevz/5FD4MC3xf1oxylBgcaQw==
kleur@^4.1.4:
version "4.1.5"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
local-access@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/local-access/-/local-access-1.1.0.tgz#e007c76ba2ca83d5877ba1a125fc8dfe23ba4798"
integrity sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==
mri@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
mrmime@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc"
integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==
sade@^1.6.0:
version "1.8.1"
resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"
integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==
dependencies:
mri "^1.1.0"
semiver@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/semiver/-/semiver-1.1.0.tgz#9c97fb02c21c7ce4fcf1b73e2c7a24324bdddd5f"
integrity sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==
sirv-cli@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/sirv-cli/-/sirv-cli-3.0.1.tgz#9a53e4fa85fdc08d54a76fd76a7c866cd4c3988b"
integrity sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==
dependencies:
console-clear "^1.1.0"
get-port "^5.1.1"
kleur "^4.1.4"
local-access "^1.0.1"
sade "^1.6.0"
semiver "^1.0.0"
sirv "^3.0.0"
tinydate "^1.0.0"
sirv@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.1.tgz#32a844794655b727f9e2867b777e0060fbe07bf3"
integrity sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==
dependencies:
"@polka/url" "^1.0.0-next.24"
mrmime "^2.0.0"
totalist "^3.0.0"
tinydate@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/tinydate/-/tinydate-1.3.0.tgz#e6ca8e5a22b51bb4ea1c3a2a4fd1352dbd4c57fb"
integrity sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==
totalist@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"
integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==
universalify@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==

1
external/components vendored

Submodule external/components deleted from 4be02430c7

2
external/lang vendored

1
external/revolt.js vendored

Submodule external/revolt.js deleted from a45710f80c

View File

@@ -1,13 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" background="#191919"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!--App Title-->
<title>Revolt</title> <title>Revolt</title>
<meta name="apple-mobile-web-app-title" content="Revolt" /> <meta name="apple-mobile-web-app-title" content="Revolt" />
<!--App Scaling-->
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no" content="width=device-width, initial-scale=1.0, user-scalable=no"
@@ -72,72 +69,14 @@
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)"
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
/> />
<!--CSS for noscript screen-->
<style>
noscript {
background: #242424;
color: white;
position: fixed;
top: 0;
left: 0;
width: 100vw;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
noscript > div {
padding: 12px;
display: flex;
font-family: "Open Sans", sans-serif;
flex-direction: column;
justify-content: center;
text-align: center;
}
noscript > div > h1 {
margin: 8px 0;
text-transform: uppercase;
font-size: 20px;
font-weight: 700;
}
noscript > div > p {
margin: 4px 0;
font-size: 14px;
}
noscript > div > a {
align-self: center;
margin-top: 20px;
padding: 8px 10px;
font-size: 14px;
width: 80px;
font-weight: 600;
background: #ed5151;
border-radius: 4px;
text-decoration: none;
color: white;
transition: background-color 0.2s;
}
noscript > div > a:hover {
background-color: #cf4848;
}
noscript > div > a:active {
background-color: #b64141;
}
</style>
</head> </head>
<body> <body onContextMenu="return false" ontouchstart="">
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<noscript>
<div>
<img src="disabled-js.svg" />
<h1>Well, this is really awkward...</h1>
<p>Seems like your JavaScript is disabled.</p>
<p>You'll need to enable JavaScript to run this app.</p>
<a href="https://app.revolt.chat" target="_blank">Reload</a>
</div>
</noscript>
</body> </body>
<style>
html {
background-color: #191919;
}
</style>
</html> </html>

View File

@@ -1,13 +1,11 @@
{ {
"version": "1.0.1", "version": "0.0.0",
"scripts": { "scripts": {
"dev": "node scripts/setup_assets.js --check && vite", "dev": "node scripts/setup_assets.js --check && vite",
"pull": "node scripts/setup_assets.js", "pull": "node scripts/setup_assets.js",
"build:deps": "cd external && cd components && yarn && yarn build:esm && cd .. && cd revolt.js && yarn && yarn build", "build": "rimraf build && node scripts/setup_assets.js --check && vite build",
"build": "yarn && rimraf build && node scripts/setup_assets.js --check && yarn build:deps && vite build",
"build:highmem": "NODE_OPTIONS='--max-old-space-size=4096' yarn build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src/**/*.{js,jsx,ts,tsx}", "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'", "fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"start": "sirv dist --cors --single --host", "start": "sirv dist --cors --single --host",
@@ -39,34 +37,27 @@
{ {
"varsIgnorePattern": "^_" "varsIgnorePattern": "^_"
} }
], ]
"react/jsx-no-literals": "warn"
} }
}, },
"dependencies": { "dependencies": {
"@revoltchat/rehype-katex": "6.0.3-patch.1",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"klaw": "^3.0.0", "klaw": "^3.0.0",
"lottie-react": "^2.4.0", "react-beautiful-dnd": "^13.1.0",
"sirv-cli": "^1.0.14", "sirv-cli": "^1.0.14",
"vite": "^3.0.5" "vite": "^2.6.14"
}, },
"devDependencies": { "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/atkinson-hyperlegible": "^4.4.5",
"@fontsource/bitter": "^4.5.7", "@fontsource/bree-serif": "^4.4.5",
"@fontsource/comic-neue": "^4.4.5", "@fontsource/comic-neue": "^4.4.5",
"@fontsource/fira-code": "^4.4.5", "@fontsource/fira-code": "^4.4.5",
"@fontsource/inter": "^4.4.5", "@fontsource/inter": "^4.4.5",
"@fontsource/jetbrains-mono": "^4.4.5", "@fontsource/jetbrains-mono": "^4.4.5",
"@fontsource/lato": "^4.4.5", "@fontsource/lato": "^4.4.5",
"@fontsource/lexend": "^4.5.2",
"@fontsource/montserrat": "^4.4.5", "@fontsource/montserrat": "^4.4.5",
"@fontsource/noto-sans": "^4.4.5", "@fontsource/noto-sans": "^4.4.5",
"@fontsource/open-sans": "^4.5.2", "@fontsource/open-sans": "^4.4.5",
"@fontsource/opendyslexic": "^4.5.2",
"@fontsource/poppins": "^4.4.5", "@fontsource/poppins": "^4.4.5",
"@fontsource/raleway": "^4.4.5", "@fontsource/raleway": "^4.4.5",
"@fontsource/roboto": "^4.4.5", "@fontsource/roboto": "^4.4.5",
@@ -75,102 +66,79 @@
"@fontsource/space-mono": "^4.4.5", "@fontsource/space-mono": "^4.4.5",
"@fontsource/ubuntu": "^4.4.5", "@fontsource/ubuntu": "^4.4.5",
"@fontsource/ubuntu-mono": "^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", "@preact/preset-vite": "^2.0.0",
"@revoltchat/ui": "^1.0.77",
"@rollup/plugin-replace": "^2.4.2", "@rollup/plugin-replace": "^2.4.2",
"@styled-icons/boxicons-logos": "^10.38.0", "@styled-icons/boxicons-logos": "^10.34.0",
"@styled-icons/boxicons-regular": "^10.38.0", "@styled-icons/boxicons-regular": "^10.34.0",
"@styled-icons/boxicons-solid": "^10.38.0", "@styled-icons/boxicons-solid": "^10.37.0",
"@styled-icons/simple-icons": "^10.45.0", "@styled-icons/simple-icons": "^10.33.0",
"@tippyjs/react": "4.2.6", "@tippyjs/react": "^4.2.5",
"@traptitech/markdown-it-katex": "^3.4.3", "@traptitech/markdown-it-katex": "^3.4.3",
"@traptitech/markdown-it-spoiler": "^1.1.6", "@traptitech/markdown-it-spoiler": "^1.1.6",
"@trivago/prettier-plugin-sort-imports": "^2.0.2", "@trivago/prettier-plugin-sort-imports": "^2.0.2",
"@types/lodash": "^4",
"@types/lodash.defaultsdeep": "^4.6.6", "@types/lodash.defaultsdeep": "^4.6.6",
"@types/lodash.isequal": "^4.5.5", "@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/preact-i18n": "^2.3.0",
"@types/prismjs": "^1.26.0", "@types/prismjs": "^1.16.5",
"@types/react-beautiful-dnd": "^13", "@types/react-beautiful-dnd": "^13.1.2",
"@types/react-helmet": "^6.1.1", "@types/react-helmet": "^6.1.1",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"@types/react-scroll": "^1.8.2", "@types/react-scroll": "^1.8.2",
"@types/semver": "^7", "@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/styled-components": "^5.1.10", "@types/styled-components": "^5.1.10",
"@types/twemoji": "^12.1.1", "@types/twemoji": "^12.1.1",
"@typescript-eslint/eslint-plugin": "^4.27.0", "@typescript-eslint/eslint-plugin": "^4.27.0",
"@typescript-eslint/parser": "^4.27.0", "@typescript-eslint/parser": "^4.27.0",
"@vitejs/plugin-legacy": "^1.7.1",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"color-rgba": "^2.4.0",
"dayjs": "^1.10.6", "dayjs": "^1.10.6",
"detect-browser": "^5.2.0", "detect-browser": "^5.2.0",
"eslint": "^7.28.0", "eslint": "^7.28.0",
"eslint-config-preact": "^1.1.4", "eslint-config-preact": "^1.1.4",
"eslint-plugin-jsdoc": "^39.3.2",
"eslint-plugin-mobx": "^0.0.8",
"eventemitter3": "^4.0.7", "eventemitter3": "^4.0.7",
"history": "4", "highlight.js": "^11.0.1",
"json-stringify-deterministic": "^1.0.2",
"localforage": "^1.9.0", "localforage": "^1.9.0",
"lodash": "^4.17.21",
"lodash.defaultsdeep": "^4.6.1", "lodash.defaultsdeep": "^4.6.1",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"long": "^5.2.0", "markdown-it": "^12.0.6",
"mdast-util-to-hast": "^12.1.2", "markdown-it-emoji": "^2.0.0",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"mediasoup-client": "npm:@insertish/mediasoup-client@3.6.36-esnext", "mediasoup-client": "npm:@insertish/mediasoup-client@3.6.36-esnext",
"mobx": "^6.6.0", "mobx": "^6.3.2",
"mobx-react-lite": "3.4.0", "mobx-react-lite": "^3.2.0",
"preact": "^10.5.14", "preact": "^10.5.14",
"preact-context-menu": "0.4.1", "preact-context-menu": "^0.2.1",
"preact-i18n": "^2.4.0-preactx", "preact-i18n": "^2.4.0-preactx",
"prettier": "^2.3.1", "prettier": "^2.3.1",
"prismjs": "^1.28.0", "prismjs": "^1.23.0",
"qrcode.react": "^3.0.2", "react-device-detect": "^1.17.0",
"react-beautiful-dnd": "^13.1.0",
"react-device-detect": "2.2.2",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-hook-form": "6.3.0", "react-hook-form": "6.3.0",
"react-overlapping-panels": "1.2.2", "react-overlapping-panels": "1.2.2",
"react-redux": "^7.2.4",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scroll": "^1.8.2", "react-scroll": "^1.8.2",
"react-virtuoso": "^2.12.0", "react-virtualized-auto-sizer": "^1.0.5",
"rehype-prism": "^2.1.3", "react-virtuoso": "^1.10.4",
"rehype-react": "^7.1.1", "redux": "^4.1.0",
"remark-breaks": "^3.0.2", "revolt-api": "0.5.3-alpha.10",
"remark-gfm": "^3.0.1", "revolt.js": "^5.1.0-alpha.10",
"remark-math": "^5.1.1",
"remark-parse": "^10.0.1",
"remark-rehype": "^10.1.0",
"revolt.js": "6.0.17",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sass": "^1.35.1", "sass": "^1.35.1",
"semver": "^7.3.7",
"shade-blend-color": "^1.0.0", "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", "styled-components": "^5.3.0",
"typescript": "^4.4.2", "typescript": "^4.4.2",
"ulid": "^2.3.0", "ulid": "^2.3.0",
"unified": "^10.1.2",
"unist-util-visit": "^4.1.0",
"use-resize-observer": "^7.0.0", "use-resize-observer": "^7.0.0",
"vite-plugin-pwa": "^0.12.3", "vite-plugin-pwa": "^0.8.1",
"workbox-precaching": "^6.1.5" "workbox-precaching": "^6.1.5"
}, },
"name": "client", "name": "client",
"main": "index.js", "main": "index.js",
"repository": "https://github.com/revoltchat/revite.git", "repository": "https://github.com/revoltchat/revite.git",
"author": "Paul <paulmakles@gmail.com>", "author": "Paul <paulmakles@gmail.com>",
"license": "MIT", "license": "MIT"
"packageManager": "yarn@3.2.0",
"resolutions": {
"@revoltchat/ui": "portal:external/components",
"revolt.js": "portal:external/revolt.js"
}
} }

View File

@@ -1,22 +1,10 @@
[ [{
{ "relation": ["delegate_permission/common.handle_all_urls"],
"relation": ["delegate_permission/common.handle_all_urls"], "target": {
"target": { "namespace": "android_app",
"namespace": "android_app", "package_name": "chat.revolt.app.twa",
"package_name": "chat.revolt.app.twa", "sha256_cert_fingerprints": [
"sha256_cert_fingerprints": [ "6E:62:C1:BF:5A:2D:11:31:A3:22:91:8D:22:2B:2C:49:D3:70:F3:A1:45:DF:11:6A:97:DC:4C:A9:3B:C3:AA:FB"
"6E:62:C1:BF:5A:2D:11:31:A3:22:91:8D:22:2B:2C:49:D3:70:F3:A1:45:DF:11:6A:97:DC:4C:A9:3B:C3:AA:FB" ]
]
}
},
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "chat.revolt.app.twa",
"sha256_cert_fingerprints": [
"2B:C7:89:87:BD:62:88:38:7B:C0:D7:5F:D1:10:F4:91:D5:24:A6:B3:25:3A:75:C2:3A:91:07:1B:63:C0:98:67"
]
}
} }
] }]

View File

@@ -1 +0,0 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="64.00001046823172" xmlns="http://www.w3.org/2000/svg" id="screenshot" version="1.1" viewBox="-0.000008942940667111543 -0.0000033745862566547657 64.00001046823172 64.00000545874563" height="64.00000545874563" style="-webkit-print-color-adjust: exact;"><g id="shape-d9b11490-3403-11ec-bc16-7b519797d558"><rect rx="0" ry="0" x="0" y="0" transform="matrix(1.0000000000000007,-8.726646259971662e-8,-1.5707963280665485e-7,1.0000000000000124,0.000005026548230091521,0.0000027925264056705146)" width="64" height="64" style="fill: rgb(255, 255, 255); fill-opacity: 1;"/></g></svg>

Before

Width:  |  Height:  |  Size: 626 B

View File

@@ -1 +0,0 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="64.00001046823172" xmlns="http://www.w3.org/2000/svg" id="screenshot" version="1.1" viewBox="-0.000008942940667111543 -0.0000033745862566547657 64.00001046823172 64.00000545874563" height="64.00000545874563" style="-webkit-print-color-adjust: exact;"><g id="shape-d9b11490-3403-11ec-bc16-7b519797d558"><rect rx="0" ry="0" x="0" y="0" transform="matrix(1.0000000000000007,-8.726646259971662e-8,-1.5707963280665485e-7,1.0000000000000124,0.000005026548230091521,0.0000027925264056705146)" width="64" height="64" style="fill: rgb(255, 255, 255); fill-opacity: 1;"/></g></svg>

Before

Width:  |  Height:  |  Size: 626 B

View File

@@ -1 +0,0 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="64.00001046823172" xmlns="http://www.w3.org/2000/svg" id="screenshot" version="1.1" viewBox="-0.000008942940667111543 -0.0000033745862566547657 64.00001046823172 64.00000545874563" height="64.00000545874563" style="-webkit-print-color-adjust: exact;"><g id="shape-d9b11490-3403-11ec-bc16-7b519797d558"><rect rx="0" ry="0" x="0" y="0" transform="matrix(1.0000000000000007,-8.726646259971662e-8,-1.5707963280665485e-7,1.0000000000000124,0.000005026548230091521,0.0000027925264056705146)" width="64" height="64" style="fill: rgb(255, 255, 255); fill-opacity: 1;"/></g></svg>

Before

Width:  |  Height:  |  Size: 626 B

View File

@@ -1 +0,0 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="64.00001046823172" xmlns="http://www.w3.org/2000/svg" id="screenshot" version="1.1" viewBox="-0.000008942940667111543 -0.0000033745862566547657 64.00001046823172 64.00000545874563" height="64.00000545874563" style="-webkit-print-color-adjust: exact;"><g id="shape-d9b11490-3403-11ec-bc16-7b519797d558"><rect rx="0" ry="0" x="0" y="0" transform="matrix(1.0000000000000007,-8.726646259971662e-8,-1.5707963280665485e-7,1.0000000000000124,0.000005026548230091521,0.0000027925264056705146)" width="64" height="64" style="fill: rgb(255, 255, 255); fill-opacity: 1;"/></g></svg>

Before

Width:  |  Height:  |  Size: 626 B

View File

@@ -1 +0,0 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="64.00001046823172" xmlns="http://www.w3.org/2000/svg" id="screenshot" version="1.1" viewBox="-0.000008942940667111543 -0.0000033745862566547657 64.00001046823172 64.00000545874563" height="64.00000545874563" style="-webkit-print-color-adjust: exact;"><g id="shape-d9b11490-3403-11ec-bc16-7b519797d558"><rect rx="0" ry="0" x="0" y="0" transform="matrix(1.0000000000000007,-8.726646259971662e-8,-1.5707963280665485e-7,1.0000000000000124,0.000005026548230091521,0.0000027925264056705146)" width="64" height="64" style="fill: rgb(255, 255, 255); fill-opacity: 1;"/></g></svg>

Before

Width:  |  Height:  |  Size: 626 B

View File

@@ -1 +0,0 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="64.00001046823172" xmlns="http://www.w3.org/2000/svg" id="screenshot" version="1.1" viewBox="-0.000008942940667111543 -0.0000033745862566547657 64.00001046823172 64.00000545874563" height="64.00000545874563" style="-webkit-print-color-adjust: exact;"><g id="shape-d9b11490-3403-11ec-bc16-7b519797d558"><rect rx="0" ry="0" x="0" y="0" transform="matrix(1.0000000000000007,-8.726646259971662e-8,-1.5707963280665485e-7,1.0000000000000124,0.000005026548230091521,0.0000027925264056705146)" width="64" height="64" style="fill: rgb(255, 255, 255); fill-opacity: 1;"/></g></svg>

Before

Width:  |  Height:  |  Size: 626 B

View File

@@ -1,6 +0,0 @@
<svg width="225" height="161" viewBox="0 0 225 161" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M69.6951 15.0002L20.9951 63.7002L69.6951 112.4L81.7601 100.325L45.1301 63.6952L81.7601 27.0652L69.6951 15.0002ZM132.955 112.39L181.655 63.6902L132.955 14.9902L120.89 27.0652L157.52 63.6952L120.89 100.325L132.955 112.39Z" fill="white"/>
<path d="M197 73H137C133.686 73 131 75.6863 131 79V139C131 142.314 133.686 145 137 145H197C200.314 145 203 142.314 203 139V79C203 75.6863 200.314 73 197 73Z" fill="#242424"/>
<path d="M191 79H143C139.686 79 137 81.6863 137 85V133C137 136.314 139.686 139 143 139H191C194.314 139 197 136.314 197 133V85C197 81.6863 194.314 79 191 79Z" fill="#D14F4F"/>
<path d="M181.5 95.5L153.5 123.5M153.5 95.5L181.5 123.5L153.5 95.5Z" stroke="white" stroke-width="5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 828 B

View File

@@ -1,22 +0,0 @@
const { readdirSync } = require("fs");
console.log(
"var locale_keys = " +
JSON.stringify([
...readdirSync("node_modules/dayjs/locale")
.filter((x) => x.endsWith(".js"))
.map((x) => {
v = x.split(".");
v.pop();
return v.join(".");
}),
...readdirSync("external/lang")
.filter((x) => x.endsWith(".json"))
.map((x) => {
v = x.split(".");
v.pop();
return v.join(".");
}),
]) +
";",
);

View File

@@ -1,35 +1,7 @@
#!/bin/bash #!/bin/bash
# Build and publish release to production server version=$(cat VERSION)
# Remote Server
if [ -z "$REMOTE" ]; then
echo "Please set REMOTE!"
exit
fi
# Remote Directory
REMOTE_DIR=/root/deployments/revite
# Post-install script
POST_INSTALL=""
# Assets
export REVOLT_SAAS=https://github.com/revoltchat/assets
# Exit when any command fails
set -e
# 1. Build Revite
yarn build:highmem
# 2. Archive built files
tar -czvf build.tar.gz dist
# 3. Upload built files
scp build.tar.gz $REMOTE:$REMOTE_DIR/build.tar.gz
rm build.tar.gz
# 4. Apply changes
ssh $REMOTE "cd $REMOTE_DIR; tar -xvzf build.tar.gz; rm build.tar.gz; $POST_INSTALL"
docker build -t revoltchat/client:${version} . &&
docker tag revoltchat/client:${version} revoltchat/client:latest &&
docker push revoltchat/client:${version} &&
docker push revoltchat/client:latest

View File

@@ -3,8 +3,8 @@ const { copy, remove, access } = require("fs-extra");
const { exec: cexec } = require("child_process"); const { exec: cexec } = require("child_process");
const { resolve } = require("path"); const { resolve } = require("path");
let target = process.env.REVOLT_SAAS; let target = process.env.REVOLT_SASS;
let branch = process.env.REVOLT_SAAS_BRANCH; let branch = process.env.REVOLT_SASS_BRANCH;
let DEFAULT_DIRECTORY = "public/assets_default"; let DEFAULT_DIRECTORY = "public/assets_default";
let OUT_DIRECTORY = "public/assets"; let OUT_DIRECTORY = "public/assets";

View File

@@ -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;

View File

@@ -151,6 +151,7 @@ export const emojiDictionary = {
hole: "🕳️", hole: "🕳️",
bomb: "💣", bomb: "💣",
speech_balloon: "💬", speech_balloon: "💬",
eye_speech_bubble: "👁️‍🗨️",
left_speech_bubble: "🗨️", left_speech_bubble: "🗨️",
right_anger_bubble: "🗯️", right_anger_bubble: "🗯️",
thought_balloon: "💭", thought_balloon: "💭",
@@ -672,7 +673,6 @@ export const emojiDictionary = {
mandarin: "🍊", mandarin: "🍊",
lemon: "🍋", lemon: "🍋",
banana: "🍌", banana: "🍌",
nanner: "🍌",
pineapple: "🍍", pineapple: "🍍",
mango: "🥭", mango: "🥭",
apple: "🍎", apple: "🍎",
@@ -876,7 +876,6 @@ export const emojiDictionary = {
train: "🚋", train: "🚋",
bus: "🚌", bus: "🚌",
oncoming_bus: "🚍", oncoming_bus: "🚍",
trolley: "🚎",
trolleybus: "🚎", trolleybus: "🚎",
minibus: "🚐", minibus: "🚐",
ambulance: "🚑", ambulance: "🚑",
@@ -1848,109 +1847,4 @@ export const emojiDictionary = {
england: "🏴󠁧󠁢󠁥󠁮󠁧󠁿", england: "🏴󠁧󠁢󠁥󠁮󠁧󠁿",
scotland: "🏴󠁧󠁢󠁳󠁣󠁴󠁿", scotland: "🏴󠁧󠁢󠁳󠁣󠁴󠁿",
wales: "🏴󠁧󠁢󠁷󠁬󠁳󠁿", wales: "🏴󠁧󠁢󠁷󠁬󠁳󠁿",
// ...{
// 1984: "custom:1984.gif",
// KekW: "custom:KekW.png",
// amogus: "custom:amogus.gif",
// awaa: "custom:awaa.png",
// boohoo: "custom:boohoo.png",
// boohoo_goes_hard: "custom:boohoo_goes_hard.png",
// boohoo_shaken: "custom:boohoo_shaken.png",
// cat_arrival: "custom:cat_arrival.gif",
// cat_awson: "custom:cat_awson.png",
// cat_blob: "custom:cat_blob.png",
// cat_bonk: "custom:cat_bonk.png",
// cat_concern: "custom:cat_concern.png",
// cat_fast: "custom:cat_fast.gif",
// cat_kitty: "custom:cat_kitty.png",
// cat_lick: "custom:cat_lick.gif",
// cat_not_like: "custom:cat_not_like.png",
// cat_put: "custom:cat_put.gif",
// cat_pwease: "custom:cat_pwease.png",
// cat_rage: "custom:cat_rage.png",
// cat_sad: "custom:cat_sad.png",
// cat_snuff: "custom:cat_snuff.gif",
// cat_spin: "custom:cat_spin.gif",
// cat_squish: "custom:cat_squish.gif",
// cat_stare: "custom:cat_stare.gif",
// cat_steal: "custom:cat_steal.gif",
// cat_sussy: "custom:cat_sussy.gif",
// clueless: "custom:clueless.png",
// death: "custom:death.gif",
// developers: "custom:developers.gif",
// fastwawa: "custom:fastwawa.gif",
// ferris: "custom:ferris.png",
// ferris_bongo: "custom:ferris_bongo.gif",
// ferris_nom: "custom:ferris_nom.png",
// ferris_pensive: "custom:ferris_pensive.png",
// ferris_unsafe: "custom:ferris_unsafe.png",
// flesh: "custom:flesh.png",
// flooshed: "custom:flooshed.png",
// flosh: "custom:flosh.png",
// flushee: "custom:flushee.png",
// forgor: "custom:forgor.png",
// hollow: "custom:hollow.png",
// john: "custom:john.png",
// lightspeed: "custom:lightspeed.png",
// little_guy: "custom:little_guy.png",
// lmaoooo: "custom:lmaoooo.gif",
// lol: "custom:lol.png",
// looking: "custom:looking.gif",
// marie: "custom:marie.png",
// marie_furret: "custom:marie_furret.gif",
// marie_smug: "custom:marie_smug.png",
// megumin: "custom:megumin.png",
// michi_above: "custom:michi_above.png",
// michi_awww: "custom:michi_awww.gif",
// michi_drag: "custom:michi_drag.gif",
// michi_flustered: "custom:michi_flustered.png",
// michi_glare: "custom:michi_glare.png",
// michi_sus: "custom:michi_sus.png",
// monkaS: "custom:monkaS.png",
// monkaStare: "custom:monkaStare.png",
// monkey_grr: "custom:monkey_grr.png",
// monkey_pensive: "custom:monkey_pensive.png",
// monkey_zany: "custom:monkey_zany.png",
// nazu_sit: "custom:nazu_sit.png",
// nazu_sus: "custom:nazu_sus.png",
// ok_and: "custom:ok_and.gif",
// owo: "custom:owo.png",
// pat: "custom:pat.png",
// pointThink: "custom:pointThink.png",
// rainbowHype: "custom:rainbowHype.gif",
// rawr: "custom:rawr.png",
// rember: "custom:rember.png",
// revolt: "custom:revolt.png",
// sickly: "custom:sickly.png",
// stare: "custom:stare.png",
// tfyoulookingat: "custom:tfyoulookingat.png",
// thanks: "custom:thanks.png",
// thonk: "custom:thonk.png",
// trol: "custom:trol.png",
// troll_smile: "custom:troll_smile.gif",
// uber: "custom:uber.png",
// ubertroll: "custom:ubertroll.png",
// verycool: "custom:verycool.png",
// verygood: "custom:verygood.png",
// wawafast: "custom:wawafast.gif",
// wawastance: "custom:wawastance.png",
// yeahokayyy: "custom:yeahokayyy.png",
// yed: "custom:yed.png",
// yems: "custom:yems.png",
// michael: "custom:michael.gif",
// 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",
// girl_happy: "custom:girl_happy.png",
// hug_plushie: "custom:hug_plushie.png",
// huggies: "custom:huggies.png",
// noted: "custom:noted.gif",
// waving: "custom:waving.png",
// mogusvented: "custom:mogusvented.png",
// },
}; };

View File

@@ -0,0 +1,29 @@
import call_join from "./call_join.mp3";
import call_leave from "./call_leave.mp3";
import message from "./message.mp3";
import outbound from "./outbound.mp3";
const SoundMap: { [key in Sounds]: string } = {
message,
outbound,
call_join,
call_leave,
};
export type Sounds = "message" | "outbound" | "call_join" | "call_leave";
export const SOUNDS_ARRAY: Sounds[] = [
"message",
"outbound",
"call_join",
"call_leave",
];
export function playSound(sound: Sounds) {
const file = SoundMap[sound];
const el = new Audio(file);
try {
el.play();
} catch (err) {
console.error("Failed to play audio file", file, err);
}
}

View File

@@ -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`

View File

@@ -1,15 +1,17 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { Channel } from "revolt.js"; import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components/macro"; import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Button, Checkbox, Preloader } from "@revoltchat/ui"; import { dispatch, getState } from "../../redux";
import { useApplicationState } from "../../mobx/State"; import Button from "../ui/Button";
import { SECTION_NSFW } from "../../mobx/stores/Layout"; import Checkbox from "../ui/Checkbox";
import { Children } from "../../types/Preact";
const Base = styled.div` const Base = styled.div`
display: flex; display: flex;
@@ -45,36 +47,16 @@ type Props = {
channel: Channel; channel: Channel;
}; };
let geoBlock:
| undefined
| {
countryCode: string;
isAgeRestrictedGeo: true;
};
export default observer((props: Props) => { export default observer((props: Props) => {
const history = useHistory(); const history = useHistory();
const layout = useApplicationState().layout; const [consent, setConsent] = useState(
const [geoLoaded, setGeoLoaded] = useState(typeof geoBlock !== "undefined"); getState().sectionToggle["nsfw"] ?? false,
);
const [ageGate, setAgeGate] = useState(false); const [ageGate, setAgeGate] = useState(false);
useEffect(() => { if (ageGate || !props.gated) {
if (!geoLoaded) {
fetch("https://geo.revolt.chat")
.then((res) => res.json())
.then((data) => {
geoBlock = data;
setGeoLoaded(true);
});
}
}, []);
if (!geoBlock) return <Preloader type="spinner" />;
if ((ageGate && !geoBlock.isAgeRestrictedGeo) || !props.gated) {
return <>{props.children}</>; return <>{props.children}</>;
} }
if ( if (
!( !(
props.channel.channel_type === "Group" || props.channel.channel_type === "Group" ||
@@ -98,40 +80,30 @@ export default observer((props: Props) => {
</a> </a>
</span> </span>
{geoBlock.isAgeRestrictedGeo ? ( <Checkbox
<div style={{ maxWidth: "420px", textAlign: "center" }}> checked={consent}
{geoBlock.countryCode === "GB" onChange={(v) => {
? "This channel is not available in your region while we review options on legal compliance." setConsent(v);
: "This content is not available in your region."} if (v) {
</div> dispatch({
) : ( type: "SECTION_TOGGLE_SET",
<> id: "nsfw",
<Checkbox state: true,
title={<Text id="app.main.channel.nsfw.confirm" />} });
value={layout.getSectionState(SECTION_NSFW, false)} } else {
onChange={() => dispatch({ type: "SECTION_TOGGLE_UNSET", id: "nsfw" });
layout.toggleSectionState(SECTION_NSFW, false) }
} }}>
/> <Text id="app.main.channel.nsfw.confirm" />
<div className="actions"> </Checkbox>
<Button <div className="actions">
palette="secondary" <Button contrast onClick={() => history.goBack()}>
onClick={() => history.goBack()}> <Text id="app.special.modals.actions.back" />
<Text id="app.special.modals.actions.back" /> </Button>
</Button> <Button contrast onClick={() => consent && setAgeGate(true)}>
<Button <Text id={`app.main.channel.nsfw.${props.type}.confirm`} />
palette="secondary" </Button>
onClick={() => </div>
layout.getSectionState(SECTION_NSFW) &&
setAgeGate(true)
}>
<Text
id={`app.main.channel.nsfw.${props.type}.confirm`}
/>
</Button>
</div>
</>
)}
</Base> </Base>
); );
}); });

View File

@@ -1,35 +1,32 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ import { Channel } from "revolt.js/dist/maps/Channels";
import { Link } from "react-router-dom"; import { User } from "revolt.js/dist/maps/Users";
import { Channel, User } from "revolt.js"; import styled, { css } from "styled-components";
import { Emoji as CustomEmoji } from "revolt.js/esm/maps/Emojis";
import styled, { css } from "styled-components/macro";
import { StateUpdater, useState } from "preact/hooks"; import { StateUpdater, useState } from "preact/hooks";
import { useClient } from "../../context/revoltjs/RevoltClient";
import { emojiDictionary } from "../../assets/emojis"; import { emojiDictionary } from "../../assets/emojis";
import { useClient } from "../../controllers/client/ClientController";
import ChannelIcon from "./ChannelIcon"; import ChannelIcon from "./ChannelIcon";
import Emoji from "./Emoji"; import Emoji from "./Emoji";
import ServerIcon from "./ServerIcon";
import Tooltip from "./Tooltip";
import UserIcon from "./user/UserIcon"; import UserIcon from "./user/UserIcon";
export type AutoCompleteState = export type AutoCompleteState =
| { type: "none" } | { type: "none" }
| ({ selected: number; within: boolean } & ( | ({ selected: number; within: boolean } & (
| { | {
type: "emoji"; type: "emoji";
matches: (string | CustomEmoji)[]; matches: string[];
} }
| { | {
type: "user"; type: "user";
matches: User[]; matches: User[];
} }
| { | {
type: "channel"; type: "channel";
matches: Channel[]; matches: Channel[];
} }
)); ));
export type SearchClues = { export type SearchClues = {
users?: { type: "channel"; id: string } | { type: "all" }; users?: { type: "channel"; id: string } | { type: "all" };
@@ -64,7 +61,7 @@ export function useAutoComplete(
const cursor = el.selectionStart; const cursor = el.selectionStart;
const content = el.value.slice(0, cursor); const content = el.value.slice(0, cursor);
const valid = /[\w\-]/; const valid = /\w/;
let j = content.length - 1; let j = content.length - 1;
if (content[j] === "@") { if (content[j] === "@") {
@@ -82,17 +79,17 @@ export function useAutoComplete(
if (current === ":" || current === "@" || current === "#") { if (current === ":" || current === "@" || current === "#") {
const search = content.slice(j + 1, content.length); const search = content.slice(j + 1, content.length);
const minLen = current === ":" ? 2 : 1; const minLen = current === ":" ? 2 : 1
if (search.length >= minLen) { if (search.length >= minLen) {
return [ return [
current === "#" current === "#"
? "channel" ? "channel"
: current === ":" : current === ":"
? "emoji" ? "emoji"
: "user", : "user",
search.toLowerCase(), search.toLowerCase(),
current === ":" ? j + 1 : j, j + 1,
]; ];
} }
} }
@@ -109,23 +106,16 @@ export function useAutoComplete(
if (type === "emoji") { if (type === "emoji") {
// ! TODO: we should convert it to a Binary Search Tree and use that // ! TODO: we should convert it to a Binary Search Tree and use that
const matches = [ const matches = Object.keys(emojiDictionary)
...Object.keys(emojiDictionary).filter((emoji: string) => .filter((emoji: string) => emoji.match(regex))
emoji.match(regex), .splice(0, 5);
),
...Array.from(client.emojis.values()).filter((emoji) =>
emoji.name.match(regex),
),
].splice(0, 5);
if (matches.length > 0) { if (matches.length > 0) {
const currentPosition = const currentPosition =
state.type !== "none" ? state.selected : 0; state.type !== "none" ? state.selected : 0;
setState({ setState({
// @ts-ignore-next-line are you high
type: "emoji", type: "emoji",
// @ts-ignore-next-line
matches, matches,
selected: Math.min(currentPosition, matches.length - 1), selected: Math.min(currentPosition, matches.length - 1),
within: false, within: false,
@@ -177,8 +167,8 @@ export function useAutoComplete(
const matches = ( const matches = (
search.length > 0 search.length > 0
? users.filter((user) => ? users.filter((user) =>
user.username.toLowerCase().match(regex), user.username.toLowerCase().match(regex),
) )
: users : users
) )
.splice(0, 5) .splice(0, 5)
@@ -209,8 +199,8 @@ export function useAutoComplete(
const matches = ( const matches = (
search.length > 0 search.length > 0
? channels.filter((channel) => ? channels.filter((channel) =>
channel.name!.toLowerCase().match(regex), channel.name!.toLowerCase().match(regex),
) )
: channels : channels
) )
.splice(0, 5) .splice(0, 5)
@@ -245,18 +235,15 @@ export function useAutoComplete(
const content = el.value.split(""); const content = el.value.split("");
if (state.type === "emoji") { if (state.type === "emoji") {
const selected = state.matches[state.selected];
content.splice( content.splice(
index, index,
search.length, search.length,
selected instanceof CustomEmoji state.matches[state.selected],
? selected._id
: selected,
": ", ": ",
); );
} else if (state.type === "user") { } else if (state.type === "user") {
content.splice( content.splice(
index, index - 1,
search.length + 1, search.length + 1,
"<@", "<@",
state.matches[state.selected]._id, state.matches[state.selected]._id,
@@ -264,7 +251,7 @@ export function useAutoComplete(
); );
} else { } else {
content.splice( content.splice(
index, index - 1,
search.length + 1, search.length + 1,
"<#", "<#",
state.matches[state.selected]._id, state.matches[state.selected]._id,
@@ -403,17 +390,12 @@ export default function AutoComplete({
setState, setState,
onClick, onClick,
}: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) { }: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) {
const client = useClient();
return ( return (
<Base detached={detached}> <Base detached={detached}>
<div> <div>
{state.type === "emoji" && {state.type === "emoji" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
style={{
display: "flex",
justifyContent: "space-between",
}}
key={match} key={match}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
@@ -432,61 +414,15 @@ export default function AutoComplete({
}) })
} }
onClick={onClick}> onClick={onClick}>
<div <Emoji
style={{ emoji={
display: "flex", (emojiDictionary as Record<string, string>)[
flexDirection: "row", match
justifyContent: "center", ]
}}> }
{match instanceof CustomEmoji ? ( size={20}
<img />
loading="lazy" :{match}:
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>
</>
)}
</button> </button>
))} ))}
{state.type === "user" && {state.type === "user" &&

View File

@@ -1,11 +1,13 @@
import { Hash, VolumeFull } from "@styled-icons/boxicons-regular"; import { Hash, VolumeFull } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js"; import { Channel } from "revolt.js/dist/maps/Channels";
import fallback from "./assets/group.png"; import { useContext } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { useClient } from "../../controllers/client/ClientController";
import { ImageIconBase, IconBaseProps } from "./IconBase"; import { ImageIconBase, IconBaseProps } from "./IconBase";
import fallback from "./assets/group.png";
interface Props extends IconBaseProps<Channel> { interface Props extends IconBaseProps<Channel> {
isServerChannel?: boolean; isServerChannel?: boolean;
@@ -19,7 +21,7 @@ export default observer(
keyof Props | "children" | "as" keyof Props | "children" | "as"
>, >,
) => { ) => {
const client = useClient(); const client = useContext(AppContext);
const { const {
size, size,
@@ -30,7 +32,7 @@ export default observer(
...imgProps ...imgProps
} = props; } = props;
const iconURL = client.generateFileURL( const iconURL = client.generateFileURL(
target?.icon ?? attachment ?? undefined, target?.icon ?? attachment,
{ max_side: 256 }, { max_side: 256 },
animate, animate,
); );

View File

@@ -1,8 +1,11 @@
import { ChevronDown } from "@styled-icons/boxicons-regular"; import { ChevronDown } from "@styled-icons/boxicons-regular";
import { Details } from "@revoltchat/ui"; import { State, store } from "../../redux";
import { Action } from "../../redux/reducers";
import { useApplicationState } from "../../mobx/State"; import Details from "../ui/Details";
import { Children } from "../../types/Preact";
interface Props { interface Props {
id: string; id: string;
@@ -22,17 +25,30 @@ export default function CollapsibleSection({
children, children,
...detailsProps ...detailsProps
}: Props) { }: Props) {
const layout = useApplicationState().layout; const state: State = store.getState();
function setState(state: boolean) {
if (state === defaultValue) {
store.dispatch({
type: "SECTION_TOGGLE_UNSET",
id,
} as Action);
} else {
store.dispatch({
type: "SECTION_TOGGLE_SET",
id,
state,
} as Action);
}
}
return ( return (
<Details <Details
open={layout.getSectionState(id, defaultValue)} open={state.sectionToggle[id] ?? defaultValue}
onToggle={(e) => onToggle={(e) => setState(e.currentTarget.open)}
layout.setSectionState(id, e.currentTarget.open, defaultValue)
}
{...detailsProps}> {...detailsProps}>
<summary> <summary>
<div className="padding"> <div class="padding">
<ChevronDown size={20} /> <ChevronDown size={20} />
{summary} {summary}
</div> </div>

View File

@@ -1,11 +1,9 @@
import { emojiDictionary } from "../../assets/emojis"; import { EmojiPacks } from "../../redux/reducers/settings";
export type EmojiPack = "mutant" | "twemoji" | "noto" | "openmoji"; let EMOJI_PACK = "mutant";
let EMOJI_PACK: EmojiPack = "mutant";
const REVISION = 3; const REVISION = 3;
export function setGlobalEmojiPack(pack: EmojiPack) { export function setEmojiPack(pack: EmojiPacks) {
EMOJI_PACK = pack; EMOJI_PACK = pack;
} }
@@ -42,13 +40,7 @@ function toCodePoint(rune: string) {
.join("-"); .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,
// )}`;
// }
const codepoint = toCodePoint(emoji); const codepoint = toCodePoint(emoji);
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`; return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
} }

View File

@@ -1,19 +1,14 @@
import { API } from "revolt.js"; import { Attachment } from "revolt-api/types/Autumn";
import { Nullable } from "revolt.js"; import styled, { css } from "styled-components";
import styled, { css } from "styled-components/macro";
import { Ref } from "preact";
export interface IconBaseProps<T> { export interface IconBaseProps<T> {
target?: T; target?: T;
url?: string; url?: string;
attachment?: Nullable<API.File>; attachment?: Attachment;
size: number; size: number;
hover?: boolean; hover?: boolean;
animate?: boolean; animate?: boolean;
innerRef?: Ref<any>;
} }
interface IconModifiers { interface IconModifiers {
@@ -26,7 +21,7 @@ interface IconModifiers {
export default styled.svg<IconModifiers>` export default styled.svg<IconModifiers>`
flex-shrink: 0; flex-shrink: 0;
cursor: pointer;
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@@ -1,21 +1,23 @@
import { ComboBox } from "@revoltchat/ui"; import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import { useApplicationState } from "../../mobx/State"; import { Language, Languages } from "../../context/Locale";
import { Language, Languages } from "../../../external/lang/Languages"; import ComboBox from "../ui/ComboBox";
/** type Props = {
* Component providing a language selector combobox. locale: string;
* Note: this is not an observer but this is fine as we are just using a combobox. };
*/
export default function LocaleSelector() {
const locale = useApplicationState().locale;
export function LocaleSelector(props: Props) {
return ( return (
<ComboBox <ComboBox
value={locale.getLanguage()} value={props.locale}
onChange={(e) => onChange={(e) =>
locale.setLanguage(e.currentTarget.value as Language) dispatch({
type: "SET_LOCALE",
locale: e.currentTarget.value as Language,
})
}> }>
{Object.keys(Languages).map((x) => { {Object.keys(Languages).map((x) => {
const l = Languages[x as keyof typeof Languages]; const l = Languages[x as keyof typeof Languages];
@@ -28,3 +30,9 @@ export default function LocaleSelector() {
</ComboBox> </ComboBox>
); );
} }
export default connectState(LocaleSelector, (state) => {
return {
locale: state.locale,
};
});

View File

@@ -2,144 +2,86 @@ import { Check } from "@styled-icons/boxicons-regular";
import { Cog } from "@styled-icons/boxicons-solid"; import { Cog } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Server } from "revolt.js"; import { ServerPermission } from "revolt.js/dist/api/permissions";
import styled, { css } from "styled-components/macro"; import { Server } from "revolt.js/dist/maps/Servers";
import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { IconButton } from "@revoltchat/ui"; import Header from "../ui/Header";
import IconButton from "../ui/IconButton";
import { modalController } from "../../controllers/modals/ModalController";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
interface Props { interface Props {
server: Server; server: Server;
background?: boolean;
} }
const ServerBanner = styled.div<Omit<Props, "server">>` const ServerName = styled.div`
flex-shrink: 0; flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
background-size: cover;
background-repeat: norepeat;
background-position: center center;
${(props) =>
props.background
? css`
height: 120px;
.container {
background: linear-gradient(
0deg,
var(--secondary-background),
transparent
);
}
`
: css`
background-color: var(--secondary-header);
`}
.container {
height: var(--header-height);
display: flex;
align-items: center;
padding: 0 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
gap: 8px;
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
cursor: pointer;
color: var(--foreground);
}
}
`; `;
export default observer(({ server }: Props) => { export default observer(({ server }: Props) => {
const bannerURL = server.generateBannerURL({ width: 480 }); const bannerURL = server.generateBannerURL({ width: 480 });
return ( return (
<ServerBanner <Header
borders
placement="secondary"
background={typeof bannerURL !== "undefined"} background={typeof bannerURL !== "undefined"}
style={{ style={{
backgroundImage: bannerURL ? `url('${bannerURL}')` : undefined, background: bannerURL ? `url('${bannerURL}')` : undefined,
}}> }}>
<div className="container"> {server.flags && server.flags & 1 ? (
{server.flags && server.flags & 1 ? ( <Tooltip
<Tooltip content={<Text id="app.special.server-badges.official" />}
content={ placement={"bottom-start"}>
<Text id="app.special.server-badges.official" /> <svg width="20" height="20">
} <image
placement={"bottom-start"}> xlinkHref="/assets/badges/verified.svg"
<svg width="20" height="20"> height="20"
<image width="20"
xlinkHref="/assets/badges/verified.svg" />
height="20" <image
width="20" xlinkHref="/assets/badges/revolt_r.svg"
/> height="15"
<image width="15"
xlinkHref="/assets/badges/revolt_r.svg" x="2"
height="15" y="3"
width="15" style={
x="2" "justify-content: center; align-items: center; filter: brightness(0);"
y="3" }
style={ />
"justify-content: center; align-items: center; filter: brightness(0);" </svg>
} </Tooltip>
/> ) : undefined}
</svg> {server.flags && server.flags & 2 ? (
</Tooltip> <Tooltip
) : undefined} content={<Text id="app.special.server-badges.verified" />}
{server.flags && server.flags & 2 ? ( placement={"bottom-start"}>
<Tooltip <svg width="20" height="20">
content={ <image
<Text id="app.special.server-badges.verified" /> xlinkHref="/assets/badges/verified.svg"
} height="20"
placement={"bottom-start"}> width="20"
<svg width="20" height="20"> />
<image <foreignObject x="2" y="2" width="15" height="15">
xlinkHref="/assets/badges/verified.svg" <Check size={15} color="black" strokeWidth={8} />
height="20" </foreignObject>
width="20" </svg>
/> </Tooltip>
<foreignObject x="2" y="2" width="15" height="15"> ) : undefined}
<Check
size={15} <ServerName>{server.name}</ServerName>
color="black" {(server.permission & ServerPermission.ManageServer) > 0 && (
strokeWidth={8} <div className="actions">
/>
</foreignObject>
</svg>
</Tooltip>
) : undefined}
<a
className="title"
onClick={() =>
modalController.push({ type: "server_info", server })
}>
{server.name}
</a>
{server.havePermission("ManageServer") && (
<Link to={`/server/${server._id}/settings`}> <Link to={`/server/${server._id}/settings`}>
<IconButton> <IconButton>
<Cog size={20} /> <Cog size={24} />
</IconButton> </IconButton>
</Link> </Link>
)} </div>
</div> )}
</ServerBanner> </Header>
); );
}); });

View File

@@ -1,10 +1,11 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Server } from "revolt.js"; import { Server } from "revolt.js/dist/maps/Servers";
import styled from "styled-components/macro"; import styled from "styled-components";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { useClient } from "../../controllers/client/ClientController"; import { AppContext } from "../../context/revoltjs/RevoltClient";
import { IconBaseProps, ImageIconBase } from "./IconBase"; import { IconBaseProps, ImageIconBase } from "./IconBase";
interface Props extends IconBaseProps<Server> { interface Props extends IconBaseProps<Server> {
@@ -12,13 +13,10 @@ interface Props extends IconBaseProps<Server> {
} }
const ServerText = styled.div` const ServerText = styled.div`
display: flex; display: grid;
align-items: center;
justify-content: center;
padding: 0.2em; padding: 0.2em;
font-size: 0.75rem;
font-weight: 600;
overflow: hidden; overflow: hidden;
place-items: center;
color: var(--foreground); color: var(--foreground);
background: var(--primary-background); background: var(--primary-background);
border-radius: var(--border-radius-half); border-radius: var(--border-radius-half);
@@ -33,12 +31,12 @@ export default observer(
keyof Props | "children" | "as" keyof Props | "children" | "as"
>, >,
) => { ) => {
const client = useClient(); const client = useContext(AppContext);
const { target, attachment, size, animate, server_name, ...imgProps } = const { target, attachment, size, animate, server_name, ...imgProps } =
props; props;
const iconURL = client.generateFileURL( const iconURL = client.generateFileURL(
target?.icon ?? attachment ?? undefined, target?.icon ?? attachment,
{ max_side: 256 }, { max_side: 256 },
animate, animate,
); );
@@ -51,9 +49,7 @@ export default observer(
{name {name
.split(" ") .split(" ")
.map((x) => x[0]) .map((x) => x[0])
.filter((x) => typeof x !== "undefined") .filter((x) => typeof x !== "undefined")}
.join("")
.substring(0, 3)}
</ServerText> </ServerText>
); );
} }

View File

@@ -1,8 +1,10 @@
import Tippy, { TippyProps } from "@tippyjs/react"; import Tippy, { TippyProps } from "@tippyjs/react";
import styled from "styled-components/macro"; import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { Children } from "../../types/Preact";
type Props = Omit<TippyProps, "children"> & { type Props = Omit<TippyProps, "children"> & {
children: Children; children: Children;
content: Children; content: Children;
@@ -12,7 +14,7 @@ export default function Tooltip(props: Props) {
const { children, content, ...tippyProps } = props; const { children, content, ...tippyProps } = props;
return ( return (
<Tippy content={content} animation="shift-away" {...tippyProps}> <Tippy content={content} {...tippyProps}>
{/* {/*
// @ts-expect-error Type mis-match. */} // @ts-expect-error Type mis-match. */}
<div style={`display: flex;`}>{children}</div> <div style={`display: flex;`}>{children}</div>

View File

@@ -1,15 +1,15 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { Download, CloudDownload } from "@styled-icons/boxicons-regular"; import { Download, CloudDownload } from "@styled-icons/boxicons-regular";
import { useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { IconButton } from "@revoltchat/ui";
import { internalSubscribe } from "../../lib/eventEmitter"; import { internalSubscribe } from "../../lib/eventEmitter";
import { useApplicationState } from "../../mobx/State"; import { ThemeContext } from "../../context/Theme";
import { updateSW } from "../../updateWorker"; import IconButton from "../ui/IconButton";
import { updateSW } from "../../main";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
let pendingUpdate = false; let pendingUpdate = false;
@@ -27,30 +27,27 @@ export default function UpdateIndicator({ style }: Props) {
}); });
if (!pending) return null; if (!pending) return null;
const theme = useApplicationState().settings.theme; const theme = useContext(ThemeContext);
if (style === "titlebar") { if (style === "titlebar") {
return ( return (
<div className="actions"> <div class="actions">
<Tooltip <Tooltip
content="A new update is available!" content="A new update is available!"
placement="bottom"> placement="bottom">
<div onClick={() => updateSW(true)}> <div onClick={() => updateSW(true)}>
<CloudDownload <CloudDownload size={22} color={theme.success} />
size={22}
color={theme.getVariable("success")}
/>
</div> </div>
</Tooltip> </Tooltip>
</div> </div>
); );
} }
if (window.isNative && window.native.getConfig().frame) return null; if (window.isNative) return null;
return ( return (
<IconButton onClick={() => updateSW(true)}> <IconButton onClick={() => updateSW(true)}>
<Download size={22} color={theme.getVariable("success")} /> <Download size={22} color={theme.success} />
</IconButton> </IconButton>
); );
} }

View File

@@ -1,20 +1,20 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Message as MessageObject } from "revolt.js"; import { Message as MessageObject } from "revolt.js/dist/maps/Messages";
import { useTriggerEvents } from "preact-context-menu"; import { attachContextMenu } from "preact-context-menu";
import { memo } from "preact/compat"; import { memo } from "preact/compat";
import { useEffect, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Category } from "@revoltchat/ui";
import { internalEmit } from "../../../lib/eventEmitter"; import { internalEmit } from "../../../lib/eventEmitter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { QueuedMessage } from "../../../mobx/stores/MessageQueue"; import { QueuedMessage } from "../../../redux/reducers/queue";
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 Markdown from "../../markdown/Markdown";
import UserIcon from "../user/UserIcon"; import UserIcon from "../user/UserIcon";
import { Username } from "../user/UserShort"; import { Username } from "../user/UserShort";
@@ -25,15 +25,13 @@ import MessageBase, {
} from "./MessageBase"; } from "./MessageBase";
import Attachment from "./attachments/Attachment"; import Attachment from "./attachments/Attachment";
import { MessageReply } from "./attachments/MessageReply"; import { MessageReply } from "./attachments/MessageReply";
import { Reactions } from "./attachments/Reactions";
import { MessageOverlayBar } from "./bars/MessageOverlayBar";
import Embed from "./embed/Embed"; import Embed from "./embed/Embed";
import InviteList from "./embed/EmbedInvite"; import InviteList from "./embed/EmbedInvite";
interface Props { interface Props {
attachContext?: boolean; attachContext?: boolean;
queued?: QueuedMessage; queued?: QueuedMessage;
message: MessageObject & { webhook: { name: string; avatar?: string } }; message: MessageObject;
highlight?: boolean; highlight?: boolean;
contrast?: boolean; contrast?: boolean;
content?: Children; content?: Children;
@@ -52,27 +50,27 @@ const Message = observer(
queued, queued,
hideReply, hideReply,
}: Props) => { }: Props) => {
const client = message.client; const client = useClient();
const user = message.author; const user = message.author;
const content = message.content; const { openScreen } = useIntermediate();
const content = message.content as string;
const head = const head =
preferHead || (message.reply_ids && message.reply_ids.length > 0); preferHead || (message.reply_ids && message.reply_ids.length > 0);
// ! TODO: tell fatal to make this type generic
// bree: Fatal please...
const userContext = attachContext const userContext = attachContext
? useTriggerEvents("Menu", { ? (attachContextMenu("Menu", {
user: message.author_id, user: message.author_id,
contextualChannel: message.channel_id, contextualChannel: message.channel_id,
contextualMessage: message._id,
// eslint-disable-next-line // eslint-disable-next-line
}) }) as any)
: undefined; : undefined;
const openProfile = () => const openProfile = () =>
modalController.push({ openScreen({ id: "profile", user_id: message.author_id });
type: "user_profile",
user_id: message.author_id,
});
const handleUserClick = (e: MouseEvent) => { const handleUserClick = (e: MouseEvent) => {
if (e.shiftKey && user?._id) { if (e.shiftKey && user?._id) {
@@ -88,9 +86,7 @@ const Message = observer(
}; };
// ! FIXME(?): animate on hover // ! FIXME(?): animate on hover
const [mouseHovering, setAnimate] = useState(false); const [animate, setAnimate] = useState(false);
const [reactionsOpen, setReactionsOpen] = useState(false);
useEffect(() => setAnimate(false), [replacement]);
return ( return (
<div id={message._id}> <div id={message._id}>
@@ -100,7 +96,7 @@ const Message = observer(
key={message_id} key={message_id}
index={index} index={index}
id={message_id} id={message_id}
channel={message.channel} channel={message.channel!}
parent_mentions={message.mention_ids ?? []} parent_mentions={message.mention_ids ?? []}
/> />
))} ))}
@@ -118,36 +114,28 @@ const Message = observer(
} }
contrast={contrast} contrast={contrast}
sending={typeof queued !== "undefined"} sending={typeof queued !== "undefined"}
mention={ mention={message.mention_ids?.includes(client.user!._id)}
message.mention_ids && client.user failed={typeof queued?.error !== "undefined"}
? message.mention_ids.includes(client.user._id) onContextMenu={
attachContext
? attachContextMenu("Menu", {
message,
contextualChannel: message.channel_id,
queued,
})
: undefined : undefined
} }
failed={typeof queued?.error !== "undefined"}
{...(attachContext
? useTriggerEvents("Menu", {
message,
contextualChannel: message.channel_id,
queued,
})
: undefined)}
onMouseEnter={() => setAnimate(true)} onMouseEnter={() => setAnimate(true)}
onMouseLeave={() => setAnimate(false)}> onMouseLeave={() => setAnimate(false)}>
<MessageInfo click={typeof head !== "undefined"}> <MessageInfo>
{head ? ( {head ? (
<UserIcon <UserIcon
className="avatar"
url={message.generateMasqAvatarURL()} url={message.generateMasqAvatarURL()}
override={
message.webhook?.avatar
? `https://autumn.revolt.chat/avatars/${message.webhook.avatar}`
: undefined
}
target={user} target={user}
size={36} size={36}
onContextMenu={userContext}
onClick={handleUserClick} onClick={handleUserClick}
animate={mouseHovering} animate={animate}
{...(userContext as any)}
showServerIdentity showServerIdentity
/> />
) : ( ) : (
@@ -162,9 +150,8 @@ const Message = observer(
className="author" className="author"
showServerIdentity showServerIdentity
onClick={handleUserClick} onClick={handleUserClick}
onContextMenu={userContext}
masquerade={message.masquerade!} masquerade={message.masquerade!}
override={message.webhook?.name}
{...userContext}
/> />
<MessageDetail <MessageDetail
message={message} message={message}
@@ -172,38 +159,21 @@ const Message = observer(
/> />
</span> </span>
)} )}
{replacement ?? {replacement ?? <Markdown content={content} />}
(content && <Markdown content={content} />)}
{!queued && <InviteList message={message} />} {!queued && <InviteList message={message} />}
{queued?.error && ( {queued?.error && (
<Category> <Overline type="error" error={queued.error} />
<I18nError error={queued.error} />
</Category>
)} )}
{message.attachments?.map((attachment, index) => ( {message.attachments?.map((attachment, index) => (
<Attachment <Attachment
key={index} key={index}
attachment={attachment} attachment={attachment}
hasContent={ hasContent={index > 0 || content.length > 0}
index > 0 ||
(content ? content.length > 0 : false)
}
/> />
))} ))}
{message.embeds?.map((embed, index) => ( {message.embeds?.map((embed, index) => (
<Embed key={index} embed={embed} /> <Embed key={index} embed={embed} />
))} ))}
<Reactions message={message} />
{(mouseHovering || reactionsOpen) &&
!replacement &&
!isTouchscreenDevice && (
<MessageOverlayBar
reactionsOpen={reactionsOpen}
setReactionsOpen={setReactionsOpen}
message={message}
queued={queued}
/>
)}
</MessageContent> </MessageContent>
</MessageBase> </MessageBase>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Message } from "revolt.js"; import { Message } from "revolt.js/dist/maps/Messages";
import styled, { css, keyframes } from "styled-components/macro"; import styled, { css, keyframes } from "styled-components";
import { decodeTime } from "ulid"; import { decodeTime } from "ulid";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
@@ -134,7 +134,7 @@ export default styled.div<BaseMessageProps>`
} }
`; `;
export const MessageInfo = styled.div<{ click: boolean }>` export const MessageInfo = styled.div`
width: 62px; width: 62px;
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
@@ -142,14 +142,6 @@ export const MessageInfo = styled.div<{ click: boolean }>`
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
.avatar {
user-select: none;
cursor: pointer;
&:active {
transform: translateY(1px);
}
}
.copyBracket { .copyBracket {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
@@ -160,6 +152,15 @@ export const MessageInfo = styled.div<{ click: boolean }>`
position: absolute; position: absolute;
} }
svg {
user-select: none;
cursor: pointer;
&:active {
transform: translateY(1px);
}
}
time { time {
opacity: 0; opacity: 0;
} }
@@ -191,19 +192,9 @@ export const MessageInfo = styled.div<{ click: boolean }>`
margin-right: 0.5em; margin-right: 0.5em;
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
} }
/*${(props) =>
props.click &&
css`
cursor: pointer;
`}*/
`; `;
export const MessageContent = styled.div` export const MessageContent = styled.div`
// Position relatively so we can put
// the overlay in the right place.
position: relative;
min-width: 0; min-width: 0;
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;

View File

@@ -1,15 +1,13 @@
import { HappyBeaming, Send, ShieldX } from "@styled-icons/boxicons-solid"; import { Send, ShieldX } from "@styled-icons/boxicons-solid";
import Axios, { CancelTokenSource } from "axios"; import Axios, { CancelTokenSource } from "axios";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js"; import { ChannelPermission } from "revolt.js/dist/api/permissions";
import styled, { css } from "styled-components/macro"; import { Channel } from "revolt.js/dist/maps/Channels";
import styled, { css } from "styled-components";
import { ulid } from "ulid"; import { ulid } from "ulid";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { memo } from "preact/compat"; import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import { IconButton, Picker } from "@revoltchat/ui";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { debounce } from "../../../lib/debounce"; import { debounce } from "../../../lib/debounce";
@@ -22,25 +20,21 @@ import {
SMOOTH_SCROLL_ON_RECEIVE, SMOOTH_SCROLL_ON_RECEIVE,
} from "../../../lib/renderer/Singleton"; } from "../../../lib/renderer/Singleton";
import { state, useApplicationState } from "../../../mobx/State"; import { dispatch, getState } from "../../../redux";
import { DraftObject } from "../../../mobx/stores/Draft"; import { Reply } from "../../../redux/reducers/queue";
import { Reply } from "../../../mobx/stores/MessageQueue";
import { dayjs } from "../../../context/Locale"; import { SoundContext } from "../../../context/Settings";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { emojiDictionary } from "../../../assets/emojis";
import {
clientController,
useClient,
} from "../../../controllers/client/ClientController";
import { takeError } from "../../../controllers/client/jsx/error";
import { import {
FileUploader, FileUploader,
grabFiles, grabFiles,
uploadFile, uploadFile,
} from "../../../controllers/client/jsx/legacy/FileUploads"; } from "../../../context/revoltjs/FileUploads";
import { modalController } from "../../../controllers/modals/ModalController"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { RenderEmoji } from "../../markdown/plugins/emoji"; import { takeError } from "../../../context/revoltjs/util";
import IconButton from "../../ui/IconButton";
import AutoComplete, { useAutoComplete } from "../AutoComplete"; import AutoComplete, { useAutoComplete } from "../AutoComplete";
import { PermissionTooltip } from "../Tooltip"; import { PermissionTooltip } from "../Tooltip";
import FilePreview from "./bars/FilePreview"; import FilePreview from "./bars/FilePreview";
@@ -63,7 +57,6 @@ export type UploadState =
| { type: "failed"; files: File[]; error: string }; | { type: "failed"; files: File[]; error: string };
const Base = styled.div` const Base = styled.div`
z-index: 1;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
background: var(--message-box); background: var(--message-box);
@@ -86,15 +79,9 @@ const Blocked = styled.div`
user-select: none; user-select: none;
font-size: var(--text-size); font-size: var(--text-size);
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
flex-grow: 1;
cursor: not-allowed;
.text { .text {
padding: var(--message-box-padding); padding: 14px 14px 14px 0;
}
> div > div {
cursor: default;
} }
svg { svg {
@@ -103,17 +90,13 @@ const Blocked = styled.div`
`; `;
const Action = styled.div` const Action = styled.div`
> a { display: flex;
place-items: center;
> div {
height: 48px; height: 48px;
width: 48px; width: 48px;
display: flex; padding: 12px;
align-items: center;
justify-content: center;
/*padding: 14px 0 14px 14px;*/
}
.mobile {
width: 62px;
} }
${() => ${() =>
@@ -125,139 +108,28 @@ const Action = styled.div`
`} `}
`; `;
const FileAction = styled.div`
> a {
height: 48px;
width: 62px;
display: flex;
align-items: center;
justify-content: center;
}
`;
const FloatingLayer = styled.div`
position: relative;
`;
const ThisCodeWillBeReplacedAnywaysSoIMightAsWellJustDoItThisWay__Padding = styled.div`
width: 16px;
`;
// For sed replacement // For sed replacement
const RE_SED = new RegExp("^s/([^])*/([^])*$"); 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 // ! FIXME: add to app config and load from app config
export const CAN_UPLOAD_AT_ONCE = 5; export const CAN_UPLOAD_AT_ONCE = 4;
export default observer(({ channel }: Props) => { export default observer(({ channel }: Props) => {
const state = useApplicationState(); const [draft, setDraft] = useState(getState().drafts[channel._id] ?? "");
const [uploadState, setUploadState] = useState<UploadState>({ const [uploadState, setUploadState] = useState<UploadState>({
type: "none", type: "none",
}); });
const [typing, setTyping] = useState<boolean | number>(false); const [typing, setTyping] = useState<boolean | number>(false);
const [replies, setReplies] = useState<Reply[]>([]); const [replies, setReplies] = useState<Reply[]>([]);
const [picker, setPicker] = useState(false); const playSound = useContext(SoundContext);
const client = useClient(); const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const translate = useTranslation(); const translate = useTranslation();
const closePicker = useCallback(() => setPicker(false), []);
const renderer = getRenderer(channel); const renderer = getRenderer(channel);
if (channel.server?.member?.timeout) { if (!(channel.permission & ChannelPermission.SendMessage)) {
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 ( return (
<Base> <Base>
<Blocked> <Blocked>
@@ -276,23 +148,27 @@ export default observer(({ channel }: Props) => {
); );
} }
// Push message content to draft.
const setMessage = useCallback( const setMessage = useCallback(
(content?: string) => { (content?: string) => {
const dobj: DraftObject = { setDraft(content ?? "");
content,
}; if (content) {
state.draft.set(channel._id, dobj); dispatch({
type: "SET_DRAFT",
channel: channel._id,
content,
});
} else {
dispatch({
type: "CLEAR_DRAFT",
channel: channel._id,
});
}
}, },
[state.draft, channel._id], [channel._id],
); );
useEffect(() => { useEffect(() => {
/**
*
* @param content
* @param action
*/
function append(content: string, action: "quote" | "mention") { function append(content: string, action: "quote" | "mention") {
const text = const text =
action === "quote" action === "quote"
@@ -302,10 +178,10 @@ export default observer(({ channel }: Props) => {
.join("\n")}\n\n` .join("\n")}\n\n`
: `${content} `; : `${content} `;
if (!state.draft.has(channel._id)) { if (!draft || draft.length === 0) {
setMessage(text); setMessage(text);
} else { } else {
setMessage(`${state.draft.get(channel._id)?.content}\n${text}`); setMessage(`${draft}\n${text}`);
} }
} }
@@ -314,20 +190,16 @@ export default observer(({ channel }: Props) => {
"append", "append",
append as (...args: unknown[]) => void, append as (...args: unknown[]) => void,
); );
}, [state.draft, channel._id, setMessage]); }, [draft, setMessage]);
/**
* Trigger send message.
*/
async function send() { async function send() {
if (uploadState.type === "uploading" || uploadState.type === "sending") if (uploadState.type === "uploading" || uploadState.type === "sending")
return; return;
const content = state.draft.get(channel._id)?.content?.trim() ?? ""; const content = draft?.trim() ?? "";
if (uploadState.type !== "none") return sendFile(content); if (uploadState.type === "attached") return sendFile(content);
if (content.length === 0) return; if (content.length === 0) return;
internalEmit("NewMessages", "hide");
stopTyping(); stopTyping();
setMessage(); setMessage();
setReplies([]); setReplies([]);
@@ -343,7 +215,7 @@ export default observer(({ channel }: Props) => {
); );
renderer.messages.reverse(); renderer.messages.reverse();
if (msg?.content) { if (msg) {
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let [_, toReplace, newText, flags] = content.split(/\//); let [_, toReplace, newText, flags] = content.split(/\//);
@@ -375,15 +247,20 @@ export default observer(({ channel }: Props) => {
} }
} }
} else { } else {
state.settings.sounds.playSound("outbound"); playSound("outbound");
state.queue.add(nonce, channel._id, { dispatch({
_id: nonce, type: "QUEUE_ADD",
nonce,
channel: channel._id, channel: channel._id,
author: client.user!._id, message: {
_id: nonce,
channel: channel._id,
author: client.user!._id,
content, content,
replies, replies,
},
}); });
defer(() => renderer.jumpToBottom(SMOOTH_SCROLL_ON_RECEIVE)); defer(() => renderer.jumpToBottom(SMOOTH_SCROLL_ON_RECEIVE));
@@ -395,22 +272,18 @@ export default observer(({ channel }: Props) => {
replies, replies,
}); });
} catch (error) { } catch (error) {
state.queue.fail(nonce, takeError(error)); dispatch({
type: "QUEUE_FAIL",
error: takeError(error),
nonce,
});
} }
} }
} }
/**
*
* @param content
* @returns
*/
async function sendFile(content: string) { async function sendFile(content: string) {
if (uploadState.type !== "attached" && uploadState.type !== "failed") if (uploadState.type !== "attached") return;
return;
const attachments: string[] = []; const attachments: string[] = [];
setMessage;
const cancel = Axios.CancelToken.source(); const cancel = Axios.CancelToken.source();
const files = uploadState.files; const files = uploadState.files;
@@ -487,7 +360,7 @@ export default observer(({ channel }: Props) => {
setMessage(); setMessage();
setReplies([]); setReplies([]);
state.settings.sounds.playSound("outbound"); playSound("outbound");
if (files.length > CAN_UPLOAD_AT_ONCE) { if (files.length > CAN_UPLOAD_AT_ONCE) {
setUploadState({ setUploadState({
@@ -499,10 +372,6 @@ export default observer(({ channel }: Props) => {
} }
} }
/**
*
* @returns
*/
function startTyping() { function startTyping() {
if (typeof typing === "number" && +new Date() < typing) return; if (typeof typing === "number" && +new Date() < typing) return;
@@ -516,10 +385,6 @@ export default observer(({ channel }: Props) => {
} }
} }
/**
*
* @param force
*/
function stopTyping(force?: boolean) { function stopTyping(force?: boolean) {
if (force || typing) { if (force || typing) {
const ws = client.websocket; const ws = client.websocket;
@@ -533,21 +398,6 @@ export default observer(({ channel }: Props) => {
} }
} }
function isInCodeBlock(cursor: number): boolean {
const content = state.draft.get(channel._id)?.content || "";
const contentBeforeCursor = content.substring(0, cursor);
let delimiterCount = 0;
for (const delimiter of contentBeforeCursor.matchAll(
RE_CODE_DELIMITER,
)) {
delimiterCount++;
}
// Odd number of ``` delimiters before cursor => we are in code block
return delimiterCount % 2 === 1;
}
// TODO: change to useDebounceCallback // TODO: change to useDebounceCallback
// eslint-disable-next-line // eslint-disable-next-line
const debouncedStopTyping = useCallback( const debouncedStopTyping = useCallback(
@@ -584,10 +434,7 @@ export default observer(({ channel }: Props) => {
files: [...uploadState.files, ...files], files: [...uploadState.files, ...files],
}), }),
() => () =>
modalController.push({ openScreen({ id: "error", error: "FileTooLarge" }),
type: "error",
error: "FileTooLarge",
}),
true, true,
) )
} }
@@ -610,25 +457,9 @@ export default observer(({ channel }: Props) => {
replies={replies} replies={replies}
setReplies={setReplies} 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> <Base>
{channel.havePermission("UploadFiles") ? ( {channel.permission & ChannelPermission.UploadFiles ? (
<FileAction> <Action>
<FileUploader <FileUploader
size={24} size={24}
behaviour="multi" behaviour="multi"
@@ -663,10 +494,8 @@ export default observer(({ channel }: Props) => {
} }
}} }}
/> />
</FileAction> </Action>
) : ( ) : undefined}
<ThisCodeWillBeReplacedAnywaysSoIMightAsWellJustDoItThisWay__Padding />
)}
<TextAreaAutoSize <TextAreaAutoSize
autoFocus autoFocus
hideBorder hideBorder
@@ -674,7 +503,7 @@ export default observer(({ channel }: Props) => {
id="message" id="message"
maxLength={2000} maxLength={2000}
onKeyUp={onKeyUp} onKeyUp={onKeyUp}
value={state.draft.get(channel._id)?.content ?? ""} value={draft ?? ""}
padding="var(--message-box-padding)" padding="var(--message-box-padding)"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") { if (e.ctrlKey && e.key === "Enter") {
@@ -686,7 +515,7 @@ export default observer(({ channel }: Props) => {
if ( if (
e.key === "ArrowUp" && e.key === "ArrowUp" &&
!state.draft.has(channel._id) (!draft || draft.length === 0)
) { ) {
e.preventDefault(); e.preventDefault();
internalEmit("MessageRenderer", "edit_last"); internalEmit("MessageRenderer", "edit_last");
@@ -697,8 +526,7 @@ export default observer(({ channel }: Props) => {
!e.shiftKey && !e.shiftKey &&
!e.isComposing && !e.isComposing &&
e.key === "Enter" && e.key === "Enter" &&
!isTouchscreenDevice && !isTouchscreenDevice
!isInCodeBlock(e.currentTarget.selectionStart)
) { ) {
e.preventDefault(); e.preventDefault();
return send(); return send();
@@ -747,17 +575,11 @@ export default observer(({ channel }: Props) => {
onBlur={onBlur} onBlur={onBlur}
/> />
<Action> <Action>
<IconButton onClick={() => setPicker(!picker)}> {/*<IconButton onClick={emojiPicker}>
<HappyBeaming size={24} /> <HappyAlt size={20} />
</IconButton> </IconButton>*/}
</Action>
<Action>
<IconButton <IconButton
className={ className="mobile"
state.settings.get("appearance:show_send_button")
? ""
: "mobile"
}
onClick={send} onClick={send}
onMouseDown={(e) => e.preventDefault()}> onMouseDown={(e) => e.preventDefault()}>
<Send size={20} /> <Send size={20} />

View File

@@ -9,26 +9,16 @@ import {
EditAlt, EditAlt,
Edit, Edit,
MessageSquareEdit, MessageSquareEdit,
Key,
} from "@styled-icons/boxicons-solid"; } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Message, API } from "revolt.js"; import { SystemMessage as SystemMessageI } from "revolt-api/types/Channels";
import styled from "styled-components/macro"; import { Message } from "revolt.js/dist/maps/Messages";
import { decodeTime } from "ulid"; import styled from "styled-components";
import { useTriggerEvents } from "preact-context-menu"; import { attachContextMenu } from "preact-context-menu";
import { Text } from "preact-i18n";
import { Row } from "@revoltchat/ui";
import { TextReact } from "../../../lib/i18n"; 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 UserShort from "../user/UserShort";
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase"; import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
@@ -39,26 +29,6 @@ const SystemContent = styled.div`
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
font-size: 14px;
color: var(--secondary-foreground);
span {
font-weight: 600;
color: var(--foreground);
}
svg {
margin-inline-end: 4px;
}
svg,
span {
cursor: pointer;
}
span:hover {
text-decoration: underline;
}
`; `;
interface Props { interface Props {
@@ -78,23 +48,20 @@ const iconDictionary = {
channel_renamed: EditAlt, channel_renamed: EditAlt,
channel_description_changed: Edit, channel_description_changed: Edit,
channel_icon_changed: MessageSquareEdit, channel_icon_changed: MessageSquareEdit,
channel_ownership_changed: Key,
text: InfoCircle, text: InfoCircle,
}; };
export const SystemMessage = observer( export const SystemMessage = observer(
({ attachContext, message, highlight, hideInfo }: Props) => { ({ attachContext, message, highlight, hideInfo }: Props) => {
const data = message.asSystemMessage; const data = message.asSystemMessage;
if (!data) return null;
const settings = useApplicationState().settings;
const SystemMessageIcon = const SystemMessageIcon =
iconDictionary[data.type as API.SystemMessage["type"]] ?? iconDictionary[data.type as SystemMessageI["type"]] ?? InfoCircle;
InfoCircle;
let children = null; let children;
switch (data.type) { switch (data.type) {
case "text":
children = <span>{data.content}</span>;
break;
case "user_added": case "user_added":
case "user_remove": case "user_remove":
children = ( children = (
@@ -114,39 +81,16 @@ export const SystemMessage = observer(
case "user_joined": case "user_joined":
case "user_left": case "user_left":
case "user_kicked": case "user_kicked":
case "user_banned": { case "user_banned":
const createdAt = data.user ? decodeTime(data.user._id) : null;
children = ( children = (
<Row centred> <TextReact
<TextReact id={`app.main.channel.system.${data.type}`}
id={`app.main.channel.system.${data.type}`} fields={{
fields={{ user: <UserShort user={data.user} />,
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>
); );
break; break;
}
case "channel_renamed": case "channel_renamed":
children = ( children = (
<TextReact <TextReact
@@ -169,35 +113,21 @@ export const SystemMessage = observer(
/> />
); );
break; 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 ( return (
<MessageBase <MessageBase
highlight={highlight} highlight={highlight}
{...(attachContext onContextMenu={
? useTriggerEvents("Menu", { attachContext
message, ? attachContextMenu("Menu", {
contextualChannel: message.channel, message,
}) contextualChannel: message.channel,
: undefined)}> })
: undefined
}>
{!hideInfo && ( {!hideInfo && (
<MessageInfo click={false}> <MessageInfo>
<MessageDetail message={message} position="left" /> <MessageDetail message={message} position="left" />
<SystemMessageIcon className="systemIcon" /> <SystemMessageIcon className="systemIcon" />
</MessageInfo> </MessageInfo>

View File

@@ -1,11 +1,13 @@
import { API } from "revolt.js"; import { Attachment as AttachmentI } from "revolt-api/types/Autumn";
import styles from "./Attachment.module.scss"; import styles from "./Attachment.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { useTriggerEvents } from "preact-context-menu"; import { attachContextMenu } from "preact-context-menu";
import { useState } from "preact/hooks"; import { useContext, useState } from "preact/hooks";
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
import { useClient } from "../../../../controllers/client/ClientController";
import AttachmentActions from "./AttachmentActions"; import AttachmentActions from "./AttachmentActions";
import { SizedGrid } from "./Grid"; import { SizedGrid } from "./Grid";
import ImageFile from "./ImageFile"; import ImageFile from "./ImageFile";
@@ -13,14 +15,14 @@ import Spoiler from "./Spoiler";
import TextFile from "./TextFile"; import TextFile from "./TextFile";
interface Props { interface Props {
attachment: API.File; attachment: AttachmentI;
hasContent?: boolean; hasContent: boolean;
} }
const MAX_ATTACHMENT_WIDTH = 480; const MAX_ATTACHMENT_WIDTH = 480;
export default function Attachment({ attachment, hasContent }: Props) { export default function Attachment({ attachment, hasContent }: Props) {
const client = useClient(); const client = useContext(AppContext);
const { filename, metadata } = attachment; const { filename, metadata } = attachment;
const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_")); const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_"));
@@ -36,8 +38,8 @@ export default function Attachment({ attachment, hasContent }: Props) {
<SizedGrid <SizedGrid
width={metadata.width} width={metadata.width}
height={metadata.height} height={metadata.height}
{...useTriggerEvents("Menu", { onContextMenu={attachContextMenu("Menu", {
attachment, attachment: attachment,
})} })}
className={classNames({ className={classNames({
[styles.margin]: hasContent, [styles.margin]: hasContent,

View File

@@ -1,33 +1,33 @@
import { import {
LinkExternal,
Headphone,
Download, Download,
LinkExternal,
File,
Headphone,
Video,
} from "@styled-icons/boxicons-regular"; } from "@styled-icons/boxicons-regular";
import { File, Video } from "@styled-icons/boxicons-solid"; import { Attachment } from "revolt-api/types/Autumn";
import { isFirefox } from "react-device-detect";
import { API } from "revolt.js";
import styles from "./AttachmentActions.module.scss"; import styles from "./AttachmentActions.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { IconButton } from "@revoltchat/ui";
import { determineFileSize } from "../../../../lib/fileSize"; import { determineFileSize } from "../../../../lib/fileSize";
import { useClient } from "../../../../controllers/client/ClientController"; import { AppContext } from "../../../../context/revoltjs/RevoltClient";
import IconButton from "../../../ui/IconButton";
interface Props { interface Props {
attachment: API.File; attachment: Attachment;
} }
export default function AttachmentActions({ attachment }: Props) { export default function AttachmentActions({ attachment }: Props) {
const client = useClient(); const client = useContext(AppContext);
const { filename, metadata, size } = attachment; const { filename, metadata, size } = attachment;
const url = client.generateFileURL(attachment); const url = client.generateFileURL(attachment)!;
const open_url = url; const open_url = `${url}/${filename}`;
const download_url = `${url}/${filename}`; const download_url = url.replace("attachments", "attachments/download");
const filesize = determineFileSize(size); const filesize = determineFileSize(size);
@@ -49,11 +49,10 @@ export default function AttachmentActions({ attachment }: Props) {
</IconButton> </IconButton>
</a> </a>
<a <a
target="_blank"
href={download_url} href={download_url}
className={styles.downloadIcon} className={styles.downloadIcon}
download download
// target={isFirefox || window.native ? "_blank" : "_self"} target="_blank"
rel="noreferrer"> rel="noreferrer">
<IconButton> <IconButton>
<Download size={24} /> <Download size={24} />
@@ -71,7 +70,7 @@ export default function AttachmentActions({ attachment }: Props) {
href={download_url} href={download_url}
className={styles.downloadIcon} className={styles.downloadIcon}
download download
target={isFirefox || window.native ? "_blank" : "_self"} target="_blank"
rel="noreferrer"> rel="noreferrer">
<IconButton> <IconButton>
<Download size={24} /> <Download size={24} />
@@ -91,7 +90,7 @@ export default function AttachmentActions({ attachment }: Props) {
href={download_url} href={download_url}
className={styles.downloadIcon} className={styles.downloadIcon}
download download
target={isFirefox || window.native ? "_blank" : "_self"} target="_blank"
rel="noreferrer"> rel="noreferrer">
<IconButton> <IconButton>
<Download size={24} /> <Download size={24} />
@@ -120,7 +119,7 @@ export default function AttachmentActions({ attachment }: Props) {
href={download_url} href={download_url}
className={styles.downloadIcon} className={styles.downloadIcon}
download download
target={isFirefox || window.native ? "_blank" : "_self"} target="_blank"
rel="noreferrer"> rel="noreferrer">
<IconButton> <IconButton>
<Download size={24} /> <Download size={24} />

View File

@@ -1,13 +1,12 @@
import styled from "styled-components/macro"; import styled from "styled-components";
import { Ref } from "preact"; import { Children } from "../../../../types/Preact";
const Grid = styled.div<{ width: number; height: number }>` const Grid = styled.div<{ width: number; height: number }>`
--width: ${(props) => props.width}px; --width: ${props => props.width}px;
--height: ${(props) => props.height}px; --height: ${props => props.height}px;
display: grid; display: grid;
overflow: hidden;
aspect-ratio: ${(props) => props.width} / ${(props) => props.height}; aspect-ratio: ${(props) => props.width} / ${(props) => props.height};
max-width: min(var(--width), var(--attachment-max-width)); max-width: min(var(--width), var(--attachment-max-width));
@@ -43,7 +42,7 @@ const Grid = styled.div<{ width: number; height: number }>`
overflow: hidden; overflow: hidden;
object-fit: contain; object-fit: contain;
// It's something // It's something
object-position: left; object-position: left;
} }
@@ -72,14 +71,13 @@ type Props = Omit<
children?: Children; children?: Children;
width: number; width: number;
height: number; height: number;
innerRef?: Ref<any>;
}; };
export function SizedGrid(props: Props) { export function SizedGrid(props: Props) {
const { width, height, children, innerRef, ...divProps } = props; const { width, height, children, ...divProps } = props;
return ( return (
<Grid {...divProps} width={width} height={height} ref={innerRef}> <Grid {...divProps} width={width} height={height}>
{children} {children}
</Grid> </Grid>
); );

View File

@@ -1,42 +1,47 @@
import { API } from "revolt.js"; import { Attachment } from "revolt-api/types/Autumn";
import styles from "./Attachment.module.scss"; import styles from "./Attachment.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { useState } from "preact/hooks"; import { useContext, useState } from "preact/hooks";
import { useClient } from "../../../../controllers/client/ClientController"; import { useIntermediate } from "../../../../context/intermediate/Intermediate";
import { modalController } from "../../../../controllers/modals/ModalController"; import { AppContext } from "../../../../context/revoltjs/RevoltClient";
enum ImageLoadingState { enum ImageLoadingState {
Loading, Loading,
Loaded, Loaded,
Error, Error
} }
type Props = JSX.HTMLAttributes<HTMLImageElement> & { type Props = JSX.HTMLAttributes<HTMLImageElement> & {
attachment: API.File; attachment: Attachment;
}; }
export default function ImageFile({ attachment, ...props }: Props) { export default function ImageFile({ attachment, ...props }: Props) {
const [loading, setLoading] = useState(ImageLoadingState.Loading); const [loading, setLoading] = useState(ImageLoadingState.Loading);
const client = useClient(); const client = useContext(AppContext);
const { openScreen } = useIntermediate();
const url = client.generateFileURL(attachment)!; const url = client.generateFileURL(attachment)!;
return ( return <img
<img {...props}
{...props} src={url}
src={url} alt={attachment.filename}
alt={attachment.filename} loading="lazy"
loading="lazy" className={classNames(styles.image, {
className={classNames(styles.image, { [styles.loading]: loading !== ImageLoadingState.Loaded
[styles.loading]: loading !== ImageLoadingState.Loaded, })}
})} onClick={() =>
onClick={() => openScreen({ id: "image_viewer", attachment })
modalController.push({ type: "image_viewer", attachment }) }
} onMouseDown={(ev) =>
onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")} ev.button === 1 && window.open(url, "_blank")
onLoad={() => setLoading(ImageLoadingState.Loaded)} }
onError={() => setLoading(ImageLoadingState.Error)} onLoad={() =>
/> setLoading(ImageLoadingState.Loaded)
); }
onError={() =>
setLoading(ImageLoadingState.Error)
}
/>
} }

View File

@@ -2,8 +2,10 @@ import { Reply } from "@styled-icons/boxicons-regular";
import { File } from "@styled-icons/boxicons-solid"; import { File } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { Channel, Message, API } from "revolt.js"; import { RelationshipStatus } from "revolt-api/types/Users";
import styled, { css } from "styled-components/macro"; import { Channel } from "revolt.js/dist/maps/Channels";
import { Message } from "revolt.js/dist/maps/Messages";
import styled, { css } from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useLayoutEffect, useState } from "preact/hooks"; import { useLayoutEffect, useState } from "preact/hooks";
@@ -16,7 +18,7 @@ import { SystemMessage } from "../SystemMessage";
interface Props { interface Props {
parent_mentions: string[]; parent_mentions: string[];
channel?: Channel; channel: Channel;
index: number; index: number;
id: string; id: string;
} }
@@ -26,25 +28,30 @@ export const ReplyBase = styled.div<{
fail?: boolean; fail?: boolean;
preview?: boolean; preview?: boolean;
}>` }>`
gap: 8px; gap: 4px;
min-width: 0; min-width: 0;
display: flex; display: flex;
margin-inline-start: 30px; margin-inline-start: 30px;
margin-inline-end: 12px; margin-inline-end: 12px;
font-size: 0.8em; font-size: 0.8em;
user-select: none; user-select: none;
align-items: end; align-items: center;
color: var(--secondary-foreground); color: var(--secondary-foreground);
/* nizune's Discord replies,
does not scale properly with messages,
reverted temporarily
&::before { &::before {
content: ""; content: "";
flex-shrink: 0;
width: 22px;
height: 10px; height: 10px;
border-inline-start: 2px solid var(--message-box); width: 28px;
border-top: 2px solid var(--message-box); margin-inline-end: 2px;
align-self: flex-end; align-self: flex-end;
} display: flex;
border-top: 2.2px solid var(--tertiary-foreground);
border-inline-start: 2.2px solid var(--tertiary-foreground);
border-start-start-radius: 6px;
}*/
* { * {
overflow: hidden; overflow: hidden;
@@ -53,7 +60,6 @@ export const ReplyBase = styled.div<{
} }
.user { .user {
//margin-inline-start: 12px;
gap: 6px; gap: 6px;
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
@@ -68,6 +74,13 @@ export const ReplyBase = styled.div<{
text-decoration: underline; text-decoration: underline;
} }
} }
/*&::before {
position:relative;
width: 50px;
height: 2px;
background: red;
}*/
} }
.content { .content {
@@ -85,16 +98,6 @@ export const ReplyBase = styled.div<{
transition: transform ease-in-out 0.1s; transition: transform ease-in-out 0.1s;
filter: brightness(1); filter: brightness(1);
> svg {
flex-shrink: 0;
}
> span > p {
display: flex;
align-items: center;
gap: 4px;
}
&:hover { &:hover {
filter: brightness(2); filter: brightness(2);
} }
@@ -106,6 +109,10 @@ export const ReplyBase = styled.div<{
> * { > * {
pointer-events: none; pointer-events: none;
} }
/*> span > p {
display: flex;
}*/
} }
> svg:first-child { > svg:first-child {
@@ -124,10 +131,6 @@ export const ReplyBase = styled.div<{
props.head && props.head &&
css` css`
margin-top: 12px; margin-top: 12px;
&::before {
border-start-start-radius: 4px;
}
`} `}
${(props) => ${(props) =>
@@ -139,8 +142,6 @@ export const ReplyBase = styled.div<{
export const MessageReply = observer( export const MessageReply = observer(
({ index, channel, id, parent_mentions }: Props) => { ({ index, channel, id, parent_mentions }: Props) => {
if (!channel) return null;
const view = getRenderer(channel); const view = getRenderer(channel);
if (view.state !== "RENDER") return null; if (view.state !== "RENDER") return null;
@@ -170,9 +171,8 @@ export const MessageReply = observer(
return ( return (
<ReplyBase head={index === 0}> <ReplyBase head={index === 0}>
{/*<Reply size={16} />*/} <Reply size={16} />
{message.author?.relationship === RelationshipStatus.Blocked ? (
{message.author?.relationship === "Blocked" ? (
<Text id="app.main.channel.misc.blocked_user" /> <Text id="app.main.channel.misc.blocked_user" />
) : ( ) : (
<> <>
@@ -182,7 +182,7 @@ export const MessageReply = observer(
<> <>
<div className="user"> <div className="user">
<UserShort <UserShort
size={14} size={16}
showServerIdentity showServerIdentity
user={message.author} user={message.author}
masquerade={message.masquerade!} masquerade={message.masquerade!}
@@ -221,15 +221,12 @@ export const MessageReply = observer(
</em> </em>
</> </>
)} )}
{message.content && ( <Markdown
<Markdown disallowBigEmoji
disallowBigEmoji content={(
content={message.content.replace( message.content as string
/\n/g, ).replace(/\n/g, " ")}
" ", />
)}
/>
)}
</div> </div>
</> </>
)} )}

View File

@@ -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,
)}
</>
);
};

View File

@@ -1,4 +1,4 @@
import styled from "styled-components/macro"; import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";

View File

@@ -1,33 +1,41 @@
import axios from "axios"; import axios from "axios";
import { API } from "revolt.js"; import { Attachment } from "revolt-api/types/Autumn";
import styles from "./Attachment.module.scss"; import styles from "./Attachment.module.scss";
import { Text } from "preact-i18n"; import { useContext, useEffect, useState } from "preact/hooks";
import { 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 Preloader from "../../../ui/Preloader";
import RequiresOnline from "../../../../controllers/client/jsx/RequiresOnline";
interface Props { interface Props {
attachment: API.File; attachment: Attachment;
} }
const fileCache: { [key: string]: string } = {}; const fileCache: { [key: string]: string } = {};
export default function TextFile({ attachment }: Props) { export default function TextFile({ attachment }: Props) {
const [gated, setGated] = useState(attachment.size > 100_000);
const [content, setContent] = useState<undefined | string>(undefined); const [content, setContent] = useState<undefined | string>(undefined);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const status = useContext(StatusContext);
const client = useContext(AppContext);
const client = useClient();
const url = client.generateFileURL(attachment)!; const url = client.generateFileURL(attachment)!;
useEffect(() => { useEffect(() => {
if (typeof content !== "undefined") return; if (typeof content !== "undefined") return;
if (loading) return; if (loading) return;
if (gated) return;
if (attachment.size > 100_000) {
setContent(
"This file is > 100 KB, for your sake I did not load it.\nSee tracking issue here for previews: https://github.com/revoltchat/revite/issues/35",
);
return;
}
setLoading(true); setLoading(true);
@@ -52,17 +60,13 @@ export default function TextFile({ attachment }: Props) {
setLoading(false); setLoading(false);
}); });
} }
}, [content, loading, gated, attachment._id, attachment.size, url]); }, [content, loading, status, attachment._id, attachment.size, url]);
return ( return (
<div <div
className={styles.textContent} className={styles.textContent}
data-loading={typeof content === "undefined"}> data-loading={typeof content === "undefined"}>
{gated ? ( {content ? (
<Button palette="accent" onClick={() => setGated(false)}>
<Text id="app.main.channel.misc.load_file" />
</Button>
) : content ? (
<pre> <pre>
<code>{content}</code> <code>{content}</code>
</pre> </pre>

View File

@@ -1,6 +1,6 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { XCircle, Plus, Share, X, File } from "@styled-icons/boxicons-regular"; import { XCircle, Plus, Share, X, File } from "@styled-icons/boxicons-regular";
import styled from "styled-components/macro"; import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
@@ -149,12 +149,12 @@ function FileEntry({
<EmptyEntry className="icon"> <EmptyEntry className="icon">
<File size={36} /> <File size={36} />
</EmptyEntry> </EmptyEntry>
<div className="overlay"> <div class="overlay">
<XCircle size={36} /> <XCircle size={36} />
</div> </div>
</PreviewBox> </PreviewBox>
<span className="fn">{file.name}</span> <span class="fn">{file.name}</span>
<span className="size">{determineFileSize(file.size)}</span> <span class="size">{determineFileSize(file.size)}</span>
</Entry> </Entry>
); );
@@ -169,18 +169,13 @@ function FileEntry({
return ( return (
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}> <Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
<PreviewBox onClick={remove}> <PreviewBox onClick={remove}>
<img <img class="icon" src={url} alt={file.name} loading="eager" />
className="icon" <div class="overlay">
src={url}
alt={file.name}
loading="eager"
/>
<div className="overlay">
<XCircle size={36} /> <XCircle size={36} />
</div> </div>
</PreviewBox> </PreviewBox>
<span className="fn">{file.name}</span> <span class="fn">{file.name}</span>
<span className="size">{determineFileSize(file.size)}</span> <span class="size">{determineFileSize(file.size)}</span>
</Entry> </Entry>
); );
} }

View File

@@ -1,121 +1,38 @@
import { DownArrowAlt } from "@styled-icons/boxicons-regular"; import { DownArrowAlt } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js"; import { Channel } from "revolt.js/dist/maps/Channels";
import styled, { css } from "styled-components/macro"; import styled, { css } from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { internalEmit } from "../../../../lib/eventEmitter";
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
import { getRenderer } from "../../../../lib/renderer/Singleton"; import { getRenderer } from "../../../../lib/renderer/Singleton";
export const Bar = styled.div<{ position: "top" | "bottom"; accent?: boolean }>` const Bar = styled.div`
z-index: 1; z-index: 10;
position: relative; position: relative;
@keyframes bottomBounce {
0% {
transform: translateY(33px);
}
100% {
transform: translateY(0px);
}
}
@keyframes topBounce {
0% {
transform: translateY(-33px);
}
100% {
transform: translateY(0px);
}
}
${(props) =>
props.position === "top" &&
css`
top: 0;
animation: topBounce 340ms cubic-bezier(0.2, 0.9, 0.5, 1.16)
forwards;
`}
${(props) =>
props.position === "bottom" &&
css`
top: -28px;
animation: bottomBounce 340ms cubic-bezier(0.2, 0.9, 0.5, 1.16)
forwards;
${() =>
isTouchscreenDevice &&
css`
top: -90px;
`}
`}
> div { > div {
top: -26px;
height: 28px; height: 28px;
width: 100%; width: 100%;
position: absolute; position: absolute;
display: flex; display: flex;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 13px;
font-weight: 600;
padding: 0 8px; padding: 0 8px;
user-select: none; user-select: none;
justify-content: space-between; justify-content: space-between;
color: var(--secondary-foreground);
transition: color ease-in-out 0.08s; transition: color ease-in-out 0.08s;
background: var(--secondary-background);
white-space: nowrap; border-radius: var(--border-radius) var(--border-radius) 0 0;
overflow: hidden;
text-overflow: ellipsis;
${(props) =>
props.accent
? css`
color: var(--accent-contrast);
background-color: rgba(
var(--accent-rgb),
max(var(--min-opacity), 0.9)
);
backdrop-filter: blur(20px);
`
: css`
color: var(--secondary-foreground);
background-color: rgba(
var(--secondary-background-rgb),
max(var(--min-opacity), 0.9)
);
backdrop-filter: blur(20px);
`}
${(props) =>
props.position === "top"
? css`
top: 48px;
border-radius: 0 0 var(--border-radius)
var(--border-radius);
`
: css`
border-radius: var(--border-radius) var(--border-radius) 0
0;
`}
${() =>
isTouchscreenDevice &&
css`
top: 56px;
`}
> div { > div {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
&:hover { &:hover {
@@ -130,15 +47,10 @@ export const Bar = styled.div<{ position: "top" | "bottom"; accent?: boolean }>`
isTouchscreenDevice && isTouchscreenDevice &&
css` css`
height: 34px; height: 34px;
top: -32px;
padding: 0 12px; padding: 0 12px;
`} `}
} }
@media only screen and (max-width: 800px) {
.right > span {
display: none;
}
}
`; `;
export default observer(({ channel }: { channel: Channel }) => { export default observer(({ channel }: { channel: Channel }) => {
@@ -146,20 +58,14 @@ export default observer(({ channel }: { channel: Channel }) => {
if (renderer.state !== "RENDER" || renderer.atBottom) return null; if (renderer.state !== "RENDER" || renderer.atBottom) return null;
return ( return (
<Bar position="bottom"> <Bar>
<div <div onClick={() => renderer.jumpToBottom(true)}>
onClick={() => {
renderer.jumpToBottom(true);
internalEmit("NewMessages", "hide");
}}>
<div> <div>
<Text id="app.main.channel.misc.viewing_old" /> <Text id="app.main.channel.misc.viewing_old" />
</div> </div>
<div className="right"> <div>
<span> <Text id="app.main.channel.misc.jump_present" />{" "}
<Text id="app.main.channel.misc.jump_present" /> <DownArrowAlt size={20} />
</span>
<DownArrowAlt size={18} />
</div> </div>
</div> </div>
</Bar> </Bar>

View File

@@ -1,236 +0,0 @@
import { DotsVerticalRounded, LinkAlt } from "@styled-icons/boxicons-regular";
import {
Pencil,
Trash,
Share,
InfoSquare,
Notification,
HappyBeaming,
} from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Message as MessageObject } from "revolt.js";
import styled from "styled-components";
import { openContextMenu } from "preact-context-menu";
import { useEffect, useState } from "preact/hooks";
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 Tooltip from "../../../common/Tooltip";
import { ReactionWrapper } from "../attachments/Reactions";
interface Props {
reactionsOpen: boolean;
setReactionsOpen: (v: boolean) => void;
message: MessageObject;
queued?: QueuedMessage;
}
const OverlayBar = styled.div`
display: flex;
position: absolute;
justify-self: end;
align-self: end;
align-content: center;
justify-content: center;
right: 0;
top: -18px;
z-index: 0;
transition: box-shadow 0.1s ease-out;
border-radius: 5px;
background: var(--primary-header);
border: 1px solid var(--background);
&:hover {
box-shadow: rgb(0 0 0 / 20%) 0px 2px 10px;
}
`;
const Entry = styled.div`
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
transition: 0.2s ease background-color;
color: var(--secondary-foreground);
&:hover {
background-color: var(--secondary-header);
color: var(--foreground);
}
&:focus {
border-radius: var(--border-radius);
box-shadow: 0 0 0 2.5pt var(--accent);
}
&:active {
svg {
transform: translateY(1px);
}
}
`;
const Divider = styled.div`
margin: 6px 4px;
width: 0.5px;
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;
const [copied, setCopied] = useState<"link" | "id">(null!);
const [extraActions, setExtra] = useState(shiftKeyPressed);
useEffect(() => {
const handler = (ev: KeyboardEvent) => setExtra(ev.shiftKey);
document.addEventListener("keyup", handler);
document.addEventListener("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>
)}
{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">
<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;
}
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>
);
},
);

View File

@@ -1,78 +0,0 @@
import { UpArrowAlt } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { Channel } from "revolt.js";
import { decodeTime } from "ulid";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import { internalSubscribe } from "../../../../lib/eventEmitter";
import { getRenderer } from "../../../../lib/renderer/Singleton";
import { dayjs } from "../../../../context/Locale";
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]);
useEffect(() => internalSubscribe("NewMessages", "hide", hide), []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) =>
e.key === "Escape" && hide();
document.addEventListener("keydown", onKeyDown);
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;
if (!last_id) return null;
if (hidden) return null;
return (
<Bar position="top" accent>
<div
onClick={() => {
setHidden(true);
if (channel.channel_type === "TextChannel") {
history.push(
`/server/${channel.server_id}/channel/${channel._id}/${last_id}`,
);
} else {
history.push(`/channel/${channel._id}/${last_id}`);
}
}}>
<div>
<Text
id="app.main.channel.misc.new_messages"
fields={{
time_ago: timeAgo,
}}
/>
</div>
<div className="right">
<span>
<Text id="app.main.channel.misc.jump_beginning" />
</span>
<UpArrowAlt size={20} />
</div>
</div>
</Bar>
);
},
);

View File

@@ -1,21 +1,20 @@
import { At } from "@styled-icons/boxicons-regular"; import { At, Reply as ReplyIcon } from "@styled-icons/boxicons-regular";
import { File, XCircle } from "@styled-icons/boxicons-solid"; import { File, XCircle } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Channel, Message } from "revolt.js"; import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components/macro"; import { Message } from "revolt.js/dist/maps/Messages";
import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { StateUpdater, useEffect } from "preact/hooks"; import { StateUpdater, useEffect } from "preact/hooks";
import { IconButton } from "@revoltchat/ui";
import { internalSubscribe } from "../../../../lib/eventEmitter"; import { internalSubscribe } from "../../../../lib/eventEmitter";
import { useApplicationState } from "../../../../mobx/State"; import { dispatch, getState } from "../../../../redux";
import { SECTION_MENTION } from "../../../../mobx/stores/Layout"; import { Reply } from "../../../../redux/reducers/queue";
import { Reply } from "../../../../mobx/stores/MessageQueue";
import IconButton from "../../../ui/IconButton";
import Tooltip from "../../../common/Tooltip";
import Markdown from "../../../markdown/Markdown"; import Markdown from "../../../markdown/Markdown";
import UserShort from "../../user/UserShort"; import UserShort from "../../user/UserShort";
import { SystemMessage } from "../SystemMessage"; import { SystemMessage } from "../SystemMessage";
@@ -28,22 +27,12 @@ interface Props {
} }
const Base = styled.div` const Base = styled.div`
@keyframes bottomBounce {
0% {
transform: translateY(33px);
}
100% {
transform: translateY(0px);
}
}
display: flex; display: flex;
height: 30px; height: 30px;
padding: 0 20px; padding: 0 12px;
user-select: none; user-select: none;
align-items: center; align-items: center;
background: var(--secondary-background); background: var(--message-box);
animation: bottomBounce 340ms cubic-bezier(0.2, 0.9, 0.5, 1.16) forwards;
> div { > div {
flex-grow: 1; flex-grow: 1;
@@ -59,34 +48,21 @@ const Base = styled.div`
display: flex; display: flex;
font-size: 12px; font-size: 12px;
align-items: center; align-items: center;
font-weight: 800; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
min-width: 6ch; min-width: 6ch;
} }
.replyto { .username {
align-self: center; display: flex;
font-weight: 500; align-items: center;
flex-shrink: 0; gap: 6px;
font-weight: 600;
} }
.content { .message {
display: flex; display: flex;
pointer-events: none; max-height: 26px;
.username {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
flex-shrink: 0;
}
.message {
display: flex;
max-height: 26px;
gap: 4px;
}
} }
.actions { .actions {
@@ -105,7 +81,6 @@ const Base = styled.div`
const MAX_REPLIES = 5; const MAX_REPLIES = 5;
export default observer(({ channel, replies, setReplies }: Props) => { export default observer(({ channel, replies, setReplies }: Props) => {
const client = channel.client; const client = channel.client;
const layout = useApplicationState().layout;
// Event listener for adding new messages to reply bar. // Event listener for adding new messages to reply bar.
useEffect(() => { useEffect(() => {
@@ -124,7 +99,7 @@ export default observer(({ channel, replies, setReplies }: Props) => {
mention: mention:
message.author_id === client.user!._id message.author_id === client.user!._id
? false ? false
: layout.getSectionState(SECTION_MENTION, false), : getState().sectionToggle.mention ?? false,
}, },
]); ]);
}); });
@@ -152,53 +127,42 @@ export default observer(({ channel, replies, setReplies }: Props) => {
return ( return (
<Base key={reply.id}> <Base key={reply.id}>
<ReplyBase preview> <ReplyBase preview>
<div className="replyto"> <ReplyIcon size={22} />
<Text id="app.main.channel.reply.replying" /> <div class="username">
<UserShort
size={16}
showServerIdentity
user={message.author}
masquerade={message.masquerade!}
/>
</div> </div>
<div className="content"> <div class="message">
<div className="username"> {message.attachments && (
<UserShort <>
size={16} <File size={16} />
showServerIdentity <em>
user={message.author} {message.attachments.length > 1 ? (
masquerade={message.masquerade!} <Text id="app.main.channel.misc.sent_multiple_files" />
) : (
<Text id="app.main.channel.misc.sent_file" />
)}
</em>
</>
)}
{message.author_id ===
"00000000000000000000000000" ? (
<SystemMessage message={message} hideInfo />
) : (
<Markdown
disallowBigEmoji
content={(
message.content as string
).replace(/\n/g, " ")}
/> />
</div> )}
<div className="message">
{message.attachments && (
<>
<File size={16} />
<em>
{message.attachments.length >
1 ? (
<Text id="app.main.channel.misc.sent_multiple_files" />
) : (
<Text id="app.main.channel.misc.sent_file" />
)}
</em>
</>
)}
{message.author_id ===
"00000000000000000000000000" ? (
<SystemMessage
message={message}
hideInfo
/>
) : (
message.content && (
<Markdown
disallowBigEmoji
content={message.content.replace(
/\n/g,
" ",
)}
/>
)
)}
</div>
</div> </div>
</ReplyBase> </ReplyBase>
<span className="actions"> <span class="actions">
{message.author_id !== client.user!._id && ( {message.author_id !== client.user!._id && (
<IconButton <IconButton
onClick={() => { onClick={() => {
@@ -217,27 +181,22 @@ export default observer(({ channel, replies, setReplies }: Props) => {
}), }),
); );
layout.setSectionState( dispatch({
SECTION_MENTION, type: "SECTION_TOGGLE_SET",
id: "mention",
state, state,
false, });
);
}}> }}>
<Tooltip <span class="toggle">
content={ <At size={15} />
<Text id="app.main.channel.reply.toggle" /> <Text
}> id={
<span className="toggle"> reply.mention
<At size={15} /> ? "general.on"
<Text : "general.off"
id={ }
reply.mention />
? "general.on" </span>
: "general.off"
}
/>
</span>
</Tooltip>
</IconButton> </IconButton>
)} )}
<IconButton <IconButton

View File

@@ -1,6 +1,7 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js"; import { RelationshipStatus } from "revolt-api/types/Users";
import styled from "styled-components/macro"; import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
@@ -12,10 +13,10 @@ const Base = styled.div`
position: relative; position: relative;
> div { > div {
height: 26px; height: 24px;
top: -26px; margin-top: -24px;
position: absolute; position: absolute;
font-size: 13px;
gap: 8px; gap: 8px;
display: flex; display: flex;
padding: 0 10px; padding: 0 10px;
@@ -24,11 +25,7 @@ const Base = styled.div`
flex-direction: row; flex-direction: row;
width: calc(100% - var(--scrollbar-thickness)); width: calc(100% - var(--scrollbar-thickness));
color: var(--secondary-foreground); color: var(--secondary-foreground);
background-color: rgba( background: var(--secondary-background);
var(--secondary-background-rgb),
max(var(--min-opacity), 0.75)
);
backdrop-filter: blur(10px);
} }
.avatars { .avatars {
@@ -39,12 +36,9 @@ const Base = styled.div`
height: 16px; height: 16px;
object-fit: cover; object-fit: cover;
border-radius: var(--border-radius-half); border-radius: var(--border-radius-half);
background: var(--secondary-background);
//background-clip: border-box;
border: 2px solid var(--secondary-background);
&:not(:first-child) { &:not(:first-child) {
margin-left: -6px; margin-left: -4px;
} }
} }
} }
@@ -55,7 +49,6 @@ const Base = styled.div`
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
//font-weight: 600;
} }
`; `;
@@ -64,7 +57,7 @@ export default observer(({ channel }: Props) => {
(x) => (x) =>
typeof x !== "undefined" && typeof x !== "undefined" &&
x._id !== x.client.user!._id && x._id !== x.client.user!._id &&
x.relationship !== "Blocked", x.relationship !== RelationshipStatus.Blocked,
); );
if (users.length > 0) { if (users.length > 0) {
@@ -76,9 +69,7 @@ export default observer(({ channel }: Props) => {
if (users.length >= 5) { if (users.length >= 5) {
text = <Text id="app.main.channel.typing.several" />; text = <Text id="app.main.channel.typing.several" />;
} else if (users.length > 1) { } else if (users.length > 1) {
const userlist = [...users].map( const userlist = [...users].map((x) => x!.username);
(x) => x!.display_name ?? x!.username,
);
const user = userlist.pop(); const user = userlist.pop();
text = ( text = (
@@ -94,9 +85,7 @@ export default observer(({ channel }: Props) => {
text = ( text = (
<Text <Text
id="app.main.channel.typing.single" id="app.main.channel.typing.single"
fields={{ fields={{ user: users[0]!.username }}
user: users[0]!.display_name ?? users[0]!.username,
}}
/> />
); );
} }

View File

@@ -4,7 +4,6 @@
iframe { iframe {
border: none; border: none;
border-radius: var(--border-radius); border-radius: var(--border-radius);
width: 100%;
} }
&.image { &.image {
@@ -13,6 +12,7 @@
&.website { &.website {
gap: 6px; gap: 6px;
display: flex;
flex-direction: row; flex-direction: row;
> div:nth-child(1) { > div:nth-child(1) {

View File

@@ -1,18 +1,17 @@
import { API } from "revolt.js"; import { Embed as EmbedI } from "revolt-api/types/January";
import styles from "./Embed.module.scss"; import styles from "./Embed.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { useClient } from "../../../../controllers/client/ClientController"; import { useIntermediate } from "../../../../context/intermediate/Intermediate";
import { modalController } from "../../../../controllers/modals/ModalController"; import { useClient } from "../../../../context/revoltjs/RevoltClient";
import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea"; import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea";
import Markdown from "../../../markdown/Markdown";
import Attachment from "../attachments/Attachment";
import EmbedMedia from "./EmbedMedia"; import EmbedMedia from "./EmbedMedia";
interface Props { interface Props {
embed: API.Embed; embed: EmbedI;
} }
const MAX_EMBED_WIDTH = 480; const MAX_EMBED_WIDTH = 480;
@@ -23,6 +22,7 @@ const MAX_PREVIEW_SIZE = 150;
export default function Embed({ embed }: Props) { export default function Embed({ embed }: Props) {
const client = useClient(); const client = useClient();
const { openScreen, openLink } = useIntermediate();
const maxWidth = Math.min( const maxWidth = Math.min(
useContext(MessageAreaWidthContext) - CONTAINER_PADDING, useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
MAX_EMBED_WIDTH, MAX_EMBED_WIDTH,
@@ -45,64 +45,39 @@ export default function Embed({ embed }: Props) {
} }
switch (embed.type) { switch (embed.type) {
case "Text":
case "Website": { case "Website": {
// Determine special embed size. // Determine special embed size.
let mw, mh; let mw, mh;
const largeMedia = const largeMedia =
embed.type === "Text" (embed.special && embed.special.type !== "None") ||
? typeof embed.media !== "undefined" embed.image?.size === "Large";
: (embed.special && embed.special.type !== "None") || switch (embed.special?.type) {
embed.image?.size === "Large"; case "YouTube":
case "Bandcamp": {
if (embed.type === "Text") { mw = embed.video?.width ?? 1280;
mw = MAX_EMBED_WIDTH; mh = embed.video?.height ?? 720;
mh = 1; break;
} else { }
switch (embed.special?.type) { case "Twitch": {
case "YouTube": mw = 1280;
case "Bandcamp": { mh = 720;
mw = embed.video?.width ?? 1280; break;
mh = embed.video?.height ?? 720; }
break; default: {
} if (embed.image?.size === "Preview") {
case "Twitch": mw = MAX_EMBED_WIDTH;
case "Lightspeed": mh = Math.min(
case "Streamable": { embed.image.height ?? 0,
mw = 1280; MAX_PREVIEW_SIZE,
mh = 720; );
break; } else {
} mw = embed.image?.width ?? MAX_EMBED_WIDTH;
default: { mh = embed.image?.height ?? 0;
if (embed.image?.size === "Preview") {
mw = MAX_EMBED_WIDTH;
mh = Math.min(
embed.image.height ?? 0,
MAX_PREVIEW_SIZE,
);
} else {
mw = embed.image?.width ?? MAX_EMBED_WIDTH;
mh = embed.image?.height ?? 0;
}
} }
} }
} }
const { width, height } = calculateSize(mw, mh); const { width, height } = calculateSize(mw, mh);
if (embed.type === "Website" && embed.special?.type === "GIF") {
return (
<EmbedMedia
embed={embed}
width={
height *
((embed.image?.width ?? 0) /
(embed.image?.height ?? 0))
}
height={height}
/>
);
}
return ( return (
<div <div
className={classNames(styles.embed, styles.website)} className={classNames(styles.embed, styles.website)}
@@ -112,9 +87,7 @@ export default function Embed({ embed }: Props) {
width: width + CONTAINER_PADDING, width: width + CONTAINER_PADDING,
}}> }}>
<div> <div>
{(embed.type === "Text" {embed.site_name && (
? embed.title
: embed.site_name) && (
<div className={styles.siteinfo}> <div className={styles.siteinfo}>
{embed.icon_url && ( {embed.icon_url && (
<img <img
@@ -129,47 +102,35 @@ export default function Embed({ embed }: Props) {
/> />
)} )}
<div className={styles.site}> <div className={styles.site}>
{embed.type === "Text" {embed.site_name}{" "}
? embed.title
: embed.site_name}{" "}
</div> </div>
</div> </div>
)} )}
{/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/} {/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/}
{embed.type === "Website" && embed.title && ( {embed.title && (
<span> <span>
<a <a
onMouseDown={(ev) => onMouseDown={(ev) =>
(ev.button === 0 || ev.button === 1) && (ev.button === 0 || ev.button === 1) &&
modalController.openLink( openLink(embed.url)
embed.url!,
undefined,
true,
)
} }
className={styles.title}> className={styles.title}>
{embed.title} {embed.title}
</a> </a>
</span> </span>
)} )}
{embed.description && {embed.description && (
(embed.type === "Text" ? ( <div className={styles.description}>
<Markdown content={embed.description} /> {embed.description}
) : ( </div>
<div className={styles.description}> )}
{embed.description}
</div>
))}
{largeMedia && {largeMedia && (
(embed.type === "Text" ? ( <EmbedMedia embed={embed} height={height} />
<Attachment attachment={embed.media!} /> )}
) : (
<EmbedMedia embed={embed} height={height} />
))}
</div> </div>
{!largeMedia && embed.type === "Website" && ( {!largeMedia && (
<div> <div>
<EmbedMedia <EmbedMedia
embed={embed} embed={embed}
@@ -194,25 +155,8 @@ export default function Embed({ embed }: Props) {
type="text/html" type="text/html"
frameBorder="0" frameBorder="0"
loading="lazy" loading="lazy"
onClick={() => onClick={() => openScreen({ id: "image_viewer", embed })}
modalController.push({ type: "image_viewer", embed }) onMouseDown={(ev) => ev.button === 1 && openLink(embed.url)}
}
onMouseDown={(ev) =>
ev.button === 1 &&
modalController.openLink(embed.url, undefined, true)
}
/>
);
}
case "Video": {
return (
<video
className={classNames(styles.embed, styles.image)}
style={calculateSize(embed.width, embed.height)}
src={client.proxyFile(embed.url)}
frameBorder="0"
loading="lazy"
controls
/> />
); );
} }

View File

@@ -1,23 +1,28 @@
import { Group } from "@styled-icons/boxicons-solid"; import { autorun } from "mobx";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { Message, API } from "revolt.js"; import { RetrievedInvite } from "revolt-api/types/Invites";
import styled, { css } from "styled-components/macro"; import { Message } from "revolt.js/dist/maps/Messages";
import styled, { css } from "styled-components";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { Button, Category, Preloader } from "@revoltchat/ui"; import { defer } from "../../../../lib/defer";
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
import { I18nError } from "../../../../context/Locale"; import { dispatch } from "../../../../redux";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../../context/revoltjs/util";
import ServerIcon from "../../../../components/common/ServerIcon"; import ServerIcon from "../../../../components/common/ServerIcon";
import { import Button from "../../../../components/ui/Button";
useClient, import Overline from "../../../ui/Overline";
useSession, import Preloader from "../../../ui/Preloader";
} from "../../../../controllers/client/ClientController";
import { takeError } from "../../../../controllers/client/jsx/error";
const EmbedInviteBase = styled.div` const EmbedInviteBase = styled.div`
width: 400px; width: 400px;
@@ -28,7 +33,7 @@ const EmbedInviteBase = styled.div`
align-items: center; align-items: center;
padding: 0 12px; padding: 0 12px;
margin-top: 2px; margin-top: 2px;
${() => ${() =>
isTouchscreenDevice && isTouchscreenDevice &&
css` css`
flex-wrap: wrap; flex-wrap: wrap;
@@ -39,17 +44,19 @@ const EmbedInviteBase = styled.div`
> button { > button {
width: 100%; width: 100%;
} }
`} `
}
`; `;
const EmbedInviteDetails = styled.div` const EmbedInviteDetails = styled.div`
flex-grow: 1; flex-grow: 1;
padding-inline-start: 12px; padding-left: 12px;
${() => ${() =>
isTouchscreenDevice && isTouchscreenDevice &&
css` css`
width: calc(100% - 55px); width: calc(100% - 55px);
`} `
}
`; `;
const EmbedInviteName = styled.div` const EmbedInviteName = styled.div`
@@ -60,44 +67,36 @@ const EmbedInviteName = styled.div`
`; `;
const EmbedInviteMemberCount = styled.div` const EmbedInviteMemberCount = styled.div`
display: flex;
align-items: center;
gap: 2px;
font-size: 0.8em; font-size: 0.8em;
> svg {
color: var(--secondary-foreground);
}
`; `;
type Props = { type Props = {
code: string; code: string;
}; };
export function EmbedInvite({ code }: Props) { export function EmbedInvite(props: Props) {
const history = useHistory(); const history = useHistory();
const session = useSession()!; const client = useContext(AppContext);
const client = session.client!; const status = useContext(StatusContext);
const code = props.code;
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const [joinError, setJoinError] = useState<string | undefined>(undefined); const [joinError, setJoinError] = useState<string | undefined>(undefined);
const [invite, setInvite] = useState< const [invite, setInvite] = useState<RetrievedInvite | undefined>(
(API.InviteResponse & { type: "Server" }) | undefined undefined,
>(undefined); );
useEffect(() => { useEffect(() => {
if ( if (
typeof invite === "undefined" && typeof invite === "undefined" &&
(session.state === "Online" || session.state === "Ready") (status === ClientStatus.ONLINE || status === ClientStatus.READY)
) { ) {
client client
.fetchInvite(code) .fetchInvite(code)
.then((data) => .then((data) => setInvite(data))
setInvite(data as API.InviteResponse & { type: "Server" }),
)
.catch((err) => setError(takeError(err))); .catch((err) => setError(takeError(err)));
} }
}, [client, code, invite, session.state]); }, [client, code, invite, status]);
if (typeof invite === "undefined") { if (typeof invite === "undefined") {
return error ? ( return error ? (
@@ -125,17 +124,7 @@ export function EmbedInvite({ code }: Props) {
<EmbedInviteDetails> <EmbedInviteDetails>
<EmbedInviteName>{invite.server_name}</EmbedInviteName> <EmbedInviteName>{invite.server_name}</EmbedInviteName>
<EmbedInviteMemberCount> <EmbedInviteMemberCount>
<Group size={12} /> {invite.member_count.toLocaleString()} {invite.member_count === 1 ? "member" : "members"}
{invite.member_count != null ? (
<>
{invite.member_count.toLocaleString()}{" "}
{invite.member_count === 1
? "member"
: "members"}
</>
) : (
"N/A"
)}
</EmbedInviteMemberCount> </EmbedInviteMemberCount>
</EmbedInviteDetails> </EmbedInviteDetails>
{processing ? ( {processing ? (
@@ -145,31 +134,49 @@ export function EmbedInvite({ code }: Props) {
) : ( ) : (
<Button <Button
onClick={async () => { onClick={async () => {
setProcessing(true);
try { try {
await client.joinInvite(invite); setProcessing(true);
history.push( if (invite.type === "Server") {
`/server/${invite.server_id}/channel/${invite.channel_id}`, if (client.servers.get(invite.server_id)) {
); history.push(
`/server/${invite.server_id}/channel/${invite.channel_id}`,
);
}
const dispose = autorun(() => {
const server = client.servers.get(
invite.server_id,
);
defer(() => {
if (server) {
dispatch({
type: "UNREADS_MARK_MULTIPLE_READ",
channels: server.channel_ids,
});
history.push(
`/server/${server._id}/channel/${invite.channel_id}`,
);
}
});
dispose();
});
}
await client.joinInvite(code);
} catch (err) { } catch (err) {
setJoinError(takeError(err)); setJoinError(takeError(err));
} finally {
setProcessing(false); setProcessing(false);
} }
}}> }}>
{client.servers.get(invite.server_id) {client.servers.get(invite.server_id) ? "Joined" : "Join"}
? "Joined"
: "Join"}
</Button> </Button>
)} )}
</EmbedInviteBase> </EmbedInviteBase>
{joinError && ( {joinError && <Overline type="error" error={joinError} />}
<Category>
<I18nError error={joinError} />
</Category>
)}
</> </>
); );
} }
@@ -200,12 +207,9 @@ export default observer(({ message }: { message: Message }) => {
return ( return (
<> <>
{entries.map( {entries.map((entry) => (
(entry) => <EmbedInvite key={entry} code={entry} />
entry !== "discover" && ( ))}
<EmbedInvite key={entry} code={entry} />
),
)}
</> </>
); );
} }

View File

@@ -1,19 +1,20 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { API } from "revolt.js"; import { Embed } from "revolt-api/types/January";
import styles from "./Embed.module.scss"; import styles from "./Embed.module.scss";
import { useClient } from "../../../../controllers/client/ClientController"; import { useIntermediate } from "../../../../context/intermediate/Intermediate";
import { modalController } from "../../../../controllers/modals/ModalController"; import { useClient } from "../../../../context/revoltjs/RevoltClient";
interface Props { interface Props {
embed: API.Embed; embed: Embed;
width?: number; width?: number;
height: number; height: number;
} }
export default function EmbedMedia({ embed, width, height }: Props) { export default function EmbedMedia({ embed, width, height }: Props) {
if (embed.type !== "Website") return null; if (embed.type !== "Website") return null;
const { openScreen } = useIntermediate();
const client = useClient(); const client = useClient();
switch (embed.special?.type) { switch (embed.special?.type) {
@@ -46,17 +47,6 @@ export default function EmbedMedia({ embed, width, height }: Props) {
style={{ height }} style={{ height }}
/> />
); );
case "Lightspeed":
return (
<iframe
src={`https://new.lightspeed.tv/embed/${embed.special.id}/stream`}
frameBorder="0"
allowFullScreen
scrolling="no"
loading="lazy"
style={{ height }}
/>
);
case "Spotify": case "Spotify":
return ( return (
<iframe <iframe
@@ -92,43 +82,19 @@ 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: { default: {
if (embed.video) { if (embed.image) {
const url = embed.video.url;
return (
<video
loading="lazy"
className={styles.image}
style={{ width, height }}
src={client.proxyFile(url)}
loop={embed.special?.type === "GIF"}
controls={embed.special?.type !== "GIF"}
autoPlay={embed.special?.type === "GIF"}
muted={embed.special?.type === "GIF" ? true : undefined}
/>
);
} else if (embed.image) {
const url = embed.image.url; const url = embed.image.url;
return ( return (
<img <img
className={styles.image} className={styles.image}
src={client.proxyFile(url)} src={client.proxyFile(url)}
loading="lazy" loading="lazy"
style={{ width: "100%", height: "100%" }} style={{ width, height }}
onClick={() => onClick={() =>
modalController.push({ openScreen({
type: "image_viewer", id: "image_viewer",
embed: embed.image!, embed: embed.image,
}) })
} }
onMouseDown={(ev) => onMouseDown={(ev) =>

View File

@@ -1,12 +1,12 @@
import { LinkExternal } from "@styled-icons/boxicons-regular"; import { LinkExternal } from "@styled-icons/boxicons-regular";
import { API } from "revolt.js"; import { EmbedImage } from "revolt-api/types/January";
import styles from "./Embed.module.scss"; import styles from "./Embed.module.scss";
import { IconButton } from "@revoltchat/ui"; import IconButton from "../../../ui/IconButton";
interface Props { interface Props {
embed: API.Image; embed: EmbedImage;
} }
export default function EmbedMediaActions({ embed }: Props) { export default function EmbedMediaActions({ embed }: Props) {
@@ -20,7 +20,7 @@ export default function EmbedMediaActions({ embed }: Props) {
</span> </span>
<a <a
href={embed.url} href={embed.url}
className={styles.openIcon} class={styles.openIcon}
target="_blank" target="_blank"
rel="noreferrer"> rel="noreferrer">
<IconButton> <IconButton>

View File

@@ -1,32 +1,20 @@
import { Shield } from "@styled-icons/boxicons-regular"; import { Shield } from "@styled-icons/boxicons-regular";
import styled from "styled-components/macro"; import { Badges } from "revolt-api/types/Users";
import styled from "styled-components";
import { Localizer, Text } from "preact-i18n"; import { Localizer, Text } from "preact-i18n";
import Tooltip from "../Tooltip"; import Tooltip from "../Tooltip";
enum Badges {
Developer = 1,
Translator = 2,
Supporter = 4,
ResponsibleDisclosure = 8,
Founder = 16,
PlatformModeration = 32,
ActiveSupporter = 64,
Paw = 128,
EarlyAdopter = 256,
ReservedRelevantJokeBadge1 = 512,
ReservedRelevantJokeBadge2 = 1024,
}
const BadgesBase = styled.div` const BadgesBase = styled.div`
gap: 8px; gap: 8px;
display: flex; display: flex;
margin-top: 4px;
flex-direction: row; flex-direction: row;
img { img {
width: 24px; width: 32px;
height: 24px; height: 32px;
} }
`; `;
@@ -102,7 +90,7 @@ export default function UserBadges({ badges, uid }: Props) {
content={ content={
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" /> <Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
}> }>
<Shield size={24} color="gray" /> <Shield size={32} color="gray" />
</Tooltip> </Tooltip>
) : ( ) : (
<></> <></>
@@ -119,7 +107,7 @@ export default function UserBadges({ badges, uid }: Props) {
}} }}
onClick={() => { onClick={() => {
window.open( window.open(
"https://wiki.revolt.chat/notes/project/financial-support/", "https://insrt.uk/donate",
"_blank", "_blank",
); );
}} }}
@@ -135,13 +123,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 ? ( {badges & Badges.Paw ? (
<Tooltip content="🦊"> <Tooltip content="🦊">
<img src="/assets/badges/paw.svg" /> <img src="/assets/badges/paw.svg" />

View File

@@ -1,24 +1,17 @@
import { User } from "revolt.js"; import { User } from "revolt.js/dist/maps/Users";
import { Checkbox, Row, Column } from "@revoltchat/ui"; import Checkbox, { CheckboxProps } from "../../ui/Checkbox";
import UserIcon from "./UserIcon"; import UserIcon from "./UserIcon";
import { Username } from "./UserShort"; 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) { export default function UserCheckbox({ user, ...props }: UserProps) {
return ( return (
<Checkbox <Checkbox {...props}>
{...props} <UserIcon target={user} size={32} />
title={ <Username user={user} />
<Row centred> </Checkbox>
<UserIcon target={user} size={32} />
<Column centred>
<Username user={user} />
</Column>
</Row>
}
/>
); );
} }

View File

@@ -1,17 +1,19 @@
import { Cog } from "@styled-icons/boxicons-solid"; import { Cog } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { User } from "revolt.js"; import { User } from "revolt.js/dist/maps/Users";
import styled from "styled-components/macro"; import styled from "styled-components";
import { openContextMenu } from "preact-context-menu"; import { openContextMenu } from "preact-context-menu";
import { Text, Localizer } from "preact-i18n"; import { Text, Localizer } from "preact-i18n";
import { Header, IconButton } from "@revoltchat/ui";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; 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 Tooltip from "../Tooltip";
import UserStatus from "./UserStatus"; import UserStatus from "./UserStatus";
@@ -29,14 +31,9 @@ const HeaderBase = styled.div`
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.new-name {
font-size: 16px;
font-weight: 600;
}
.username { .username {
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 16px;
font-weight: 600; font-weight: 600;
} }
@@ -52,22 +49,17 @@ interface Props {
} }
export default observer(({ user }: Props) => { export default observer(({ user }: Props) => {
const { writeClipboard } = useIntermediate();
return ( return (
<Header topBorder palette="secondary"> <Header borders placement="secondary">
<HeaderBase> <HeaderBase>
<div className="new-name">
{user.display_name ?? user.username}
</div>
<Localizer> <Localizer>
<Tooltip content={<Text id="app.special.copy_username" />}> <Tooltip content={<Text id="app.special.copy_username" />}>
<span <span
className="username" className="username"
onClick={() => onClick={() => writeClipboard(user.username)}>
modalController.writeText(user.username) @{user.username}
}>
{user.username}
{"#"}
{user.discriminator}
</span> </span>
</Tooltip> </Tooltip>
</Localizer> </Localizer>

View File

@@ -1,6 +1,7 @@
import { User } from "revolt.js"; import { User } from "revolt.js/dist/maps/Users";
import styled from "styled-components/macro"; import styled from "styled-components";
import { Children } from "../../../types/Preact";
import Tooltip from "../Tooltip"; import Tooltip from "../Tooltip";
import { Username } from "./UserShort"; import { Username } from "./UserShort";
import UserStatus from "./UserStatus"; import UserStatus from "./UserStatus";
@@ -41,7 +42,10 @@ export default function UserHover({ user, children }: Props) {
placement="right-end" placement="right-end"
content={ content={
<Base> <Base>
<Username className="username" user={user} /> <Username
className="username"
user={user}
/>
<span className="status"> <span className="status">
<UserStatus user={user} /> <UserStatus user={user} />
</span> </span>

View File

@@ -1,37 +1,40 @@
import { VolumeMute, MicrophoneOff } from "@styled-icons/boxicons-solid"; import { MicrophoneOff } from "@styled-icons/boxicons-regular";
import { VolumeMute } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { User, API } from "revolt.js"; import { Masquerade } from "revolt-api/types/Channels";
import styled, { css } from "styled-components/macro"; import { Presence } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users";
import { Nullable } from "revolt.js/dist/util/null";
import styled, { css } from "styled-components";
import { useApplicationState } from "../../../mobx/State"; import { useContext } from "preact/hooks";
import { ThemeContext } from "../../../context/Theme";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import fallback from "../assets/user.png"; import fallback from "../assets/user.png";
import { useClient } from "../../../controllers/client/ClientController";
import IconBase, { IconBaseProps } from "../IconBase"; import IconBase, { IconBaseProps } from "../IconBase";
type VoiceStatus = "muted" | "deaf"; type VoiceStatus = "muted" | "deaf";
interface Props extends IconBaseProps<User> { interface Props extends IconBaseProps<User> {
status?: boolean; status?: boolean;
override?: string;
voice?: VoiceStatus; voice?: VoiceStatus;
masquerade?: API.Masquerade; masquerade?: Masquerade;
showServerIdentity?: boolean; showServerIdentity?: boolean;
} }
export function useStatusColour(user?: User) { export function useStatusColour(user?: User) {
const theme = useApplicationState().settings.theme; const theme = useContext(ThemeContext);
return user?.online && user?.status?.presence !== "Invisible" return user?.online && user?.status?.presence !== Presence.Invisible
? user?.status?.presence === "Idle" ? user?.status?.presence === Presence.Idle
? theme.getVariable("status-away") ? theme["status-away"]
: user?.status?.presence === "Focus" : user?.status?.presence === Presence.Busy
? theme.getVariable("status-focus") ? theme["status-busy"]
: user?.status?.presence === "Busy" : theme["status-online"]
? theme.getVariable("status-busy") : theme["status-invisible"];
: theme.getVariable("status-online")
: theme.getVariable("status-invisible");
} }
const VoiceIndicator = styled.div<{ status: VoiceStatus }>` const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
@@ -43,6 +46,10 @@ const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
align-items: center; align-items: center;
justify-content: center; justify-content: center;
svg {
stroke: white;
}
${(props) => ${(props) =>
(props.status === "muted" || props.status === "deaf") && (props.status === "muted" || props.status === "deaf") &&
css` css`
@@ -70,16 +77,12 @@ export default observer(
hover, hover,
showServerIdentity, showServerIdentity,
masquerade, masquerade,
innerRef,
override,
...svgProps ...svgProps
} = props; } = props;
let { url } = props; let { url } = props;
if (masquerade?.avatar) { if (masquerade?.avatar) {
url = client.proxyFile(masquerade.avatar); url = masquerade.avatar;
} else if (override) {
url = override;
} else if (!url) { } else if (!url) {
let override; let override;
if (target && showServerIdentity) { if (target && showServerIdentity) {
@@ -98,7 +101,7 @@ export default observer(
url = url =
client.generateFileURL( client.generateFileURL(
override ?? target?.avatar ?? attachment ?? undefined, override ?? target?.avatar ?? attachment,
{ max_side: 256 }, { max_side: 256 },
animate, animate,
) ?? (target ? target.defaultAvatarURL : fallback); ) ?? (target ? target.defaultAvatarURL : fallback);
@@ -107,7 +110,6 @@ export default observer(
return ( return (
<IconBase <IconBase
{...svgProps} {...svgProps}
ref={innerRef}
width={size} width={size}
height={size} height={size}
hover={hover} hover={hover}
@@ -119,7 +121,7 @@ export default observer(
y="0" y="0"
width="32" width="32"
height="32" height="32"
className="icon" class="icon"
mask={mask ?? (status ? "url(#user)" : undefined)}> mask={mask ?? (status ? "url(#user)" : undefined)}>
{<img src={url} draggable={false} loading="lazy" />} {<img src={url} draggable={false} loading="lazy" />}
</foreignObject> </foreignObject>

View File

@@ -1,19 +1,17 @@
import { TimeFive } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { User, API } from "revolt.js"; import { Masquerade } from "revolt-api/types/Channels";
import styled, { css } from "styled-components/macro"; import { User } from "revolt.js/dist/maps/Users";
import { Nullable } from "revolt.js/dist/util/null";
import styled from "styled-components";
import { Ref } from "preact";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { internalEmit } from "../../../lib/eventEmitter"; 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"; import UserIcon from "./UserIcon";
const BotBadge = styled.div` const BotBadge = styled.div`
@@ -23,60 +21,33 @@ const BotBadge = styled.div`
padding: 0 4px; padding: 0 4px;
font-size: 0.6em; font-size: 0.6em;
user-select: none; user-select: none;
margin-inline-start: 4px; margin-inline-start: 2px;
text-transform: uppercase; text-transform: uppercase;
color: var(--accent-contrast);
color: var(--foreground);
background: var(--accent); background: var(--accent);
border-radius: calc(var(--border-radius) / 2); border-radius: calc(var(--border-radius) / 2);
`; `;
type UsernameProps = Omit< type UsernameProps = JSX.HTMLAttributes<HTMLElement> & {
JSX.HTMLAttributes<HTMLElement>,
"children" | "as"
> & {
user?: User; user?: User;
prefixAt?: boolean; prefixAt?: boolean;
masquerade?: API.Masquerade; masquerade?: Masquerade;
showServerIdentity?: boolean | "both"; 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( export const Username = observer(
({ ({
user, user,
prefixAt, prefixAt,
masquerade, masquerade,
showServerIdentity, showServerIdentity,
innerRef,
override,
...otherProps ...otherProps
}: UsernameProps) => { }: UsernameProps) => {
let username = let username = user?.username;
(user as unknown as { display_name: string })?.display_name ?? let color;
user?.username;
let color = masquerade?.colour;
let timed_out: Date | undefined;
if (override) { if (user && showServerIdentity) {
username = override;
} else if (user && showServerIdentity) {
const { server } = useParams<{ server?: string }>(); const { server } = useParams<{ server?: string }>();
if (server) { if (server) {
const client = useClient(); const client = useClient();
@@ -94,14 +65,15 @@ export const Username = observer(
} }
} }
if (member.timeout) { if (member.roles && member.roles.length > 0) {
timed_out = member.timeout; const srv = client.servers.get(member._id.server);
} if (srv?.roles) {
for (const role of member.roles) {
if (!color) { const c = srv.roles[role].colour;
for (const [_, { colour }] of member.orderedRoles) { if (c) {
if (colour) { color = c;
color = colour; continue;
}
} }
} }
} }
@@ -109,53 +81,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) { if (user?.bot) {
return ( return (
<> <>
{el} <span {...otherProps} style={{ color }}>
<BotBadge> {masquerade?.name ?? username ?? (
{masquerade ? ( <Text id="app.main.channel.unknown_user" />
<Text id="app.main.channel.bridge" />
) : (
<Text id="app.main.channel.bot" />
)} )}
</BotBadge> </span>
</>
);
}
if (override) {
return (
<>
{el}
<BotBadge> <BotBadge>
<Text id="app.main.channel.bot" /> <Text id="app.main.channel.bot" />
</BotBadge> </BotBadge>
@@ -163,7 +96,14 @@ export const Username = observer(
); );
} }
return el; return (
<span {...otherProps} style={{ color }}>
{prefixAt ? "@" : undefined}
{masquerade?.name ?? username ?? (
<Text id="app.main.channel.unknown_user" />
)}
</span>
);
}, },
); );
@@ -177,12 +117,12 @@ export default function UserShort({
user?: User; user?: User;
size?: number; size?: number;
prefixAt?: boolean; prefixAt?: boolean;
masquerade?: API.Masquerade; masquerade?: Masquerade;
showServerIdentity?: boolean; showServerIdentity?: boolean;
}) { }) {
const { openScreen } = useIntermediate();
const openProfile = () => const openProfile = () =>
user && user && openScreen({ id: "profile", user_id: user._id });
modalController.push({ type: "user_profile", user_id: user._id });
const handleUserClick = (e: MouseEvent) => { const handleUserClick = (e: MouseEvent) => {
if (e.shiftKey && user?._id) { if (e.shiftKey && user?._id) {

View File

@@ -1,5 +1,6 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { User, API } from "revolt.js"; import { Presence } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
@@ -24,19 +25,15 @@ export default observer(({ user, tooltip }: Props) => {
return <>{user.status.text}</>; return <>{user.status.text}</>;
} }
if (user.status?.presence === "Busy") { if (user.status?.presence === Presence.Busy) {
return <Text id="app.status.busy" />; return <Text id="app.status.busy" />;
} }
if (user.status?.presence === "Idle") { if (user.status?.presence === Presence.Idle) {
return <Text id="app.status.idle" />; return <Text id="app.status.idle" />;
} }
if (user.status?.presence === "Focus") { if (user.status?.presence === Presence.Invisible) {
return <Text id="app.status.focus" />;
}
if (user.status?.presence === "Invisible") {
return <Text id="app.status.offline" />; return <Text id="app.status.offline" />;
} }

View File

@@ -0,0 +1,214 @@
.markdown {
:global(.emoji) {
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;
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;
}
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;
}
}

View File

@@ -1,15 +1,13 @@
import { Suspense, lazy } from "preact/compat"; import { Suspense, lazy } from "preact/compat";
const Renderer = lazy(() => import("./RemarkRenderer")); const Renderer = lazy(() => import("./Renderer"));
export interface MarkdownProps { export interface MarkdownProps {
content: string; content?: string;
disallowBigEmoji?: boolean; disallowBigEmoji?: boolean;
} }
export default function Markdown(props: MarkdownProps) { export default function Markdown(props: MarkdownProps) {
if (!props.content) return null;
return ( return (
// @ts-expect-error Typings mis-match. // @ts-expect-error Typings mis-match.
<Suspense fallback={props.content}> <Suspense fallback={props.content}>

View File

@@ -1,266 +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 =
/(^(?:(?:[>*+-]|\d+\.)[^\S\r\n]*){5})(?:(?:[>*+-]|\d+\.)[^\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(() => {
try {
render
.process(sanitisedContent)
.then((file) => setContent(file.result));
} catch (err) {
setContent("Message failed to render." as never);
}
}, [sanitisedContent]);
const largeEmoji = useMemo(
() => !disallowBigEmoji && isOnlyEmoji(content!),
[content, disallowBigEmoji],
);
return <Container largeEmoji={largeEmoji}>{Content}</Container>;
});

View File

@@ -0,0 +1,269 @@
/* 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";
// @ts-expect-error No typings.
import MarkdownSub from "markdown-it-sub";
// @ts-expect-error No typings.
import MarkdownSup from "markdown-it-sup";
import Prism from "prismjs";
import "prismjs/themes/prism-tomorrow.css";
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 { 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";
// 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(MarkdownSup)
.use(MarkdownSub)
.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;
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
const client = useContext(AppContext);
const { openLink } = useIntermediate();
if (typeof content === "undefined") return null;
if (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_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}
/>
);
}

View File

@@ -1,7 +0,0 @@
import { passThroughComponents } from "./plugins/remarkRegexComponent";
import { timestampHandler } from "./plugins/timestamps";
export const handlers = {
...passThroughComponents("emoji", "spoiler", "mention", "channel"),
timestamp: timestampHandler,
};

Some files were not shown because too many files have changed in this diff Show More