Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
No results found
Show changes
Commits on Source (107)
Showing
with 765 additions and 585 deletions
...@@ -3,3 +3,4 @@ node_modules ...@@ -3,3 +3,4 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
*.log
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
module.exports = { module.exports = {
"tabWidth": 4, tabWidth: 4,
"trailingComma": "all", trailingComma: "all",
"jsxBracketSameLine": true, jsxBracketSameLine: true,
"importOrder": ["preact|classnames|.scss$", "/(lib)", "/(redux)", "/(context)", "/(ui|common)|.svg$", "^[./]"], importOrder: [
"importOrderSeparation": true, "preact|classnames|.scss$",
} "/(lib)",
"/(redux|mobx)",
\ No newline at end of file "/(context)",
"/(ui|common)|.svg$",
"^[./]",
],
importOrderSeparation: true,
};
{ {
"recommendations": ["esbenp.prettier-vscode"] "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
} }
Subproject commit b40f8ce53831a590c0ffdd02f8da9fd35b7a3701 Subproject commit 09955e9d30c19c1a180fd3aacdb85961641da2bc
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Revolt</title> <title>Revolt</title>
<meta name="apple-mobile-web-app-title" content="Revolt"> <meta name="apple-mobile-web-app-title" content="Revolt" />
<!--<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />--> <meta
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> name="viewport"
<meta name="apple-mobile-web-app-capable" content="yes"> content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<!--App Icons--> <!--App Icons-->
<link rel="apple-touch-icon" href="public/assets/icons/apple-touch.png"> <link
<link rel="icon" type="image/png" href="/src/assets/logo_round.png" /> rel="apple-touch-icon"
href="public/assets/icons/apple-touch.png"
/>
<link rel="icon" type="image/png" href="/src/assets/logo_round.png" />
<!--Splash Screens for iOS Devices--> <!--Splash Screens for iOS Devices-->
<link href="public/assets/splashscreens/iphone5_splash.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" /> <link
<link href="public/assets/splashscreens/iphone6_splash.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" /> href="public/assets/splashscreens/iphone5_splash.png"
<link href="public/assets/splashscreens/iphoneplus_splash.png" media="(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" /> media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)"
<link href="public/assets/splashscreens/iphonex_splash.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" /> rel="apple-touch-startup-image"
<link href="public/assets/splashscreens/iphonexr_splash.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" /> />
<link href="public/assets/splashscreens/iphonexsmax_splash.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" /> <link
<link href="public/assets/splashscreens/ipad_splash.png" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" /> href="public/assets/splashscreens/iphone6_splash.png"
<link href="public/assets/splashscreens/ipadpro1_splash.png" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" /> media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)"
<link href="public/assets/splashscreens/ipadpro3_splash.png" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" /> rel="apple-touch-startup-image"
<link href="public/assets/splashscreens/ipadpro2_splash.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" /> />
</head> <link
<body> href="public/assets/splashscreens/iphoneplus_splash.png"
<div id="app"></div> media="(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)"
<script type="module" src="/src/main.tsx"></script> rel="apple-touch-startup-image"
</body> />
<style> <link
html { href="public/assets/splashscreens/iphonex_splash.png"
background-color: #191919; media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)"
} rel="apple-touch-startup-image"
</style> />
</html> <link
\ No newline at end of file href="public/assets/splashscreens/iphonexr_splash.png"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)"
rel="apple-touch-startup-image"
/>
<link
href="public/assets/splashscreens/iphonexsmax_splash.png"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)"
rel="apple-touch-startup-image"
/>
<link
href="public/assets/splashscreens/ipad_splash.png"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)"
rel="apple-touch-startup-image"
/>
<link
href="public/assets/splashscreens/ipadpro1_splash.png"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)"
rel="apple-touch-startup-image"
/>
<link
href="public/assets/splashscreens/ipadpro3_splash.png"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)"
rel="apple-touch-startup-image"
/>
<link
href="public/assets/splashscreens/ipadpro2_splash.png"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)"
rel="apple-touch-startup-image"
/>
</head>
<body ontouchstart="">
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<style>
html {
background-color: #191919;
}
</style>
</html>
{ {
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "rimraf build && vite build", "build": "rimraf build && vite 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"
}, },
"eslintConfig": { "eslintConfig": {
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"extends": [ "extends": [
"preact", "preact",
"plugin:@typescript-eslint/recommended" "plugin:@typescript-eslint/recommended"
], ],
"ignorePatterns": [ "ignorePatterns": [
"build/" "build/"
], ],
"rules": { "rules": {
"@typescript-eslint/explicit-module-boundary-types": "off" "radix": "off",
} "no-spaced-func": "off",
}, "react/no-danger": "off",
"dependencies": { "@typescript-eslint/explicit-module-boundary-types": "off",
"preact": "^10.5.13" "@typescript-eslint/no-non-null-assertion": "off",
}, "@typescript-eslint/no-unused-vars": [
"devDependencies": { "warn",
"@fontsource/atkinson-hyperlegible": "^4.4.5", {
"@fontsource/bree-serif": "^4.4.5", "varsIgnorePattern": "^_"
"@fontsource/comic-neue": "^4.4.5", }
"@fontsource/fira-code": "^4.4.5", ],
"@fontsource/inter": "^4.4.5", "no-unused-vars": [
"@fontsource/lato": "^4.4.5", "warn",
"@fontsource/montserrat": "^4.4.5", {
"@fontsource/noto-sans": "^4.4.5", "varsIgnorePattern": "^_"
"@fontsource/open-sans": "^4.4.5", }
"@fontsource/poppins": "^4.4.5", ]
"@fontsource/raleway": "^4.4.5", }
"@fontsource/roboto": "^4.4.5", },
"@fontsource/roboto-mono": "^4.4.5", "dependencies": {
"@fontsource/source-code-pro": "^4.4.5", "vite": "npm:@insertish/vite@2.4.0-beta.3-dynamic-import-css-3c1466b"
"@fontsource/space-mono": "^4.4.5", },
"@fontsource/ubuntu": "^4.4.5", "devDependencies": {
"@fontsource/ubuntu-mono": "^4.4.5", "@fontsource/atkinson-hyperlegible": "^4.4.5",
"@hcaptcha/react-hcaptcha": "^0.3.6", "@fontsource/bree-serif": "^4.4.5",
"@preact/preset-vite": "^2.0.0", "@fontsource/comic-neue": "^4.4.5",
"@rollup/plugin-replace": "^2.4.2", "@fontsource/fira-code": "^4.4.5",
"@styled-icons/boxicons-logos": "^10.34.0", "@fontsource/inter": "^4.4.5",
"@styled-icons/boxicons-regular": "^10.34.0", "@fontsource/lato": "^4.4.5",
"@styled-icons/boxicons-solid": "^10.34.0", "@fontsource/montserrat": "^4.4.5",
"@styled-icons/simple-icons": "^10.33.0", "@fontsource/noto-sans": "^4.4.5",
"@tippyjs/react": "^4.2.5", "@fontsource/open-sans": "^4.4.5",
"@traptitech/markdown-it-katex": "^3.4.3", "@fontsource/poppins": "^4.4.5",
"@traptitech/markdown-it-spoiler": "^1.1.6", "@fontsource/raleway": "^4.4.5",
"@trivago/prettier-plugin-sort-imports": "^2.0.2", "@fontsource/roboto": "^4.4.5",
"@types/lodash.defaultsdeep": "^4.6.6", "@fontsource/roboto-mono": "^4.4.5",
"@types/lodash.isequal": "^4.5.5", "@fontsource/source-code-pro": "^4.4.5",
"@types/markdown-it": "^12.0.2", "@fontsource/space-mono": "^4.4.5",
"@types/node": "^15.12.4", "@fontsource/ubuntu": "^4.4.5",
"@types/preact-i18n": "^2.3.0", "@fontsource/ubuntu-mono": "^4.4.5",
"@types/prismjs": "^1.16.5", "@hcaptcha/react-hcaptcha": "^0.3.6",
"@types/react-helmet": "^6.1.1", "@preact/preset-vite": "^2.0.0",
"@types/react-router-dom": "^5.1.7", "@rollup/plugin-replace": "^2.4.2",
"@types/react-scroll": "^1.8.2", "@styled-icons/boxicons-logos": "^10.34.0",
"@types/styled-components": "^5.1.10", "@styled-icons/boxicons-regular": "^10.34.0",
"@types/twemoji": "^12.1.1", "@styled-icons/boxicons-solid": "^10.37.0",
"@typescript-eslint/eslint-plugin": "^4.27.0", "@styled-icons/simple-icons": "^10.33.0",
"@typescript-eslint/parser": "^4.27.0", "@tippyjs/react": "^4.2.5",
"classnames": "^2.3.1", "@traptitech/markdown-it-katex": "^3.4.3",
"dayjs": "^1.10.6", "@traptitech/markdown-it-spoiler": "^1.1.6",
"detect-browser": "^5.2.0", "@trivago/prettier-plugin-sort-imports": "^2.0.2",
"eslint": "^7.28.0", "@types/lodash.defaultsdeep": "^4.6.6",
"eslint-config-preact": "^1.1.4", "@types/lodash.isequal": "^4.5.5",
"eventemitter3": "^4.0.7", "@types/markdown-it": "^12.0.2",
"highlight.js": "^11.0.1", "@types/node": "^15.12.4",
"idb": "^6.1.2", "@types/preact-i18n": "^2.3.0",
"localforage": "^1.9.0", "@types/prismjs": "^1.16.5",
"lodash.defaultsdeep": "^4.6.1", "@types/react-helmet": "^6.1.1",
"lodash.isequal": "^4.5.0", "@types/react-router-dom": "^5.1.7",
"markdown-it": "^12.0.6", "@types/react-scroll": "^1.8.2",
"markdown-it-emoji": "^2.0.0", "@types/styled-components": "^5.1.10",
"markdown-it-sub": "^1.0.0", "@types/twemoji": "^12.1.1",
"markdown-it-sup": "^1.0.0", "@typescript-eslint/eslint-plugin": "^4.27.0",
"mediasoup-client": "npm:@insertish/mediasoup-client@3.6.36-esnext", "@typescript-eslint/parser": "^4.27.0",
"preact-context-menu": "^0.1.5", "classnames": "^2.3.1",
"preact-i18n": "^2.4.0-preactx", "dayjs": "^1.10.6",
"prettier": "^2.3.1", "detect-browser": "^5.2.0",
"prismjs": "^1.23.0", "eslint": "^7.28.0",
"react-device-detect": "^1.17.0", "eslint-config-preact": "^1.1.4",
"react-helmet": "^6.1.0", "eventemitter3": "^4.0.7",
"react-hook-form": "6.3.0", "highlight.js": "^11.0.1",
"react-overlapping-panels": "1.2.2", "localforage": "^1.9.0",
"react-redux": "^7.2.4", "lodash.defaultsdeep": "^4.6.1",
"react-router-dom": "^5.2.0", "lodash.isequal": "^4.5.0",
"react-scroll": "^1.8.2", "markdown-it": "^12.0.6",
"redux": "^4.1.0", "markdown-it-emoji": "^2.0.0",
"revolt.js": "4.3.3-alpha.16", "markdown-it-sub": "^1.0.0",
"rimraf": "^3.0.2", "markdown-it-sup": "^1.0.0",
"sass": "^1.35.1", "mediasoup-client": "npm:@insertish/mediasoup-client@3.6.36-esnext",
"shade-blend-color": "^1.0.0", "mobx": "^6.3.2",
"styled-components": "^5.3.0", "mobx-react-lite": "^3.2.0",
"typescript": "^4.3.2", "preact": "^10.5.14",
"ulid": "^2.3.0", "preact-context-menu": "^0.1.5",
"use-resize-observer": "^7.0.0", "preact-i18n": "^2.4.0-preactx",
"vite": "npm:@insertish/vite@2.4.0-beta.3-dynamic-import-css-3c1466b", "prettier": "^2.3.1",
"vite-plugin-pwa": "^0.8.1", "prismjs": "^1.23.0",
"workbox-precaching": "^6.1.5" "react-device-detect": "^1.17.0",
} "react-helmet": "^6.1.0",
"react-hook-form": "6.3.0",
"react-overlapping-panels": "1.2.2",
"react-redux": "^7.2.4",
"react-router-dom": "^5.2.0",
"react-scroll": "^1.8.2",
"redux": "^4.1.0",
"revolt-api": "0.5.1-alpha.10-patch.0",
"revolt.js": "5.0.0-alpha.18",
"rimraf": "^3.0.2",
"sass": "^1.35.1",
"shade-blend-color": "^1.0.0",
"styled-components": "^5.3.0",
"typescript": "^4.3.2",
"ulid": "^2.3.0",
"use-resize-observer": "^7.0.0",
"vite-plugin-pwa": "^0.8.1",
"workbox-precaching": "^6.1.5"
},
"name": "client",
"main": "index.js",
"repository": "https://gitlab.insrt.uk/revolt/revite.git",
"author": "Paul <paulmakles@gmail.com>",
"license": "MIT"
} }
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"; import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
...@@ -46,7 +47,7 @@ type Props = { ...@@ -46,7 +47,7 @@ type Props = {
channel: Channel; channel: Channel;
}; };
export default function AgeGate(props: Props) { export default observer((props: Props) => {
const history = useHistory(); const history = useHistory();
const [consent, setConsent] = useState( const [consent, setConsent] = useState(
getState().sectionToggle["nsfw"] ?? false, getState().sectionToggle["nsfw"] ?? false,
...@@ -105,4 +106,4 @@ export default function AgeGate(props: Props) { ...@@ -105,4 +106,4 @@ export default function AgeGate(props: Props) {
</div> </div>
</Base> </Base>
); );
} });
import { SYSTEM_USER_ID, User } from "revolt.js"; import { SYSTEM_USER_ID } from "revolt.js";
import { Channels } from "revolt.js/dist/api/objects"; import { Channel } from "revolt.js/dist/maps/Channels";
import { User } from "revolt.js/dist/maps/Users";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { StateUpdater, useState } from "preact/hooks"; import { StateUpdater, useState } from "preact/hooks";
...@@ -24,7 +25,7 @@ export type AutoCompleteState = ...@@ -24,7 +25,7 @@ export type AutoCompleteState =
} }
| { | {
type: "channel"; type: "channel";
matches: Channels.TextChannel[]; matches: Channel[];
} }
)); ));
...@@ -103,7 +104,7 @@ export function useAutoComplete( ...@@ -103,7 +104,7 @@ export function useAutoComplete(
const regex = new RegExp(search, "i"); const regex = new RegExp(search, "i");
if (type === "emoji") { if (type === "emoji") {
// ! FIXME: 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 = Object.keys(emojiDictionary) const matches = Object.keys(emojiDictionary)
.filter((emoji: string) => emoji.match(regex)) .filter((emoji: string) => emoji.match(regex))
.splice(0, 5); .splice(0, 5);
...@@ -127,7 +128,7 @@ export function useAutoComplete( ...@@ -127,7 +128,7 @@ export function useAutoComplete(
let users: User[] = []; let users: User[] = [];
switch (searchClues.users.type) { switch (searchClues.users.type) {
case "all": case "all":
users = client.users.toArray(); users = [...client.users.values()];
break; break;
case "channel": { case "channel": {
const channel = client.channels.get( const channel = client.channels.get(
...@@ -136,25 +137,21 @@ export function useAutoComplete( ...@@ -136,25 +137,21 @@ export function useAutoComplete(
switch (channel?.channel_type) { switch (channel?.channel_type) {
case "Group": case "Group":
case "DirectMessage": case "DirectMessage":
users = client.users users = channel.recipients!.filter(
.mapKeys(channel.recipients) (x) => typeof x !== "undefined",
.filter( ) as User[];
(x) => typeof x !== "undefined",
) as User[];
break; break;
case "TextChannel": case "TextChannel":
const server = channel.server; {
users = client.servers.members const server = channel.server_id;
.toArray() users = [...client.members.keys()]
.filter( .map((x) => JSON.parse(x))
(x) => x._id.substr(0, 26) === server, .filter((x) => x.server === server)
) .map((x) => client.users.get(x.user))
.map((x) => .filter(
client.users.get(x._id.substr(26)), (x) => typeof x !== "undefined",
) ) as User[];
.filter( }
(x) => typeof x !== "undefined",
) as User[];
break; break;
default: default:
return; return;
...@@ -192,15 +189,14 @@ export function useAutoComplete( ...@@ -192,15 +189,14 @@ export function useAutoComplete(
if (type === "channel" && searchClues?.channels) { if (type === "channel" && searchClues?.channels) {
const channels = client.servers const channels = client.servers
.get(searchClues.channels.server) .get(searchClues.channels.server)
?.channels.map((x) => client.channels.get(x)) ?.channels.filter(
.filter(
(x) => typeof x !== "undefined", (x) => typeof x !== "undefined",
) as Channels.TextChannel[]; ) as Channel[];
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
) )
...@@ -309,7 +305,7 @@ export function useAutoComplete( ...@@ -309,7 +305,7 @@ export function useAutoComplete(
function onKeyUp(e: KeyboardEvent) { function onKeyUp(e: KeyboardEvent) {
if (e.currentTarget !== null) { if (e.currentTarget !== null) {
// @ts-expect-error // @ts-expect-error Type mis-match.
onChange(e); onChange(e);
} }
} }
...@@ -396,6 +392,7 @@ export default function AutoComplete({ ...@@ -396,6 +392,7 @@ export default function AutoComplete({
{state.type === "emoji" && {state.type === "emoji" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
key={match}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
...@@ -427,6 +424,7 @@ export default function AutoComplete({ ...@@ -427,6 +424,7 @@ export default function AutoComplete({
{state.type === "user" && {state.type === "user" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
key={match}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
...@@ -451,6 +449,7 @@ export default function AutoComplete({ ...@@ -451,6 +449,7 @@ export default function AutoComplete({
{state.type === "channel" && {state.type === "channel" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
key={match}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
......
import { Hash, VolumeFull } from "@styled-icons/boxicons-regular"; import { Hash, VolumeFull } from "@styled-icons/boxicons-regular";
import { Channels } from "revolt.js/dist/api/objects"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
...@@ -8,58 +9,59 @@ import { AppContext } from "../../context/revoltjs/RevoltClient"; ...@@ -8,58 +9,59 @@ import { AppContext } from "../../context/revoltjs/RevoltClient";
import { ImageIconBase, IconBaseProps } from "./IconBase"; import { ImageIconBase, IconBaseProps } from "./IconBase";
import fallback from "./assets/group.png"; import fallback from "./assets/group.png";
interface Props interface Props extends IconBaseProps<Channel> {
extends IconBaseProps<
Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel
> {
isServerChannel?: boolean; isServerChannel?: boolean;
} }
export default function ChannelIcon( export default observer(
props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>, (
) { props: Props &
const client = useContext(AppContext); Omit<
JSX.HTMLAttributes<HTMLImageElement>,
keyof Props | "children" | "as"
>,
) => {
const client = useContext(AppContext);
const { const {
size, size,
target, target,
attachment, attachment,
isServerChannel: server, isServerChannel: server,
animate, animate,
children, ...imgProps
as, } = props;
...imgProps const iconURL = client.generateFileURL(
} = props; target?.icon ?? attachment,
const iconURL = client.generateFileURL( { max_side: 256 },
target?.icon ?? attachment, animate,
{ max_side: 256 }, );
animate, const isServerChannel =
); server ||
const isServerChannel = (target &&
server || (target.channel_type === "TextChannel" ||
(target && target.channel_type === "VoiceChannel"));
(target.channel_type === "TextChannel" ||
target.channel_type === "VoiceChannel"));
if (typeof iconURL === "undefined") { if (typeof iconURL === "undefined") {
if (isServerChannel) { if (isServerChannel) {
if (target?.channel_type === "VoiceChannel") { if (target?.channel_type === "VoiceChannel") {
return <VolumeFull size={size} />; return <VolumeFull size={size} />;
}
return <Hash size={size} />;
} }
return <Hash size={size} />;
} }
}
return ( return (
// ! fixme: replace fallback with <picture /> + <source /> // ! TODO: replace fallback with <picture /> + <source />
<ImageIconBase <ImageIconBase
{...imgProps} {...imgProps}
width={size} width={size}
height={size} height={size}
loading="lazy" loading="lazy"
aria-hidden="true" aria-hidden="true"
square={isServerChannel} square={isServerChannel}
src={iconURL ?? fallback} src={iconURL ?? fallback}
/> />
); );
} },
);
import { Attachment } from "revolt.js/dist/api/objects"; import { Attachment } from "revolt-api/types/Autumn";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
export interface IconBaseProps<T> { export interface IconBaseProps<T> {
...@@ -6,11 +6,13 @@ export interface IconBaseProps<T> { ...@@ -6,11 +6,13 @@ export interface IconBaseProps<T> {
attachment?: Attachment; attachment?: Attachment;
size: number; size: number;
hover?: boolean;
animate?: boolean; animate?: boolean;
} }
interface IconModifiers { interface IconModifiers {
square?: boolean; square?: boolean;
hover?: boolean;
} }
export default styled.svg<IconModifiers>` export default styled.svg<IconModifiers>`
...@@ -27,6 +29,14 @@ export default styled.svg<IconModifiers>` ...@@ -27,6 +29,14 @@ export default styled.svg<IconModifiers>`
border-radius: 50%; border-radius: 50%;
`} `}
} }
${(props) =>
props.hover &&
css`
&:hover .icon {
filter: brightness(0.8);
}
`}
`; `;
export const ImageIconBase = styled.img<IconModifiers>` export const ImageIconBase = styled.img<IconModifiers>`
...@@ -38,4 +48,12 @@ export const ImageIconBase = styled.img<IconModifiers>` ...@@ -38,4 +48,12 @@ export const ImageIconBase = styled.img<IconModifiers>`
css` css`
border-radius: 50%; border-radius: 50%;
`} `}
${(props) =>
props.hover &&
css`
&:hover img {
filter: brightness(0.8);
}
`}
`; `;
...@@ -22,7 +22,7 @@ export function LocaleSelector(props: Props) { ...@@ -22,7 +22,7 @@ export function LocaleSelector(props: Props) {
{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];
return ( return (
<option value={x}> <option value={x} key={x}>
{l.emoji} {l.display} {l.emoji} {l.display}
</option> </option>
); );
......
import { Cog } from "@styled-icons/boxicons-solid"; import { Cog } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Server } from "revolt.js/dist/api/objects";
import { ServerPermission } from "revolt.js/dist/api/permissions"; import { ServerPermission } from "revolt.js/dist/api/permissions";
import { Server } from "revolt.js/dist/maps/Servers";
import styled from "styled-components"; import styled from "styled-components";
import { HookContext, useServerPermission } from "../../context/revoltjs/hooks";
import Header from "../ui/Header"; import Header from "../ui/Header";
import IconButton from "../ui/IconButton"; import IconButton from "../ui/IconButton";
interface Props { interface Props {
server: Server; server: Server;
ctx: HookContext;
} }
const ServerName = styled.div` const ServerName = styled.div`
flex-grow: 1; flex-grow: 1;
`; `;
export default function ServerHeader({ server, ctx }: Props) { export default observer(({ server }: Props) => {
const permissions = useServerPermission(server._id, ctx); const bannerURL = server.generateBannerURL({ width: 480 });
const bannerURL = ctx.client.servers.getBannerURL(
server._id,
{ width: 480 },
true,
);
return ( return (
<Header <Header
...@@ -35,7 +28,7 @@ export default function ServerHeader({ server, ctx }: Props) { ...@@ -35,7 +28,7 @@ export default function ServerHeader({ server, ctx }: Props) {
background: bannerURL ? `url('${bannerURL}')` : undefined, background: bannerURL ? `url('${bannerURL}')` : undefined,
}}> }}>
<ServerName>{server.name}</ServerName> <ServerName>{server.name}</ServerName>
{(permissions & ServerPermission.ManageServer) > 0 && ( {(server.permission & ServerPermission.ManageServer) > 0 && (
<div className="actions"> <div className="actions">
<Link to={`/server/${server._id}/settings`}> <Link to={`/server/${server._id}/settings`}>
<IconButton> <IconButton>
...@@ -46,4 +39,4 @@ export default function ServerHeader({ server, ctx }: Props) { ...@@ -46,4 +39,4 @@ export default function ServerHeader({ server, ctx }: Props) {
)} )}
</Header> </Header>
); );
} });
import { Server } from "revolt.js/dist/api/objects"; import { observer } from "mobx-react-lite";
import { Server } from "revolt.js/dist/maps/Servers";
import styled from "styled-components"; import styled from "styled-components";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
...@@ -21,49 +22,47 @@ const ServerText = styled.div` ...@@ -21,49 +22,47 @@ const ServerText = styled.div`
background: var(--primary-background); background: var(--primary-background);
`; `;
const fallback = "/assets/group.png"; // const fallback = "/assets/group.png";
export default function ServerIcon( export default observer(
props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>, (
) { props: Props &
const client = useContext(AppContext); Omit<
JSX.HTMLAttributes<HTMLImageElement>,
keyof Props | "children" | "as"
>,
) => {
const client = useContext(AppContext);
const { const { target, attachment, size, animate, server_name, ...imgProps } =
target, props;
attachment, const iconURL = client.generateFileURL(
size, target?.icon ?? attachment,
animate, { max_side: 256 },
server_name, animate,
children, );
as,
...imgProps if (typeof iconURL === "undefined") {
} = props; const name = target?.name ?? server_name ?? "";
const iconURL = client.generateFileURL(
target?.icon ?? attachment,
{ max_side: 256 },
animate,
);
if (typeof iconURL === "undefined") { return (
const name = target?.name ?? server_name ?? ""; <ServerText style={{ width: size, height: size }}>
{name
.split(" ")
.map((x) => x[0])
.filter((x) => typeof x !== "undefined")}
</ServerText>
);
}
return ( return (
<ServerText style={{ width: size, height: size }}> <ImageIconBase
{name {...imgProps}
.split(" ") width={size}
.map((x) => x[0]) height={size}
.filter((x) => typeof x !== "undefined")} src={iconURL}
</ServerText> loading="lazy"
aria-hidden="true"
/>
); );
} },
);
return (
<ImageIconBase
{...imgProps}
width={size}
height={size}
src={iconURL}
loading="lazy"
aria-hidden="true"
/>
);
}
...@@ -16,8 +16,8 @@ export default function Tooltip(props: Props) { ...@@ -16,8 +16,8 @@ export default function Tooltip(props: Props) {
return ( return (
<Tippy content={content} {...tippyProps}> <Tippy content={content} {...tippyProps}>
{/* {/*
// @ts-expect-error */} // @ts-expect-error Type mis-match. */}
<div>{children}</div> <div style={`display: flex;`}>{children}</div>
</Tippy> </Tippy>
); );
} }
...@@ -35,7 +35,7 @@ const PermissionTooltipBase = styled.div` ...@@ -35,7 +35,7 @@ const PermissionTooltipBase = styled.div`
} }
code { code {
font-family: var(--monoscape-font); font-family: var(--monospace-font);
} }
`; `;
......
import { Download } from "@styled-icons/boxicons-regular"; /* eslint-disable react-hooks/rules-of-hooks */
import { Download, CloudDownload } from "@styled-icons/boxicons-regular";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
...@@ -9,11 +10,16 @@ import { ThemeContext } from "../../context/Theme"; ...@@ -9,11 +10,16 @@ import { ThemeContext } from "../../context/Theme";
import IconButton from "../ui/IconButton"; import IconButton from "../ui/IconButton";
import { updateSW } from "../../main"; import { updateSW } from "../../main";
import Tooltip from "./Tooltip";
let pendingUpdate = false; let pendingUpdate = false;
internalSubscribe("PWA", "update", () => (pendingUpdate = true)); internalSubscribe("PWA", "update", () => (pendingUpdate = true));
export default function UpdateIndicator() { interface Props {
style: "titlebar" | "channel";
}
export default function UpdateIndicator({ style }: Props) {
const [pending, setPending] = useState(pendingUpdate); const [pending, setPending] = useState(pendingUpdate);
useEffect(() => { useEffect(() => {
...@@ -23,6 +29,22 @@ export default function UpdateIndicator() { ...@@ -23,6 +29,22 @@ export default function UpdateIndicator() {
if (!pending) return null; if (!pending) return null;
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
if (style === "titlebar") {
return (
<div class="actions">
<Tooltip
content="A new update is available!"
placement="bottom">
<div onClick={() => updateSW(true)}>
<CloudDownload size={22} color={theme.success} />
</div>
</Tooltip>
</div>
);
}
if (window.isNative) return null;
return ( return (
<IconButton onClick={() => updateSW(true)}> <IconButton onClick={() => updateSW(true)}>
<Download size={22} color={theme.success} /> <Download size={22} color={theme.success} />
......
import { observer } from "mobx-react-lite";
import { Message as MessageObject } from "revolt.js/dist/maps/Messages";
import { attachContextMenu } from "preact-context-menu"; import { attachContextMenu } from "preact-context-menu";
import { memo } from "preact/compat"; import { memo } from "preact/compat";
import { useContext, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { QueuedMessage } from "../../../redux/reducers/queue"; import { QueuedMessage } from "../../../redux/reducers/queue";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { useClient } from "../../../context/revoltjs/RevoltClient";
import { useUser } from "../../../context/revoltjs/hooks";
import { MessageObject } from "../../../context/revoltjs/util";
import Overline from "../../ui/Overline"; import Overline from "../../ui/Overline";
...@@ -34,109 +35,123 @@ interface Props { ...@@ -34,109 +35,123 @@ interface Props {
head?: boolean; head?: boolean;
} }
function Message({ const Message = observer(
highlight, ({
attachContext, highlight,
message, attachContext,
contrast, message,
content: replacement, contrast,
head: preferHead, content: replacement,
queued, head: preferHead,
}: Props) { queued,
// TODO: Can improve re-renders here by providing a list }: Props) => {
// TODO: of dependencies. We only need to update on u/avatar. const client = useClient();
const user = useUser(message.author); const user = message.author;
const client = useContext(AppContext);
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const content = message.content as string; const content = message.content as string;
const head = preferHead || (message.replies && message.replies.length > 0); const head =
preferHead || (message.reply_ids && message.reply_ids.length > 0);
// ! FIXME: tell fatal to make this type generic // ! TODO: tell fatal to make this type generic
// bree: Fatal please... // bree: Fatal please...
const userContext = attachContext const userContext = attachContext
? (attachContextMenu("Menu", { ? (attachContextMenu("Menu", {
user: message.author, user: message.author_id,
contextualChannel: message.channel, contextualChannel: message.channel_id,
}) as any) // eslint-disable-next-line
: undefined; }) as any)
: undefined;
const openProfile = () => const openProfile = () =>
openScreen({ id: "profile", user_id: message.author }); openScreen({ id: "profile", user_id: message.author_id });
// ! FIXME: animate on hover // ! FIXME(?): animate on hover
const [animate, setAnimate] = useState(false); const [animate, setAnimate] = useState(false);
return ( return (
<div id={message._id}> <div id={message._id}>
{message.replies?.map((message_id, index) => ( {message.reply_ids?.map((message_id, index) => (
<MessageReply <MessageReply
index={index} key={message_id}
id={message_id} index={index}
channel={message.channel} id={message_id}
/> channel={message.channel!}
))} />
<MessageBase ))}
highlight={highlight} <MessageBase
head={head && !(message.replies && message.replies.length > 0)} highlight={highlight}
contrast={contrast} head={
sending={typeof queued !== "undefined"} (head &&
mention={message.mentions?.includes(client.user!._id)} !(
failed={typeof queued?.error !== "undefined"} message.reply_ids &&
onContextMenu={ message.reply_ids.length > 0
attachContext )) ??
? attachContextMenu("Menu", { false
message, }
contextualChannel: message.channel, contrast={contrast}
queued, sending={typeof queued !== "undefined"}
}) mention={message.mention_ids?.includes(client.user!._id)}
: undefined failed={typeof queued?.error !== "undefined"}
} onContextMenu={
onMouseEnter={() => setAnimate(true)} attachContext
onMouseLeave={() => setAnimate(false)}> ? attachContextMenu("Menu", {
<MessageInfo> message,
{head ? ( contextualChannel: message.channel_id,
<UserIcon queued,
target={user} })
size={36} : undefined
onContextMenu={userContext} }
onClick={openProfile} onMouseEnter={() => setAnimate(true)}
animate={animate} onMouseLeave={() => setAnimate(false)}>
/> <MessageInfo>
) : ( {head ? (
<MessageDetail message={message} position="left" /> <UserIcon
)} target={user}
</MessageInfo> size={36}
<MessageContent>
{head && (
<span className="detail">
<Username
className="author"
user={user}
onContextMenu={userContext} onContextMenu={userContext}
onClick={openProfile} onClick={openProfile}
animate={animate}
/> />
<MessageDetail message={message} position="top" /> ) : (
</span> <MessageDetail message={message} position="left" />
)} )}
{replacement ?? <Markdown content={content} />} </MessageInfo>
{queued?.error && ( <MessageContent>
<Overline type="error" error={queued.error} /> {head && (
)} <span className="detail">
{message.attachments?.map((attachment, index) => ( <Username
<Attachment className="author"
key={index} user={user}
attachment={attachment} onContextMenu={userContext}
hasContent={index > 0 || content.length > 0} onClick={openProfile}
/> />
))} <MessageDetail
{message.embeds?.map((embed, index) => ( message={message}
<Embed key={index} embed={embed} /> position="top"
))} />
</MessageContent> </span>
</MessageBase> )}
</div> {replacement ?? <Markdown content={content} />}
); {queued?.error && (
} <Overline type="error" error={queued.error} />
)}
{message.attachments?.map((attachment, index) => (
<Attachment
key={index}
attachment={attachment}
hasContent={index > 0 || content.length > 0}
/>
))}
{message.embeds?.map((embed, index) => (
<Embed key={index} embed={embed} />
))}
</MessageContent>
</MessageBase>
</div>
);
},
);
export default memo(Message); export default memo(Message);
import { observer } from "mobx-react-lite";
import { Message } from "revolt.js/dist/maps/Messages";
import styled, { css, keyframes } from "styled-components"; import styled, { css, keyframes } from "styled-components";
import { decodeTime } from "ulid"; import { decodeTime } from "ulid";
...@@ -6,7 +8,6 @@ import { Text } from "preact-i18n"; ...@@ -6,7 +8,6 @@ import { Text } from "preact-i18n";
import { useDictionary } from "../../../lib/i18n"; import { useDictionary } from "../../../lib/i18n";
import { dayjs } from "../../../context/Locale"; import { dayjs } from "../../../context/Locale";
import { MessageObject } from "../../../context/revoltjs/util";
import Tooltip from "../Tooltip"; import Tooltip from "../Tooltip";
...@@ -31,7 +32,11 @@ export default styled.div<BaseMessageProps>` ...@@ -31,7 +32,11 @@ export default styled.div<BaseMessageProps>`
overflow: none; overflow: none;
padding: 0.125rem; padding: 0.125rem;
flex-direction: row; flex-direction: row;
padding-right: 16px; padding-inline-end: 16px;
@media (pointer: coarse) {
user-select: none;
}
${(props) => ${(props) =>
props.contrast && props.contrast &&
...@@ -89,12 +94,20 @@ export default styled.div<BaseMessageProps>` ...@@ -89,12 +94,20 @@ export default styled.div<BaseMessageProps>`
gap: 8px; gap: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0;
} }
.author { .author {
overflow: hidden;
cursor: pointer; cursor: pointer;
font-weight: 600 !important; font-weight: 600 !important;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
white-space: normal;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
...@@ -172,12 +185,13 @@ export const MessageContent = styled.div` ...@@ -172,12 +185,13 @@ export const MessageContent = styled.div`
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
// overflow: hidden; // overflow: hidden;
font-size: var(--text-size);
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
font-size: var(--text-size);
`; `;
export const DetailBase = styled.div` export const DetailBase = styled.div`
flex-shrink: 0;
gap: 4px; gap: 4px;
font-size: 10px; font-size: 10px;
display: inline-flex; display: inline-flex;
...@@ -192,57 +206,54 @@ export const DetailBase = styled.div` ...@@ -192,57 +206,54 @@ export const DetailBase = styled.div`
} }
`; `;
export function MessageDetail({ export const MessageDetail = observer(
message, ({ message, position }: { message: Message; position: "left" | "top" }) => {
position, const dict = useDictionary();
}: {
message: MessageObject; if (position === "left") {
position: "left" | "top"; if (message.edited) {
}) { return (
const dict = useDictionary(); <>
<time className="copyTime">
if (position === "left") { <i className="copyBracket">[</i>
if (message.edited) { {dayjs(decodeTime(message._id)).format(
dict.dayjs?.timeFormat,
)}
<i className="copyBracket">]</i>
</time>
<span className="edited">
<Tooltip
content={dayjs(message.edited).format("LLLL")}>
<Text id="app.main.channel.edited" />
</Tooltip>
</span>
</>
);
}
return ( return (
<> <>
<time className="copyTime"> <time>
<i className="copyBracket">[</i> <i className="copyBracket">[</i>
{dayjs(decodeTime(message._id)).format( {dayjs(decodeTime(message._id)).format(
dict.dayjs.timeFormat, dict.dayjs?.timeFormat,
)} )}
<i className="copyBracket">]</i> <i className="copyBracket">]</i>
</time> </time>
<span className="edited">
<Tooltip content={dayjs(message.edited).format("LLLL")}>
<Text id="app.main.channel.edited" />
</Tooltip>
</span>
</> </>
); );
} }
return ( return (
<> <DetailBase>
<time> <time>{dayjs(decodeTime(message._id)).calendar()}</time>
<i className="copyBracket">[</i> {message.edited && (
{dayjs(decodeTime(message._id)).format( <Tooltip content={dayjs(message.edited).format("LLLL")}>
dict.dayjs.timeFormat, <span className="edited">
)} <Text id="app.main.channel.edited" />
<i className="copyBracket">]</i> </span>
</time> </Tooltip>
</> )}
</DetailBase>
); );
} },
);
return (
<DetailBase>
<time>{dayjs(decodeTime(message._id)).calendar()}</time>
{message.edited && (
<Tooltip content={dayjs(message.edited).format("LLLL")}>
<span className="edited">
<Text id="app.main.channel.edited" />
</span>
</Tooltip>
)}
</DetailBase>
);
}
import { Send, HappyAlt, ShieldX } from "@styled-icons/boxicons-solid"; import { Send, ShieldX } from "@styled-icons/boxicons-solid";
import { Styleshare } from "@styled-icons/simple-icons";
import Axios, { CancelTokenSource } from "axios"; import Axios, { CancelTokenSource } from "axios";
import { Channel } from "revolt.js"; import { observer } from "mobx-react-lite";
import { ChannelPermission } from "revolt.js/dist/api/permissions"; import { ChannelPermission } from "revolt.js/dist/api/permissions";
import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components"; import styled from "styled-components";
import { ulid } from "ulid"; import { ulid } from "ulid";
...@@ -31,7 +31,6 @@ import { ...@@ -31,7 +31,6 @@ import {
uploadFile, uploadFile,
} from "../../../context/revoltjs/FileUploads"; } from "../../../context/revoltjs/FileUploads";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useChannelPermission } from "../../../context/revoltjs/hooks";
import { takeError } from "../../../context/revoltjs/util"; import { takeError } from "../../../context/revoltjs/util";
import IconButton from "../../ui/IconButton"; import IconButton from "../../ui/IconButton";
...@@ -110,7 +109,7 @@ const Action = styled.div` ...@@ -110,7 +109,7 @@ const Action = styled.div`
// ! 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 = 4; export const CAN_UPLOAD_AT_ONCE = 4;
export default function MessageBox({ channel }: Props) { export default observer(({ channel }: Props) => {
const [draft, setDraft] = useState(getState().drafts[channel._id] ?? ""); const [draft, setDraft] = useState(getState().drafts[channel._id] ?? "");
const [uploadState, setUploadState] = useState<UploadState>({ const [uploadState, setUploadState] = useState<UploadState>({
...@@ -123,8 +122,7 @@ export default function MessageBox({ channel }: Props) { ...@@ -123,8 +122,7 @@ export default function MessageBox({ channel }: Props) {
const client = useContext(AppContext); const client = useContext(AppContext);
const translate = useTranslation(); const translate = useTranslation();
const permissions = useChannelPermission(channel._id); if (!(channel.permission & ChannelPermission.SendMessage)) {
if (!(permissions & ChannelPermission.SendMessage)) {
return ( return (
<Base> <Base>
<Blocked> <Blocked>
...@@ -143,22 +141,25 @@ export default function MessageBox({ channel }: Props) { ...@@ -143,22 +141,25 @@ export default function MessageBox({ channel }: Props) {
); );
} }
function setMessage(content?: string) { const setMessage = useCallback(
setDraft(content ?? ""); (content?: string) => {
setDraft(content ?? "");
if (content) { if (content) {
dispatch({ dispatch({
type: "SET_DRAFT", type: "SET_DRAFT",
channel: channel._id, channel: channel._id,
content, content,
}); });
} else { } else {
dispatch({ dispatch({
type: "CLEAR_DRAFT", type: "CLEAR_DRAFT",
channel: channel._id, channel: channel._id,
}); });
} }
} },
[channel._id],
);
useEffect(() => { useEffect(() => {
function append(content: string, action: "quote" | "mention") { function append(content: string, action: "quote" | "mention") {
...@@ -177,8 +178,12 @@ export default function MessageBox({ channel }: Props) { ...@@ -177,8 +178,12 @@ export default function MessageBox({ channel }: Props) {
} }
} }
return internalSubscribe("MessageBox", "append", append); return internalSubscribe(
}, [draft]); "MessageBox",
"append",
append as (...args: unknown[]) => void,
);
}, [draft, setMessage]);
async function send() { async function send() {
if (uploadState.type === "uploading" || uploadState.type === "sending") if (uploadState.type === "uploading" || uploadState.type === "sending")
...@@ -216,7 +221,7 @@ export default function MessageBox({ channel }: Props) { ...@@ -216,7 +221,7 @@ export default function MessageBox({ channel }: Props) {
); );
try { try {
await client.channels.sendMessage(channel._id, { await channel.sendMessage({
content, content,
nonce, nonce,
replies, replies,
...@@ -290,7 +295,7 @@ export default function MessageBox({ channel }: Props) { ...@@ -290,7 +295,7 @@ export default function MessageBox({ channel }: Props) {
const nonce = ulid(); const nonce = ulid();
try { try {
await client.channels.sendMessage(channel._id, { await channel.sendMessage({
content, content,
nonce, nonce,
replies, replies,
...@@ -325,7 +330,7 @@ export default function MessageBox({ channel }: Props) { ...@@ -325,7 +330,7 @@ export default function MessageBox({ channel }: Props) {
const ws = client.websocket; const ws = client.websocket;
if (ws.connected) { if (ws.connected) {
setTyping(+new Date() + 4000); setTyping(+new Date() + 2500);
ws.send({ ws.send({
type: "BeginTyping", type: "BeginTyping",
channel: channel._id, channel: channel._id,
...@@ -346,9 +351,11 @@ export default function MessageBox({ channel }: Props) { ...@@ -346,9 +351,11 @@ export default function MessageBox({ channel }: Props) {
} }
} }
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ // eslint-disable-next-line
channel._id, const debouncedStopTyping = useCallback(
]); debounce(stopTyping as (...args: unknown[]) => void, 1000),
[channel._id],
);
const { const {
onChange, onChange,
onKeyUp, onKeyUp,
...@@ -360,7 +367,7 @@ export default function MessageBox({ channel }: Props) { ...@@ -360,7 +367,7 @@ export default function MessageBox({ channel }: Props) {
users: { type: "channel", id: channel._id }, users: { type: "channel", id: channel._id },
channels: channels:
channel.channel_type === "TextChannel" channel.channel_type === "TextChannel"
? { server: channel.server } ? { server: channel.server_id! }
: undefined, : undefined,
}); });
...@@ -403,7 +410,7 @@ export default function MessageBox({ channel }: Props) { ...@@ -403,7 +410,7 @@ export default function MessageBox({ channel }: Props) {
setReplies={setReplies} setReplies={setReplies}
/> />
<Base> <Base>
{permissions & ChannelPermission.UploadFiles ? ( {channel.permission & ChannelPermission.UploadFiles ? (
<Action> <Action>
<FileUploader <FileUploader
size={24} size={24}
...@@ -475,14 +482,12 @@ export default function MessageBox({ channel }: Props) { ...@@ -475,14 +482,12 @@ export default function MessageBox({ channel }: Props) {
placeholder={ placeholder={
channel.channel_type === "DirectMessage" channel.channel_type === "DirectMessage"
? translate("app.main.channel.message_who", { ? translate("app.main.channel.message_who", {
person: client.users.get( person: channel.recipient?.username,
client.channels.getRecipient(channel._id),
)?.username,
}) })
: channel.channel_type === "SavedMessages" : channel.channel_type === "SavedMessages"
? translate("app.main.channel.message_saved") ? translate("app.main.channel.message_saved")
: translate("app.main.channel.message_where", { : translate("app.main.channel.message_where", {
channel_name: channel.name, channel_name: channel.name ?? undefined,
}) })
} }
disabled={ disabled={
...@@ -511,4 +516,4 @@ export default function MessageBox({ channel }: Props) { ...@@ -511,4 +516,4 @@ export default function MessageBox({ channel }: Props) {
</Base> </Base>
</> </>
); );
} });
import { User } from "revolt.js"; import { observer } from "mobx-react-lite";
import { Message } from "revolt.js/dist/maps/Messages";
import { User } from "revolt.js/dist/maps/Users";
import styled from "styled-components"; import styled from "styled-components";
import { attachContextMenu } from "preact-context-menu"; import { attachContextMenu } from "preact-context-menu";
import { TextReact } from "../../../lib/i18n"; import { TextReact } from "../../../lib/i18n";
import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks"; import { useClient } from "../../../context/revoltjs/RevoltClient";
import { MessageObject } from "../../../context/revoltjs/util";
import UserShort from "../user/UserShort"; import UserShort from "../user/UserShort";
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase"; import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
...@@ -34,137 +35,136 @@ type SystemMessageParsed = ...@@ -34,137 +35,136 @@ type SystemMessageParsed =
interface Props { interface Props {
attachContext?: boolean; attachContext?: boolean;
message: MessageObject; message: Message;
highlight?: boolean; highlight?: boolean;
hideInfo?: boolean; hideInfo?: boolean;
} }
export function SystemMessage({ export const SystemMessage = observer(
attachContext, ({ attachContext, message, highlight, hideInfo }: Props) => {
message, const client = useClient();
highlight,
hideInfo,
}: Props) {
const ctx = useForceUpdate();
let data: SystemMessageParsed; let data: SystemMessageParsed;
const content = message.content; const content = message.content;
if (typeof content === "object") { if (typeof content === "object") {
switch (content.type) { switch (content.type) {
case "text":
data = content;
break;
case "user_added":
case "user_remove":
data = {
type: content.type,
user: client.users.get(content.id)!,
by: client.users.get(content.by)!,
};
break;
case "user_joined":
case "user_left":
case "user_kicked":
case "user_banned":
data = {
type: content.type,
user: client.users.get(content.id)!,
};
break;
case "channel_renamed":
data = {
type: "channel_renamed",
name: content.name,
by: client.users.get(content.by)!,
};
break;
case "channel_description_changed":
case "channel_icon_changed":
data = {
type: content.type,
by: client.users.get(content.by)!,
};
break;
default:
data = { type: "text", content: JSON.stringify(content) };
}
} else {
data = { type: "text", content };
}
let children;
switch (data.type) {
case "text": case "text":
data = content; children = <span>{data.content}</span>;
break; break;
case "user_added": case "user_added":
case "user_remove": case "user_remove":
data = { children = (
type: content.type, <TextReact
user: useUser(content.id, ctx) as User, id={`app.main.channel.system.${
by: useUser(content.by, ctx) as User, data.type === "user_added"
}; ? "added_by"
: "removed_by"
}`}
fields={{
user: <UserShort user={data.user} />,
other_user: <UserShort user={data.by} />,
}}
/>
);
break; break;
case "user_joined": case "user_joined":
case "user_left": case "user_left":
case "user_kicked": case "user_kicked":
case "user_banned": case "user_banned":
data = { children = (
type: content.type, <TextReact
user: useUser(content.id, ctx) as User, id={`app.main.channel.system.${data.type}`}
}; fields={{
user: <UserShort user={data.user} />,
}}
/>
);
break; break;
case "channel_renamed": case "channel_renamed":
data = { children = (
type: "channel_renamed", <TextReact
name: content.name, id={`app.main.channel.system.channel_renamed`}
by: useUser(content.by, ctx) as User, fields={{
}; user: <UserShort user={data.by} />,
name: <b>{data.name}</b>,
}}
/>
);
break; break;
case "channel_description_changed": case "channel_description_changed":
case "channel_icon_changed": case "channel_icon_changed":
data = { children = (
type: content.type, <TextReact
by: useUser(content.by, ctx) as User, id={`app.main.channel.system.${data.type}`}
}; fields={{
user: <UserShort user={data.by} />,
}}
/>
);
break; break;
default:
data = { type: "text", content: JSON.stringify(content) };
} }
} else {
data = { type: "text", content };
}
let children; return (
switch (data.type) { <MessageBase
case "text": highlight={highlight}
children = <span>{data.content}</span>; onContextMenu={
break; attachContext
case "user_added": ? attachContextMenu("Menu", {
case "user_remove": message,
children = ( contextualChannel: message.channel,
<TextReact })
id={`app.main.channel.system.${ : undefined
data.type === "user_added" ? "added_by" : "removed_by" }>
}`} {!hideInfo && (
fields={{ <MessageInfo>
user: <UserShort user={data.user} />, <MessageDetail message={message} position="left" />
other_user: <UserShort user={data.by} />, </MessageInfo>
}} )}
/> <SystemContent>{children}</SystemContent>
); </MessageBase>
break; );
case "user_joined": },
case "user_left": );
case "user_kicked":
case "user_banned":
children = (
<TextReact
id={`app.main.channel.system.${data.type}`}
fields={{
user: <UserShort user={data.user} />,
}}
/>
);
break;
case "channel_renamed":
children = (
<TextReact
id={`app.main.channel.system.channel_renamed`}
fields={{
user: <UserShort user={data.by} />,
name: <b>{data.name}</b>,
}}
/>
);
break;
case "channel_description_changed":
case "channel_icon_changed":
children = (
<TextReact
id={`app.main.channel.system.${data.type}`}
fields={{
user: <UserShort user={data.by} />,
}}
/>
);
break;
}
return (
<MessageBase
highlight={highlight}
onContextMenu={
attachContext
? attachContextMenu("Menu", {
message,
contextualChannel: message.channel,
})
: undefined
}>
{!hideInfo && (
<MessageInfo>
<MessageDetail message={message} position="left" />
</MessageInfo>
)}
<SystemContent>{children}</SystemContent>
</MessageBase>
);
}