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 (165)
Showing
with 582 additions and 313 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", "dbaeumer.vscode-eslint"]
}
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
\ No newline at end of file
Subproject commit 39359e76b961fa7ee9f83a0cabdc811ccecdb600 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.10", "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"
} }
export const emojiDictionary = { export const emojiDictionary = {
"100": "💯", 100: "💯",
"1234": "🔢", 1234: "🔢",
grinning: "😀", grinning: "😀",
smiley: "😃", smiley: "😃",
smile: "😄", smile: "😄",
......
...@@ -19,8 +19,8 @@ export const SOUNDS_ARRAY: Sounds[] = [ ...@@ -19,8 +19,8 @@ export const SOUNDS_ARRAY: Sounds[] = [
]; ];
export function playSound(sound: Sounds) { export function playSound(sound: Sounds) {
let file = SoundMap[sound]; const file = SoundMap[sound];
let el = new Audio(file); const el = new Audio(file);
try { try {
el.play(); el.play();
} catch (err) { } catch (err) {
......
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import { dispatch, getState } from "../../redux";
import Button from "../ui/Button";
import Checkbox from "../ui/Checkbox";
import { Children } from "../../types/Preact";
const Base = styled.div`
display: flex;
flex-grow: 1;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
padding: 12px;
img {
height: 150px;
}
.subtext {
color: var(--secondary-foreground);
margin-bottom: 12px;
font-size: 14px;
}
.actions {
margin-top: 20px;
display: flex;
gap: 12px;
}
`;
type Props = {
gated: boolean;
children: Children;
} & {
type: "channel";
channel: Channel;
};
export default observer((props: Props) => {
const history = useHistory();
const [consent, setConsent] = useState(
getState().sectionToggle["nsfw"] ?? false,
);
const [ageGate, setAgeGate] = useState(false);
if (ageGate || !props.gated) {
return <>{props.children}</>;
}
if (
!(
props.channel.channel_type === "Group" ||
props.channel.channel_type === "TextChannel"
)
)
return <>{props.children}</>;
return (
<Base>
<img
loading="eager"
src={"https://static.revolt.chat/emoji/mutant/26a0.svg"}
draggable={false}
/>
<h2>{props.channel.name}</h2>
<span className="subtext">
<Text id={`app.main.channel.nsfw.${props.type}.marked`} />{" "}
<a href="#">
<Text id={`app.main.channel.nsfw.learn_more`} />
</a>
</span>
<Checkbox
checked={consent}
onChange={(v) => {
setConsent(v);
if (v) {
dispatch({
type: "SECTION_TOGGLE_SET",
id: "nsfw",
state: true,
});
} else {
dispatch({ type: "SECTION_TOGGLE_UNSET", id: "nsfw" });
}
}}>
<Text id="app.main.channel.nsfw.confirm" />
</Checkbox>
<div className="actions">
<Button contrast onClick={() => history.goBack()}>
<Text id="app.special.modals.actions.back" />
</Button>
<Button contrast onClick={() => consent && setAgeGate(true)}>
<Text id={`app.main.channel.nsfw.${props.type}.confirm`} />
</Button>
</div>
</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, useContext, useState } from "preact/hooks"; import { StateUpdater, useState } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient"; import { useClient } from "../../context/revoltjs/RevoltClient";
import { emojiDictionary } from "../../assets/emojis"; import { emojiDictionary } from "../../assets/emojis";
import ChannelIcon from "./ChannelIcon"; import ChannelIcon from "./ChannelIcon";
...@@ -24,7 +25,7 @@ export type AutoCompleteState = ...@@ -24,7 +25,7 @@ export type AutoCompleteState =
} }
| { | {
type: "channel"; type: "channel";
matches: Channels.TextChannel[]; matches: Channel[];
} }
)); ));
...@@ -52,16 +53,16 @@ export function useAutoComplete( ...@@ -52,16 +53,16 @@ export function useAutoComplete(
): AutoCompleteProps { ): AutoCompleteProps {
const [state, setState] = useState<AutoCompleteState>({ type: "none" }); const [state, setState] = useState<AutoCompleteState>({ type: "none" });
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const client = useContext(AppContext); const client = useClient();
function findSearchString( function findSearchString(
el: HTMLTextAreaElement, el: HTMLTextAreaElement,
): ["emoji" | "user" | "channel", string, number] | undefined { ): ["emoji" | "user" | "channel", string, number] | undefined {
if (el.selectionStart === el.selectionEnd) { if (el.selectionStart === el.selectionEnd) {
let cursor = el.selectionStart; const cursor = el.selectionStart;
let content = el.value.slice(0, cursor); const content = el.value.slice(0, cursor);
let valid = /\w/; const valid = /\w/;
let j = content.length - 1; let j = content.length - 1;
if (content[j] === "@") { if (content[j] === "@") {
...@@ -75,10 +76,10 @@ export function useAutoComplete( ...@@ -75,10 +76,10 @@ export function useAutoComplete(
} }
if (j === -1) return; if (j === -1) return;
let current = content[j]; const current = content[j];
if (current === ":" || current === "@" || current === "#") { if (current === ":" || current === "@" || current === "#") {
let search = content.slice(j + 1, content.length); const search = content.slice(j + 1, content.length);
if (search.length > 0) { if (search.length > 0) {
return [ return [
current === "#" current === "#"
...@@ -97,19 +98,19 @@ export function useAutoComplete( ...@@ -97,19 +98,19 @@ export function useAutoComplete(
function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) { function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) {
const el = ev.currentTarget; const el = ev.currentTarget;
let result = findSearchString(el); const result = findSearchString(el);
if (result) { if (result) {
let [type, search] = result; const [type, search] = result;
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
let 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);
if (matches.length > 0) { if (matches.length > 0) {
let currentPosition = const currentPosition =
state.type !== "none" ? state.selected : 0; state.type !== "none" ? state.selected : 0;
setState({ setState({
...@@ -127,32 +128,30 @@ export function useAutoComplete( ...@@ -127,32 +128,30 @@ 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": {
let channel = client.channels.get(searchClues.users.id); const channel = client.channels.get(
searchClues.users.id,
);
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;
...@@ -162,7 +161,7 @@ export function useAutoComplete( ...@@ -162,7 +161,7 @@ export function useAutoComplete(
users = users.filter((x) => x._id !== SYSTEM_USER_ID); users = users.filter((x) => x._id !== SYSTEM_USER_ID);
let 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),
...@@ -173,7 +172,7 @@ export function useAutoComplete( ...@@ -173,7 +172,7 @@ export function useAutoComplete(
.filter((x) => typeof x !== "undefined"); .filter((x) => typeof x !== "undefined");
if (matches.length > 0) { if (matches.length > 0) {
let currentPosition = const currentPosition =
state.type !== "none" ? state.selected : 0; state.type !== "none" ? state.selected : 0;
setState({ setState({
...@@ -188,17 +187,16 @@ export function useAutoComplete( ...@@ -188,17 +187,16 @@ export function useAutoComplete(
} }
if (type === "channel" && searchClues?.channels) { if (type === "channel" && searchClues?.channels) {
let 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[];
let 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
) )
...@@ -206,7 +204,7 @@ export function useAutoComplete( ...@@ -206,7 +204,7 @@ export function useAutoComplete(
.filter((x) => typeof x !== "undefined"); .filter((x) => typeof x !== "undefined");
if (matches.length > 0) { if (matches.length > 0) {
let currentPosition = const currentPosition =
state.type !== "none" ? state.selected : 0; state.type !== "none" ? state.selected : 0;
setState({ setState({
...@@ -228,11 +226,11 @@ export function useAutoComplete( ...@@ -228,11 +226,11 @@ export function useAutoComplete(
function selectCurrent(el: HTMLTextAreaElement) { function selectCurrent(el: HTMLTextAreaElement) {
if (state.type !== "none") { if (state.type !== "none") {
let result = findSearchString(el); const result = findSearchString(el);
if (result) { if (result) {
let [_type, search, index] = result; const [_type, search, index] = result;
let content = el.value.split(""); const content = el.value.split("");
if (state.type === "emoji") { if (state.type === "emoji") {
content.splice( content.splice(
index, index,
...@@ -307,7 +305,7 @@ export function useAutoComplete( ...@@ -307,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);
} }
} }
...@@ -353,12 +351,13 @@ const Base = styled.div<{ detached?: boolean }>` ...@@ -353,12 +351,13 @@ const Base = styled.div<{ detached?: boolean }>`
display: flex; display: flex;
font-size: 1em; font-size: 1em;
cursor: pointer; cursor: pointer;
border-radius: 6px;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
font-family: inherit;
background: transparent; background: transparent;
color: var(--foreground); color: var(--foreground);
width: calc(100% - 12px); width: calc(100% - 12px);
border-radius: var(--border-radius);
span { span {
display: grid; display: grid;
...@@ -376,7 +375,7 @@ const Base = styled.div<{ detached?: boolean }>` ...@@ -376,7 +375,7 @@ const Base = styled.div<{ detached?: boolean }>`
bottom: 8px; bottom: 8px;
> div { > div {
border-radius: 4px; border-radius: var(--border-radius);
} }
`} `}
`; `;
...@@ -393,6 +392,7 @@ export default function AutoComplete({ ...@@ -393,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) &&
...@@ -424,6 +424,7 @@ export default function AutoComplete({ ...@@ -424,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) &&
...@@ -448,6 +449,7 @@ export default function AutoComplete({ ...@@ -448,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} />;
} else { }
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}
aria-hidden="true" loading="lazy"
square={isServerChannel} aria-hidden="true"
src={iconURL ?? fallback} square={isServerChannel}
/> src={iconURL ?? fallback}
); />
} );
},
);
import { EmojiPacks } from "../../redux/reducers/settings"; import { EmojiPacks } from "../../redux/reducers/settings";
var EMOJI_PACK = "mutant"; let EMOJI_PACK = "mutant";
const REVISION = 3; const REVISION = 3;
export function setEmojiPack(pack: EmojiPacks) { export function setEmojiPack(pack: EmojiPacks) {
...@@ -41,7 +41,7 @@ function toCodePoint(rune: string) { ...@@ -41,7 +41,7 @@ function toCodePoint(rune: string) {
} }
function parseEmoji(emoji: string) { function parseEmoji(emoji: string) {
let 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}`;
} }
...@@ -55,6 +55,7 @@ export default function Emoji({ ...@@ -55,6 +55,7 @@ export default function Emoji({
return ( return (
<img <img
alt={emoji} alt={emoji}
loading="lazy"
className="emoji" className="emoji"
draggable={false} draggable={false}
src={parseEmoji(emoji)} src={parseEmoji(emoji)}
...@@ -66,7 +67,7 @@ export default function Emoji({ ...@@ -66,7 +67,7 @@ export default function Emoji({
} }
export function generateEmoji(emoji: string) { export function generateEmoji(emoji: string) {
return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji( return `<img loading="lazy" class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(
emoji, emoji,
)}" />`; )}" />`;
} }
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,48 +22,47 @@ const ServerText = styled.div` ...@@ -21,48 +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}
aria-hidden="true"
src={iconURL}
/>
);
}
...@@ -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";
var 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} />
......