Run prettier on all files.

This commit is contained in:
Paul 2021-07-05 11:23:23 +01:00
parent 56058c1e75
commit 7bd33d8d34
181 changed files with 18084 additions and 13521 deletions

View file

@ -3,7 +3,7 @@ module.exports = {
"useTabs": true, "useTabs": true,
"trailingComma": "all", "trailingComma": "all",
"jsxBracketSameLine": true, "jsxBracketSameLine": true,
"importOrder": ["/(lib)", "/(redux)", "/(context)", "/(ui|common)|.svg$", "^[./]"], "importOrder": ["preact|classnames|.scss$", "/(lib)", "/(redux)", "/(context)", "/(ui|common)|.svg$", "^[./]"],
"importOrderSeparation": true, "importOrderSeparation": true,
} }

File diff suppressed because one or more lines are too long

View file

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

View file

@ -1,449 +1,475 @@
import { StateUpdater, useContext, useState } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { Channels } from "revolt.js/dist/api/objects";
import { emojiDictionary } from "../../assets/emojis";
import { SYSTEM_USER_ID, User } from "revolt.js"; import { SYSTEM_USER_ID, User } from "revolt.js";
import UserIcon from "./user/UserIcon"; import { Channels } from "revolt.js/dist/api/objects";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import Emoji from "./Emoji";
import { StateUpdater, useContext, useState } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { emojiDictionary } from "../../assets/emojis";
import ChannelIcon from "./ChannelIcon"; import ChannelIcon from "./ChannelIcon";
import Emoji from "./Emoji";
import UserIcon from "./user/UserIcon";
export type AutoCompleteState = export type AutoCompleteState =
| { type: "none" } | { type: "none" }
| ({ selected: number; within: boolean; } & ( | ({ selected: number; within: boolean } & (
{ | {
type: "emoji"; type: "emoji";
matches: string[]; matches: string[];
} | }
{ | {
type: "user"; type: "user";
matches: User[]; matches: User[];
} | }
{ | {
type: "channel"; type: "channel";
matches: Channels.TextChannel[]; matches: Channels.TextChannel[];
} }
)); ));
export type SearchClues = { export type SearchClues = {
users?: { type: 'channel', id: string } | { type: 'all' }, users?: { type: "channel"; id: string } | { type: "all" };
channels?: { server: string } channels?: { server: string };
}; };
export type AutoCompleteProps = { export type AutoCompleteProps = {
detached?: boolean, detached?: boolean;
state: AutoCompleteState, state: AutoCompleteState;
setState: StateUpdater<AutoCompleteState>, setState: StateUpdater<AutoCompleteState>;
onKeyUp: (ev: KeyboardEvent) => void, onKeyUp: (ev: KeyboardEvent) => void;
onKeyDown: (ev: KeyboardEvent) => boolean, onKeyDown: (ev: KeyboardEvent) => boolean;
onChange: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void, onChange: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void;
onClick: JSX.MouseEventHandler<HTMLButtonElement>, onClick: JSX.MouseEventHandler<HTMLButtonElement>;
onFocus: JSX.FocusEventHandler<HTMLTextAreaElement>, onFocus: JSX.FocusEventHandler<HTMLTextAreaElement>;
onBlur: JSX.FocusEventHandler<HTMLTextAreaElement> onBlur: JSX.FocusEventHandler<HTMLTextAreaElement>;
} };
export function useAutoComplete(setValue: (v?: string) => void, searchClues?: SearchClues): AutoCompleteProps { export function useAutoComplete(
const [state, setState] = useState<AutoCompleteState>({ type: 'none' }); setValue: (v?: string) => void,
const [focused, setFocused] = useState(false); searchClues?: SearchClues,
const client = useContext(AppContext); ): AutoCompleteProps {
const [state, setState] = useState<AutoCompleteState>({ type: "none" });
const [focused, setFocused] = useState(false);
const client = useContext(AppContext);
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; let cursor = el.selectionStart;
let content = el.value.slice(0, cursor); let content = el.value.slice(0, cursor);
let valid = /\w/; let valid = /\w/;
let j = content.length - 1; let j = content.length - 1;
if (content[j] === '@') { if (content[j] === "@") {
return [ return ["user", "", j];
"user", } else if (content[j] === "#") {
"", return ["channel", "", j];
j }
];
} else if (content[j] === '#') {
return [
"channel",
"",
j
];
}
while (j >= 0 && valid.test(content[j])) { while (j >= 0 && valid.test(content[j])) {
j--; j--;
} }
if (j === -1) return; if (j === -1) return;
let current = content[j]; let current = content[j];
if (current === ":" || current === "@" || current === "#") { if (current === ":" || current === "@" || current === "#") {
let search = content.slice(j + 1, content.length); let search = content.slice(j + 1, content.length);
if (search.length > 0) { if (search.length > 0) {
return [ return [
current === "#" ? "channel" : current === "#"
current === ":" ? "emoji" : "user", ? "channel"
search.toLowerCase(), : current === ":"
j + 1 ? "emoji"
]; : "user",
} search.toLowerCase(),
} j + 1,
} ];
} }
}
}
}
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); let result = findSearchString(el);
if (result) { if (result) {
let [type, search] = result; let [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 // ! FIXME: we should convert it to a Binary Search Tree and use that
let matches = Object.keys(emojiDictionary) let 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 = let currentPosition =
state.type !== "none" state.type !== "none" ? state.selected : 0;
? state.selected
: 0;
setState({ setState({
type: "emoji", type: "emoji",
matches, matches,
selected: Math.min(currentPosition, matches.length - 1), selected: Math.min(currentPosition, matches.length - 1),
within: false within: false,
}); });
return; return;
} }
} }
if (type === "user" && searchClues?.users) { if (type === "user" && searchClues?.users) {
let users: User[] = []; let users: User[] = [];
switch (searchClues.users.type) { switch (searchClues.users.type) {
case 'all': users = client.users.toArray(); break; case "all":
case 'channel': { users = client.users.toArray();
let channel = client.channels.get(searchClues.users.id); break;
switch (channel?.channel_type) { case "channel": {
case 'Group': let channel = client.channels.get(searchClues.users.id);
case 'DirectMessage': switch (channel?.channel_type) {
users = client.users.mapKeys(channel.recipients) case "Group":
.filter(x => typeof x !== 'undefined') as User[]; case "DirectMessage":
break; users = client.users
case 'TextChannel': .mapKeys(channel.recipients)
const server = channel.server; .filter(
users = client.servers.members.toArray() (x) => typeof x !== "undefined",
.filter(x => x._id.substr(0, 26) === server) ) as User[];
.map(x => client.users.get(x._id.substr(26))) break;
.filter(x => typeof x !== 'undefined') as User[]; case "TextChannel":
break; const server = channel.server;
default: return; users = client.servers.members
} .toArray()
} .filter(
} (x) => x._id.substr(0, 26) === server,
)
.map((x) =>
client.users.get(x._id.substr(26)),
)
.filter(
(x) => typeof x !== "undefined",
) as User[];
break;
default:
return;
}
}
}
users = users.filter(x => x._id !== SYSTEM_USER_ID); users = users.filter((x) => x._id !== SYSTEM_USER_ID);
let matches = (search.length > 0 ? users.filter(user => user.username.toLowerCase().match(regex)) : users) let matches = (
.splice(0, 5) search.length > 0
.filter(x => typeof x !== "undefined"); ? users.filter((user) =>
user.username.toLowerCase().match(regex),
)
: users
)
.splice(0, 5)
.filter((x) => typeof x !== "undefined");
if (matches.length > 0) { if (matches.length > 0) {
let currentPosition = let currentPosition =
state.type !== "none" state.type !== "none" ? state.selected : 0;
? state.selected
: 0;
setState({ setState({
type: "user", type: "user",
matches, matches,
selected: Math.min(currentPosition, matches.length - 1), selected: Math.min(currentPosition, matches.length - 1),
within: false within: false,
}); });
return; return;
} }
} }
if (type === 'channel' && searchClues?.channels) { if (type === "channel" && searchClues?.channels) {
let channels = client.servers.get(searchClues.channels.server) let channels = client.servers
?.channels .get(searchClues.channels.server)
.map(x => client.channels.get(x)) ?.channels.map((x) => client.channels.get(x))
.filter(x => typeof x !== 'undefined') as Channels.TextChannel[]; .filter(
(x) => typeof x !== "undefined",
) as Channels.TextChannel[];
let matches = (search.length > 0 ? channels.filter(channel => channel.name.toLowerCase().match(regex)) : channels) let matches = (
.splice(0, 5) search.length > 0
.filter(x => typeof x !== "undefined"); ? channels.filter((channel) =>
channel.name.toLowerCase().match(regex),
)
: channels
)
.splice(0, 5)
.filter((x) => typeof x !== "undefined");
if (matches.length > 0) { if (matches.length > 0) {
let currentPosition = let currentPosition =
state.type !== "none" state.type !== "none" ? state.selected : 0;
? state.selected
: 0;
setState({ setState({
type: "channel", type: "channel",
matches, matches,
selected: Math.min(currentPosition, matches.length - 1), selected: Math.min(currentPosition, matches.length - 1),
within: false within: false,
}); });
return; return;
} }
} }
} }
if (state.type !== "none") { if (state.type !== "none") {
setState({ type: "none" }); setState({ type: "none" });
} }
} }
function selectCurrent(el: HTMLTextAreaElement) { function selectCurrent(el: HTMLTextAreaElement) {
if (state.type !== "none") { if (state.type !== "none") {
let result = findSearchString(el); let result = findSearchString(el);
if (result) { if (result) {
let [_type, search, index] = result; let [_type, search, index] = result;
let content = el.value.split(""); let content = el.value.split("");
if (state.type === "emoji") { if (state.type === "emoji") {
content.splice( content.splice(
index, index,
search.length, search.length,
state.matches[state.selected], state.matches[state.selected],
": " ": ",
); );
} else if (state.type === 'user') { } else if (state.type === "user") {
content.splice( content.splice(
index - 1, index - 1,
search.length + 1, search.length + 1,
"<@", "<@",
state.matches[state.selected]._id, state.matches[state.selected]._id,
"> " "> ",
); );
} else { } else {
content.splice( content.splice(
index - 1, index - 1,
search.length + 1, search.length + 1,
"<#", "<#",
state.matches[state.selected]._id, state.matches[state.selected]._id,
"> " "> ",
); );
} }
setValue(content.join("")); setValue(content.join(""));
} }
} }
} }
function onClick(ev: JSX.TargetedMouseEvent<HTMLButtonElement>) { function onClick(ev: JSX.TargetedMouseEvent<HTMLButtonElement>) {
ev.preventDefault(); ev.preventDefault();
selectCurrent(document.querySelector("#message")!); selectCurrent(document.querySelector("#message")!);
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (focused && state.type !== 'none') { if (focused && state.type !== "none") {
if (e.key === "ArrowUp") { if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
if (state.selected > 0) { if (state.selected > 0) {
setState({ setState({
...state, ...state,
selected: state.selected - 1 selected: state.selected - 1,
}); });
} }
return true; return true;
} }
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
if (state.selected < state.matches.length - 1) { if (state.selected < state.matches.length - 1) {
setState({ setState({
...state, ...state,
selected: state.selected + 1 selected: state.selected + 1,
}); });
} }
return true; return true;
} }
if (e.key === "Enter" || e.key === "Tab") { if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault(); e.preventDefault();
selectCurrent( selectCurrent(e.currentTarget as HTMLTextAreaElement);
e.currentTarget as HTMLTextAreaElement
);
return true; return true;
} }
} }
return false; return false;
} }
function onKeyUp(e: KeyboardEvent) { function onKeyUp(e: KeyboardEvent) {
if (e.currentTarget !== null) { if (e.currentTarget !== null) {
// @ts-expect-error // @ts-expect-error
onChange(e); onChange(e);
} }
} }
function onFocus(ev: JSX.TargetedFocusEvent<HTMLTextAreaElement>) { function onFocus(ev: JSX.TargetedFocusEvent<HTMLTextAreaElement>) {
setFocused(true); setFocused(true);
onChange(ev); onChange(ev);
} }
function onBlur() { function onBlur() {
if (state.type !== 'none' && state.within) return; if (state.type !== "none" && state.within) return;
setFocused(false); setFocused(false);
} }
return { return {
state: focused ? state : { type: 'none' }, state: focused ? state : { type: "none" },
setState, setState,
onClick, onClick,
onChange, onChange,
onKeyUp, onKeyUp,
onKeyDown, onKeyDown,
onFocus, onFocus,
onBlur onBlur,
} };
} }
const Base = styled.div<{ detached?: boolean }>` const Base = styled.div<{ detached?: boolean }>`
position: relative; position: relative;
> div { > div {
bottom: 0; bottom: 0;
width: 100%; width: 100%;
position: absolute; position: absolute;
background: var(--primary-header); background: var(--primary-header);
} }
button { button {
gap: 8px; gap: 8px;
margin: 4px; margin: 4px;
padding: 6px; padding: 6px;
border: none; border: none;
display: flex; display: flex;
font-size: 1em; font-size: 1em;
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: 6px;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
background: transparent; background: transparent;
color: var(--foreground); color: var(--foreground);
width: calc(100% - 12px); width: calc(100% - 12px);
span { span {
display: grid; display: grid;
place-items: center; place-items: center;
} }
&.active { &.active {
background: var(--primary-background); background: var(--primary-background);
} }
} }
${ props => props.detached && css` ${(props) =>
bottom: 8px; props.detached &&
css`
bottom: 8px;
> div { > div {
border-radius: 4px; border-radius: 4px;
} }
` } `}
`; `;
export default function AutoComplete({ detached, state, setState, onClick }: Pick<AutoCompleteProps, 'detached' | 'state' | 'setState' | 'onClick'>) { export default function AutoComplete({
return ( detached,
<Base detached={detached}> state,
<div> setState,
{state.type === "emoji" && onClick,
state.matches.map((match, i) => ( }: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) {
<button return (
className={i === state.selected ? "active" : ''} <Base detached={detached}>
onMouseEnter={() => <div>
(i !== state.selected || {state.type === "emoji" &&
!state.within) && state.matches.map((match, i) => (
setState({ <button
...state, className={i === state.selected ? "active" : ""}
selected: i, onMouseEnter={() =>
within: true (i !== state.selected || !state.within) &&
}) setState({
} ...state,
onMouseLeave={() => selected: i,
state.within && within: true,
setState({ })
...state, }
within: false onMouseLeave={() =>
}) state.within &&
} setState({
onClick={onClick}> ...state,
<Emoji emoji={(emojiDictionary as Record<string, string>)[match]} size={20} /> within: false,
:{match}: })
</button> }
))} onClick={onClick}>
{state.type === "user" && <Emoji
state.matches.map((match, i) => ( emoji={
<button (emojiDictionary as Record<string, string>)[
className={i === state.selected ? "active" : ''} match
onMouseEnter={() => ]
(i !== state.selected || }
!state.within) && size={20}
setState({ />
...state, :{match}:
selected: i, </button>
within: true ))}
}) {state.type === "user" &&
} state.matches.map((match, i) => (
onMouseLeave={() => <button
state.within && className={i === state.selected ? "active" : ""}
setState({ onMouseEnter={() =>
...state, (i !== state.selected || !state.within) &&
within: false setState({
}) ...state,
} selected: i,
onClick={onClick}> within: true,
<UserIcon })
size={24} }
target={match} onMouseLeave={() =>
status={true} /> state.within &&
{match.username} setState({
</button> ...state,
))} within: false,
{state.type === "channel" && })
state.matches.map((match, i) => ( }
<button onClick={onClick}>
className={i === state.selected ? "active" : ''} <UserIcon size={24} target={match} status={true} />
onMouseEnter={() => {match.username}
(i !== state.selected || </button>
!state.within) && ))}
setState({ {state.type === "channel" &&
...state, state.matches.map((match, i) => (
selected: i, <button
within: true className={i === state.selected ? "active" : ""}
}) onMouseEnter={() =>
} (i !== state.selected || !state.within) &&
onMouseLeave={() => setState({
state.within && ...state,
setState({ selected: i,
...state, within: true,
within: false })
}) }
} onMouseLeave={() =>
onClick={onClick}> state.within &&
<ChannelIcon setState({
size={24} ...state,
target={match} /> within: false,
{match.name} })
</button> }
))} onClick={onClick}>
</div> <ChannelIcon size={24} target={match} />
</Base> {match.name}
) </button>
))}
</div>
</Base>
);
} }

View file

@ -1,43 +1,65 @@
import { useContext } from "preact/hooks";
import { Channels } from "revolt.js/dist/api/objects";
import { Hash, VolumeFull } from "@styled-icons/boxicons-regular"; import { Hash, VolumeFull } from "@styled-icons/boxicons-regular";
import { ImageIconBase, IconBaseProps } from "./IconBase"; import { Channels } from "revolt.js/dist/api/objects";
import { useContext } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient"; import { AppContext } from "../../context/revoltjs/RevoltClient";
interface Props extends IconBaseProps<Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel> { import { ImageIconBase, IconBaseProps } from "./IconBase";
isServerChannel?: boolean; import fallback from "./assets/group.png";
interface Props
extends IconBaseProps<
Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel
> {
isServerChannel?: boolean;
} }
import fallback from './assets/group.png'; export default function ChannelIcon(
props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>,
) {
const client = useContext(AppContext);
export default function ChannelIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) { const {
const client = useContext(AppContext); size,
target,
attachment,
isServerChannel: server,
animate,
children,
as,
...imgProps
} = props;
const iconURL = client.generateFileURL(
target?.icon ?? attachment,
{ max_side: 256 },
animate,
);
const isServerChannel =
server ||
(target &&
(target.channel_type === "TextChannel" ||
target.channel_type === "VoiceChannel"));
const { size, target, attachment, isServerChannel: server, animate, children, as, ...imgProps } = props; if (typeof iconURL === "undefined") {
const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate); if (isServerChannel) {
const isServerChannel = server || (target && (target.channel_type === 'TextChannel' || target.channel_type === 'VoiceChannel')); if (target?.channel_type === "VoiceChannel") {
return <VolumeFull size={size} />;
} else {
return <Hash size={size} />;
}
}
}
if (typeof iconURL === 'undefined') { return (
if (isServerChannel) { // ! fixme: replace fallback with <picture /> + <source />
if (target?.channel_type === 'VoiceChannel') { <ImageIconBase
return ( {...imgProps}
<VolumeFull size={size} /> width={size}
) height={size}
} else { aria-hidden="true"
return ( square={isServerChannel}
<Hash size={size} /> src={iconURL ?? fallback}
) />
} );
}
}
return (
// ! fixme: replace fallback with <picture /> + <source />
<ImageIconBase {...imgProps}
width={size}
height={size}
aria-hidden="true"
square={isServerChannel}
src={iconURL ?? fallback} />
);
} }

View file

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

View file

@ -1,62 +1,72 @@
import { EmojiPacks } from '../../redux/reducers/settings'; import { EmojiPacks } from "../../redux/reducers/settings";
var EMOJI_PACK = 'mutant'; var EMOJI_PACK = "mutant";
const REVISION = 3; const REVISION = 3;
export function setEmojiPack(pack: EmojiPacks) { export function setEmojiPack(pack: EmojiPacks) {
EMOJI_PACK = pack; EMOJI_PACK = pack;
} }
// Originally taken from Twemoji source code, // Originally taken from Twemoji source code,
// re-written by bree to be more readable. // re-written by bree to be more readable.
function codePoints(rune: string) { function codePoints(rune: string) {
const pairs = []; const pairs = [];
let low = 0; let low = 0;
let i = 0; let i = 0;
while (i < rune.length) { while (i < rune.length) {
const charCode = rune.charCodeAt(i++); const charCode = rune.charCodeAt(i++);
if (low) { if (low) {
pairs.push(0x10000 + ((low - 0xd800) << 10) + (charCode - 0xdc00)); pairs.push(0x10000 + ((low - 0xd800) << 10) + (charCode - 0xdc00));
low = 0; low = 0;
} else if (0xd800 <= charCode && charCode <= 0xdbff) { } else if (0xd800 <= charCode && charCode <= 0xdbff) {
low = charCode; low = charCode;
} else { } else {
pairs.push(charCode); pairs.push(charCode);
} }
} }
return pairs; return pairs;
} }
// Taken from Twemoji source code. // Taken from Twemoji source code.
// scripts/build.js#344 // scripts/build.js#344
// grabTheRightIcon(rawText); // grabTheRightIcon(rawText);
const UFE0Fg = /\uFE0F/g; const UFE0Fg = /\uFE0F/g;
const U200D = String.fromCharCode(0x200D); const U200D = String.fromCharCode(0x200d);
function toCodePoint(rune: string) { function toCodePoint(rune: string) {
return codePoints(rune.indexOf(U200D) < 0 ? rune.replace(UFE0Fg, '') : rune) return codePoints(rune.indexOf(U200D) < 0 ? rune.replace(UFE0Fg, "") : rune)
.map((val) => val.toString(16)) .map((val) => val.toString(16))
.join("-") .join("-");
} }
function parseEmoji(emoji: string) { function parseEmoji(emoji: string) {
let codepoint = toCodePoint(emoji); let 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}`;
} }
export default function Emoji({ emoji, size }: { emoji: string, size?: number }) { export default function Emoji({
return ( emoji,
<img size,
alt={emoji} }: {
className="emoji" emoji: string;
draggable={false} size?: number;
src={parseEmoji(emoji)} }) {
style={size ? { width: `${size}px`, height: `${size}px` } : undefined} return (
/> <img
) alt={emoji}
className="emoji"
draggable={false}
src={parseEmoji(emoji)}
style={
size ? { width: `${size}px`, height: `${size}px` } : undefined
}
/>
);
} }
export function generateEmoji(emoji: string) { export function generateEmoji(emoji: string) {
return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(emoji)}" />`; return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(
emoji,
)}" />`;
} }

View file

@ -2,36 +2,40 @@ import { Attachment } from "revolt.js/dist/api/objects";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
export interface IconBaseProps<T> { export interface IconBaseProps<T> {
target?: T; target?: T;
attachment?: Attachment; attachment?: Attachment;
size: number; size: number;
animate?: boolean; animate?: boolean;
} }
interface IconModifiers { interface IconModifiers {
square?: boolean square?: boolean;
} }
export default styled.svg<IconModifiers>` export default styled.svg<IconModifiers>`
flex-shrink: 0; flex-shrink: 0;
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
${ props => !props.square && css` ${(props) =>
border-radius: 50%; !props.square &&
` } css`
} border-radius: 50%;
`}
}
`; `;
export const ImageIconBase = styled.img<IconModifiers>` export const ImageIconBase = styled.img<IconModifiers>`
flex-shrink: 0; flex-shrink: 0;
object-fit: cover; object-fit: cover;
${ props => !props.square && css` ${(props) =>
border-radius: 50%; !props.square &&
` } css`
border-radius: 50%;
`}
`; `;

View file

@ -1,40 +1,38 @@
import ComboBox from "../ui/ComboBox";
import { dispatch } from "../../redux"; import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import { Language, LanguageEntry, Languages } from "../../context/Locale";
import { Language, Languages } from "../../context/Locale";
import ComboBox from "../ui/ComboBox";
type Props = { type Props = {
locale: string; locale: string;
}; };
export function LocaleSelector(props: Props) { export function LocaleSelector(props: Props) {
return ( return (
<ComboBox <ComboBox
value={props.locale} value={props.locale}
onChange={e => onChange={(e) =>
dispatch({ dispatch({
type: "SET_LOCALE", type: "SET_LOCALE",
locale: e.currentTarget.value as Language locale: e.currentTarget.value as Language,
}) })
} }>
> {Object.keys(Languages).map((x) => {
{Object.keys(Languages).map(x => { const l = Languages[x as keyof typeof Languages];
const l = Languages[x as keyof typeof Languages]; return (
return ( <option value={x}>
<option value={x}> {l.emoji} {l.display}
{l.emoji} {l.display} </option>
</option> );
); })}
})} </ComboBox>
</ComboBox> );
);
} }
export default connectState( export default connectState(LocaleSelector, (state) => {
LocaleSelector, return {
state => { locale: state.locale,
return { };
locale: state.locale });
};
}
);

View file

@ -1,40 +1,49 @@
import Header from "../ui/Header";
import styled from "styled-components";
import { Link } from "react-router-dom";
import IconButton from "../ui/IconButton";
import { Cog } from "@styled-icons/boxicons-solid"; import { Cog } from "@styled-icons/boxicons-solid";
import { Link } from "react-router-dom";
import { Server } from "revolt.js/dist/api/objects"; 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 styled from "styled-components";
import { HookContext, useServerPermission } from "../../context/revoltjs/hooks"; import { HookContext, useServerPermission } from "../../context/revoltjs/hooks";
import Header from "../ui/Header";
import IconButton from "../ui/IconButton";
interface Props { interface Props {
server: Server, server: Server;
ctx: HookContext 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 function ServerHeader({ server, ctx }: Props) {
const permissions = useServerPermission(server._id, ctx); const permissions = useServerPermission(server._id, ctx);
const bannerURL = ctx.client.servers.getBannerURL(server._id, { width: 480 }, true); const bannerURL = ctx.client.servers.getBannerURL(
server._id,
{ width: 480 },
true,
);
return ( return (
<Header borders <Header
placement="secondary" borders
background={typeof bannerURL !== 'undefined'} placement="secondary"
style={{ background: bannerURL ? `url('${bannerURL}')` : undefined }}> background={typeof bannerURL !== "undefined"}
<ServerName> style={{
{ server.name } background: bannerURL ? `url('${bannerURL}')` : undefined,
</ServerName> }}>
{ (permissions & ServerPermission.ManageServer) > 0 && <div className="actions"> <ServerName>{server.name}</ServerName>
<Link to={`/server/${server._id}/settings`}> {(permissions & ServerPermission.ManageServer) > 0 && (
<IconButton> <div className="actions">
<Cog size={24} /> <Link to={`/server/${server._id}/settings`}>
</IconButton> <IconButton>
</Link> <Cog size={24} />
</div> } </IconButton>
</Header> </Link>
) </div>
)}
</Header>
);
} }

View file

@ -1,47 +1,68 @@
import styled from "styled-components";
import { useContext } from "preact/hooks";
import { Server } from "revolt.js/dist/api/objects"; import { Server } from "revolt.js/dist/api/objects";
import { IconBaseProps, ImageIconBase } from "./IconBase"; import styled from "styled-components";
import { useContext } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient"; import { AppContext } from "../../context/revoltjs/RevoltClient";
import { IconBaseProps, ImageIconBase } from "./IconBase";
interface Props extends IconBaseProps<Server> { interface Props extends IconBaseProps<Server> {
server_name?: string; server_name?: string;
} }
const ServerText = styled.div` const ServerText = styled.div`
display: grid; display: grid;
padding: .2em; padding: 0.2em;
overflow: hidden; overflow: hidden;
border-radius: 50%; border-radius: 50%;
place-items: center; place-items: center;
color: var(--foreground); color: var(--foreground);
background: var(--primary-background); background: var(--primary-background);
`; `;
const fallback = '/assets/group.png'; const fallback = "/assets/group.png";
export default function ServerIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) { export default function ServerIcon(
const client = useContext(AppContext); props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>,
) {
const client = useContext(AppContext);
const { target, attachment, size, animate, server_name, children, as, ...imgProps } = props; const {
const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate); target,
attachment,
size,
animate,
server_name,
children,
as,
...imgProps
} = props;
const iconURL = client.generateFileURL(
target?.icon ?? attachment,
{ max_side: 256 },
animate,
);
if (typeof iconURL === 'undefined') { if (typeof iconURL === "undefined") {
const name = target?.name ?? server_name ?? ''; const name = target?.name ?? server_name ?? "";
return ( return (
<ServerText style={{ width: size, height: size }}> <ServerText style={{ width: size, height: size }}>
{ name.split(' ') {name
.map(x => x[0]) .split(" ")
.filter(x => typeof x !== 'undefined') } .map((x) => x[0])
</ServerText> .filter((x) => typeof x !== "undefined")}
) </ServerText>
} );
}
return ( return (
<ImageIconBase {...imgProps} <ImageIconBase
width={size} {...imgProps}
height={size} width={size}
aria-hidden="true" height={size}
src={iconURL} /> aria-hidden="true"
); src={iconURL}
/>
);
} }

View file

@ -1,49 +1,60 @@
import { Text } from "preact-i18n"; import Tippy, { TippyProps } from "@tippyjs/react";
import styled from "styled-components"; import styled from "styled-components";
import { Children } from "../../types/Preact";
import Tippy, { TippyProps } from '@tippyjs/react';
type Props = Omit<TippyProps, 'children'> & { import { Text } from "preact-i18n";
children: Children;
content: Children; import { Children } from "../../types/Preact";
}
type Props = Omit<TippyProps, "children"> & {
children: Children;
content: Children;
};
export default function Tooltip(props: Props) { export default function Tooltip(props: Props) {
const { children, content, ...tippyProps } = props; const { children, content, ...tippyProps } = props;
return ( return (
<Tippy content={content} {...tippyProps}> <Tippy content={content} {...tippyProps}>
{/* {/*
// @ts-expect-error */} // @ts-expect-error */}
<div>{ children }</div> <div>{children}</div>
</Tippy> </Tippy>
); );
} }
const PermissionTooltipBase = styled.div` const PermissionTooltipBase = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
span { span {
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
color: var(--secondary-foreground); color: var(--secondary-foreground);
font-size: 11px; font-size: 11px;
} }
code { code {
font-family: var(--monoscape-font); font-family: var(--monoscape-font);
} }
`; `;
export function PermissionTooltip(props: Omit<Props, 'content'> & { permission: string }) { export function PermissionTooltip(
const { permission, ...tooltipProps } = props; props: Omit<Props, "content"> & { permission: string },
) {
const { permission, ...tooltipProps } = props;
return ( return (
<Tooltip content={<PermissionTooltipBase> <Tooltip
<span><Text id="app.permissions.required" /></span> content={
<code>{ permission }</code> <PermissionTooltipBase>
</PermissionTooltipBase>} {...tooltipProps} /> <span>
) <Text id="app.permissions.required" />
</span>
<code>{permission}</code>
</PermissionTooltipBase>
}
{...tooltipProps}
/>
);
} }

View file

@ -1,26 +1,31 @@
import { updateSW } from "../../main";
import IconButton from "../ui/IconButton";
import { ThemeContext } from "../../context/Theme";
import { Download } from "@styled-icons/boxicons-regular"; import { Download } from "@styled-icons/boxicons-regular";
import { internalSubscribe } from "../../lib/eventEmitter";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { internalSubscribe } from "../../lib/eventEmitter";
import { ThemeContext } from "../../context/Theme";
import IconButton from "../ui/IconButton";
import { updateSW } from "../../main";
var pendingUpdate = false; var pendingUpdate = false;
internalSubscribe('PWA', 'update', () => pendingUpdate = true); internalSubscribe("PWA", "update", () => (pendingUpdate = true));
export default function UpdateIndicator() { export default function UpdateIndicator() {
const [ pending, setPending ] = useState(pendingUpdate); const [pending, setPending] = useState(pendingUpdate);
useEffect(() => { useEffect(() => {
return internalSubscribe('PWA', 'update', () => setPending(true)); return internalSubscribe("PWA", "update", () => setPending(true));
}); });
if (!pending) return null; if (!pending) return null;
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
return ( return (
<IconButton onClick={() => updateSW(true)}> <IconButton onClick={() => updateSW(true)}>
<Download size={22} color={theme.success} /> <Download size={22} color={theme.success} />
</IconButton> </IconButton>
) );
} }

View file

@ -1,78 +1,133 @@
import Embed from "./embed/Embed"; import { attachContextMenu } from "preact-context-menu";
import { memo } from "preact/compat";
import { useContext } from "preact/hooks";
import { QueuedMessage } from "../../../redux/reducers/queue";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useUser } from "../../../context/revoltjs/hooks";
import { MessageObject } from "../../../context/revoltjs/util";
import Overline from "../../ui/Overline";
import { Children } from "../../../types/Preact";
import Markdown from "../../markdown/Markdown";
import UserIcon from "../user/UserIcon"; import UserIcon from "../user/UserIcon";
import { Username } from "../user/UserShort"; import { Username } from "../user/UserShort";
import Markdown from "../../markdown/Markdown"; import MessageBase, {
import { Children } from "../../../types/Preact"; MessageContent,
MessageDetail,
MessageInfo,
} from "./MessageBase";
import Attachment from "./attachments/Attachment"; import Attachment from "./attachments/Attachment";
import { attachContextMenu } from "preact-context-menu";
import { useUser } from "../../../context/revoltjs/hooks";
import { QueuedMessage } from "../../../redux/reducers/queue";
import { MessageObject } from "../../../context/revoltjs/util";
import MessageBase, { MessageContent, MessageDetail, MessageInfo } from "./MessageBase";
import Overline from "../../ui/Overline";
import { useContext } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { memo } from "preact/compat";
import { MessageReply } from "./attachments/MessageReply"; import { MessageReply } from "./attachments/MessageReply";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import Embed from "./embed/Embed";
interface Props { interface Props {
attachContext?: boolean attachContext?: boolean;
queued?: QueuedMessage queued?: QueuedMessage;
message: MessageObject message: MessageObject;
contrast?: boolean contrast?: boolean;
content?: Children content?: Children;
head?: boolean head?: boolean;
} }
function Message({ attachContext, message, contrast, content: replacement, head: preferHead, queued }: Props) { function Message({
// TODO: Can improve re-renders here by providing a list attachContext,
// TODO: of dependencies. We only need to update on u/avatar. message,
const user = useUser(message.author); contrast,
const client = useContext(AppContext); content: replacement,
const { openScreen } = useIntermediate(); head: preferHead,
queued,
}: Props) {
// TODO: Can improve re-renders here by providing a list
// TODO: of dependencies. We only need to update on u/avatar.
const user = useUser(message.author);
const client = useContext(AppContext);
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.replies && message.replies.length > 0);
// ! FIXME: tell fatal to make this type generic // ! FIXME: tell fatal to make this type generic
// bree: Fatal please... // bree: Fatal please...
const userContext = attachContext const userContext = attachContext
? attachContextMenu('Menu', { user: message.author, contextualChannel: message.channel }) as any ? (attachContextMenu("Menu", {
: undefined; user: message.author,
contextualChannel: message.channel,
}) as any)
: undefined;
const openProfile = () => openScreen({ id: 'profile', user_id: message.author }); const openProfile = () =>
openScreen({ id: "profile", user_id: message.author });
return ( return (
<div id={message._id}> <div id={message._id}>
{ message.replies?.map((message_id, index) => <MessageReply index={index} id={message_id} channel={message.channel} />) } {message.replies?.map((message_id, index) => (
<MessageBase <MessageReply
head={head && !(message.replies && message.replies.length > 0)} index={index}
contrast={contrast} id={message_id}
sending={typeof queued !== 'undefined'} channel={message.channel}
mention={message.mentions?.includes(client.user!._id)} />
failed={typeof queued?.error !== 'undefined'} ))}
onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel, queued }) : undefined}> <MessageBase
<MessageInfo> head={head && !(message.replies && message.replies.length > 0)}
{ head ? contrast={contrast}
<UserIcon target={user} size={36} onContextMenu={userContext} onClick={openProfile} /> : sending={typeof queued !== "undefined"}
<MessageDetail message={message} position="left" /> } mention={message.mentions?.includes(client.user!._id)}
</MessageInfo> failed={typeof queued?.error !== "undefined"}
<MessageContent> onContextMenu={
{ head && <span className="detail"> attachContext
<Username className="author" user={user} onContextMenu={userContext} onClick={openProfile} /> ? attachContextMenu("Menu", {
<MessageDetail message={message} position="top" /> message,
</span> } contextualChannel: message.channel,
{ replacement ?? <Markdown content={content} /> } queued,
{ queued?.error && <Overline type="error" error={queued.error} /> } })
{ message.attachments?.map((attachment, index) => : undefined
<Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) } }>
{ message.embeds?.map((embed, index) => <MessageInfo>
<Embed key={index} embed={embed} />) } {head ? (
</MessageContent> <UserIcon
</MessageBase> target={user}
</div> size={36}
) onContextMenu={userContext}
onClick={openProfile}
/>
) : (
<MessageDetail message={message} position="left" />
)}
</MessageInfo>
<MessageContent>
{head && (
<span className="detail">
<Username
className="author"
user={user}
onContextMenu={userContext}
onClick={openProfile}
/>
<MessageDetail message={message} position="top" />
</span>
)}
{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);

View file

@ -1,189 +1,212 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import Tooltip from "../Tooltip";
import { decodeTime } from "ulid";
import { Text } from "preact-i18n";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { decodeTime } from "ulid";
import { Text } from "preact-i18n";
import { MessageObject } from "../../../context/revoltjs/util"; import { MessageObject } from "../../../context/revoltjs/util";
import Tooltip from "../Tooltip";
export interface BaseMessageProps { export interface BaseMessageProps {
head?: boolean, head?: boolean;
failed?: boolean, failed?: boolean;
mention?: boolean, mention?: boolean;
blocked?: boolean, blocked?: boolean;
sending?: boolean, sending?: boolean;
contrast?: boolean contrast?: boolean;
} }
export default styled.div<BaseMessageProps>` export default styled.div<BaseMessageProps>`
display: flex; display: flex;
overflow-x: none; overflow-x: none;
padding: .125rem; padding: 0.125rem;
flex-direction: row; flex-direction: row;
padding-right: 16px; padding-right: 16px;
${ props => props.contrast && css` ${(props) =>
padding: .3rem; props.contrast &&
border-radius: 4px; css`
background: var(--hover); padding: 0.3rem;
` } border-radius: 4px;
background: var(--hover);
`}
${ props => props.head && css` ${(props) =>
margin-top: 12px; props.head &&
` } css`
margin-top: 12px;
`}
${ props => props.mention && css` ${(props) =>
background: var(--mention); props.mention &&
` } css`
background: var(--mention);
`}
${ props => props.blocked && css` ${(props) =>
filter: blur(4px); props.blocked &&
transition: 0.2s ease filter; css`
filter: blur(4px);
transition: 0.2s ease filter;
&:hover { &:hover {
filter: none; filter: none;
} }
` } `}
${ props => props.sending && css` ${(props) =>
opacity: 0.8; props.sending &&
color: var(--tertiary-foreground); css`
` } opacity: 0.8;
color: var(--tertiary-foreground);
`}
${ props => props.failed && css` ${(props) =>
color: var(--error); props.failed &&
` } css`
color: var(--error);
`}
.detail { .detail {
gap: 8px; gap: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.author { .author {
cursor: pointer; cursor: pointer;
font-weight: 600 !important; font-weight: 600 !important;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
.copy { .copy {
display: block; display: block;
overflow: hidden; overflow: hidden;
} }
&:hover { &:hover {
background: var(--hover); background: var(--hover);
time { time {
opacity: 1; opacity: 1;
} }
} }
`; `;
export const MessageInfo = styled.div` export const MessageInfo = styled.div`
width: 62px; width: 62px;
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
padding-top: 2px; padding-top: 2px;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
.copyBracket { .copyBracket {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
} }
.copyTime { .copyTime {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
} }
svg { svg {
user-select: none; user-select: none;
cursor: pointer; cursor: pointer;
&:active { &:active {
transform: translateY(1px); transform: translateY(1px);
} }
} }
time { time {
opacity: 0; opacity: 0;
} }
time, .edited { time,
margin-top: 1px; .edited {
cursor: default; margin-top: 1px;
display: inline; cursor: default;
font-size: 10px; display: inline;
color: var(--tertiary-foreground); font-size: 10px;
} color: var(--tertiary-foreground);
}
time, .edited > div { time,
&::selection { .edited > div {
background-color: transparent; &::selection {
color: var(--tertiary-foreground); background-color: transparent;
} color: var(--tertiary-foreground);
} }
}
`; `;
export const MessageContent = styled.div` export const MessageContent = styled.div`
min-width: 0; min-width: 0;
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
// overflow: hidden; // overflow: hidden;
font-size: .875rem; font-size: 0.875rem;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
`; `;
export const DetailBase = styled.div` export const DetailBase = styled.div`
gap: 4px; gap: 4px;
font-size: 10px; font-size: 10px;
display: inline-flex; display: inline-flex;
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
`; `;
export function MessageDetail({ message, position }: { message: MessageObject, position: 'left' | 'top' }) { export function MessageDetail({
if (position === 'left') { message,
if (message.edited) { position,
return ( }: {
<> message: MessageObject;
<time className="copyTime"> position: "left" | "top";
<i className="copyBracket">[</i> }) {
{dayjs(decodeTime(message._id)).format("H:mm")} if (position === "left") {
<i className="copyBracket">]</i> if (message.edited) {
</time> return (
<span className="edited"> <>
<Tooltip content={dayjs(message.edited).format("LLLL")}> <time className="copyTime">
<Text id="app.main.channel.edited" /> <i className="copyBracket">[</i>
</Tooltip> {dayjs(decodeTime(message._id)).format("H:mm")}
</span> <i className="copyBracket">]</i>
</> </time>
) <span className="edited">
} else { <Tooltip content={dayjs(message.edited).format("LLLL")}>
return ( <Text id="app.main.channel.edited" />
<> </Tooltip>
<time> </span>
<i className="copyBracket">[</i> </>
{ dayjs(decodeTime(message._id)).format("H:mm") } );
<i className="copyBracket">]</i> } else {
</time> return (
</> <>
) <time>
} <i className="copyBracket">[</i>
} {dayjs(decodeTime(message._id)).format("H:mm")}
<i className="copyBracket">]</i>
</time>
</>
);
}
}
return ( return (
<DetailBase> <DetailBase>
<time> <time>{dayjs(decodeTime(message._id)).calendar()}</time>
{dayjs(decodeTime(message._id)).calendar()} {message.edited && (
</time> <Tooltip content={dayjs(message.edited).format("LLLL")}>
{ message.edited && <Tooltip content={dayjs(message.edited).format("LLLL")}> <Text id="app.main.channel.edited" />
<Text id="app.main.channel.edited" /> </Tooltip>
</Tooltip> } )}
</DetailBase> </DetailBase>
) );
} }

View file

@ -1,388 +1,496 @@
import { ulid } from "ulid"; import { ShieldX } from "@styled-icons/boxicons-regular";
import { Text } from "preact-i18n"; import { Send } from "@styled-icons/boxicons-solid";
import { Channel } from "revolt.js";
import styled from "styled-components";
import { dispatch } from "../../../redux";
import { defer } from "../../../lib/defer";
import IconButton from "../../ui/IconButton";
import { PermissionTooltip } from "../Tooltip";
import { Send } from '@styled-icons/boxicons-solid';
import { debounce } from "../../../lib/debounce";
import Axios, { CancelTokenSource } from "axios"; import Axios, { CancelTokenSource } from "axios";
import { useTranslation } from "../../../lib/i18n"; import { Channel } from "revolt.js";
import { Reply } from "../../../redux/reducers/queue";
import { connectState } from "../../../redux/connector";
import { SoundContext } from "../../../context/Settings";
import { takeError } from "../../../context/revoltjs/util";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import AutoComplete, { useAutoComplete } from "../AutoComplete";
import { ChannelPermission } from "revolt.js/dist/api/permissions"; import { ChannelPermission } from "revolt.js/dist/api/permissions";
import styled from "styled-components";
import { ulid } from "ulid";
import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { debounce } from "../../../lib/debounce";
import { defer } from "../../../lib/defer";
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
import { useTranslation } from "../../../lib/i18n";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import {
SingletonMessageRenderer,
SMOOTH_SCROLL_ON_RECEIVE,
} from "../../../lib/renderer/Singleton";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import { Reply } from "../../../redux/reducers/queue";
import { SoundContext } from "../../../context/Settings";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import {
FileUploader,
grabFiles,
uploadFile,
} from "../../../context/revoltjs/FileUploads";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useChannelPermission } from "../../../context/revoltjs/hooks"; import { useChannelPermission } from "../../../context/revoltjs/hooks";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { takeError } from "../../../context/revoltjs/util";
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { FileUploader, grabFiles, uploadFile } from "../../../context/revoltjs/FileUploads";
import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton";
import { ShieldX } from "@styled-icons/boxicons-regular";
import IconButton from "../../ui/IconButton";
import AutoComplete, { useAutoComplete } from "../AutoComplete";
import { PermissionTooltip } from "../Tooltip";
import FilePreview from "./bars/FilePreview";
import ReplyBar from "./bars/ReplyBar"; import ReplyBar from "./bars/ReplyBar";
import FilePreview from './bars/FilePreview';
type Props = { type Props = {
channel: Channel; channel: Channel;
draft?: string; draft?: string;
}; };
export type UploadState = export type UploadState =
| { type: "none" } | { type: "none" }
| { type: "attached"; files: File[] } | { type: "attached"; files: File[] }
| { type: "uploading"; files: File[]; percent: number; cancel: CancelTokenSource } | {
| { type: "sending"; files: File[] } type: "uploading";
| { type: "failed"; files: File[]; error: string }; files: File[];
percent: number;
cancel: CancelTokenSource;
}
| { type: "sending"; files: File[] }
| { type: "failed"; files: File[]; error: string };
const Base = styled.div` const Base = styled.div`
display: flex; display: flex;
padding: 0 12px; padding: 0 12px;
background: var(--message-box); background: var(--message-box);
textarea { textarea {
font-size: .875rem; font-size: 0.875rem;
background: transparent; background: transparent;
} }
`; `;
const Blocked = styled.div` const Blocked = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
padding: 14px 0; padding: 14px 0;
user-select: none; user-select: none;
font-size: .875rem; font-size: 0.875rem;
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
svg { svg {
flex-shrink: 0; flex-shrink: 0;
margin-inline-end: 10px; margin-inline-end: 10px;
} }
`; `;
const Action = styled.div` const Action = styled.div`
display: grid; display: grid;
place-items: center; place-items: center;
`; `;
// ! FIXME: add to app config and load from app config // ! FIXME: add to app config and load from app config
export const CAN_UPLOAD_AT_ONCE = 5; export const CAN_UPLOAD_AT_ONCE = 5;
function MessageBox({ channel, draft }: Props) { function MessageBox({ channel, draft }: Props) {
const [ uploadState, setUploadState ] = useState<UploadState>({ type: 'none' }); const [uploadState, setUploadState] = useState<UploadState>({
const [ typing, setTyping ] = useState<boolean | number>(false); type: "none",
const [ replies, setReplies ] = useState<Reply[]>([]); });
const playSound = useContext(SoundContext); const [typing, setTyping] = useState<boolean | number>(false);
const { openScreen } = useIntermediate(); const [replies, setReplies] = useState<Reply[]>([]);
const client = useContext(AppContext); const playSound = useContext(SoundContext);
const translate = useTranslation(); const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const translate = useTranslation();
const permissions = useChannelPermission(channel._id); const permissions = useChannelPermission(channel._id);
if (!(permissions & ChannelPermission.SendMessage)) { if (!(permissions & ChannelPermission.SendMessage)) {
return ( return (
<Base> <Base>
<Blocked> <Blocked>
<PermissionTooltip permission="SendMessages" placement="top"> <PermissionTooltip
<ShieldX size={22}/> permission="SendMessages"
</PermissionTooltip> placement="top">
<Text id="app.main.channel.misc.no_sending" /> <ShieldX size={22} />
</Blocked> </PermissionTooltip>
</Base> <Text id="app.main.channel.misc.no_sending" />
) </Blocked>
} </Base>
);
}
function setMessage(content?: string) { function setMessage(content?: string) {
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,
}); });
} }
} }
useEffect(() => { useEffect(() => {
function append(content: string, action: 'quote' | 'mention') { function append(content: string, action: "quote" | "mention") {
const text = const text =
action === "quote" action === "quote"
? `${content ? `${content
.split("\n") .split("\n")
.map(x => `> ${x}`) .map((x) => `> ${x}`)
.join("\n")}\n\n` .join("\n")}\n\n`
: `${content} `; : `${content} `;
if (!draft || draft.length === 0) { if (!draft || draft.length === 0) {
setMessage(text); setMessage(text);
} else { } else {
setMessage(`${draft}\n${text}`); setMessage(`${draft}\n${text}`);
} }
} }
return internalSubscribe("MessageBox", "append", append); return internalSubscribe("MessageBox", "append", append);
}, [ draft ]); }, [draft]);
async function send() { async function send() {
if (uploadState.type === 'uploading' || uploadState.type === 'sending') return; if (uploadState.type === "uploading" || uploadState.type === "sending")
return;
const content = draft?.trim() ?? ''; const content = draft?.trim() ?? "";
if (uploadState.type === 'attached') return sendFile(content); if (uploadState.type === "attached") return sendFile(content);
if (content.length === 0) return; if (content.length === 0) return;
stopTyping(); stopTyping();
setMessage(); setMessage();
setReplies([]); setReplies([]);
playSound('outbound'); playSound("outbound");
const nonce = ulid(); const nonce = ulid();
dispatch({ dispatch({
type: "QUEUE_ADD", type: "QUEUE_ADD",
nonce, nonce,
channel: channel._id, channel: channel._id,
message: { message: {
_id: nonce, _id: nonce,
channel: channel._id, channel: channel._id,
author: client.user!._id, author: client.user!._id,
content, content,
replies replies,
} },
}); });
defer(() => SingletonMessageRenderer.jumpToBottom(channel._id, SMOOTH_SCROLL_ON_RECEIVE)); defer(() =>
SingletonMessageRenderer.jumpToBottom(
channel._id,
SMOOTH_SCROLL_ON_RECEIVE,
),
);
try { try {
await client.channels.sendMessage(channel._id, { await client.channels.sendMessage(channel._id, {
content, content,
nonce, nonce,
replies replies,
}); });
} catch (error) { } catch (error) {
dispatch({ dispatch({
type: "QUEUE_FAIL", type: "QUEUE_FAIL",
error: takeError(error), error: takeError(error),
nonce nonce,
}); });
} }
} }
async function sendFile(content: string) { async function sendFile(content: string) {
if (uploadState.type !== 'attached') return; if (uploadState.type !== "attached") return;
let attachments: string[] = []; let attachments: string[] = [];
const cancel = Axios.CancelToken.source(); const cancel = Axios.CancelToken.source();
const files = uploadState.files; const files = uploadState.files;
stopTyping(); stopTyping();
setUploadState({ type: "uploading", files, percent: 0, cancel }); setUploadState({ type: "uploading", files, percent: 0, cancel });
try { try {
for (let i=0;i<files.length&&i<CAN_UPLOAD_AT_ONCE;i++) { for (let i = 0; i < files.length && i < CAN_UPLOAD_AT_ONCE; i++) {
const file = files[i]; const file = files[i];
attachments.push( attachments.push(
await uploadFile(client.configuration!.features.autumn.url, 'attachments', file, { await uploadFile(
onUploadProgress: e => client.configuration!.features.autumn.url,
setUploadState({ "attachments",
type: "uploading", file,
files, {
percent: Math.round(((i * 100) + (100 * e.loaded) / e.total) / Math.min(files.length, CAN_UPLOAD_AT_ONCE)), onUploadProgress: (e) =>
cancel setUploadState({
}), type: "uploading",
cancelToken: cancel.token files,
}) percent: Math.round(
); (i * 100 + (100 * e.loaded) / e.total) /
} Math.min(
} catch (err) { files.length,
if (err?.message === "cancel") { CAN_UPLOAD_AT_ONCE,
setUploadState({ ),
type: "attached", ),
files cancel,
}); }),
} else { cancelToken: cancel.token,
setUploadState({ },
type: "failed", ),
files, );
error: takeError(err) }
}); } catch (err) {
} if (err?.message === "cancel") {
setUploadState({
type: "attached",
files,
});
} else {
setUploadState({
type: "failed",
files,
error: takeError(err),
});
}
return; return;
} }
setUploadState({ setUploadState({
type: "sending", type: "sending",
files files,
}); });
const nonce = ulid(); const nonce = ulid();
try { try {
await client.channels.sendMessage(channel._id, { await client.channels.sendMessage(channel._id, {
content, content,
nonce, nonce,
replies, replies,
attachments attachments,
}); });
} catch (err) { } catch (err) {
setUploadState({ setUploadState({
type: "failed", type: "failed",
files, files,
error: takeError(err) error: takeError(err),
}); });
return; return;
} }
setMessage(); setMessage();
setReplies([]); setReplies([]);
playSound('outbound'); playSound("outbound");
if (files.length > CAN_UPLOAD_AT_ONCE) { if (files.length > CAN_UPLOAD_AT_ONCE) {
setUploadState({ setUploadState({
type: "attached", type: "attached",
files: files.slice(CAN_UPLOAD_AT_ONCE) files: files.slice(CAN_UPLOAD_AT_ONCE),
}); });
} else { } else {
setUploadState({ type: "none" }); setUploadState({ type: "none" });
} }
} }
function startTyping() { function startTyping() {
if (typeof typing === 'number' && + new Date() < typing) return; if (typeof typing === "number" && +new Date() < typing) return;
const ws = client.websocket; const ws = client.websocket;
if (ws.connected) { if (ws.connected) {
setTyping(+ new Date() + 4000); setTyping(+new Date() + 4000);
ws.send({ ws.send({
type: "BeginTyping", type: "BeginTyping",
channel: channel._id channel: channel._id,
}); });
} }
} }
function stopTyping(force?: boolean) { function stopTyping(force?: boolean) {
if (force || typing) { if (force || typing) {
const ws = client.websocket; const ws = client.websocket;
if (ws.connected) { if (ws.connected) {
setTyping(false); setTyping(false);
ws.send({ ws.send({
type: "EndTyping", type: "EndTyping",
channel: channel._id channel: channel._id,
}); });
} }
} }
} }
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]); const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [
const { onChange, onKeyUp, onKeyDown, onFocus, onBlur, ...autoCompleteProps } = channel._id,
useAutoComplete(setMessage, { ]);
users: { type: 'channel', id: channel._id }, const {
channels: channel.channel_type === 'TextChannel' ? { server: channel.server } : undefined onChange,
}); onKeyUp,
onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete(setMessage, {
users: { type: "channel", id: channel._id },
channels:
channel.channel_type === "TextChannel"
? { server: channel.server }
: undefined,
});
return ( return (
<> <>
<AutoComplete {...autoCompleteProps} /> <AutoComplete {...autoCompleteProps} />
<FilePreview state={uploadState} addFile={() => uploadState.type === 'attached' && <FilePreview
grabFiles(20_000_000, files => setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] }), state={uploadState}
() => openScreen({ id: "error", error: "FileTooLarge" }), true)} addFile={() =>
removeFile={index => { uploadState.type === "attached" &&
if (uploadState.type !== 'attached') return; grabFiles(
if (uploadState.files.length === 1) { 20_000_000,
setUploadState({ type: 'none' }); (files) =>
} else { setUploadState({
setUploadState({ type: 'attached', files: uploadState.files.filter((_, i) => index !== i) }); type: "attached",
} files: [...uploadState.files, ...files],
}} /> }),
<ReplyBar channel={channel._id} replies={replies} setReplies={setReplies} /> () =>
<Base> openScreen({ id: "error", error: "FileTooLarge" }),
{ (permissions & ChannelPermission.UploadFiles) ? <Action> true,
<FileUploader )
size={24} }
behaviour='multi' removeFile={(index) => {
style='attachment' if (uploadState.type !== "attached") return;
fileType='attachments' if (uploadState.files.length === 1) {
maxFileSize={20_000_000} setUploadState({ type: "none" });
} else {
setUploadState({
type: "attached",
files: uploadState.files.filter(
(_, i) => index !== i,
),
});
}
}}
/>
<ReplyBar
channel={channel._id}
replies={replies}
setReplies={setReplies}
/>
<Base>
{permissions & ChannelPermission.UploadFiles ? (
<Action>
<FileUploader
size={24}
behaviour="multi"
style="attachment"
fileType="attachments"
maxFileSize={20_000_000}
attached={uploadState.type !== "none"}
uploading={
uploadState.type === "uploading" ||
uploadState.type === "sending"
}
remove={async () =>
setUploadState({ type: "none" })
}
onChange={(files) =>
setUploadState({ type: "attached", files })
}
cancel={() =>
uploadState.type === "uploading" &&
uploadState.cancel.cancel("cancel")
}
append={(files) => {
if (files.length === 0) return;
attached={uploadState.type !== 'none'} if (uploadState.type === "none") {
uploading={uploadState.type === 'uploading' || uploadState.type === 'sending'} setUploadState({ type: "attached", files });
} else if (uploadState.type === "attached") {
setUploadState({
type: "attached",
files: [...uploadState.files, ...files],
});
}
}}
/>
</Action>
) : undefined}
<TextAreaAutoSize
autoFocus
hideBorder
maxRows={5}
padding={14}
id="message"
value={draft ?? ""}
onKeyUp={onKeyUp}
onKeyDown={(e) => {
if (onKeyDown(e)) return;
remove={async () => setUploadState({ type: "none" })} if (
onChange={files => setUploadState({ type: "attached", files })} e.key === "ArrowUp" &&
cancel={() => uploadState.type === 'uploading' && uploadState.cancel.cancel("cancel")} (!draft || draft.length === 0)
append={files => { ) {
if (files.length === 0) return; e.preventDefault();
internalEmit("MessageRenderer", "edit_last");
return;
}
if (uploadState.type === 'none') { if (
setUploadState({ type: 'attached', files }); !e.shiftKey &&
} else if (uploadState.type === 'attached') { e.key === "Enter" &&
setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] }); !isTouchscreenDevice
} ) {
}} e.preventDefault();
/> return send();
</Action> : undefined } }
<TextAreaAutoSize
autoFocus
hideBorder
maxRows={5}
padding={14}
id="message"
value={draft ?? ''}
onKeyUp={onKeyUp}
onKeyDown={e => {
if (onKeyDown(e)) return;
if ( debouncedStopTyping(true);
e.key === "ArrowUp" && }}
(!draft || draft.length === 0) placeholder={
) { channel.channel_type === "DirectMessage"
e.preventDefault(); ? translate("app.main.channel.message_who", {
internalEmit("MessageRenderer", "edit_last"); person: client.users.get(
return; client.channels.getRecipient(
} channel._id,
),
if (!e.shiftKey && e.key === "Enter" && !isTouchscreenDevice) { )?.username,
e.preventDefault(); })
return send(); : channel.channel_type === "SavedMessages"
} ? translate("app.main.channel.message_saved")
: translate("app.main.channel.message_where", {
debouncedStopTyping(true); channel_name: channel.name,
}} })
placeholder={ }
channel.channel_type === "DirectMessage" ? translate("app.main.channel.message_who", { disabled={
person: client.users.get(client.channels.getRecipient(channel._id))?.username }) uploadState.type === "uploading" ||
: channel.channel_type === "SavedMessages" ? translate("app.main.channel.message_saved") uploadState.type === "sending"
: translate("app.main.channel.message_where", { channel_name: channel.name }) }
} onChange={(e) => {
disabled={uploadState.type === 'uploading' || uploadState.type === 'sending'} setMessage(e.currentTarget.value);
onChange={e => { startTyping();
setMessage(e.currentTarget.value); onChange(e);
startTyping(); }}
onChange(e); onFocus={onFocus}
}} onBlur={onBlur}
onFocus={onFocus} />
onBlur={onBlur} /> {isTouchscreenDevice && (
{ isTouchscreenDevice && <Action> <Action>
<IconButton onClick={send}> <IconButton onClick={send}>
<Send size={20} /> <Send size={20} />
</IconButton> </IconButton>
</Action> } </Action>
</Base> )}
</> </Base>
) </>
);
} }
export default connectState<Omit<Props, "dispatch" | "draft">>(MessageBox, (state, { channel }) => { export default connectState<Omit<Props, "dispatch" | "draft">>(
return { MessageBox,
draft: state.drafts[channel._id] (state, { channel }) => {
} return {
}, true) draft: state.drafts[channel._id],
};
},
true,
);

View file

@ -1,149 +1,160 @@
import { User } from "revolt.js"; import { User } from "revolt.js";
import styled from "styled-components"; import styled from "styled-components";
import UserShort from "../user/UserShort";
import { TextReact } from "../../../lib/i18n";
import { attachContextMenu } from "preact-context-menu"; import { attachContextMenu } from "preact-context-menu";
import { MessageObject } from "../../../context/revoltjs/util";
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase"; import { TextReact } from "../../../lib/i18n";
import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks"; import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks";
import { MessageObject } from "../../../context/revoltjs/util";
import UserShort from "../user/UserShort";
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
const SystemContent = styled.div` const SystemContent = styled.div`
gap: 4px; gap: 4px;
display: flex; display: flex;
padding: 2px 0; padding: 2px 0;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
`; `;
type SystemMessageParsed = type SystemMessageParsed =
| { type: "text"; content: string } | { type: "text"; content: string }
| { type: "user_added"; user: User; by: User } | { type: "user_added"; user: User; by: User }
| { type: "user_remove"; user: User; by: User } | { type: "user_remove"; user: User; by: User }
| { type: "user_joined"; user: User } | { type: "user_joined"; user: User }
| { type: "user_left"; user: User } | { type: "user_left"; user: User }
| { type: "user_kicked"; user: User } | { type: "user_kicked"; user: User }
| { type: "user_banned"; user: User } | { type: "user_banned"; user: User }
| { type: "channel_renamed"; name: string; by: User } | { type: "channel_renamed"; name: string; by: User }
| { type: "channel_description_changed"; by: User } | { type: "channel_description_changed"; by: User }
| { type: "channel_icon_changed"; by: User }; | { type: "channel_icon_changed"; by: User };
interface Props { interface Props {
attachContext?: boolean; attachContext?: boolean;
message: MessageObject; message: MessageObject;
} }
export function SystemMessage({ attachContext, message }: Props) { export function SystemMessage({ attachContext, message }: Props) {
const ctx = useForceUpdate(); const ctx = useForceUpdate();
let data: SystemMessageParsed; let data: SystemMessageParsed;
let content = message.content; let content = message.content;
if (typeof content === "object") { if (typeof content === "object") {
switch (content.type) { switch (content.type) {
case "text": case "text":
data = content; data = content;
break; break;
case "user_added": case "user_added":
case "user_remove": case "user_remove":
data = { data = {
type: content.type, type: content.type,
user: useUser(content.id, ctx) as User, user: useUser(content.id, ctx) as User,
by: useUser(content.by, ctx) as User by: useUser(content.by, ctx) as User,
}; };
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 = { data = {
type: content.type, type: content.type,
user: useUser(content.id, ctx) as User user: useUser(content.id, ctx) as User,
}; };
break; break;
case "channel_renamed": case "channel_renamed":
data = { data = {
type: "channel_renamed", type: "channel_renamed",
name: content.name, name: content.name,
by: useUser(content.by, ctx) as User by: useUser(content.by, ctx) as User,
}; };
break; break;
case "channel_description_changed": case "channel_description_changed":
case "channel_icon_changed": case "channel_icon_changed":
data = { data = {
type: content.type, type: content.type,
by: useUser(content.by, ctx) as User by: useUser(content.by, ctx) as User,
}; };
break; break;
default: default:
data = { type: "text", content: JSON.stringify(content) }; data = { type: "text", content: JSON.stringify(content) };
} }
} else { } else {
data = { type: "text", content }; data = { type: "text", content };
} }
let children; let children;
switch (data.type) { switch (data.type) {
case "text": case "text":
children = <span>{data.content}</span>; children = <span>{data.content}</span>;
break; break;
case "user_added": case "user_added":
case "user_remove": case "user_remove":
children = ( children = (
<TextReact <TextReact
id={`app.main.channel.system.${data.type === 'user_added' ? "added_by" : "removed_by"}`} id={`app.main.channel.system.${
fields={{ data.type === "user_added" ? "added_by" : "removed_by"
user: <UserShort user={data.user} />, }`}
other_user: <UserShort user={data.by} /> fields={{
}} user: <UserShort user={data.user} />,
/> other_user: <UserShort user={data.by} />,
); }}
break; />
case "user_joined": );
case "user_left": break;
case "user_kicked": case "user_joined":
case "user_banned": case "user_left":
children = ( case "user_kicked":
<TextReact case "user_banned":
id={`app.main.channel.system.${data.type}`} children = (
fields={{ <TextReact
user: <UserShort user={data.user} /> id={`app.main.channel.system.${data.type}`}
}} fields={{
/> user: <UserShort user={data.user} />,
); }}
break; />
case "channel_renamed": );
children = ( break;
<TextReact case "channel_renamed":
id={`app.main.channel.system.channel_renamed`} children = (
fields={{ <TextReact
user: <UserShort user={data.by} />, id={`app.main.channel.system.channel_renamed`}
name: <b>{data.name}</b> fields={{
}} user: <UserShort user={data.by} />,
/> name: <b>{data.name}</b>,
); }}
break; />
case "channel_description_changed": );
case "channel_icon_changed": break;
children = ( case "channel_description_changed":
<TextReact case "channel_icon_changed":
id={`app.main.channel.system.${data.type}`} children = (
fields={{ <TextReact
user: <UserShort user={data.by} /> id={`app.main.channel.system.${data.type}`}
}} fields={{
/> user: <UserShort user={data.by} />,
); }}
break; />
} );
break;
}
return ( return (
<MessageBase <MessageBase
onContextMenu={attachContext ? attachContextMenu('Menu', onContextMenu={
{ message, contextualChannel: message.channel } attachContext
) : undefined}> ? attachContextMenu("Menu", {
<MessageInfo> message,
<MessageDetail message={message} position="left" /> contextualChannel: message.channel,
</MessageInfo> })
<SystemContent>{children}</SystemContent> : undefined
</MessageBase> }>
); <MessageInfo>
<MessageDetail message={message} position="left" />
</MessageInfo>
<SystemContent>{children}</SystemContent>
</MessageBase>
);
} }

View file

@ -1,126 +1,133 @@
import TextFile from "./TextFile";
import { Text } from "preact-i18n";
import classNames from "classnames";
import styles from "./Attachment.module.scss";
import AttachmentActions from "./AttachmentActions";
import { useContext, useState } from "preact/hooks";
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
import { Attachment as AttachmentRJS } from "revolt.js/dist/api/objects"; import { Attachment as AttachmentRJS } from "revolt.js/dist/api/objects";
import styles from "./Attachment.module.scss";
import classNames from "classnames";
import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks";
import { useIntermediate } from "../../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../../context/intermediate/Intermediate";
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea"; import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea";
import AttachmentActions from "./AttachmentActions";
import TextFile from "./TextFile";
interface Props { interface Props {
attachment: AttachmentRJS; attachment: AttachmentRJS;
hasContent: boolean; hasContent: boolean;
} }
const MAX_ATTACHMENT_WIDTH = 480; const MAX_ATTACHMENT_WIDTH = 480;
export default function Attachment({ attachment, hasContent }: Props) { export default function Attachment({ attachment, hasContent }: Props) {
const client = useContext(AppContext); const client = useContext(AppContext);
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const { filename, metadata } = attachment; const { filename, metadata } = attachment;
const [ spoiler, setSpoiler ] = useState(filename.startsWith("SPOILER_")); const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_"));
const [ loaded, setLoaded ] = useState(false) const [loaded, setLoaded] = useState(false);
const url = client.generateFileURL(attachment, { width: MAX_ATTACHMENT_WIDTH * 1.5 }, true); const url = client.generateFileURL(
attachment,
{ width: MAX_ATTACHMENT_WIDTH * 1.5 },
true,
);
switch (metadata.type) {
switch (metadata.type) { case "Image": {
case "Image": { return (
return ( <div
<div className={styles.container}
className={styles.container} onClick={() => spoiler && setSpoiler(false)}>
onClick={() => spoiler && setSpoiler(false)} {spoiler && (
> <div className={styles.overflow}>
{spoiler && ( <span>
<div className={styles.overflow}> <Text id="app.main.channel.misc.spoiler_attachment" />
<span><Text id="app.main.channel.misc.spoiler_attachment" /></span> </span>
</div> </div>
)} )}
<img <img
src={url} src={url}
alt={filename} alt={filename}
width={metadata.width} width={metadata.width}
height={metadata.height} height={metadata.height}
data-spoiler={spoiler} data-spoiler={spoiler}
data-has-content={hasContent} data-has-content={hasContent}
className={classNames(styles.attachment, styles.image, loaded && styles.loaded)} className={classNames(
onClick={() => styles.attachment,
openScreen({ id: "image_viewer", attachment }) styles.image,
} loaded && styles.loaded,
onMouseDown={ev => )}
ev.button === 1 && onClick={() =>
window.open(url, "_blank") openScreen({ id: "image_viewer", attachment })
} }
onLoad={() => setLoaded(true)} onMouseDown={(ev) =>
/> ev.button === 1 && window.open(url, "_blank")
</div> }
); onLoad={() => setLoaded(true)}
} />
case "Audio": { </div>
return ( );
<div }
className={classNames(styles.attachment, styles.audio)} case "Audio": {
data-has-content={hasContent} return (
> <div
<AttachmentActions attachment={attachment} /> className={classNames(styles.attachment, styles.audio)}
<audio src={url} controls /> data-has-content={hasContent}>
</div> <AttachmentActions attachment={attachment} />
); <audio src={url} controls />
} </div>
case "Video": { );
return ( }
<div case "Video": {
className={styles.container} return (
onClick={() => spoiler && setSpoiler(false)}> <div
{spoiler && ( className={styles.container}
<div className={styles.overflow}> onClick={() => spoiler && setSpoiler(false)}>
<span><Text id="app.main.channel.misc.spoiler_attachment" /></span> {spoiler && (
</div> <div className={styles.overflow}>
)} <span>
<div <Text id="app.main.channel.misc.spoiler_attachment" />
data-spoiler={spoiler} </span>
data-has-content={hasContent} </div>
className={classNames(styles.attachment, styles.video)} )}
> <div
<AttachmentActions attachment={attachment} /> data-spoiler={spoiler}
<video data-has-content={hasContent}
src={url} className={classNames(styles.attachment, styles.video)}>
width={metadata.width} <AttachmentActions attachment={attachment} />
height={metadata.height} <video
className={classNames(loaded && styles.loaded)} src={url}
controls width={metadata.width}
onMouseDown={ev => height={metadata.height}
ev.button === 1 && className={classNames(loaded && styles.loaded)}
window.open(url, "_blank") controls
} onMouseDown={(ev) =>
onLoadedMetadata={() => setLoaded(true)} ev.button === 1 && window.open(url, "_blank")
/> }
</div> onLoadedMetadata={() => setLoaded(true)}
</div> />
); </div>
} </div>
case 'Text': { );
return ( }
<div case "Text": {
className={classNames(styles.attachment, styles.text)} return (
data-has-content={hasContent} <div
> className={classNames(styles.attachment, styles.text)}
<TextFile attachment={attachment} /> data-has-content={hasContent}>
<AttachmentActions attachment={attachment} /> <TextFile attachment={attachment} />
</div> <AttachmentActions attachment={attachment} />
); </div>
} );
default: { }
return ( default: {
<div return (
className={classNames(styles.attachment, styles.file)} <div
data-has-content={hasContent} className={classNames(styles.attachment, styles.file)}
> data-has-content={hasContent}>
<AttachmentActions attachment={attachment} /> <AttachmentActions attachment={attachment} />
</div> </div>
); );
} }
} }
} }

View file

@ -1,83 +1,115 @@
import { useContext } from 'preact/hooks'; import {
import styles from './Attachment.module.scss'; Download,
import IconButton from '../../../ui/IconButton'; LinkExternal,
File,
Headphone,
Video,
} from "@styled-icons/boxicons-regular";
import { Attachment } from "revolt.js/dist/api/objects"; import { Attachment } from "revolt.js/dist/api/objects";
import { determineFileSize } from '../../../../lib/fileSize';
import { AppContext } from '../../../../context/revoltjs/RevoltClient'; import styles from "./Attachment.module.scss";
import { Download, LinkExternal, File, Headphone, Video } from '@styled-icons/boxicons-regular'; import classNames from "classnames";
import classNames from 'classnames'; import { useContext } from "preact/hooks";
import { determineFileSize } from "../../../../lib/fileSize";
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
import IconButton from "../../../ui/IconButton";
interface Props { interface Props {
attachment: Attachment; attachment: Attachment;
} }
export default function AttachmentActions({ attachment }: Props) { export default function AttachmentActions({ attachment }: Props) {
const client = useContext(AppContext); const client = useContext(AppContext);
const { filename, metadata, size } = attachment; const { filename, metadata, size } = attachment;
const url = client.generateFileURL(attachment)!; const url = client.generateFileURL(attachment)!;
const open_url = `${url}/${filename}`; const open_url = `${url}/${filename}`;
const download_url = url.replace('attachments', 'attachments/download') const download_url = url.replace("attachments", "attachments/download");
// for some reason revolt.js says the size is a string even though it's a number const filesize = determineFileSize(size);
const filesize = determineFileSize(size);
switch (metadata.type) { switch (metadata.type) {
case 'Image': case "Image":
return ( return (
<div className={classNames(styles.actions, styles.imageAction)}> <div className={classNames(styles.actions, styles.imageAction)}>
<span className={styles.filename}>{filename}</span> <span className={styles.filename}>{filename}</span>
<span className={styles.filesize}>{metadata.width + 'x' + metadata.height} ({filesize})</span> <span className={styles.filesize}>
<a href={open_url} target="_blank" className={styles.iconType} > {metadata.width + "x" + metadata.height} ({filesize})
<IconButton> </span>
<LinkExternal size={24} /> <a
</IconButton> href={open_url}
</a> target="_blank"
<a href={download_url} className={styles.downloadIcon} download target="_blank"> className={styles.iconType}>
<IconButton> <IconButton>
<Download size={24} /> <LinkExternal size={24} />
</IconButton> </IconButton>
</a> </a>
</div> <a
) href={download_url}
case 'Audio': className={styles.downloadIcon}
return ( download
<div className={classNames(styles.actions, styles.audioAction)}> target="_blank">
<Headphone size={24} className={styles.iconType} /> <IconButton>
<span className={styles.filename}>{filename}</span> <Download size={24} />
<span className={styles.filesize}>{filesize}</span> </IconButton>
<a href={download_url} className={styles.downloadIcon} download target="_blank"> </a>
<IconButton> </div>
<Download size={24} /> );
</IconButton> case "Audio":
</a> return (
</div> <div className={classNames(styles.actions, styles.audioAction)}>
) <Headphone size={24} className={styles.iconType} />
case 'Video': <span className={styles.filename}>{filename}</span>
return ( <span className={styles.filesize}>{filesize}</span>
<div className={classNames(styles.actions, styles.videoAction)}> <a
<Video size={24} className={styles.iconType} /> href={download_url}
<span className={styles.filename}>{filename}</span> className={styles.downloadIcon}
<span className={styles.filesize}>{metadata.width + 'x' + metadata.height} ({filesize})</span> download
<a href={download_url} className={styles.downloadIcon} download target="_blank"> target="_blank">
<IconButton> <IconButton>
<Download size={24} /> <Download size={24} />
</IconButton> </IconButton>
</a> </a>
</div> </div>
) );
default: case "Video":
return ( return (
<div className={styles.actions}> <div className={classNames(styles.actions, styles.videoAction)}>
<File size={24} className={styles.iconType} /> <Video size={24} className={styles.iconType} />
<span className={styles.filename}>{filename}</span> <span className={styles.filename}>{filename}</span>
<span className={styles.filesize}>{filesize}</span> <span className={styles.filesize}>
<a href={download_url} className={styles.downloadIcon} download target="_blank"> {metadata.width + "x" + metadata.height} ({filesize})
<IconButton> </span>
<Download size={24} /> <a
</IconButton> href={download_url}
</a> className={styles.downloadIcon}
</div> download
) target="_blank">
} <IconButton>
<Download size={24} />
</IconButton>
</a>
</div>
);
default:
return (
<div className={styles.actions}>
<File size={24} className={styles.iconType} />
<span className={styles.filename}>{filename}</span>
<span className={styles.filesize}>{filesize}</span>
<a
href={download_url}
className={styles.downloadIcon}
download
target="_blank">
<IconButton>
<Download size={24} />
</IconButton>
</a>
</div>
);
}
} }

View file

@ -1,72 +1,93 @@
import { Text } from "preact-i18n";
import UserShort from "../../user/UserShort";
import styled, { css } from "styled-components";
import Markdown from "../../../markdown/Markdown";
import { Reply, File } from "@styled-icons/boxicons-regular"; import { Reply, File } from "@styled-icons/boxicons-regular";
import { useUser } from "../../../../context/revoltjs/hooks"; import styled, { css } from "styled-components";
import { Text } from "preact-i18n";
import { useRenderState } from "../../../../lib/renderer/Singleton"; import { useRenderState } from "../../../../lib/renderer/Singleton";
import { useUser } from "../../../../context/revoltjs/hooks";
import Markdown from "../../../markdown/Markdown";
import UserShort from "../../user/UserShort";
interface Props { interface Props {
channel: string channel: string;
index: number index: number;
id: string id: string;
} }
export const ReplyBase = styled.div<{ head?: boolean, fail?: boolean, preview?: boolean }>` export const ReplyBase = styled.div<{
gap: 4px; head?: boolean;
display: flex; fail?: boolean;
font-size: 0.8em; preview?: boolean;
margin-left: 30px; }>`
user-select: none; gap: 4px;
margin-bottom: 4px; display: flex;
align-items: center; font-size: 0.8em;
color: var(--secondary-foreground); margin-left: 30px;
user-select: none;
margin-bottom: 4px;
align-items: center;
color: var(--secondary-foreground);
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
svg:first-child { svg:first-child {
flex-shrink: 0; flex-shrink: 0;
transform: scaleX(-1); transform: scaleX(-1);
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
} }
${ props => props.fail && css` ${(props) =>
color: var(--tertiary-foreground); props.fail &&
` } css`
color: var(--tertiary-foreground);
`}
${ props => props.head && css` ${(props) =>
margin-top: 12px; props.head &&
` } css`
margin-top: 12px;
`}
${ props => props.preview && css` ${(props) =>
margin-left: 0; props.preview &&
` } css`
margin-left: 0;
`}
`; `;
export function MessageReply({ index, channel, id }: Props) { export function MessageReply({ index, channel, id }: Props) {
const view = useRenderState(channel); const view = useRenderState(channel);
if (view?.type !== 'RENDER') return null; if (view?.type !== "RENDER") return null;
const message = view.messages.find(x => x._id === id); const message = view.messages.find((x) => x._id === id);
if (!message) { if (!message) {
return ( return (
<ReplyBase head={index === 0} fail> <ReplyBase head={index === 0} fail>
<Reply size={16} /> <Reply size={16} />
<span><Text id="app.main.channel.misc.failed_load" /></span> <span>
</ReplyBase> <Text id="app.main.channel.misc.failed_load" />
) </span>
} </ReplyBase>
);
}
const user = useUser(message.author); const user = useUser(message.author);
return ( return (
<ReplyBase head={index === 0}> <ReplyBase head={index === 0}>
<Reply size={16} /> <Reply size={16} />
<UserShort user={user} size={16} /> <UserShort user={user} size={16} />
{ message.attachments && message.attachments.length > 0 && <File size={16} /> } {message.attachments && message.attachments.length > 0 && (
<Markdown disallowBigEmoji content={(message.content as string).replace(/\n/g, ' ')} /> <File size={16} />
</ReplyBase> )}
) <Markdown
disallowBigEmoji
content={(message.content as string).replace(/\n/g, " ")}
/>
</ReplyBase>
);
} }

View file

@ -1,57 +1,72 @@
import axios from 'axios'; import axios from "axios";
import Preloader from '../../../ui/Preloader'; import { Attachment } from "revolt.js/dist/api/objects";
import styles from './Attachment.module.scss';
import { Attachment } from 'revolt.js/dist/api/objects'; import styles from "./Attachment.module.scss";
import { useContext, useEffect, useState } from 'preact/hooks'; import { useContext, useEffect, useState } from "preact/hooks";
import RequiresOnline from '../../../../context/revoltjs/RequiresOnline';
import { AppContext, StatusContext } from '../../../../context/revoltjs/RevoltClient'; import RequiresOnline from "../../../../context/revoltjs/RequiresOnline";
import {
AppContext,
StatusContext,
} from "../../../../context/revoltjs/RevoltClient";
import Preloader from "../../../ui/Preloader";
interface Props { interface Props {
attachment: Attachment; attachment: Attachment;
} }
const fileCache: { [key: string]: string } = {}; const fileCache: { [key: string]: string } = {};
export default function TextFile({ attachment }: Props) { export default function TextFile({ attachment }: Props) {
const [ content, setContent ] = useState<undefined | string>(undefined); const [content, setContent] = useState<undefined | string>(undefined);
const [ loading, setLoading ] = useState(false); const [loading, setLoading] = useState(false);
const status = useContext(StatusContext); const status = useContext(StatusContext);
const client = useContext(AppContext); const client = useContext(AppContext);
const url = client.generateFileURL(attachment)!; const url = client.generateFileURL(attachment)!;
useEffect(() => { useEffect(() => {
if (typeof content !== 'undefined') return; if (typeof content !== "undefined") return;
if (loading) return; if (loading) return;
setLoading(true); setLoading(true);
let cached = fileCache[attachment._id]; let cached = fileCache[attachment._id];
if (cached) { if (cached) {
setContent(cached); setContent(cached);
setLoading(false); setLoading(false);
} else { } else {
axios.get(url) axios
.then(res => { .get(url)
setContent(res.data); .then((res) => {
fileCache[attachment._id] = res.data; setContent(res.data);
setLoading(false); fileCache[attachment._id] = res.data;
}) setLoading(false);
.catch(() => { })
console.error("Failed to load text file. [", attachment._id, "]"); .catch(() => {
setLoading(false) console.error(
}) "Failed to load text file. [",
} attachment._id,
}, [ content, loading, status ]); "]",
);
setLoading(false);
});
}
}, [content, loading, status]);
return ( return (
<div className={styles.textContent} data-loading={typeof content === 'undefined'}> <div
{ className={styles.textContent}
content ? data-loading={typeof content === "undefined"}>
<pre><code>{ content }</code></pre> {content ? (
: <RequiresOnline> <pre>
<Preloader type="ring" /> <code>{content}</code>
</RequiresOnline> </pre>
} ) : (
</div> <RequiresOnline>
) <Preloader type="ring" />
</RequiresOnline>
)}
</div>
);
} }

View file

@ -1,194 +1,233 @@
import { Text } from "preact-i18n";
import styled from "styled-components";
import { CAN_UPLOAD_AT_ONCE, UploadState } from "../MessageBox";
import { useEffect, useState } from 'preact/hooks';
import { determineFileSize } from '../../../../lib/fileSize';
import { XCircle, Plus, Share, X, File } from "@styled-icons/boxicons-regular"; import { XCircle, Plus, Share, X, File } from "@styled-icons/boxicons-regular";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import { determineFileSize } from "../../../../lib/fileSize";
import { CAN_UPLOAD_AT_ONCE, UploadState } from "../MessageBox";
interface Props { interface Props {
state: UploadState, state: UploadState;
addFile: () => void, addFile: () => void;
removeFile: (index: number) => void removeFile: (index: number) => void;
} }
const Container = styled.div` const Container = styled.div`
gap: 4px; gap: 4px;
padding: 8px; padding: 8px;
display: flex; display: flex;
user-select: none; user-select: none;
flex-direction: column; flex-direction: column;
background: var(--message-box); background: var(--message-box);
`; `;
const Carousel = styled.div` const Carousel = styled.div`
gap: 8px; gap: 8px;
display: flex; display: flex;
overflow-x: scroll; overflow-x: scroll;
flex-direction: row; flex-direction: row;
`; `;
const Entry = styled.div` const Entry = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
&.fade { &.fade {
opacity: 0.4; opacity: 0.4;
} }
span.fn { span.fn {
margin: auto; margin: auto;
font-size: .8em; font-size: 0.8em;
overflow: hidden; overflow: hidden;
max-width: 180px; max-width: 180px;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
color: var(--secondary-foreground); color: var(--secondary-foreground);
} }
span.size { span.size {
font-size: .6em; font-size: 0.6em;
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
text-align: center; text-align: center;
} }
`; `;
const Description = styled.div` const Description = styled.div`
gap: 4px; gap: 4px;
display: flex; display: flex;
font-size: 0.9em; font-size: 0.9em;
align-items: center; align-items: center;
color: var(--secondary-foreground); color: var(--secondary-foreground);
`; `;
const Divider = styled.div` const Divider = styled.div`
width: 4px; width: 4px;
height: 130px; height: 130px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 4px; border-radius: 4px;
background: var(--tertiary-background); background: var(--tertiary-background);
`; `;
const EmptyEntry = styled.div` const EmptyEntry = styled.div`
width: 100px; width: 100px;
height: 100px; height: 100px;
display: grid; display: grid;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
place-items: center; place-items: center;
background: var(--primary-background); background: var(--primary-background);
transition: 0.1s ease background-color; transition: 0.1s ease background-color;
&:hover { &:hover {
background: var(--secondary-background); background: var(--secondary-background);
} }
`; `;
const PreviewBox = styled.div` const PreviewBox = styled.div`
display: grid; display: grid;
grid-template: "main" 100px / minmax(100px, 1fr); grid-template: "main" 100px / minmax(100px, 1fr);
justify-items: center; justify-items: center;
background: var(--primary-background); background: var(--primary-background);
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
.icon, .overlay { grid-area: main } .icon,
.overlay {
grid-area: main;
}
.icon { .icon {
height: 100px; height: 100px;
width: 100%; width: 100%;
margin-bottom: 4px; margin-bottom: 4px;
object-fit: contain; object-fit: contain;
} }
.overlay { .overlay {
display: grid; display: grid;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: 0.1s ease opacity; transition: 0.1s ease opacity;
} }
&:hover { &:hover {
.overlay { .overlay {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
background-color: rgba(0, 0, 0, 0.8); background-color: rgba(0, 0, 0, 0.8);
} }
} }
` `;
function FileEntry({ file, remove, index }: { file: File, remove?: () => void, index: number }) { function FileEntry({
if (!file.type.startsWith('image/')) return ( file,
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? 'fade' : ''}> remove,
<PreviewBox onClick={remove}> index,
<EmptyEntry className="icon"> }: {
<File size={36} /> file: File;
</EmptyEntry> remove?: () => void;
<div class="overlay"><XCircle size={36} /></div> index: number;
</PreviewBox> }) {
<span class="fn">{file.name}</span> if (!file.type.startsWith("image/"))
<span class="size">{determineFileSize(file.size)}</span> return (
</Entry> <Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
); <PreviewBox onClick={remove}>
<EmptyEntry className="icon">
<File size={36} />
</EmptyEntry>
<div class="overlay">
<XCircle size={36} />
</div>
</PreviewBox>
<span class="fn">{file.name}</span>
<span class="size">{determineFileSize(file.size)}</span>
</Entry>
);
const [ url, setURL ] = useState(''); const [url, setURL] = useState("");
useEffect(() => { useEffect(() => {
let url: string = URL.createObjectURL(file); let url: string = URL.createObjectURL(file);
setURL(url); setURL(url);
return () => URL.revokeObjectURL(url); return () => URL.revokeObjectURL(url);
}, [ file ]); }, [file]);
return ( return (
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? 'fade' : ''}> <Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
<PreviewBox onClick={remove}> <PreviewBox onClick={remove}>
<img class="icon" src={url} alt={file.name} /> <img class="icon" src={url} alt={file.name} />
<div class="overlay"><XCircle size={36} /></div> <div class="overlay">
</PreviewBox> <XCircle size={36} />
<span class="fn">{file.name}</span> </div>
<span class="size">{determineFileSize(file.size)}</span> </PreviewBox>
</Entry> <span class="fn">{file.name}</span>
) <span class="size">{determineFileSize(file.size)}</span>
</Entry>
);
} }
export default function FilePreview({ state, addFile, removeFile }: Props) { export default function FilePreview({ state, addFile, removeFile }: Props) {
if (state.type === 'none') return null; if (state.type === "none") return null;
return ( return (
<Container> <Container>
<Carousel> <Carousel>
{ state.files.map((file, index) => {state.files.map((file, index) => (
<> <>
{ index === CAN_UPLOAD_AT_ONCE && <Divider /> } {index === CAN_UPLOAD_AT_ONCE && <Divider />}
<FileEntry index={index} file={file} key={file.name} remove={state.type === 'attached' ? () => removeFile(index) : undefined} /> <FileEntry
</> index={index}
) } file={file}
{ state.type === 'attached' && <EmptyEntry onClick={addFile}><Plus size={48} /></EmptyEntry> } key={file.name}
</Carousel> remove={
{ state.type === 'uploading' && <Description> state.type === "attached"
<Share size={24} /> ? () => removeFile(index)
<Text id="app.main.channel.uploading_file" /> ({state.percent}%) : undefined
</Description> } }
{ state.type === 'sending' && <Description> />
<Share size={24} /> </>
Sending... ))}
</Description> } {state.type === "attached" && (
{ state.type === 'failed' && <Description> <EmptyEntry onClick={addFile}>
<X size={24} /> <Plus size={48} />
<Text id={`error.${state.error}`} /> </EmptyEntry>
</Description> } )}
</Container> </Carousel>
); {state.type === "uploading" && (
<Description>
<Share size={24} />
<Text id="app.main.channel.uploading_file" /> (
{state.percent}%)
</Description>
)}
{state.type === "sending" && (
<Description>
<Share size={24} />
Sending...
</Description>
)}
{state.type === "failed" && (
<Description>
<X size={24} />
<Text id={`error.${state.error}`} />
</Description>
)}
</Container>
);
} }

View file

@ -1,53 +1,64 @@
import { Text } from "preact-i18n";
import styled from "styled-components";
import { DownArrow } from "@styled-icons/boxicons-regular"; import { DownArrow } from "@styled-icons/boxicons-regular";
import { SingletonMessageRenderer, useRenderState } from "../../../../lib/renderer/Singleton"; import styled from "styled-components";
import { Text } from "preact-i18n";
import {
SingletonMessageRenderer,
useRenderState,
} from "../../../../lib/renderer/Singleton";
const Bar = styled.div` const Bar = styled.div`
z-index: 10; z-index: 10;
position: relative; position: relative;
> div { > div {
top: -26px; top: -26px;
width: 100%; width: 100%;
position: absolute; position: absolute;
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
display: flex; display: flex;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
padding: 4px 8px; padding: 4px 8px;
user-select: none; user-select: none;
color: var(--secondary-foreground); color: var(--secondary-foreground);
background: var(--secondary-background); background: var(--secondary-background);
justify-content: space-between; justify-content: space-between;
transition: color ease-in-out .08s; transition: color ease-in-out 0.08s;
> div { > div {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
&:hover { &:hover {
color: var(--primary-text); color: var(--primary-text);
} }
&:active { &:active {
transform: translateY(1px); transform: translateY(1px);
} }
} }
`; `;
export default function JumpToBottom({ id }: { id: string }) { export default function JumpToBottom({ id }: { id: string }) {
const view = useRenderState(id); const view = useRenderState(id);
if (!view || view.type !== 'RENDER' || view.atBottom) return null; if (!view || view.type !== "RENDER" || view.atBottom) return null;
return ( return (
<Bar> <Bar>
<div onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}> <div
<div><Text id="app.main.channel.misc.viewing_old" /></div> onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}>
<div><Text id="app.main.channel.misc.jump_present" /> <DownArrow size={18}/></div> <div>
</div> <Text id="app.main.channel.misc.viewing_old" />
</Bar> </div>
) <div>
<Text id="app.main.channel.misc.jump_present" />{" "}
<DownArrow size={18} />
</div>
</div>
</Bar>
);
} }

View file

@ -1,94 +1,141 @@
import { Text } from "preact-i18n"; import {
At,
Reply as ReplyIcon,
File,
XCircle,
} from "@styled-icons/boxicons-regular";
import styled from "styled-components"; import styled from "styled-components";
import UserShort from "../../user/UserShort";
import IconButton from "../../../ui/IconButton"; import { Text } from "preact-i18n";
import Markdown from "../../../markdown/Markdown";
import { StateUpdater, useEffect } from "preact/hooks"; import { StateUpdater, useEffect } from "preact/hooks";
import { ReplyBase } from "../attachments/MessageReply";
import { Reply } from "../../../../redux/reducers/queue";
import { useUsers } from "../../../../context/revoltjs/hooks";
import { internalSubscribe } from "../../../../lib/eventEmitter"; import { internalSubscribe } from "../../../../lib/eventEmitter";
import { useRenderState } from "../../../../lib/renderer/Singleton"; import { useRenderState } from "../../../../lib/renderer/Singleton";
import { At, Reply as ReplyIcon, File, XCircle } from "@styled-icons/boxicons-regular";
import { Reply } from "../../../../redux/reducers/queue";
import { useUsers } from "../../../../context/revoltjs/hooks";
import IconButton from "../../../ui/IconButton";
import Markdown from "../../../markdown/Markdown";
import UserShort from "../../user/UserShort";
import { ReplyBase } from "../attachments/MessageReply";
interface Props { interface Props {
channel: string, channel: string;
replies: Reply[], replies: Reply[];
setReplies: StateUpdater<Reply[]> setReplies: StateUpdater<Reply[]>;
} }
const Base = styled.div` const Base = styled.div`
display: flex; display: flex;
padding: 0 22px; padding: 0 22px;
user-select: none; user-select: none;
align-items: center; align-items: center;
background: var(--message-box); background: var(--message-box);
div { div {
flex-grow: 1; flex-grow: 1;
} }
.actions { .actions {
gap: 12px; gap: 12px;
display: flex; display: flex;
} }
.toggle { .toggle {
gap: 4px; gap: 4px;
display: flex; display: flex;
font-size: 0.7em; font-size: 0.7em;
align-items: center; align-items: center;
} }
`; `;
// ! FIXME: Move to global config // ! FIXME: Move to global config
const MAX_REPLIES = 5; const MAX_REPLIES = 5;
export default function ReplyBar({ channel, replies, setReplies }: Props) { export default function ReplyBar({ channel, replies, setReplies }: Props) {
useEffect(() => { useEffect(() => {
return internalSubscribe("ReplyBar", "add", id => replies.length < MAX_REPLIES && !replies.find(x => x.id === id) && setReplies([ ...replies, { id, mention: false } ])); return internalSubscribe(
}, [ replies ]); "ReplyBar",
"add",
(id) =>
replies.length < MAX_REPLIES &&
!replies.find((x) => x.id === id) &&
setReplies([...replies, { id, mention: false }]),
);
}, [replies]);
const view = useRenderState(channel); const view = useRenderState(channel);
if (view?.type !== 'RENDER') return null; if (view?.type !== "RENDER") return null;
const ids = replies.map(x => x.id); const ids = replies.map((x) => x.id);
const messages = view.messages.filter(x => ids.includes(x._id)); const messages = view.messages.filter((x) => ids.includes(x._id));
const users = useUsers(messages.map(x => x.author)); const users = useUsers(messages.map((x) => x.author));
return ( return (
<div> <div>
{ replies.map((reply, index) => { {replies.map((reply, index) => {
let message = messages.find(x => reply.id === x._id); let message = messages.find((x) => reply.id === x._id);
// ! FIXME: better solution would be to // ! FIXME: better solution would be to
// ! have a hook for resolving messages from // ! have a hook for resolving messages from
// ! render state along with relevant users // ! render state along with relevant users
// -> which then fetches any unknown messages // -> which then fetches any unknown messages
if (!message) return <span><Text id="app.main.channel.misc.failed_load" /></span>; if (!message)
return (
<span>
<Text id="app.main.channel.misc.failed_load" />
</span>
);
let user = users.find(x => message!.author === x?._id); let user = users.find((x) => message!.author === x?._id);
if (!user) return; if (!user) return;
return ( return (
<Base key={reply.id}> <Base key={reply.id}>
<ReplyBase preview> <ReplyBase preview>
<ReplyIcon size={22} /> <ReplyIcon size={22} />
<UserShort user={user} size={16} /> <UserShort user={user} size={16} />
{ message.attachments && message.attachments.length > 0 && <File size={16} /> } {message.attachments &&
<Markdown disallowBigEmoji content={(message.content as string).replace(/\n/g, ' ')} /> message.attachments.length > 0 && (
</ReplyBase> <File size={16} />
<span class="actions"> )}
<IconButton onClick={() => setReplies(replies.map((_, i) => i === index ? { ..._, mention: !_.mention } : _))}> <Markdown
<span class="toggle"> disallowBigEmoji
<At size={16} /> { reply.mention ? 'ON' : 'OFF' } content={(message.content as string).replace(
</span> /\n/g,
</IconButton> " ",
<IconButton onClick={() => setReplies(replies.filter((_, i) => i !== index))}> )}
<XCircle size={16} /> />
</IconButton> </ReplyBase>
</span> <span class="actions">
</Base> <IconButton
) onClick={() =>
}) } setReplies(
</div> replies.map((_, i) =>
) i === index
? { ..._, mention: !_.mention }
: _,
),
)
}>
<span class="toggle">
<At size={16} />{" "}
{reply.mention ? "ON" : "OFF"}
</span>
</IconButton>
<IconButton
onClick={() =>
setReplies(
replies.filter((_, i) => i !== index),
)
}>
<XCircle size={16} />
</IconButton>
</span>
</Base>
);
})}
</div>
);
} }

View file

@ -1,111 +1,121 @@
import { User } from 'revolt.js'; import { User } from "revolt.js";
import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import styled from 'styled-components'; import { useContext } from "preact/hooks";
import { useContext } from 'preact/hooks';
import { connectState } from '../../../../redux/connector'; import { connectState } from "../../../../redux/connector";
import { useUsers } from '../../../../context/revoltjs/hooks'; import { TypingUser } from "../../../../redux/reducers/typing";
import { TypingUser } from '../../../../redux/reducers/typing';
import { AppContext } from '../../../../context/revoltjs/RevoltClient'; import { AppContext } from "../../../../context/revoltjs/RevoltClient";
import { useUsers } from "../../../../context/revoltjs/hooks";
interface Props { interface Props {
typing?: TypingUser[] typing?: TypingUser[];
} }
const Base = styled.div` const Base = styled.div`
position: relative; position: relative;
> div { > div {
height: 24px; height: 24px;
margin-top: -24px; margin-top: -24px;
position: absolute; position: absolute;
gap: 8px; gap: 8px;
display: flex; display: flex;
padding: 0 10px; padding: 0 10px;
user-select: none; user-select: none;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
width: calc(100% - 3px); width: calc(100% - 3px);
color: var(--secondary-foreground); color: var(--secondary-foreground);
background: var(--secondary-background); background: var(--secondary-background);
} }
.avatars { .avatars {
display: flex; display: flex;
img { img {
width: 16px; width: 16px;
height: 16px; height: 16px;
object-fit: cover; object-fit: cover;
border-radius: 50%; border-radius: 50%;
&:not(:first-child) { &:not(:first-child) {
margin-left: -4px; margin-left: -4px;
} }
} }
} }
.usernames { .usernames {
min-width: 0; min-width: 0;
font-size: 13px; font-size: 13px;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
`; `;
export function TypingIndicator({ typing }: Props) { export function TypingIndicator({ typing }: Props) {
if (typing && typing.length > 0) { if (typing && typing.length > 0) {
const client = useContext(AppContext); const client = useContext(AppContext);
const users = useUsers(typing.map(x => x.id)) const users = useUsers(typing.map((x) => x.id)).filter(
.filter(x => typeof x !== 'undefined') as User[]; (x) => typeof x !== "undefined",
) as User[];
users.sort((a, b) => a._id.toUpperCase().localeCompare(b._id.toUpperCase())); users.sort((a, b) =>
a._id.toUpperCase().localeCompare(b._id.toUpperCase()),
);
let text; let text;
if (users.length >= 5) { if (users.length >= 5) {
text = <Text id="app.main.channel.typing.several" />; text = <Text id="app.main.channel.typing.several" />;
} else if (users.length > 1) { } else if (users.length > 1) {
const usersCopy = [...users]; const usersCopy = [...users];
text = ( text = (
<Text <Text
id="app.main.channel.typing.multiple" id="app.main.channel.typing.multiple"
fields={{ fields={{
user: usersCopy.pop()?.username, user: usersCopy.pop()?.username,
userlist: usersCopy.map(x => x.username).join(", ") userlist: usersCopy.map((x) => x.username).join(", "),
}} }}
/> />
); );
} else { } else {
text = ( text = (
<Text <Text
id="app.main.channel.typing.single" id="app.main.channel.typing.single"
fields={{ user: users[0].username }} fields={{ user: users[0].username }}
/> />
); );
} }
return ( return (
<Base> <Base>
<div> <div>
<div className="avatars"> <div className="avatars">
{users.map(user => ( {users.map((user) => (
<img <img
src={client.users.getAvatarURL(user._id, { max_side: 256 }, true)} src={client.users.getAvatarURL(
/> user._id,
))} { max_side: 256 },
</div> true,
<div className="usernames">{text}</div> )}
</div> />
</Base> ))}
); </div>
} <div className="usernames">{text}</div>
</div>
</Base>
);
}
return null; return null;
} }
export default connectState<{ id: string }>(TypingIndicator, (state, props) => { export default connectState<{ id: string }>(TypingIndicator, (state, props) => {
return { return {
typing: state.typing && state.typing[props.id] typing: state.typing && state.typing[props.id],
}; };
}); });

View file

@ -1,13 +1,16 @@
import classNames from 'classnames';
import EmbedMedia from './EmbedMedia';
import styles from "./Embed.module.scss";
import { useContext } from 'preact/hooks';
import { Embed as EmbedRJS } from "revolt.js/dist/api/objects"; import { Embed as EmbedRJS } from "revolt.js/dist/api/objects";
import { useIntermediate } from '../../../../context/intermediate/Intermediate';
import { MessageAreaWidthContext } from '../../../../pages/channels/messaging/MessageArea'; import styles from "./Embed.module.scss";
import classNames from "classnames";
import { useContext } from "preact/hooks";
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea";
import EmbedMedia from "./EmbedMedia";
interface Props { interface Props {
embed: EmbedRJS; embed: EmbedRJS;
} }
const MAX_EMBED_WIDTH = 480; const MAX_EMBED_WIDTH = 480;
@ -16,113 +19,149 @@ const CONTAINER_PADDING = 24;
const MAX_PREVIEW_SIZE = 150; const MAX_PREVIEW_SIZE = 150;
export default function Embed({ embed }: Props) { export default function Embed({ embed }: Props) {
// ! FIXME: temp code // ! FIXME: temp code
// ! add proxy function to client // ! add proxy function to client
function proxyImage(url: string) { function proxyImage(url: string) {
return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url); return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
} }
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const maxWidth = Math.min(useContext(MessageAreaWidthContext) - CONTAINER_PADDING, MAX_EMBED_WIDTH); const maxWidth = Math.min(
useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
MAX_EMBED_WIDTH,
);
function calculateSize(w: number, h: number): { width: number, height: number } { function calculateSize(
let limitingWidth = Math.min( w: number,
maxWidth, h: number,
w ): { width: number; height: number } {
); let limitingWidth = Math.min(maxWidth, w);
let limitingHeight = Math.min( let limitingHeight = Math.min(MAX_EMBED_HEIGHT, h);
MAX_EMBED_HEIGHT,
h
);
// Calculate smallest possible WxH. // Calculate smallest possible WxH.
let width = Math.min( let width = Math.min(limitingWidth, limitingHeight * (w / h));
limitingWidth,
limitingHeight * (w / h)
);
let height = Math.min( let height = Math.min(limitingHeight, limitingWidth * (h / w));
limitingHeight,
limitingWidth * (h / w)
);
return { width, height }; return { width, height };
} }
switch (embed.type) { switch (embed.type) {
case 'Website': { case "Website": {
// Determine special embed size. // Determine special embed size.
let mw, mh; let mw, mh;
let largeMedia = (embed.special && embed.special.type !== 'None') || embed.image?.size === 'Large'; let largeMedia =
switch (embed.special?.type) { (embed.special && embed.special.type !== "None") ||
case 'YouTube': embed.image?.size === "Large";
case 'Bandcamp': { switch (embed.special?.type) {
mw = embed.video?.width ?? 1280; case "YouTube":
mh = embed.video?.height ?? 720; case "Bandcamp": {
break; mw = embed.video?.width ?? 1280;
} mh = embed.video?.height ?? 720;
case 'Twitch': { break;
mw = 1280; }
mh = 720; case "Twitch": {
break; mw = 1280;
} mh = 720;
default: { break;
if (embed.image?.size === 'Preview') { }
mw = MAX_EMBED_WIDTH; default: {
mh = Math.min(embed.image.height ?? 0, MAX_PREVIEW_SIZE); if (embed.image?.size === "Preview") {
} else { mw = MAX_EMBED_WIDTH;
mw = embed.image?.width ?? MAX_EMBED_WIDTH; mh = Math.min(
mh = embed.image?.height ?? 0; embed.image.height ?? 0,
} MAX_PREVIEW_SIZE,
} );
} } else {
mw = embed.image?.width ?? MAX_EMBED_WIDTH;
mh = embed.image?.height ?? 0;
}
}
}
let { width, height } = calculateSize(mw, mh); let { width, height } = calculateSize(mw, mh);
return ( return (
<div <div
className={classNames(styles.embed, styles.website)} className={classNames(styles.embed, styles.website)}
style={{ style={{
borderInlineStartColor: embed.color ?? 'var(--tertiary-background)', borderInlineStartColor:
width: width + CONTAINER_PADDING embed.color ?? "var(--tertiary-background)",
}}> width: width + CONTAINER_PADDING,
<div> }}>
{ embed.site_name && <div className={styles.siteinfo}> <div>
{ embed.icon_url && <img className={styles.favicon} src={proxyImage(embed.icon_url)} draggable={false} onError={e => e.currentTarget.style.display = 'none'} /> } {embed.site_name && (
<div className={styles.site}>{ embed.site_name } </div> <div className={styles.siteinfo}>
</div> } {embed.icon_url && (
<img
className={styles.favicon}
src={proxyImage(embed.icon_url)}
draggable={false}
onError={(e) =>
(e.currentTarget.style.display =
"none")
}
/>
)}
<div className={styles.site}>
{embed.site_name}{" "}
</div>
</div>
)}
{/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/} {/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/}
{ embed.title && <span><a href={embed.url} target={"_blank"} className={styles.title}>{ embed.title }</a></span> } {embed.title && (
{ embed.description && <div className={styles.description}>{ embed.description }</div> } <span>
<a
href={embed.url}
target={"_blank"}
className={styles.title}>
{embed.title}
</a>
</span>
)}
{embed.description && (
<div className={styles.description}>
{embed.description}
</div>
)}
{ largeMedia && <EmbedMedia embed={embed} height={height} /> } {largeMedia && (
</div> <EmbedMedia embed={embed} height={height} />
{ )}
!largeMedia && <div> </div>
<EmbedMedia embed={embed} width={height * ((embed.image?.width ?? 0) / (embed.image?.height ?? 0))} height={height} /> {!largeMedia && (
</div> <div>
} <EmbedMedia
</div> embed={embed}
) width={
} height *
case 'Image': { ((embed.image?.width ?? 0) /
return ( (embed.image?.height ?? 0))
<img className={classNames(styles.embed, styles.image)} }
style={calculateSize(embed.width, embed.height)} height={height}
src={proxyImage(embed.url)} />
type="text/html" </div>
frameBorder="0" )}
onClick={() => </div>
openScreen({ id: "image_viewer", embed }) );
} }
onMouseDown={ev => case "Image": {
ev.button === 1 && return (
window.open(embed.url, "_blank") <img
} className={classNames(styles.embed, styles.image)}
/> style={calculateSize(embed.width, embed.height)}
) src={proxyImage(embed.url)}
} type="text/html"
default: return null; frameBorder="0"
} onClick={() => openScreen({ id: "image_viewer", embed })}
onMouseDown={(ev) =>
ev.button === 1 && window.open(embed.url, "_blank")
}
/>
);
}
default:
return null;
}
} }

View file

@ -1,78 +1,100 @@
import styles from './Embed.module.scss';
import { Embed } from "revolt.js/dist/api/objects"; import { Embed } from "revolt.js/dist/api/objects";
import { useIntermediate } from '../../../../context/intermediate/Intermediate';
import styles from "./Embed.module.scss";
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
interface Props { interface Props {
embed: Embed; embed: Embed;
width?: number; width?: number;
height: number; height: number;
} }
export default function EmbedMedia({ embed, width, height }: Props) { export default function EmbedMedia({ embed, width, height }: Props) {
// ! FIXME: temp code // ! FIXME: temp code
// ! add proxy function to client // ! add proxy function to client
function proxyImage(url: string) { function proxyImage(url: string) {
return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url); return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
} }
if (embed.type !== 'Website') return null; if (embed.type !== "Website") return null;
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
switch (embed.special?.type) { switch (embed.special?.type) {
case 'YouTube': return ( case "YouTube":
<iframe return (
src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`} <iframe
allowFullScreen src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`}
style={{ height }} /> allowFullScreen
) style={{ height }}
case 'Twitch': return ( />
<iframe );
src={`https://player.twitch.tv/?${embed.special.content_type.toLowerCase()}=${embed.special.id}&parent=${window.location.hostname}&autoplay=false`} case "Twitch":
frameBorder="0" return (
allowFullScreen <iframe
scrolling="no" src={`https://player.twitch.tv/?${embed.special.content_type.toLowerCase()}=${
style={{ height, }} /> embed.special.id
) }&parent=${window.location.hostname}&autoplay=false`}
case 'Spotify': return ( frameBorder="0"
<iframe allowFullScreen
src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`} scrolling="no"
frameBorder="0" style={{ height }}
allowFullScreen />
allowTransparency );
style={{ height }} /> case "Spotify":
) return (
case 'Soundcloud': return ( <iframe
<iframe src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`}
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(embed.url!)}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`} frameBorder="0"
frameBorder="0" allowFullScreen
scrolling="no" allowTransparency
style={{ height }} /> style={{ height }}
) />
case 'Bandcamp': { );
return <iframe case "Soundcloud":
src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${embed.special.id}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`} return (
seamless <iframe
style={{ height }} />; src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(
} embed.url!,
default: { )}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`}
if (embed.image) { frameBorder="0"
let url = embed.image.url; scrolling="no"
return ( style={{ height }}
<img />
className={styles.image} );
src={proxyImage(url)} case "Bandcamp": {
style={{ width, height }} return (
onClick={() => <iframe
openScreen({ id: "image_viewer", embed: embed.image }) src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${
} embed.special.id
onMouseDown={ev => }/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`}
ev.button === 1 && seamless
window.open(url, "_blank") style={{ height }}
} /> />
); );
} }
} default: {
} if (embed.image) {
let url = embed.image.url;
return (
<img
className={styles.image}
src={proxyImage(url)}
style={{ width, height }}
onClick={() =>
openScreen({
id: "image_viewer",
embed: embed.image,
})
}
onMouseDown={(ev) =>
ev.button === 1 && window.open(url, "_blank")
}
/>
);
}
}
}
return null; return null;
} }

View file

@ -1,26 +1,30 @@
import styles from './Embed.module.scss'; import { LinkExternal } from "@styled-icons/boxicons-regular";
import IconButton from '../../../ui/IconButton';
import { LinkExternal } from '@styled-icons/boxicons-regular';
import { EmbedImage } from "revolt.js/dist/api/objects"; import { EmbedImage } from "revolt.js/dist/api/objects";
import styles from "./Embed.module.scss";
import IconButton from "../../../ui/IconButton";
interface Props { interface Props {
embed: EmbedImage; embed: EmbedImage;
} }
export default function EmbedMediaActions({ embed }: Props) { export default function EmbedMediaActions({ embed }: Props) {
const filename = embed.url.split('/').pop(); const filename = embed.url.split("/").pop();
return ( return (
<div className={styles.actions}> <div className={styles.actions}>
<div className={styles.info}> <div className={styles.info}>
<span className={styles.filename}>{filename}</span> <span className={styles.filename}>{filename}</span>
<span className={styles.filesize}>{embed.width + 'x' + embed.height}</span> <span className={styles.filesize}>
</div> {embed.width + "x" + embed.height}
<a href={embed.url} target="_blank"> </span>
<IconButton> </div>
<LinkExternal size={24} /> <a href={embed.url} target="_blank">
</IconButton> <IconButton>
</a> <LinkExternal size={24} />
</div> </IconButton>
) </a>
</div>
);
} }

View file

@ -1,14 +1,16 @@
import { User } from "revolt.js"; import { User } from "revolt.js";
import UserIcon from "./UserIcon";
import Checkbox, { CheckboxProps } from "../../ui/Checkbox"; import Checkbox, { CheckboxProps } from "../../ui/Checkbox";
import UserIcon from "./UserIcon";
type UserProps = Omit<CheckboxProps, "children"> & { user: User }; type UserProps = Omit<CheckboxProps, "children"> & { user: User };
export default function UserCheckbox({ user, ...props }: UserProps) { export default function UserCheckbox({ user, ...props }: UserProps) {
return ( return (
<Checkbox {...props}> <Checkbox {...props}>
<UserIcon target={user} size={32} /> <UserIcon target={user} size={32} />
{user.username} {user.username}
</Checkbox> </Checkbox>
); );
} }

View file

@ -1,75 +1,84 @@
import Tooltip from "../Tooltip";
import { User } from "revolt.js";
import UserIcon from "./UserIcon";
import { Text } from "preact-i18n";
import Header from "../../ui/Header";
import UserStatus from './UserStatus';
import styled from "styled-components";
import { Localizer } from 'preact-i18n';
import { Link } from "react-router-dom";
import IconButton from "../../ui/IconButton";
import { Cog } from "@styled-icons/boxicons-solid"; import { Cog } from "@styled-icons/boxicons-solid";
import { Link } from "react-router-dom";
import { User } from "revolt.js";
import styled from "styled-components";
import { openContextMenu } from "preact-context-menu"; import { openContextMenu } from "preact-context-menu";
import { Text } from "preact-i18n";
import { Localizer } from "preact-i18n";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import Header from "../../ui/Header";
import IconButton from "../../ui/IconButton";
import Tooltip from "../Tooltip";
import UserIcon from "./UserIcon";
import UserStatus from "./UserStatus";
const HeaderBase = styled.div` const HeaderBase = styled.div`
gap: 0; gap: 0;
flex-grow: 1; flex-grow: 1;
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
* { * {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.username { .username {
cursor: pointer; cursor: pointer;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
} }
.status { .status {
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
margin-top: -2px; margin-top: -2px;
} }
`; `;
interface Props { interface Props {
user: User user: User;
} }
export default function UserHeader({ user }: Props) { export default function UserHeader({ user }: Props) {
const { writeClipboard } = useIntermediate(); const { writeClipboard } = useIntermediate();
return ( return (
<Header borders placement="secondary"> <Header borders placement="secondary">
<HeaderBase> <HeaderBase>
<Localizer> <Localizer>
<Tooltip content={<Text id="app.special.copy_username" />}> <Tooltip content={<Text id="app.special.copy_username" />}>
<span className="username" <span
onClick={() => writeClipboard(user.username)}> className="username"
@{user.username} onClick={() => writeClipboard(user.username)}>
</span> @{user.username}
</Tooltip> </span>
</Localizer> </Tooltip>
<span className="status" </Localizer>
onClick={() => openContextMenu("Status")}> <span
<UserStatus user={user} /> className="status"
</span> onClick={() => openContextMenu("Status")}>
</HeaderBase> <UserStatus user={user} />
{ !isTouchscreenDevice && <div className="actions"> </span>
<Link to="/settings"> </HeaderBase>
<IconButton> {!isTouchscreenDevice && (
<Cog size={24} /> <div className="actions">
</IconButton> <Link to="/settings">
</Link> <IconButton>
</div> } <Cog size={24} />
</Header> </IconButton>
) </Link>
</div>
)}
</Header>
);
} }

View file

@ -1,93 +1,104 @@
import { User } from "revolt.js";
import { useContext } from "preact/hooks";
import { MicrophoneOff } from "@styled-icons/boxicons-regular"; import { MicrophoneOff } from "@styled-icons/boxicons-regular";
import styled, { css } from "styled-components"; import { User } from "revolt.js";
import { Users } from "revolt.js/dist/api/objects"; import { Users } from "revolt.js/dist/api/objects";
import styled, { css } from "styled-components";
import { useContext } from "preact/hooks";
import { ThemeContext } from "../../../context/Theme"; import { ThemeContext } from "../../../context/Theme";
import IconBase, { IconBaseProps } from "../IconBase";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import IconBase, { IconBaseProps } from "../IconBase";
import fallback from "../assets/user.png";
type VoiceStatus = "muted"; type VoiceStatus = "muted";
interface Props extends IconBaseProps<User> { interface Props extends IconBaseProps<User> {
mask?: string; mask?: string;
status?: boolean; status?: boolean;
voice?: VoiceStatus; voice?: VoiceStatus;
} }
export function useStatusColour(user?: User) { export function useStatusColour(user?: User) {
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
return ( return user?.online && user?.status?.presence !== Users.Presence.Invisible
user?.online && ? user?.status?.presence === Users.Presence.Idle
user?.status?.presence !== Users.Presence.Invisible ? theme["status-away"]
? user?.status?.presence === Users.Presence.Idle : user?.status?.presence === Users.Presence.Busy
? theme["status-away"] ? theme["status-busy"]
: user?.status?.presence === : theme["status-online"]
Users.Presence.Busy : theme["status-invisible"];
? theme["status-busy"]
: theme["status-online"]
: theme["status-invisible"]
);
} }
const VoiceIndicator = styled.div<{ status: VoiceStatus }>` const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
svg { svg {
stroke: white; stroke: white;
} }
${ props => props.status === 'muted' && css` ${(props) =>
background: var(--error); props.status === "muted" &&
` } css`
background: var(--error);
`}
`; `;
import fallback from '../assets/user.png'; export default function UserIcon(
props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>,
) {
const client = useContext(AppContext);
export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>) { const {
const client = useContext(AppContext); target,
attachment,
size,
voice,
status,
animate,
mask,
children,
as,
...svgProps
} = props;
const iconURL =
client.generateFileURL(
target?.avatar ?? attachment,
{ max_side: 256 },
animate,
) ?? (target ? client.users.getDefaultAvatarURL(target._id) : fallback);
const { target, attachment, size, voice, status, animate, mask, children, as, ...svgProps } = props; return (
const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate) <IconBase
?? (target ? client.users.getDefaultAvatarURL(target._id) : fallback); {...svgProps}
width={size}
return ( height={size}
<IconBase {...svgProps} aria-hidden="true"
width={size} viewBox="0 0 32 32">
height={size} <foreignObject
aria-hidden="true" x="0"
viewBox="0 0 32 32"> y="0"
<foreignObject x="0" y="0" width="32" height="32" mask={mask ?? (status ? "url(#user)" : undefined)}> width="32"
{ height="32"
<img src={iconURL} mask={mask ?? (status ? "url(#user)" : undefined)}>
draggable={false} /> {<img src={iconURL} draggable={false} />}
} </foreignObject>
</foreignObject> {props.status && (
{props.status && ( <circle cx="27" cy="27" r="5" fill={useStatusColour(target)} />
<circle )}
cx="27" {props.voice && (
cy="27" <foreignObject x="22" y="22" width="10" height="10">
r="5" <VoiceIndicator status={props.voice}>
fill={useStatusColour(target)} {props.voice === "muted" && <MicrophoneOff size={6} />}
/> </VoiceIndicator>
)} </foreignObject>
{props.voice && ( )}
<foreignObject </IconBase>
x="22" );
y="22"
width="10"
height="10">
<VoiceIndicator status={props.voice}>
{props.voice === "muted" && <MicrophoneOff size={6} />}
</VoiceIndicator>
</foreignObject>
)}
</IconBase>
);
} }

View file

@ -1,14 +1,31 @@
import { User } from "revolt.js"; import { User } from "revolt.js";
import UserIcon from "./UserIcon";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
export function Username({ user, ...otherProps }: { user?: User } & JSX.HTMLAttributes<HTMLElement>) { import UserIcon from "./UserIcon";
return <span {...otherProps}>{ user?.username ?? <Text id="app.main.channel.unknown_user" /> }</span>;
export function Username({
user,
...otherProps
}: { user?: User } & JSX.HTMLAttributes<HTMLElement>) {
return (
<span {...otherProps}>
{user?.username ?? <Text id="app.main.channel.unknown_user" />}
</span>
);
} }
export default function UserShort({ user, size }: { user?: User, size?: number }) { export default function UserShort({
return <> user,
<UserIcon size={size ?? 24} target={user} /> size,
<Username user={user} /> }: {
</>; user?: User;
size?: number;
}) {
return (
<>
<UserIcon size={size ?? 24} target={user} />
<Username user={user} />
</>
);
} }

View file

@ -1,31 +1,32 @@
import { User } from "revolt.js"; import { User } from "revolt.js";
import { Text } from "preact-i18n";
import { Users } from "revolt.js/dist/api/objects"; import { Users } from "revolt.js/dist/api/objects";
import { Text } from "preact-i18n";
interface Props { interface Props {
user: User; user: User;
} }
export default function UserStatus({ user }: Props) { export default function UserStatus({ user }: Props) {
if (user.online) { if (user.online) {
if (user.status?.text) { if (user.status?.text) {
return <>{user.status?.text}</>; return <>{user.status?.text}</>;
} }
if (user.status?.presence === Users.Presence.Busy) { if (user.status?.presence === Users.Presence.Busy) {
return <Text id="app.status.busy" />; return <Text id="app.status.busy" />;
} }
if (user.status?.presence === Users.Presence.Idle) { if (user.status?.presence === Users.Presence.Idle) {
return <Text id="app.status.idle" />; return <Text id="app.status.idle" />;
} }
if (user.status?.presence === Users.Presence.Invisible) { if (user.status?.presence === Users.Presence.Invisible) {
return <Text id="app.status.offline" />; return <Text id="app.status.offline" />;
} }
return <Text id="app.status.online" />; return <Text id="app.status.online" />;
} }
return <Text id="app.status.offline" />; return <Text id="app.status.offline" />;
} }

View file

@ -1,17 +1,17 @@
import { Suspense, lazy } from "preact/compat"; import { Suspense, lazy } from "preact/compat";
const Renderer = lazy(() => import('./Renderer')); const Renderer = lazy(() => import("./Renderer"));
export interface MarkdownProps { export interface MarkdownProps {
content?: string; content?: string;
disallowBigEmoji?: boolean; disallowBigEmoji?: boolean;
} }
export default function Markdown(props: MarkdownProps) { export default function Markdown(props: MarkdownProps) {
return ( return (
// @ts-expect-error // @ts-expect-error
<Suspense fallback={props.content}> <Suspense fallback={props.content}>
<Renderer {...props} /> <Renderer {...props} />
</Suspense> </Suspense>
) );
} }

View file

@ -1,187 +1,192 @@
import MarkdownIt from "markdown-it";
import { RE_MENTIONS } from "revolt.js";
import { useContext } from "preact/hooks";
import { MarkdownProps } from "./Markdown";
import styles from "./Markdown.module.scss";
import { generateEmoji } from "../common/Emoji";
import { internalEmit } from "../../lib/eventEmitter";
import { emojiDictionary } from "../../assets/emojis";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import Prism from "prismjs";
import "katex/dist/katex.min.css";
import "prismjs/themes/prism-tomorrow.css";
import MarkdownKatex from "@traptitech/markdown-it-katex"; import MarkdownKatex from "@traptitech/markdown-it-katex";
import MarkdownSpoilers from "@traptitech/markdown-it-spoiler"; import MarkdownSpoilers from "@traptitech/markdown-it-spoiler";
import "katex/dist/katex.min.css";
import MarkdownIt from "markdown-it";
// @ts-ignore // @ts-ignore
import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare"; import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare";
// @ts-ignore // @ts-ignore
import MarkdownSup from "markdown-it-sup";
// @ts-ignore
import MarkdownSub from "markdown-it-sub"; import MarkdownSub from "markdown-it-sub";
// @ts-ignore
import MarkdownSup from "markdown-it-sup";
import Prism from "prismjs";
import "prismjs/themes/prism-tomorrow.css";
import { RE_MENTIONS } from "revolt.js";
import styles from "./Markdown.module.scss";
import { useContext } from "preact/hooks";
import { internalEmit } from "../../lib/eventEmitter";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { generateEmoji } from "../common/Emoji";
import { emojiDictionary } from "../../assets/emojis";
import { MarkdownProps } from "./Markdown";
// TODO: global.d.ts file for defining globals // TODO: global.d.ts file for defining globals
declare global { declare global {
interface Window { interface Window {
copycode: (element: HTMLDivElement) => void copycode: (element: HTMLDivElement) => void;
} }
} }
// Handler for code block copy. // Handler for code block copy.
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.copycode = function(element: HTMLDivElement) { window.copycode = function (element: HTMLDivElement) {
try { try {
let code = element.parentElement?.parentElement?.children[1]; let code = element.parentElement?.parentElement?.children[1];
if (code) { if (code) {
navigator.clipboard.writeText(code.textContent?.trim() ?? ''); navigator.clipboard.writeText(code.textContent?.trim() ?? "");
} }
} catch (e) {} } catch (e) {}
}; };
} }
export const md: MarkdownIt = MarkdownIt({ export const md: MarkdownIt = MarkdownIt({
breaks: true, breaks: true,
linkify: true, linkify: true,
highlight: (str, lang) => { highlight: (str, lang) => {
let v = Prism.languages[lang]; let v = Prism.languages[lang];
if (v) { if (v) {
let out = Prism.highlight(str, v, lang); let out = Prism.highlight(str, v, lang);
return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`; return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`;
} }
return `<pre class="code"><code>${md.utils.escapeHtml(str)}</code></pre>`; return `<pre class="code"><code>${md.utils.escapeHtml(
} str,
)}</code></pre>`;
},
}) })
.disable("image") .disable("image")
.use(MarkdownEmoji, { defs: emojiDictionary }) .use(MarkdownEmoji, { defs: emojiDictionary })
.use(MarkdownSpoilers) .use(MarkdownSpoilers)
.use(MarkdownSup) .use(MarkdownSup)
.use(MarkdownSub) .use(MarkdownSub)
.use(MarkdownKatex, { .use(MarkdownKatex, {
throwOnError: false, throwOnError: false,
maxExpand: 0 maxExpand: 0,
}); });
// ? Force links to open _blank. // ? Force links to open _blank.
// From: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer // From: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
const defaultRender = const defaultRender =
md.renderer.rules.link_open || md.renderer.rules.link_open ||
function(tokens, idx, options, _env, self) { function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options); return self.renderToken(tokens, idx, options);
}; };
// TODO: global.d.ts file for defining globals // TODO: global.d.ts file for defining globals
declare global { declare global {
interface Window { interface Window {
internalHandleURL: (element: HTMLAnchorElement) => void internalHandleURL: (element: HTMLAnchorElement) => void;
} }
} }
// Handler for internal links, pushes events to React using magic. // Handler for internal links, pushes events to React using magic.
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.internalHandleURL = function(element: HTMLAnchorElement) { window.internalHandleURL = function (element: HTMLAnchorElement) {
const url = new URL(element.href, location.href); const url = new URL(element.href, location.href);
const pathname = url.pathname; const pathname = url.pathname;
if (pathname.startsWith("/@")) { if (pathname.startsWith("/@")) {
internalEmit("Intermediate", "openProfile", pathname.substr(2)); internalEmit("Intermediate", "openProfile", pathname.substr(2));
} else { } else {
internalEmit("Intermediate", "navigate", pathname); internalEmit("Intermediate", "navigate", pathname);
} }
}; };
} }
md.renderer.rules.link_open = function(tokens, idx, options, env, self) { md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
let internal; let internal;
const hIndex = tokens[idx].attrIndex("href"); const hIndex = tokens[idx].attrIndex("href");
if (hIndex >= 0) { if (hIndex >= 0) {
try { try {
// For internal links, we should use our own handler to use react-router history. // For internal links, we should use our own handler to use react-router history.
// @ts-ignore // @ts-ignore
const href = tokens[idx].attrs[hIndex][1]; const href = tokens[idx].attrs[hIndex][1];
const url = new URL(href, location.href); const url = new URL(href, location.href);
if (url.hostname === location.hostname) { if (url.hostname === location.hostname) {
internal = true; internal = true;
// I'm sorry. // I'm sorry.
tokens[idx].attrPush([ tokens[idx].attrPush([
"onclick", "onclick",
"internalHandleURL(this); return false" "internalHandleURL(this); return false",
]); ]);
if (url.pathname.startsWith("/@")) { if (url.pathname.startsWith("/@")) {
tokens[idx].attrPush(["data-type", "mention"]); tokens[idx].attrPush(["data-type", "mention"]);
} }
} }
} catch (err) { } catch (err) {
// Ignore the error, treat as normal link. // Ignore the error, treat as normal link.
} }
} }
if (!internal) { if (!internal) {
// Add target=_blank for external links. // Add target=_blank for external links.
const aIndex = tokens[idx].attrIndex("target"); const aIndex = tokens[idx].attrIndex("target");
if (aIndex < 0) { if (aIndex < 0) {
tokens[idx].attrPush(["target", "_blank"]); tokens[idx].attrPush(["target", "_blank"]);
} else { } else {
try { try {
// @ts-ignore // @ts-ignore
tokens[idx].attrs[aIndex][1] = "_blank"; tokens[idx].attrs[aIndex][1] = "_blank";
} catch (_) {} } catch (_) {}
} }
} }
return defaultRender(tokens, idx, options, env, self); return defaultRender(tokens, idx, options, env, self);
}; };
md.renderer.rules.emoji = function(token, idx) { md.renderer.rules.emoji = function (token, idx) {
return generateEmoji(token[idx].content); return generateEmoji(token[idx].content);
}; };
const RE_TWEMOJI = /:(\w+):/g; const RE_TWEMOJI = /:(\w+):/g;
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) { export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
const client = useContext(AppContext); const client = useContext(AppContext);
if (typeof content === "undefined") return null; if (typeof content === "undefined") return null;
if (content.length === 0) return null; if (content.length === 0) return null;
// We replace the message with the mention at the time of render. // We replace the message with the mention at the time of render.
// We don't care if the mention changes. // We don't care if the mention changes.
let newContent = content.replace( let newContent = content.replace(
RE_MENTIONS, RE_MENTIONS,
(sub: string, ...args: any[]) => { (sub: string, ...args: any[]) => {
const id = args[0], const id = args[0],
user = client.users.get(id); user = client.users.get(id);
if (user) { if (user) {
return `[@${user.username}](/@${id})`; return `[@${user.username}](/@${id})`;
} }
return sub; return sub;
} },
); );
const useLargeEmojis = disallowBigEmoji ? false : content.replace(RE_TWEMOJI, '').trim().length === 0; const useLargeEmojis = disallowBigEmoji
? false
: content.replace(RE_TWEMOJI, "").trim().length === 0;
return ( return (
<span <span
className={styles.markdown} className={styles.markdown}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: md.render(newContent) __html: md.render(newContent),
}} }}
data-large-emojis={useLargeEmojis} data-large-emojis={useLargeEmojis}
onClick={ev => { onClick={(ev) => {
if (ev.target) { if (ev.target) {
let element = ev.currentTarget; let element = ev.currentTarget;
if (element.classList.contains("spoiler")) { if (element.classList.contains("spoiler")) {
element.classList.add("shown"); element.classList.add("shown");
} }
} }
}} }}
/> />
); );
} }

View file

@ -1,88 +1,96 @@
import IconButton from "../ui/IconButton";
import UserIcon from "../common/user/UserIcon";
import styled, { css } from "styled-components";
import { useSelf } from "../../context/revoltjs/hooks";
import { useHistory, useLocation } from "react-router";
import ConditionalLink from "../../lib/ConditionalLink";
import { Message, Group } from "@styled-icons/boxicons-solid"; import { Message, Group } from "@styled-icons/boxicons-solid";
import { LastOpened } from "../../redux/reducers/last_opened"; import { useHistory, useLocation } from "react-router";
import styled, { css } from "styled-components";
import ConditionalLink from "../../lib/ConditionalLink";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import { LastOpened } from "../../redux/reducers/last_opened";
import { useSelf } from "../../context/revoltjs/hooks";
import UserIcon from "../common/user/UserIcon";
import IconButton from "../ui/IconButton";
const NavigationBase = styled.div` const NavigationBase = styled.div`
z-index: 100; z-index: 100;
height: 50px; height: 50px;
display: flex; display: flex;
background: var(--secondary-background); background: var(--secondary-background);
`; `;
const Button = styled.a<{ active: boolean }>` const Button = styled.a<{ active: boolean }>`
flex: 1; flex: 1;
> a, > div, > a > div { > a,
width: 100%; > div,
height: 100%; > a > div {
} width: 100%;
height: 100%;
}
${ props => props.active && css` ${(props) =>
background: var(--hover); props.active &&
` } css`
background: var(--hover);
`}
`; `;
interface Props { interface Props {
lastOpened: LastOpened lastOpened: LastOpened;
} }
export function BottomNavigation({ lastOpened }: Props) { export function BottomNavigation({ lastOpened }: Props) {
const user = useSelf(); const user = useSelf();
const history = useHistory(); const history = useHistory();
const path = useLocation().pathname; const path = useLocation().pathname;
const channel_id = lastOpened['home']; const channel_id = lastOpened["home"];
const friendsActive = path.startsWith("/friends"); const friendsActive = path.startsWith("/friends");
const settingsActive = path.startsWith("/settings"); const settingsActive = path.startsWith("/settings");
const homeActive = !(friendsActive || settingsActive); const homeActive = !(friendsActive || settingsActive);
return ( return (
<NavigationBase> <NavigationBase>
<Button active={homeActive}> <Button active={homeActive}>
<IconButton <IconButton
onClick={() => { onClick={() => {
if (settingsActive) { if (settingsActive) {
if (history.length > 0) { if (history.length > 0) {
history.goBack(); history.goBack();
} }
} }
if (channel_id) { if (channel_id) {
history.push(`/channel/${channel_id}`); history.push(`/channel/${channel_id}`);
} else { } else {
history.push('/'); history.push("/");
} }
}}> }}>
<Message size={24} /> <Message size={24} />
</IconButton> </IconButton>
</Button> </Button>
<Button active={friendsActive}> <Button active={friendsActive}>
<ConditionalLink active={friendsActive} to="/friends"> <ConditionalLink active={friendsActive} to="/friends">
<IconButton> <IconButton>
<Group size={25} /> <Group size={25} />
</IconButton> </IconButton>
</ConditionalLink> </ConditionalLink>
</Button> </Button>
<Button active={settingsActive}> <Button active={settingsActive}>
<ConditionalLink active={settingsActive} to="/settings"> <ConditionalLink active={settingsActive} to="/settings">
<IconButton> <IconButton>
<UserIcon target={user} size={26} status={true} /> <UserIcon target={user} size={26} status={true} />
</IconButton> </IconButton>
</ConditionalLink> </ConditionalLink>
</Button> </Button>
</NavigationBase> </NavigationBase>
); );
} }
export default connectState(BottomNavigation, state => { export default connectState(BottomNavigation, (state) => {
return { return {
lastOpened: state.lastOpened lastOpened: state.lastOpened,
} };
}); });

View file

@ -1,32 +1,32 @@
import { Route, Switch } from "react-router"; import { Route, Switch } from "react-router";
import SidebarBase from "./SidebarBase";
import SidebarBase from "./SidebarBase";
import HomeSidebar from "./left/HomeSidebar";
import ServerListSidebar from "./left/ServerListSidebar"; import ServerListSidebar from "./left/ServerListSidebar";
import ServerSidebar from "./left/ServerSidebar"; import ServerSidebar from "./left/ServerSidebar";
import HomeSidebar from "./left/HomeSidebar";
export default function LeftSidebar() { export default function LeftSidebar() {
return ( return (
<SidebarBase> <SidebarBase>
<Switch> <Switch>
<Route path="/settings" /> <Route path="/settings" />
<Route path="/server/:server/channel/:channel"> <Route path="/server/:server/channel/:channel">
<ServerListSidebar /> <ServerListSidebar />
<ServerSidebar /> <ServerSidebar />
</Route> </Route>
<Route path="/server/:server"> <Route path="/server/:server">
<ServerListSidebar /> <ServerListSidebar />
<ServerSidebar /> <ServerSidebar />
</Route> </Route>
<Route path="/channel/:channel"> <Route path="/channel/:channel">
<ServerListSidebar /> <ServerListSidebar />
<HomeSidebar /> <HomeSidebar />
</Route> </Route>
<Route path="/"> <Route path="/">
<ServerListSidebar /> <ServerListSidebar />
<HomeSidebar /> <HomeSidebar />
</Route> </Route>
</Switch> </Switch>
</SidebarBase> </SidebarBase>
); );
}; }

View file

@ -1,19 +1,19 @@
import { Route, Switch } from "react-router"; import { Route, Switch } from "react-router";
import SidebarBase from "./SidebarBase";
import SidebarBase from "./SidebarBase";
import MemberSidebar from "./right/MemberSidebar"; import MemberSidebar from "./right/MemberSidebar";
export default function RightSidebar() { export default function RightSidebar() {
return ( return (
<SidebarBase> <SidebarBase>
<Switch> <Switch>
<Route path="/server/:server/channel/:channel"> <Route path="/server/:server/channel/:channel">
<MemberSidebar /> <MemberSidebar />
</Route> </Route>
<Route path="/channel/:channel"> <Route path="/channel/:channel">
<MemberSidebar /> <MemberSidebar />
</Route> </Route>
</Switch> </Switch>
</SidebarBase> </SidebarBase>
); );
}; }

View file

@ -1,34 +1,38 @@
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
export default styled.div` export default styled.div`
height: 100%; height: 100%;
display: flex; display: flex;
user-select: none; user-select: none;
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
`; `;
export const GenericSidebarBase = styled.div<{ padding?: boolean }>` export const GenericSidebarBase = styled.div<{ padding?: boolean }>`
height: 100%; height: 100%;
width: 240px; width: 240px;
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
flex-direction: column; flex-direction: column;
background: var(--secondary-background); background: var(--secondary-background);
border-end-start-radius: 8px; border-end-start-radius: 8px;
${ props => props.padding && isTouchscreenDevice && css` ${(props) =>
padding-bottom: 50px; props.padding &&
` } isTouchscreenDevice &&
css`
padding-bottom: 50px;
`}
`; `;
export const GenericSidebarList = styled.div` export const GenericSidebarList = styled.div`
padding: 6px; padding: 6px;
flex-grow: 1; flex-grow: 1;
overflow-y: scroll; overflow-y: scroll;
> img { > img {
width: 100%; width: 100%;
} }
`; `;

View file

@ -1,158 +1,223 @@
import classNames from 'classnames';
import styles from "./Item.module.scss";
import Tooltip from '../../common/Tooltip';
import IconButton from '../../ui/IconButton';
import { Localizer, Text } from "preact-i18n";
import { X, Crown } from "@styled-icons/boxicons-regular"; import { X, Crown } from "@styled-icons/boxicons-regular";
import { Children } from "../../../types/Preact";
import UserIcon from '../../common/user/UserIcon';
import ChannelIcon from '../../common/ChannelIcon';
import UserStatus from '../../common/user/UserStatus';
import { attachContextMenu } from 'preact-context-menu';
import { Channels, Users } from "revolt.js/dist/api/objects"; import { Channels, Users } from "revolt.js/dist/api/objects";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useIntermediate } from '../../../context/intermediate/Intermediate';
import { stopPropagation } from '../../../lib/stopPropagation';
type CommonProps = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as'> & { import styles from "./Item.module.scss";
active?: boolean import classNames from "classnames";
alert?: 'unread' | 'mention' import { attachContextMenu } from "preact-context-menu";
alertCount?: number import { Localizer, Text } from "preact-i18n";
}
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { stopPropagation } from "../../../lib/stopPropagation";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import ChannelIcon from "../../common/ChannelIcon";
import Tooltip from "../../common/Tooltip";
import UserIcon from "../../common/user/UserIcon";
import UserStatus from "../../common/user/UserStatus";
import IconButton from "../../ui/IconButton";
import { Children } from "../../../types/Preact";
type CommonProps = Omit<
JSX.HTMLAttributes<HTMLDivElement>,
"children" | "as"
> & {
active?: boolean;
alert?: "unread" | "mention";
alertCount?: number;
};
type UserProps = CommonProps & { type UserProps = CommonProps & {
user: Users.User, user: Users.User;
context?: Channels.Channel, context?: Channels.Channel;
channel?: Channels.DirectMessageChannel channel?: Channels.DirectMessageChannel;
} };
export function UserButton(props: UserProps) { export function UserButton(props: UserProps) {
const { active, alert, alertCount, user, context, channel, ...divProps } = props; const { active, alert, alertCount, user, context, channel, ...divProps } =
const { openScreen } = useIntermediate(); props;
const { openScreen } = useIntermediate();
return ( return (
<div {...divProps} <div
className={classNames(styles.item, styles.user)} {...divProps}
data-active={active} className={classNames(styles.item, styles.user)}
data-alert={typeof alert === 'string'} data-active={active}
data-online={typeof channel !== 'undefined' || (user.online && user.status?.presence !== Users.Presence.Invisible)} data-alert={typeof alert === "string"}
onContextMenu={attachContextMenu('Menu', { data-online={
user: user._id, typeof channel !== "undefined" ||
channel: channel?._id, (user.online &&
unread: alert, user.status?.presence !== Users.Presence.Invisible)
contextualChannel: context?._id }
})}> onContextMenu={attachContextMenu("Menu", {
<UserIcon className={styles.avatar} target={user} size={32} status /> user: user._id,
<div className={styles.name}> channel: channel?._id,
<div>{user.username}</div> unread: alert,
{ contextualChannel: context?._id,
<div className={styles.subText}> })}>
{ channel?.last_message && alert ? ( <UserIcon
channel.last_message.short className={styles.avatar}
) : ( target={user}
<UserStatus user={user} /> size={32}
) } status
</div> />
} <div className={styles.name}>
</div> <div>{user.username}</div>
<div className={styles.button}> {
{ context?.channel_type === "Group" && <div className={styles.subText}>
context.owner === user._id && ( {channel?.last_message && alert ? (
<Localizer> channel.last_message.short
<Tooltip ) : (
content={ <UserStatus user={user} />
<Text id="app.main.groups.owner" /> )}
} </div>
> }
<Crown size={20} /> </div>
</Tooltip> <div className={styles.button}>
</Localizer> {context?.channel_type === "Group" &&
)} context.owner === user._id && (
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>} <Localizer>
{ !isTouchscreenDevice && channel && <Tooltip
<IconButton className={styles.icon} content={<Text id="app.main.groups.owner" />}>
onClick={e => stopPropagation(e) && openScreen({ id: 'special_prompt', type: 'close_dm', target: channel })}> <Crown size={20} />
<X size={24} /> </Tooltip>
</IconButton> </Localizer>
} )}
</div> {alert && (
</div> <div className={styles.alert} data-style={alert}>
) {alertCount}
</div>
)}
{!isTouchscreenDevice && channel && (
<IconButton
className={styles.icon}
onClick={(e) =>
stopPropagation(e) &&
openScreen({
id: "special_prompt",
type: "close_dm",
target: channel,
})
}>
<X size={24} />
</IconButton>
)}
</div>
</div>
);
} }
type ChannelProps = CommonProps & { type ChannelProps = CommonProps & {
channel: Channels.Channel & { unread?: string }, channel: Channels.Channel & { unread?: string };
user?: Users.User user?: Users.User;
compact?: boolean compact?: boolean;
} };
export function ChannelButton(props: ChannelProps) { export function ChannelButton(props: ChannelProps) {
const { active, alert, alertCount, channel, user, compact, ...divProps } = props; const { active, alert, alertCount, channel, user, compact, ...divProps } =
props;
if (channel.channel_type === 'SavedMessages') throw "Invalid channel type."; if (channel.channel_type === "SavedMessages") throw "Invalid channel type.";
if (channel.channel_type === 'DirectMessage') { if (channel.channel_type === "DirectMessage") {
if (typeof user === 'undefined') throw "No user provided."; if (typeof user === "undefined") throw "No user provided.";
return <UserButton {...{ active, alert, channel, user }} /> return <UserButton {...{ active, alert, channel, user }} />;
} }
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
return ( return (
<div {...divProps} <div
data-active={active} {...divProps}
data-alert={typeof alert === 'string'} data-active={active}
aria-label={{}} /*FIXME: ADD ARIA LABEL*/ data-alert={typeof alert === "string"}
className={classNames(styles.item, { [styles.compact]: compact })} aria-label={{}} /*FIXME: ADD ARIA LABEL*/
onContextMenu={attachContextMenu('Menu', { channel: channel._id, unread: typeof channel.unread !== 'undefined' })}> className={classNames(styles.item, { [styles.compact]: compact })}
<ChannelIcon className={styles.avatar} target={channel} size={compact ? 24 : 32} /> onContextMenu={attachContextMenu("Menu", {
<div className={styles.name}> channel: channel._id,
<div>{channel.name}</div> unread: typeof channel.unread !== "undefined",
{ channel.channel_type === 'Group' && })}>
<div className={styles.subText}> <ChannelIcon
{(channel.last_message && alert) ? ( className={styles.avatar}
channel.last_message.short target={channel}
) : ( size={compact ? 24 : 32}
<Text />
id="quantities.members" <div className={styles.name}>
plural={channel.recipients.length} <div>{channel.name}</div>
fields={{ count: channel.recipients.length }} {channel.channel_type === "Group" && (
/> <div className={styles.subText}>
)} {channel.last_message && alert ? (
</div> channel.last_message.short
} ) : (
</div> <Text
<div className={styles.button}> id="quantities.members"
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>} plural={channel.recipients.length}
{!isTouchscreenDevice && channel.channel_type === "Group" && ( fields={{ count: channel.recipients.length }}
<IconButton />
className={styles.icon} )}
onClick={() => openScreen({ id: 'special_prompt', type: 'leave_group', target: channel })}> </div>
<X size={24} /> )}
</IconButton> </div>
)} <div className={styles.button}>
</div> {alert && (
</div> <div className={styles.alert} data-style={alert}>
) {alertCount}
</div>
)}
{!isTouchscreenDevice && channel.channel_type === "Group" && (
<IconButton
className={styles.icon}
onClick={() =>
openScreen({
id: "special_prompt",
type: "leave_group",
target: channel,
})
}>
<X size={24} />
</IconButton>
)}
</div>
</div>
);
} }
type ButtonProps = CommonProps & { type ButtonProps = CommonProps & {
onClick?: () => void onClick?: () => void;
children?: Children children?: Children;
className?: string className?: string;
compact?: boolean compact?: boolean;
} };
export default function ButtonItem(props: ButtonProps) { export default function ButtonItem(props: ButtonProps) {
const { active, alert, alertCount, onClick, className, children, compact, ...divProps } = props; const {
active,
alert,
alertCount,
onClick,
className,
children,
compact,
...divProps
} = props;
return ( return (
<div {...divProps} <div
className={classNames(styles.item, { [styles.compact]: compact, [styles.normal]: !compact }, className)} {...divProps}
onClick={onClick} className={classNames(
data-active={active} styles.item,
data-alert={typeof alert === 'string'}> { [styles.compact]: compact, [styles.normal]: !compact },
<div className={styles.content}>{ children }</div> className,
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>} )}
</div> onClick={onClick}
) data-active={active}
data-alert={typeof alert === "string"}>
<div className={styles.content}>{children}</div>
{alert && (
<div className={styles.alert} data-style={alert}>
{alertCount}
</div>
)}
</div>
);
} }

View file

@ -1,35 +1,40 @@
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import Banner from "../../ui/Banner";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
import {
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import Banner from "../../ui/Banner";
export default function ConnectionStatus() { export default function ConnectionStatus() {
const status = useContext(StatusContext); const status = useContext(StatusContext);
if (status === ClientStatus.OFFLINE) { if (status === ClientStatus.OFFLINE) {
return ( return (
<Banner> <Banner>
<Text id="app.special.status.offline" /> <Text id="app.special.status.offline" />
</Banner> </Banner>
); );
} else if (status === ClientStatus.DISCONNECTED) { } else if (status === ClientStatus.DISCONNECTED) {
return ( return (
<Banner> <Banner>
<Text id="app.special.status.disconnected" /> <Text id="app.special.status.disconnected" />
</Banner> </Banner>
); );
} else if (status === ClientStatus.CONNECTING) { } else if (status === ClientStatus.CONNECTING) {
return ( return (
<Banner> <Banner>
<Text id="app.special.status.connecting" /> <Text id="app.special.status.connecting" />
</Banner> </Banner>
); );
} else if (status === ClientStatus.RECONNECTING) { } else if (status === ClientStatus.RECONNECTING) {
return ( return (
<Banner> <Banner>
<Text id="app.special.status.reconnecting" /> <Text id="app.special.status.reconnecting" />
</Banner> </Banner>
); );
} }
return null; return null;
} }

View file

@ -1,152 +1,193 @@
import {
Home,
UserDetail,
Wrench,
Notepad,
} from "@styled-icons/boxicons-solid";
import { Link, Redirect, useLocation, useParams } from "react-router-dom";
import { Channels } from "revolt.js/dist/api/objects";
import { Users as UsersNS } from "revolt.js/dist/api/objects";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect } from "preact/hooks"; import { useContext, useEffect } from "preact/hooks";
import { Home, UserDetail, Wrench, Notepad } from "@styled-icons/boxicons-solid";
import Category from '../../ui/Category';
import { dispatch } from "../../../redux";
import PaintCounter from "../../../lib/PaintCounter";
import UserHeader from "../../common/user/UserHeader";
import { Channels } from "revolt.js/dist/api/objects";
import { connectState } from "../../../redux/connector";
import ConnectionStatus from '../items/ConnectionStatus';
import { Unreads } from "../../../redux/reducers/unreads";
import ConditionalLink from "../../../lib/ConditionalLink"; import ConditionalLink from "../../../lib/ConditionalLink";
import { mapChannelWithUnread, useUnreads } from "./common"; import PaintCounter from "../../../lib/PaintCounter";
import { Users as UsersNS } from 'revolt.js/dist/api/objects';
import ButtonItem, { ChannelButton } from '../items/ButtonItem';
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
import { Link, Redirect, useLocation, useParams } from "react-router-dom";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { useDMs, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import { Unreads } from "../../../redux/reducers/unreads";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import {
useDMs,
useForceUpdate,
useUsers,
} from "../../../context/revoltjs/hooks";
import UserHeader from "../../common/user/UserHeader";
import Category from "../../ui/Category";
import placeholderSVG from "../items/placeholder.svg"; import placeholderSVG from "../items/placeholder.svg";
import { mapChannelWithUnread, useUnreads } from "./common";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
import ButtonItem, { ChannelButton } from "../items/ButtonItem";
import ConnectionStatus from "../items/ConnectionStatus";
type Props = { type Props = {
unreads: Unreads; unreads: Unreads;
}
function HomeSidebar(props: Props) {
const { pathname } = useLocation();
const client = useContext(AppContext);
const { channel } = useParams<{ channel: string }>();
const { openScreen } = useIntermediate();
const ctx = useForceUpdate();
const channels = useDMs(ctx);
const obj = channels.find(x => x?._id === channel);
if (channel && !obj) return <Redirect to="/" />;
if (obj) useUnreads({ ...props, channel: obj });
useEffect(() => {
if (!channel) return;
dispatch({
type: 'LAST_OPENED_SET',
parent: 'home',
child: channel
});
}, [ channel ]);
const channelsArr = channels
.filter(x => x.channel_type !== 'SavedMessages')
.map(x => mapChannelWithUnread(x, props.unreads));
const users = useUsers(
(channelsArr as (Channels.DirectMessageChannel | Channels.GroupChannel)[])
.reduce((prev: any, cur) => [ ...prev, ...cur.recipients ], [])
, ctx);
channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
return (
<GenericSidebarBase padding>
<UserHeader user={client.user!} />
<ConnectionStatus />
<GenericSidebarList>
{!isTouchscreenDevice && (
<>
<ConditionalLink active={pathname === "/"} to="/">
<ButtonItem active={pathname === "/"}>
<Home size={20} />
<span><Text id="app.navigation.tabs.home" /></span>
</ButtonItem>
</ConditionalLink>
<ConditionalLink active={pathname === "/friends"} to="/friends">
<ButtonItem
active={pathname === "/friends"}
alert={
typeof users.find(
user =>
user?.relationship ===
UsersNS.Relationship.Incoming
) !== "undefined" ? 'unread' : undefined
}
>
<UserDetail size={20} />
<span><Text id="app.navigation.tabs.friends" /></span>
</ButtonItem>
</ConditionalLink>
</>
)}
<ConditionalLink active={obj?.channel_type === "SavedMessages"} to="/open/saved">
<ButtonItem active={obj?.channel_type === "SavedMessages"}>
<Notepad size={20} />
<span><Text id="app.navigation.tabs.saved" /></span>
</ButtonItem>
</ConditionalLink>
{import.meta.env.DEV && (
<Link to="/dev">
<ButtonItem active={pathname === "/dev"}>
<Wrench size={20} />
<span><Text id="app.navigation.tabs.dev" /></span>
</ButtonItem>
</Link>
)}
<Category
text={<Text id="app.main.categories.conversations" />}
action={() => openScreen({ id: "special_input", type: "create_group" })} />
{channelsArr.length === 0 && <img src={placeholderSVG} />}
{channelsArr.map(x => {
let user;
if (x.channel_type === 'DirectMessage') {
if (!x.active) return null;
let recipient = client.channels.getRecipient(x._id);
user = users.find(x => x?._id === recipient);
if (!user) {
console.warn(`Skipped DM ${x._id} because user was missing.`);
return null;
}
}
return (
<ConditionalLink active={x._id === channel} to={`/channel/${x._id}`}>
<ChannelButton
user={user}
channel={x}
alert={x.unread}
alertCount={x.alertCount}
active={x._id === channel}
/>
</ConditionalLink>
);
})}
<PaintCounter />
</GenericSidebarList>
</GenericSidebarBase>
);
}; };
function HomeSidebar(props: Props) {
const { pathname } = useLocation();
const client = useContext(AppContext);
const { channel } = useParams<{ channel: string }>();
const { openScreen } = useIntermediate();
const ctx = useForceUpdate();
const channels = useDMs(ctx);
const obj = channels.find((x) => x?._id === channel);
if (channel && !obj) return <Redirect to="/" />;
if (obj) useUnreads({ ...props, channel: obj });
useEffect(() => {
if (!channel) return;
dispatch({
type: "LAST_OPENED_SET",
parent: "home",
child: channel,
});
}, [channel]);
const channelsArr = channels
.filter((x) => x.channel_type !== "SavedMessages")
.map((x) => mapChannelWithUnread(x, props.unreads));
const users = useUsers(
(
channelsArr as (
| Channels.DirectMessageChannel
| Channels.GroupChannel
)[]
).reduce((prev: any, cur) => [...prev, ...cur.recipients], []),
ctx,
);
channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
return (
<GenericSidebarBase padding>
<UserHeader user={client.user!} />
<ConnectionStatus />
<GenericSidebarList>
{!isTouchscreenDevice && (
<>
<ConditionalLink active={pathname === "/"} to="/">
<ButtonItem active={pathname === "/"}>
<Home size={20} />
<span>
<Text id="app.navigation.tabs.home" />
</span>
</ButtonItem>
</ConditionalLink>
<ConditionalLink
active={pathname === "/friends"}
to="/friends">
<ButtonItem
active={pathname === "/friends"}
alert={
typeof users.find(
(user) =>
user?.relationship ===
UsersNS.Relationship.Incoming,
) !== "undefined"
? "unread"
: undefined
}>
<UserDetail size={20} />
<span>
<Text id="app.navigation.tabs.friends" />
</span>
</ButtonItem>
</ConditionalLink>
</>
)}
<ConditionalLink
active={obj?.channel_type === "SavedMessages"}
to="/open/saved">
<ButtonItem active={obj?.channel_type === "SavedMessages"}>
<Notepad size={20} />
<span>
<Text id="app.navigation.tabs.saved" />
</span>
</ButtonItem>
</ConditionalLink>
{import.meta.env.DEV && (
<Link to="/dev">
<ButtonItem active={pathname === "/dev"}>
<Wrench size={20} />
<span>
<Text id="app.navigation.tabs.dev" />
</span>
</ButtonItem>
</Link>
)}
<Category
text={<Text id="app.main.categories.conversations" />}
action={() =>
openScreen({
id: "special_input",
type: "create_group",
})
}
/>
{channelsArr.length === 0 && <img src={placeholderSVG} />}
{channelsArr.map((x) => {
let user;
if (x.channel_type === "DirectMessage") {
if (!x.active) return null;
let recipient = client.channels.getRecipient(x._id);
user = users.find((x) => x?._id === recipient);
if (!user) {
console.warn(
`Skipped DM ${x._id} because user was missing.`,
);
return null;
}
}
return (
<ConditionalLink
active={x._id === channel}
to={`/channel/${x._id}`}>
<ChannelButton
user={user}
channel={x}
alert={x.unread}
alertCount={x.alertCount}
active={x._id === channel}
/>
</ConditionalLink>
);
})}
<PaintCounter />
</GenericSidebarList>
</GenericSidebarBase>
);
}
export default connectState( export default connectState(
HomeSidebar, HomeSidebar,
state => { (state) => {
return { return {
unreads: state.unreads unreads: state.unreads,
}; };
}, },
true true,
); );

View file

@ -1,257 +1,298 @@
import { Plus } from "@styled-icons/boxicons-regular";
import { useLocation, useParams } from "react-router-dom";
import { Channel, Servers } from "revolt.js/dist/api/objects";
import styled, { css } from "styled-components";
import { attachContextMenu, openContextMenu } from "preact-context-menu";
import ConditionalLink from "../../../lib/ConditionalLink";
import PaintCounter from "../../../lib/PaintCounter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { connectState } from "../../../redux/connector";
import { LastOpened } from "../../../redux/reducers/last_opened";
import { Unreads } from "../../../redux/reducers/unreads";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import {
useChannels,
useForceUpdate,
useSelf,
useServers,
} from "../../../context/revoltjs/hooks";
import ServerIcon from "../../common/ServerIcon";
import Tooltip from "../../common/Tooltip"; import Tooltip from "../../common/Tooltip";
import UserIcon from "../../common/user/UserIcon";
import IconButton from "../../ui/IconButton"; import IconButton from "../../ui/IconButton";
import LineDivider from "../../ui/LineDivider"; import LineDivider from "../../ui/LineDivider";
import { mapChannelWithUnread } from "./common"; import { mapChannelWithUnread } from "./common";
import styled, { css } from "styled-components";
import ServerIcon from "../../common/ServerIcon";
import { Children } from "../../../types/Preact";
import UserIcon from "../../common/user/UserIcon";
import PaintCounter from "../../../lib/PaintCounter";
import { Plus } from "@styled-icons/boxicons-regular";
import { connectState } from "../../../redux/connector";
import { useLocation, useParams } from "react-router-dom";
import { Unreads } from "../../../redux/reducers/unreads";
import ConditionalLink from "../../../lib/ConditionalLink";
import { Channel, Servers } from "revolt.js/dist/api/objects";
import { LastOpened } from "../../../redux/reducers/last_opened";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { attachContextMenu, openContextMenu } from 'preact-context-menu';
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { useChannels, useForceUpdate, useSelf, useServers } from "../../../context/revoltjs/hooks";
function Icon({ children, unread, size }: { children: Children, unread?: 'mention' | 'unread', size: number }) { import { Children } from "../../../types/Preact";
return (
<svg function Icon({
width={size} children,
height={size} unread,
aria-hidden="true" size,
viewBox="0 0 32 32" }: {
> children: Children;
<use href="#serverIndicator" /> unread?: "mention" | "unread";
<foreignObject x="0" y="0" width="32" height="32" mask={ unread ? "url(#server)" : undefined }> size: number;
{ children } }) {
</foreignObject> return (
{unread === 'unread' && ( <svg width={size} height={size} aria-hidden="true" viewBox="0 0 32 32">
<circle <use href="#serverIndicator" />
cx="27" <foreignObject
cy="5" x="0"
r="5" y="0"
fill={"white"} width="32"
/> height="32"
)} mask={unread ? "url(#server)" : undefined}>
{unread === 'mention' && ( {children}
<circle </foreignObject>
cx="27" {unread === "unread" && (
cy="5" <circle cx="27" cy="5" r="5" fill={"white"} />
r="5" )}
fill={"red"} {unread === "mention" && (
/> <circle cx="27" cy="5" r="5" fill={"red"} />
)} )}
</svg> </svg>
) );
} }
const ServersBase = styled.div` const ServersBase = styled.div`
width: 56px; width: 56px;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
${ isTouchscreenDevice && css` ${isTouchscreenDevice &&
padding-bottom: 50px; css`
` } padding-bottom: 50px;
`}
`; `;
const ServerList = styled.div` const ServerList = styled.div`
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
overflow-y: scroll; overflow-y: scroll;
padding-bottom: 48px; padding-bottom: 48px;
flex-direction: column; flex-direction: column;
// border-right: 2px solid var(--accent); // border-right: 2px solid var(--accent);
scrollbar-width: none; scrollbar-width: none;
> :first-child > svg { > :first-child > svg {
margin: 6px 0 6px 4px; margin: 6px 0 6px 4px;
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 0px; width: 0px;
} }
`; `;
const ServerEntry = styled.div<{ active: boolean, home?: boolean }>` const ServerEntry = styled.div<{ active: boolean; home?: boolean }>`
height: 58px; height: 58px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
> * { > * {
// outline: 1px solid red; // outline: 1px solid red;
} }
> div { > div {
width: 46px; width: 46px;
height: 46px; height: 46px;
display: grid; display: grid;
place-items: center; place-items: center;
border-start-start-radius: 50%; border-start-start-radius: 50%;
border-end-start-radius: 50%; border-end-start-radius: 50%;
&:active { &:active {
transform: translateY(1px); transform: translateY(1px);
} }
${ props => props.active && css` ${(props) =>
background: var(--sidebar-active); props.active &&
&:active { css`
transform: none; background: var(--sidebar-active);
} &:active {
` } transform: none;
} }
`}
}
span { span {
width: 6px; width: 6px;
height: 46px; height: 46px;
${ props => props.active && css` ${(props) =>
background-color: var(--sidebar-active); props.active &&
css`
background-color: var(--sidebar-active);
&::before, &::after { &::before,
// outline: 1px solid blue; &::after {
} // outline: 1px solid blue;
}
&::before, &::after { &::before,
content: ""; &::after {
display: block; content: "";
position: relative; display: block;
position: relative;
width: 31px; width: 31px;
height: 72px; height: 72px;
margin-top: -72px; margin-top: -72px;
margin-left: -25px; margin-left: -25px;
z-index: -1; z-index: -1;
background-color: var(--background); background-color: var(--background);
border-bottom-right-radius: 32px; border-bottom-right-radius: 32px;
box-shadow: 0 32px 0 0 var(--sidebar-active); box-shadow: 0 32px 0 0 var(--sidebar-active);
} }
&::after { &::after {
transform: scaleY(-1) translateY(-118px); transform: scaleY(-1) translateY(-118px);
} }
` } `}
} }
${ props => (!props.active || props.home) && css` ${(props) =>
cursor: pointer; (!props.active || props.home) &&
` } css`
cursor: pointer;
`}
`; `;
interface Props { interface Props {
unreads: Unreads; unreads: Unreads;
lastOpened: LastOpened; lastOpened: LastOpened;
} }
export function ServerListSidebar({ unreads, lastOpened }: Props) { export function ServerListSidebar({ unreads, lastOpened }: Props) {
const ctx = useForceUpdate(); const ctx = useForceUpdate();
const self = useSelf(ctx); const self = useSelf(ctx);
const activeServers = useServers(undefined, ctx) as Servers.Server[]; const activeServers = useServers(undefined, ctx) as Servers.Server[];
const channels = (useChannels(undefined, ctx) as Channel[]) const channels = (useChannels(undefined, ctx) as Channel[]).map((x) =>
.map(x => mapChannelWithUnread(x, unreads)); mapChannelWithUnread(x, unreads),
);
const unreadChannels = channels.filter(x => x.unread) const unreadChannels = channels.filter((x) => x.unread).map((x) => x._id);
.map(x => x._id);
const servers = activeServers.map(server => { const servers = activeServers.map((server) => {
let alertCount = 0; let alertCount = 0;
for (let id of server.channels) { for (let id of server.channels) {
let channel = channels.find(x => x._id === id); let channel = channels.find((x) => x._id === id);
if (channel?.alertCount) { if (channel?.alertCount) {
alertCount += channel.alertCount; alertCount += channel.alertCount;
} }
} }
return { return {
...server, ...server,
unread: (typeof server.channels.find(x => unreadChannels.includes(x)) !== 'undefined' ? unread: (typeof server.channels.find((x) =>
( alertCount > 0 ? 'mention' : 'unread' ) : undefined) as 'mention' | 'unread' | undefined, unreadChannels.includes(x),
alertCount ) !== "undefined"
} ? alertCount > 0
}); ? "mention"
: "unread"
: undefined) as "mention" | "unread" | undefined,
alertCount,
};
});
const path = useLocation().pathname; const path = useLocation().pathname;
const { server: server_id } = useParams<{ server?: string }>(); const { server: server_id } = useParams<{ server?: string }>();
const server = servers.find(x => x!._id == server_id); const server = servers.find((x) => x!._id == server_id);
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
let homeUnread: 'mention' | 'unread' | undefined; let homeUnread: "mention" | "unread" | undefined;
let alertCount = 0; let alertCount = 0;
for (let x of channels) { for (let x of channels) {
if (((x.channel_type === 'DirectMessage' && x.active) || x.channel_type === 'Group') && x.unread) { if (
homeUnread = 'unread'; ((x.channel_type === "DirectMessage" && x.active) ||
alertCount += x.alertCount ?? 0; x.channel_type === "Group") &&
} x.unread
} ) {
homeUnread = "unread";
alertCount += x.alertCount ?? 0;
}
}
if (alertCount > 0) homeUnread = 'mention'; if (alertCount > 0) homeUnread = "mention";
const homeActive = typeof server === 'undefined' && !path.startsWith('/invite'); const homeActive =
typeof server === "undefined" && !path.startsWith("/invite");
return ( return (
<ServersBase> <ServersBase>
<ServerList> <ServerList>
<ConditionalLink active={homeActive} to={lastOpened.home ? `/channel/${lastOpened.home}` : '/'}> <ConditionalLink
<ServerEntry home active={homeActive}> active={homeActive}
<div onContextMenu={attachContextMenu('Status')} to={lastOpened.home ? `/channel/${lastOpened.home}` : "/"}>
onClick={() => homeActive && openContextMenu("Status")}> <ServerEntry home active={homeActive}>
<Icon size={42} unread={homeUnread}> <div
<UserIcon target={self} size={32} status /> onContextMenu={attachContextMenu("Status")}
</Icon> onClick={() =>
</div> homeActive && openContextMenu("Status")
<span /> }>
</ServerEntry> <Icon size={42} unread={homeUnread}>
</ConditionalLink> <UserIcon target={self} size={32} status />
<LineDivider /> </Icon>
{ </div>
servers.map(entry => { <span />
const active = entry!._id === server?._id; </ServerEntry>
const id = lastOpened[entry!._id]; </ConditionalLink>
<LineDivider />
{servers.map((entry) => {
const active = entry!._id === server?._id;
const id = lastOpened[entry!._id];
return ( return (
<ConditionalLink active={active} to={`/server/${entry!._id}` + (id ? `/channel/${id}` : '')}> <ConditionalLink
<ServerEntry active={active}
active={active} to={
onContextMenu={attachContextMenu('Menu', { server: entry!._id })}> `/server/${entry!._id}` +
<Tooltip content={entry.name} placement="right"> (id ? `/channel/${id}` : "")
<Icon size={42} unread={entry.unread}> }>
<ServerIcon size={32} target={entry} /> <ServerEntry
</Icon> active={active}
</Tooltip> onContextMenu={attachContextMenu("Menu", {
<span /> server: entry!._id,
</ServerEntry> })}>
</ConditionalLink> <Tooltip content={entry.name} placement="right">
) <Icon size={42} unread={entry.unread}>
}) <ServerIcon size={32} target={entry} />
} </Icon>
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_server' })}> </Tooltip>
<Plus size={36} /> <span />
</IconButton> </ServerEntry>
<PaintCounter small /> </ConditionalLink>
</ServerList> );
</ServersBase> })}
) <IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "create_server",
})
}>
<Plus size={36} />
</IconButton>
<PaintCounter small />
</ServerList>
</ServersBase>
);
} }
export default connectState( export default connectState(ServerListSidebar, (state) => {
ServerListSidebar, return {
state => { unreads: state.unreads,
return { lastOpened: state.lastOpened,
unreads: state.unreads, };
lastOpened: state.lastOpened });
};
}
);

View file

@ -1,134 +1,150 @@
import { Redirect, useParams } from "react-router"; import { Redirect, useParams } from "react-router";
import { ChannelButton } from "../items/ButtonItem";
import { Channels } from "revolt.js/dist/api/objects"; import { Channels } from "revolt.js/dist/api/objects";
import { Unreads } from "../../../redux/reducers/unreads";
import { useChannels, useForceUpdate, useServer } from "../../../context/revoltjs/hooks";
import { mapChannelWithUnread, useUnreads } from "./common";
import ConnectionStatus from '../items/ConnectionStatus';
import { connectState } from "../../../redux/connector";
import PaintCounter from "../../../lib/PaintCounter";
import styled from "styled-components"; import styled from "styled-components";
import { attachContextMenu } from 'preact-context-menu';
import ServerHeader from "../../common/ServerHeader"; import { attachContextMenu } from "preact-context-menu";
import { useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import Category from "../../ui/Category";
import { dispatch } from "../../../redux";
import ConditionalLink from "../../../lib/ConditionalLink"; import ConditionalLink from "../../../lib/ConditionalLink";
import PaintCounter from "../../../lib/PaintCounter";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import { Unreads } from "../../../redux/reducers/unreads";
import {
useChannels,
useForceUpdate,
useServer,
} from "../../../context/revoltjs/hooks";
import CollapsibleSection from "../../common/CollapsibleSection"; import CollapsibleSection from "../../common/CollapsibleSection";
import ServerHeader from "../../common/ServerHeader";
import Category from "../../ui/Category";
import { mapChannelWithUnread, useUnreads } from "./common";
import { ChannelButton } from "../items/ButtonItem";
import ConnectionStatus from "../items/ConnectionStatus";
interface Props { interface Props {
unreads: Unreads; unreads: Unreads;
} }
const ServerBase = styled.div` const ServerBase = styled.div`
height: 100%; height: 100%;
width: 240px; width: 240px;
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
flex-direction: column; flex-direction: column;
background: var(--secondary-background); background: var(--secondary-background);
border-start-start-radius: 8px; border-start-start-radius: 8px;
border-end-start-radius: 8px; border-end-start-radius: 8px;
overflow: hidden; overflow: hidden;
`; `;
const ServerList = styled.div` const ServerList = styled.div`
padding: 6px; padding: 6px;
flex-grow: 1; flex-grow: 1;
overflow-y: scroll; overflow-y: scroll;
> svg { > svg {
width: 100%; width: 100%;
} }
`; `;
function ServerSidebar(props: Props) { function ServerSidebar(props: Props) {
const { server: server_id, channel: channel_id } = useParams<{ server?: string, channel?: string }>(); const { server: server_id, channel: channel_id } =
const ctx = useForceUpdate(); useParams<{ server?: string; channel?: string }>();
const ctx = useForceUpdate();
const server = useServer(server_id, ctx); const server = useServer(server_id, ctx);
if (!server) return <Redirect to="/" />; if (!server) return <Redirect to="/" />;
const channels = (useChannels(server.channels, ctx) const channels = (
.filter(entry => typeof entry !== 'undefined') as Readonly<Channels.TextChannel | Channels.VoiceChannel>[]) useChannels(server.channels, ctx).filter(
.map(x => mapChannelWithUnread(x, props.unreads)); (entry) => typeof entry !== "undefined",
) as Readonly<Channels.TextChannel | Channels.VoiceChannel>[]
).map((x) => mapChannelWithUnread(x, props.unreads));
const channel = channels.find(x => x?._id === channel_id); const channel = channels.find((x) => x?._id === channel_id);
if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />; if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />;
if (channel) useUnreads({ ...props, channel }, ctx); if (channel) useUnreads({ ...props, channel }, ctx);
useEffect(() => { useEffect(() => {
if (!channel_id) return; if (!channel_id) return;
dispatch({ dispatch({
type: 'LAST_OPENED_SET', type: "LAST_OPENED_SET",
parent: server_id!, parent: server_id!,
child: channel_id! child: channel_id!,
}); });
}, [ channel_id ]); }, [channel_id]);
let uncategorised = new Set(server.channels); let uncategorised = new Set(server.channels);
let elements = []; let elements = [];
function addChannel(id: string) { function addChannel(id: string) {
const entry = channels.find(x => x._id === id); const entry = channels.find((x) => x._id === id);
if (!entry) return; if (!entry) return;
const active = channel?._id === entry._id; const active = channel?._id === entry._id;
return ( return (
<ConditionalLink key={entry._id} active={active} to={`/server/${server!._id}/channel/${entry._id}`}> <ConditionalLink
<ChannelButton key={entry._id}
channel={entry} active={active}
active={active} to={`/server/${server!._id}/channel/${entry._id}`}>
alert={entry.unread} <ChannelButton
compact channel={entry}
/> active={active}
</ConditionalLink> alert={entry.unread}
); compact
} />
</ConditionalLink>
);
}
if (server.categories) { if (server.categories) {
for (let category of server.categories) { for (let category of server.categories) {
let channels = []; let channels = [];
for (let id of category.channels) { for (let id of category.channels) {
uncategorised.delete(id); uncategorised.delete(id);
channels.push(addChannel(id)); channels.push(addChannel(id));
} }
elements.push( elements.push(
<CollapsibleSection <CollapsibleSection
id={`category_${category.id}`} id={`category_${category.id}`}
defaultValue defaultValue
summary={<Category text={category.title} />}> summary={<Category text={category.title} />}>
{ channels } {channels}
</CollapsibleSection> </CollapsibleSection>,
); );
} }
} }
for (let id of Array.from(uncategorised).reverse()) { for (let id of Array.from(uncategorised).reverse()) {
elements.unshift(addChannel(id)); elements.unshift(addChannel(id));
} }
return ( return (
<ServerBase> <ServerBase>
<ServerHeader server={server} ctx={ctx} /> <ServerHeader server={server} ctx={ctx} />
<ConnectionStatus /> <ConnectionStatus />
<ServerList onContextMenu={attachContextMenu('Menu', { server_list: server._id })}> <ServerList
{ elements } onContextMenu={attachContextMenu("Menu", {
</ServerList> server_list: server._id,
<PaintCounter small /> })}>
</ServerBase> {elements}
) </ServerList>
}; <PaintCounter small />
</ServerBase>
);
}
export default connectState( export default connectState(ServerSidebar, (state) => {
ServerSidebar, return {
state => { unreads: state.unreads,
return { };
unreads: state.unreads });
};
}
);

View file

@ -1,76 +1,103 @@
import { Channel } from "revolt.js"; import { Channel } from "revolt.js";
import { dispatch } from "../../../redux";
import { useLayoutEffect } from "preact/hooks"; import { useLayoutEffect } from "preact/hooks";
import { dispatch } from "../../../redux";
import { Unreads } from "../../../redux/reducers/unreads"; import { Unreads } from "../../../redux/reducers/unreads";
import { HookContext, useForceUpdate } from "../../../context/revoltjs/hooks"; import { HookContext, useForceUpdate } from "../../../context/revoltjs/hooks";
type UnreadProps = { type UnreadProps = {
channel: Channel; channel: Channel;
unreads: Unreads; unreads: Unreads;
} };
export function useUnreads({ channel, unreads }: UnreadProps, context?: HookContext) { export function useUnreads(
const ctx = useForceUpdate(context); { channel, unreads }: UnreadProps,
context?: HookContext,
) {
const ctx = useForceUpdate(context);
useLayoutEffect(() => { useLayoutEffect(() => {
function checkUnread(target?: Channel) { function checkUnread(target?: Channel) {
if (!target) return; if (!target) return;
if (target._id !== channel._id) return; if (target._id !== channel._id) return;
if (target.channel_type === "SavedMessages" || if (
target.channel_type === "VoiceChannel") return; target.channel_type === "SavedMessages" ||
target.channel_type === "VoiceChannel"
)
return;
const unread = unreads[channel._id]?.last_id; const unread = unreads[channel._id]?.last_id;
if (target.last_message) { if (target.last_message) {
const message = typeof target.last_message === 'string' ? target.last_message : target.last_message._id; const message =
if (!unread || (unread && message.localeCompare(unread) > 0)) { typeof target.last_message === "string"
dispatch({ ? target.last_message
type: "UNREADS_MARK_READ", : target.last_message._id;
channel: channel._id, if (!unread || (unread && message.localeCompare(unread) > 0)) {
message dispatch({
}); type: "UNREADS_MARK_READ",
channel: channel._id,
message,
});
ctx.client.req('PUT', `/channels/${channel._id}/ack/${message}` as '/channels/id/ack/id'); ctx.client.req(
} "PUT",
} `/channels/${channel._id}/ack/${message}` as "/channels/id/ack/id",
} );
}
}
}
checkUnread(channel); checkUnread(channel);
ctx.client.channels.addListener("mutation", checkUnread); ctx.client.channels.addListener("mutation", checkUnread);
return () => ctx.client.channels.removeListener("mutation", checkUnread); return () =>
}, [channel, unreads]); ctx.client.channels.removeListener("mutation", checkUnread);
}, [channel, unreads]);
} }
export function mapChannelWithUnread(channel: Channel, unreads: Unreads) { export function mapChannelWithUnread(channel: Channel, unreads: Unreads) {
let last_message_id; let last_message_id;
if (channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group') { if (
last_message_id = channel.last_message?._id; channel.channel_type === "DirectMessage" ||
} else if (channel.channel_type === 'TextChannel') { channel.channel_type === "Group"
last_message_id = channel.last_message; ) {
} else { last_message_id = channel.last_message?._id;
return { ...channel, unread: undefined, alertCount: undefined, timestamp: channel._id }; } else if (channel.channel_type === "TextChannel") {
} last_message_id = channel.last_message;
} else {
return {
...channel,
unread: undefined,
alertCount: undefined,
timestamp: channel._id,
};
}
let unread: 'mention' | 'unread' | undefined; let unread: "mention" | "unread" | undefined;
let alertCount: undefined | number; let alertCount: undefined | number;
if (last_message_id && unreads) { if (last_message_id && unreads) {
const u = unreads[channel._id]; const u = unreads[channel._id];
if (u) { if (u) {
if (u.mentions && u.mentions.length > 0) { if (u.mentions && u.mentions.length > 0) {
alertCount = u.mentions.length; alertCount = u.mentions.length;
unread = 'mention'; unread = "mention";
} else if (u.last_id && last_message_id.localeCompare(u.last_id) > 0) { } else if (
unread = 'unread'; u.last_id &&
} last_message_id.localeCompare(u.last_id) > 0
} else { ) {
unread = 'unread'; unread = "unread";
} }
} } else {
unread = "unread";
}
}
return { return {
...channel, ...channel,
timestamp: last_message_id ?? channel._id, timestamp: last_message_id ?? channel._id,
unread, unread,
alertCount alertCount,
}; };
} }

View file

@ -1,37 +1,40 @@
import { useRenderState } from "../../../lib/renderer/Singleton"; import { useRenderState } from "../../../lib/renderer/Singleton";
interface Props { interface Props {
id: string; id: string;
} }
export function ChannelDebugInfo({ id }: Props) { export function ChannelDebugInfo({ id }: Props) {
if (process.env.NODE_ENV !== "development") return null; if (process.env.NODE_ENV !== "development") return null;
let view = useRenderState(id); let view = useRenderState(id);
if (!view) return null; if (!view) return null;
return ( return (
<span style={{ display: "block", padding: "12px 10px 0 10px" }}> <span style={{ display: "block", padding: "12px 10px 0 10px" }}>
<span <span
style={{ style={{
display: "block", display: "block",
fontSize: "12px", fontSize: "12px",
textTransform: "uppercase", textTransform: "uppercase",
fontWeight: "600" fontWeight: "600",
}} }}>
> Channel Info
Channel Info </span>
</span> <p style={{ fontSize: "10px", userSelect: "text" }}>
<p style={{ fontSize: "10px", userSelect: "text" }}> State: <b>{view.type}</b> <br />
State: <b>{ view.type }</b> <br /> {view.type === "RENDER" && view.messages.length > 0 && (
{ view.type === 'RENDER' && view.messages.length > 0 && <>
<> Start: <b>{view.messages[0]._id}</b> <br />
Start: <b>{view.messages[0]._id}</b> <br /> End:{" "}
End: <b>{view.messages[view.messages.length - 1]._id}</b> <br /> <b>
At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br /> {view.messages[view.messages.length - 1]._id}
At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b> </b>{" "}
</> <br />
} At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br />
</p> At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b>
</span> </>
); )}
</p>
</span>
);
} }

View file

@ -1,45 +1,62 @@
import { useParams } from "react-router";
import { User } from "revolt.js";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { User } from "revolt.js"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import {
HookContext,
useChannel,
useForceUpdate,
useUsers,
} from "../../../context/revoltjs/hooks";
import Category from "../../ui/Category"; import Category from "../../ui/Category";
import { useParams } from "react-router"; import Preloader from "../../ui/Preloader";
import placeholderSVG from "../items/placeholder.svg";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
import { UserButton } from "../items/ButtonItem"; import { UserButton } from "../items/ButtonItem";
import { ChannelDebugInfo } from "./ChannelDebugInfo"; import { ChannelDebugInfo } from "./ChannelDebugInfo";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { AppContext, ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
import { HookContext, useChannel, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
import placeholderSVG from "../items/placeholder.svg";
import Preloader from "../../ui/Preloader";
interface Props { interface Props {
ctx: HookContext ctx: HookContext;
} }
export default function MemberSidebar(props: { channel?: Channels.Channel }) { export default function MemberSidebar(props: { channel?: Channels.Channel }) {
const ctx = useForceUpdate(); const ctx = useForceUpdate();
const { channel: cid } = useParams<{ channel: string }>(); const { channel: cid } = useParams<{ channel: string }>();
const channel = props.channel ?? useChannel(cid, ctx); const channel = props.channel ?? useChannel(cid, ctx);
switch (channel?.channel_type) { switch (channel?.channel_type) {
case 'Group': return <GroupMemberSidebar channel={channel} ctx={ctx} />; case "Group":
case 'TextChannel': return <ServerMemberSidebar channel={channel} ctx={ctx} />; return <GroupMemberSidebar channel={channel} ctx={ctx} />;
default: return null; case "TextChannel":
} return <ServerMemberSidebar channel={channel} ctx={ctx} />;
default:
return null;
}
} }
export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels.GroupChannel }) { export function GroupMemberSidebar({
const { openScreen } = useIntermediate(); channel,
const users = useUsers(undefined, ctx); ctx,
let members = channel.recipients }: Props & { channel: Channels.GroupChannel }) {
.map(x => users.find(y => y?._id === x)) const { openScreen } = useIntermediate();
.filter(x => typeof x !== "undefined") as User[]; const users = useUsers(undefined, ctx);
let members = channel.recipients
.map((x) => users.find((y) => y?._id === x))
.filter((x) => typeof x !== "undefined") as User[];
/*const voice = useContext(VoiceContext); /*const voice = useContext(VoiceContext);
const voiceActive = voice.roomId === channel._id; const voiceActive = voice.roomId === channel._id;
let voiceParticipants: User[] = []; let voiceParticipants: User[] = [];
@ -54,24 +71,32 @@ export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels
voiceParticipants.sort((a, b) => a.username.localeCompare(b.username)); voiceParticipants.sort((a, b) => a.username.localeCompare(b.username));
}*/ }*/
members.sort((a, b) => { members.sort((a, b) => {
// ! FIXME: should probably rewrite all this code // ! FIXME: should probably rewrite all this code
let l = +((a.online && a.status?.presence !== Users.Presence.Invisible) ?? false) | 0; let l =
let r = +((b.online && b.status?.presence !== Users.Presence.Invisible) ?? false) | 0; +(
(a.online && a.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
let r =
+(
(b.online && b.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
let n = r - l; let n = r - l;
if (n !== 0) { if (n !== 0) {
return n; return n;
} }
return a.username.localeCompare(b.username); return a.username.localeCompare(b.username);
}); });
return ( return (
<GenericSidebarBase> <GenericSidebarBase>
<GenericSidebarList> <GenericSidebarList>
<ChannelDebugInfo id={channel._id} /> <ChannelDebugInfo id={channel._id} />
{/*voiceActive && voiceParticipants.length !== 0 && ( {/*voiceActive && voiceParticipants.length !== 0 && (
<Fragment> <Fragment>
<Category <Category
type="members" type="members"
@ -96,104 +121,146 @@ export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels
)} )}
</Fragment> </Fragment>
)*/} )*/}
{!(members.length === 0 /*&& voiceActive*/) && ( {!((members.length === 0) /*&& voiceActive*/) && (
<Category <Category
variant="uniform" variant="uniform"
text={ text={
<span> <span>
<Text id="app.main.categories.members" /> {" "} <Text id="app.main.categories.members" /> {" "}
{channel.recipients.length} {channel.recipients.length}
</span> </span>
} }
/> />
)} )}
{members.length === 0 && /*!voiceActive &&*/ <img src={placeholderSVG} />} {members.length === 0 && (
{members.map( /*!voiceActive &&*/ <img src={placeholderSVG} />
user => )}
user && ( {members.map(
<UserButton (user) =>
key={user._id} user && (
user={user} <UserButton
context={channel} key={user._id}
onClick={() => openScreen({ id: 'profile', user_id: user._id })} /> user={user}
) context={channel}
)} onClick={() =>
</GenericSidebarList> openScreen({
</GenericSidebarBase> id: "profile",
); user_id: user._id,
})
}
/>
),
)}
</GenericSidebarList>
</GenericSidebarBase>
);
} }
export function ServerMemberSidebar({ channel, ctx }: Props & { channel: Channels.TextChannel }) { export function ServerMemberSidebar({
const [members, setMembers] = useState<Servers.Member[] | undefined>(undefined); channel,
const users = useUsers(members?.map(x => x._id.user) ?? []).filter(x => typeof x !== 'undefined', ctx) as Users.User[]; ctx,
const { openScreen } = useIntermediate(); }: Props & { channel: Channels.TextChannel }) {
const status = useContext(StatusContext); const [members, setMembers] = useState<Servers.Member[] | undefined>(
const client = useContext(AppContext); undefined,
);
const users = useUsers(members?.map((x) => x._id.user) ?? []).filter(
(x) => typeof x !== "undefined",
ctx,
) as Users.User[];
const { openScreen } = useIntermediate();
const status = useContext(StatusContext);
const client = useContext(AppContext);
useEffect(() => { useEffect(() => {
if (status === ClientStatus.ONLINE && typeof members === 'undefined') { if (status === ClientStatus.ONLINE && typeof members === "undefined") {
client.servers.members.fetchMembers(channel.server) client.servers.members
.then(members => setMembers(members)) .fetchMembers(channel.server)
} .then((members) => setMembers(members));
}, [ status ]); }
}, [status]);
// ! FIXME: temporary code // ! FIXME: temporary code
useEffect(() => { useEffect(() => {
function onPacket(packet: ClientboundNotification) { function onPacket(packet: ClientboundNotification) {
if (!members) return; if (!members) return;
if (packet.type === 'ServerMemberJoin') { if (packet.type === "ServerMemberJoin") {
if (packet.id !== channel.server) return; if (packet.id !== channel.server) return;
setMembers([ ...members, { _id: { server: packet.id, user: packet.user } } ]); setMembers([
} else if (packet.type === 'ServerMemberLeave') { ...members,
if (packet.id !== channel.server) return; { _id: { server: packet.id, user: packet.user } },
setMembers(members.filter(x => !(x._id.user === packet.user && x._id.server === packet.id))); ]);
} } else if (packet.type === "ServerMemberLeave") {
} if (packet.id !== channel.server) return;
setMembers(
members.filter(
(x) =>
!(
x._id.user === packet.user &&
x._id.server === packet.id
),
),
);
}
}
client.addListener('packet', onPacket); client.addListener("packet", onPacket);
return () => client.removeListener('packet', onPacket); return () => client.removeListener("packet", onPacket);
}, [ members ]); }, [members]);
// copy paste from above // copy paste from above
users.sort((a, b) => { users.sort((a, b) => {
// ! FIXME: should probably rewrite all this code // ! FIXME: should probably rewrite all this code
let l = +((a.online && a.status?.presence !== Users.Presence.Invisible) ?? false) | 0; let l =
let r = +((b.online && b.status?.presence !== Users.Presence.Invisible) ?? false) | 0; +(
(a.online && a.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
let r =
+(
(b.online && b.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
let n = r - l; let n = r - l;
if (n !== 0) { if (n !== 0) {
return n; return n;
} }
return a.username.localeCompare(b.username); return a.username.localeCompare(b.username);
}); });
return ( return (
<GenericSidebarBase> <GenericSidebarBase>
<GenericSidebarList> <GenericSidebarList>
<ChannelDebugInfo id={channel._id} /> <ChannelDebugInfo id={channel._id} />
<Category <Category
variant="uniform" variant="uniform"
text={ text={
<span> <span>
<Text id="app.main.categories.members" /> {" "} <Text id="app.main.categories.members" /> {" "}
{users.length} {users.length}
</span> </span>
} }
/> />
{!members && <Preloader type="ring" />} {!members && <Preloader type="ring" />}
{members && users.length === 0 && <img src={placeholderSVG} />} {members && users.length === 0 && <img src={placeholderSVG} />}
{users.map( {users.map(
user => (user) =>
user && ( user && (
<UserButton <UserButton
key={user._id} key={user._id}
user={user} user={user}
context={channel} context={channel}
onClick={() => openScreen({ id: 'profile', user_id: user._id })} /> onClick={() =>
) openScreen({
)} id: "profile",
</GenericSidebarList> user_id: user._id,
</GenericSidebarBase> })
); }
/>
),
)}
</GenericSidebarList>
</GenericSidebarBase>
);
} }

View file

@ -1,10 +1,10 @@
import styled from "styled-components"; import styled from "styled-components";
export default styled.div` export default styled.div`
padding: 8px; padding: 8px;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
color: var(--accent); color: var(--accent);
background: var(--primary-background); background: var(--primary-background);
`; `;

View file

@ -1,71 +1,71 @@
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
interface Props { interface Props {
readonly contrast?: boolean; readonly contrast?: boolean;
readonly error?: boolean; readonly error?: boolean;
} }
export default styled.button<Props>` export default styled.button<Props>`
z-index: 1; z-index: 1;
padding: 8px; padding: 8px;
font-size: 16px; font-size: 16px;
text-align: center; text-align: center;
font-family: inherit; font-family: inherit;
transition: 0.2s ease opacity; transition: 0.2s ease opacity;
transition: 0.2s ease background-color; transition: 0.2s ease background-color;
background: var(--primary-background); background: var(--primary-background);
color: var(--foreground); color: var(--foreground);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
border: none; border: none;
&:hover { &:hover {
background: var(--secondary-header); background: var(--secondary-header);
} }
&:disabled { &:disabled {
background: var(--primary-background); background: var(--primary-background);
} }
&:active { &:active {
background: var(--secondary-background); background: var(--secondary-background);
} }
${(props) => ${(props) =>
props.contrast && props.contrast &&
css` css`
padding: 4px 8px; padding: 4px 8px;
background: var(--secondary-header); background: var(--secondary-header);
&:hover { &:hover {
background: var(--primary-header); background: var(--primary-header);
} }
&:disabled { &:disabled {
background: var(--secondary-header); background: var(--secondary-header);
} }
&:active { &:active {
background: var(--secondary-background); background: var(--secondary-background);
} }
`} `}
${(props) => ${(props) =>
props.error && props.error &&
css` css`
color: white; color: white;
background: var(--error); background: var(--error);
&:hover { &:hover {
filter: brightness(1.2); filter: brightness(1.2);
background: var(--error); background: var(--error);
} }
&:disabled { &:disabled {
background: var(--error); background: var(--error);
} }
`} `}
`; `;

View file

@ -1,51 +1,55 @@
import styled, { css } from "styled-components";
import { Children } from "../../types/Preact";
import { Plus } from "@styled-icons/boxicons-regular"; import { Plus } from "@styled-icons/boxicons-regular";
import styled, { css } from "styled-components";
const CategoryBase = styled.div<Pick<Props, 'variant'>>` import { Children } from "../../types/Preact";
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
margin-top: 4px; const CategoryBase = styled.div<Pick<Props, "variant">>`
padding: 6px 0; font-size: 12px;
margin-bottom: 4px; font-weight: 700;
white-space: nowrap; text-transform: uppercase;
display: flex; margin-top: 4px;
align-items: center; padding: 6px 0;
flex-direction: row; margin-bottom: 4px;
justify-content: space-between; white-space: nowrap;
svg { display: flex;
cursor: pointer; align-items: center;
} flex-direction: row;
justify-content: space-between;
&:first-child { svg {
margin-top: 0; cursor: pointer;
padding-top: 0; }
}
${ props => props.variant === 'uniform' && css` &:first-child {
padding-top: 6px; margin-top: 0;
` } padding-top: 0;
}
${(props) =>
props.variant === "uniform" &&
css`
padding-top: 6px;
`}
`; `;
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as' | 'action'> & { type Props = Omit<
text: Children; JSX.HTMLAttributes<HTMLDivElement>,
action?: () => void; "children" | "as" | "action"
variant?: 'default' | 'uniform'; > & {
} text: Children;
action?: () => void;
variant?: "default" | "uniform";
};
export default function Category(props: Props) { export default function Category(props: Props) {
let { text, action, ...otherProps } = props; let { text, action, ...otherProps } = props;
return ( return (
<CategoryBase {...otherProps}> <CategoryBase {...otherProps}>
{text} {text}
{action && ( {action && <Plus size={16} onClick={action} />}
<Plus size={16} onClick={action} /> </CategoryBase>
)} );
</CategoryBase> }
);
};

View file

@ -1,107 +1,108 @@
import { Check } from "@styled-icons/boxicons-regular"; import { Check } from "@styled-icons/boxicons-regular";
import { Children } from "../../types/Preact";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { Children } from "../../types/Preact";
const CheckboxBase = styled.label` const CheckboxBase = styled.label`
margin-top: 20px; margin-top: 20px;
gap: 4px; gap: 4px;
z-index: 1; z-index: 1;
display: flex; display: flex;
border-radius: 4px; border-radius: 4px;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
font-size: 18px; font-size: 18px;
user-select: none; user-select: none;
transition: 0.2s ease all; transition: 0.2s ease all;
input { input {
display: none; display: none;
} }
&:hover { &:hover {
.check { .check {
background: var(--background); background: var(--background);
} }
} }
&[disabled] { &[disabled] {
opacity: .5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
&:hover { &:hover {
background: unset; background: unset;
} }
} }
`; `;
const CheckboxContent = styled.span` const CheckboxContent = styled.span`
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
flex-direction: column; flex-direction: column;
`; `;
const CheckboxDescription = styled.span` const CheckboxDescription = styled.span`
font-size: .75rem; font-size: 0.75rem;
font-weight: 400; font-weight: 400;
color: var(--secondary-foreground); color: var(--secondary-foreground);
`; `;
const Checkmark = styled.div<{ checked: boolean }>` const Checkmark = styled.div<{ checked: boolean }>`
margin: 4px; margin: 4px;
width: 24px; width: 24px;
height: 24px; height: 24px;
display: grid; display: grid;
flex-shrink: 0; flex-shrink: 0;
border-radius: 4px; border-radius: 4px;
place-items: center; place-items: center;
transition: 0.2s ease all; transition: 0.2s ease all;
background: var(--secondary-background); background: var(--secondary-background);
svg { svg {
color: var(--secondary-background); color: var(--secondary-background);
} }
${(props) => ${(props) =>
props.checked && props.checked &&
css` css`
background: var(--accent) !important; background: var(--accent) !important;
`} `}
`; `;
export interface CheckboxProps { export interface CheckboxProps {
checked: boolean; checked: boolean;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
children: Children; children: Children;
description?: Children; description?: Children;
onChange: (state: boolean) => void; onChange: (state: boolean) => void;
} }
export default function Checkbox(props: CheckboxProps) { export default function Checkbox(props: CheckboxProps) {
return ( return (
<CheckboxBase disabled={props.disabled} className={props.className}> <CheckboxBase disabled={props.disabled} className={props.className}>
<CheckboxContent> <CheckboxContent>
<span>{props.children}</span> <span>{props.children}</span>
{props.description && ( {props.description && (
<CheckboxDescription> <CheckboxDescription>
{props.description} {props.description}
</CheckboxDescription> </CheckboxDescription>
)} )}
</CheckboxContent> </CheckboxContent>
<input <input
type="checkbox" type="checkbox"
checked={props.checked} checked={props.checked}
onChange={() => onChange={() =>
!props.disabled && props.onChange(!props.checked) !props.disabled && props.onChange(!props.checked)
} }
/> />
<Checkmark checked={props.checked} className="check"> <Checkmark checked={props.checked} className="check">
<Check size={20} /> <Check size={20} />
</Checkmark> </Checkmark>
</CheckboxBase> </CheckboxBase>
); );
} }

View file

@ -1,130 +1,127 @@
import { useRef } from "preact/hooks";
import { Check } from "@styled-icons/boxicons-regular"; import { Check } from "@styled-icons/boxicons-regular";
import { Palette } from "@styled-icons/boxicons-solid"; import { Palette } from "@styled-icons/boxicons-solid";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { useRef } from "preact/hooks";
interface Props { interface Props {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
const presets = [ const presets = [
[ [
"#7B68EE", "#7B68EE",
"#3498DB", "#3498DB",
"#1ABC9C", "#1ABC9C",
"#F1C40F", "#F1C40F",
"#FF7F50", "#FF7F50",
"#FD6671", "#FD6671",
"#E91E63", "#E91E63",
"#D468EE", "#D468EE",
], ],
[ [
"#594CAD", "#594CAD",
"#206694", "#206694",
"#11806A", "#11806A",
"#C27C0E", "#C27C0E",
"#CD5B45", "#CD5B45",
"#FF424F", "#FF424F",
"#AD1457", "#AD1457",
"#954AA8", "#954AA8",
], ],
]; ];
const SwatchesBase = styled.div` const SwatchesBase = styled.div`
gap: 8px; gap: 8px;
display: flex; display: flex;
input { input {
opacity: 0; opacity: 0;
margin-top: 44px; margin-top: 44px;
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
} }
`; `;
const Swatch = styled.div<{ type: "small" | "large"; colour: string }>` const Swatch = styled.div<{ type: "small" | "large"; colour: string }>`
flex-shrink: 0; flex-shrink: 0;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
background-color: ${(props) => props.colour}; background-color: ${(props) => props.colour};
display: grid; display: grid;
place-items: center; place-items: center;
&:hover { &:hover {
border: 3px solid var(--foreground); border: 3px solid var(--foreground);
transition: border ease-in-out 0.07s; transition: border ease-in-out 0.07s;
} }
svg { svg {
color: white; color: white;
} }
${(props) => ${(props) =>
props.type === "small" props.type === "small"
? css` ? css`
width: 30px; width: 30px;
height: 30px; height: 30px;
svg { svg {
/*stroke-width: 2;*/ /*stroke-width: 2;*/
} }
` `
: css` : css`
width: 68px; width: 68px;
height: 68px; height: 68px;
`} `}
`; `;
const Rows = styled.div` const Rows = styled.div`
gap: 8px; gap: 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
> div { > div {
gap: 8px; gap: 8px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
`; `;
export default function ColourSwatches({ value, onChange }: Props) { export default function ColourSwatches({ value, onChange }: Props) {
const ref = useRef<HTMLInputElement>(); const ref = useRef<HTMLInputElement>();
return ( return (
<SwatchesBase> <SwatchesBase>
<Swatch <Swatch
colour={value} colour={value}
type="large" type="large"
onClick={() => ref.current.click()} onClick={() => ref.current.click()}>
> <Palette size={32} />
<Palette size={32} /> </Swatch>
</Swatch> <input
<input type="color"
type="color" value={value}
value={value} ref={ref}
ref={ref} onChange={(ev) => onChange(ev.currentTarget.value)}
onChange={(ev) => onChange(ev.currentTarget.value)} />
/> <Rows>
<Rows> {presets.map((row, i) => (
{presets.map((row, i) => ( <div key={i}>
<div key={i}> {row.map((swatch, i) => (
{row.map((swatch, i) => ( <Swatch
<Swatch colour={swatch}
colour={swatch} type="small"
type="small" key={i}
key={i} onClick={() => onChange(swatch)}>
onClick={() => onChange(swatch)} {swatch === value && <Check size={18} />}
> </Swatch>
{swatch === value && ( ))}
<Check size={18} /> </div>
)} ))}
</Swatch> </Rows>
))} </SwatchesBase>
</div> );
))}
</Rows>
</SwatchesBase>
);
} }

View file

@ -1,20 +1,20 @@
import styled from "styled-components"; import styled from "styled-components";
export default styled.select` export default styled.select`
padding: 8px; padding: 8px;
border-radius: 6px; border-radius: 6px;
font-family: inherit; font-family: inherit;
color: var(--secondary-foreground); color: var(--secondary-foreground);
background: var(--secondary-background); background: var(--secondary-background);
font-size: .875rem; font-size: 0.875rem;
border: none; border: none;
outline: 2px solid transparent; outline: 2px solid transparent;
transition: outline-color 0.2s ease-in-out; transition: outline-color 0.2s ease-in-out;
transition: box-shadow .3s; transition: box-shadow 0.3s;
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
&:focus { &:focus {
box-shadow: 0 0 0 2pt var(--accent); box-shadow: 0 0 0 2pt var(--accent);
} }
`; `;

View file

@ -2,47 +2,47 @@ import dayjs from "dayjs";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
const Base = styled.div<{ unread?: boolean }>` const Base = styled.div<{ unread?: boolean }>`
height: 0; height: 0;
display: flex; display: flex;
user-select: none; user-select: none;
align-items: center; align-items: center;
margin: 17px 12px 5px; margin: 17px 12px 5px;
border-top: thin solid var(--tertiary-foreground); border-top: thin solid var(--tertiary-foreground);
time { time {
margin-top: -2px; margin-top: -2px;
font-size: .6875rem; font-size: 0.6875rem;
line-height: .6875rem; line-height: 0.6875rem;
padding: 2px 5px 2px 0; padding: 2px 5px 2px 0;
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
background: var(--primary-background); background: var(--primary-background);
} }
${ props => props.unread && css` ${(props) =>
border-top: thin solid var(--accent); props.unread &&
` } css`
border-top: thin solid var(--accent);
`}
`; `;
const Unread = styled.div` const Unread = styled.div`
background: var(--accent); background: var(--accent);
color: white; color: white;
padding: 5px 8px; padding: 5px 8px;
border-radius: 60px; border-radius: 60px;
font-weight: 600; font-weight: 600;
`; `;
interface Props { interface Props {
date: Date; date: Date;
unread?: boolean; unread?: boolean;
} }
export default function DateDivider(props: Props) { export default function DateDivider(props: Props) {
return ( return (
<Base unread={props.unread}> <Base unread={props.unread}>
{ props.unread && <Unread>NEW</Unread> } {props.unread && <Unread>NEW</Unread>}
<time> <time>{dayjs(props.date).format("LL")}</time>
{ dayjs(props.date).format("LL") } </Base>
</time> );
</Base>
);
} }

View file

@ -1,68 +1,74 @@
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
export default styled.details<{ sticky?: boolean, large?: boolean }>` export default styled.details<{ sticky?: boolean; large?: boolean }>`
summary { summary {
${ props => props.sticky && css` ${(props) =>
top: -1px; props.sticky &&
z-index: 10; css`
position: sticky; top: -1px;
` } z-index: 10;
position: sticky;
`}
${ props => props.large && css` ${(props) =>
/*padding: 5px 0;*/ props.large &&
background: var(--primary-background); css`
color: var(--secondary-foreground); /*padding: 5px 0;*/
background: var(--primary-background);
color: var(--secondary-foreground);
.padding { /*TOFIX: make this applicable only for the friends list menu, DO NOT REMOVE.*/ .padding {
display: flex; /*TOFIX: make this applicable only for the friends list menu, DO NOT REMOVE.*/
align-items: center; display: flex;
padding: 5px 0; align-items: center;
margin: 0.8em 0px 0.4em; padding: 5px 0;
cursor: pointer; margin: 0.8em 0px 0.4em;
} cursor: pointer;
` } }
`}
outline: none; outline: none;
cursor: pointer; cursor: pointer;
list-style: none; list-style: none;
align-items: center; align-items: center;
transition: .2s opacity; transition: 0.2s opacity;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
&::marker, &::-webkit-details-marker { &::marker,
display: none; &::-webkit-details-marker {
} display: none;
}
.title { .title {
flex-grow: 1; flex-grow: 1;
margin-top: 1px; margin-top: 1px;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
.padding { .padding {
display: flex; display: flex;
align-items: center; align-items: center;
> svg { > svg {
flex-shrink: 0; flex-shrink: 0;
margin-inline-end: 4px; margin-inline-end: 4px;
transition: .2s ease transform; transition: 0.2s ease transform;
} }
} }
} }
&:not([open]) { &:not([open]) {
summary { summary {
opacity: .7; opacity: 0.7;
} }
summary svg { summary svg {
transform: rotateZ(-90deg); transform: rotateZ(-90deg);
} }
} }
`; `;

View file

@ -1,51 +1,57 @@
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
interface Props { interface Props {
borders?: boolean; borders?: boolean;
background?: boolean; background?: boolean;
placement: 'primary' | 'secondary' placement: "primary" | "secondary";
} }
export default styled.div<Props>` export default styled.div<Props>`
gap: 6px; gap: 6px;
height: 48px; height: 48px;
flex: 0 auto; flex: 0 auto;
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
padding: 0 16px; padding: 0 16px;
font-weight: 600; font-weight: 600;
user-select: none; user-select: none;
align-items: center; align-items: center;
background-size: cover !important; background-size: cover !important;
background-position: center !important; background-position: center !important;
background-color: var(--primary-header); background-color: var(--primary-header);
svg { svg {
flex-shrink: 0; flex-shrink: 0;
} }
/*@media only screen and (max-width: 768px) { /*@media only screen and (max-width: 768px) {
padding: 0 12px; padding: 0 12px;
}*/ }*/
@media (pointer: coarse) { @media (pointer: coarse) {
height: 56px; height: 56px;
} }
${ props => props.background && css` ${(props) =>
height: 120px !important; props.background &&
align-items: flex-end; css`
height: 120px !important;
align-items: flex-end;
text-shadow: 0px 0px 1px black; text-shadow: 0px 0px 1px black;
` } `}
${ props => props.placement === 'secondary' && css` ${(props) =>
background-color: var(--secondary-header); props.placement === "secondary" &&
padding: 14px; css`
` } background-color: var(--secondary-header);
padding: 14px;
`}
${ props => props.borders && css` ${(props) =>
border-start-start-radius: 8px; props.borders &&
` } css`
border-start-start-radius: 8px;
`}
`; `;

View file

@ -1,44 +1,46 @@
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
interface Props { interface Props {
type?: 'default' | 'circle' type?: "default" | "circle";
} }
const normal = `var(--secondary-foreground)`; const normal = `var(--secondary-foreground)`;
const hover = `var(--foreground)`; const hover = `var(--foreground)`;
export default styled.div<Props>` export default styled.div<Props>`
z-index: 1; z-index: 1;
display: grid; display: grid;
cursor: pointer; cursor: pointer;
place-items: center; place-items: center;
transition: .1s ease background-color; transition: 0.1s ease background-color;
fill: ${normal}; fill: ${normal};
color: ${normal}; color: ${normal};
/*stroke: ${normal};*/ /*stroke: ${normal};*/
a { a {
color: ${normal}; color: ${normal};
} }
&:hover { &:hover {
fill: ${hover}; fill: ${hover};
color: ${hover}; color: ${hover};
/*stroke: ${hover};*/ /*stroke: ${hover};*/
a { a {
color: ${hover}; color: ${hover};
} }
} }
${ props => props.type === 'circle' && css` ${(props) =>
padding: 4px; props.type === "circle" &&
border-radius: 50%; css`
background-color: var(--secondary-header); padding: 4px;
border-radius: 50%;
background-color: var(--secondary-header);
&:hover { &:hover {
background-color: var(--primary-header); background-color: var(--primary-header);
} }
` } `}
`; `;

View file

@ -1,39 +1,39 @@
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
interface Props { interface Props {
readonly contrast?: boolean; readonly contrast?: boolean;
} }
export default styled.input<Props>` export default styled.input<Props>`
z-index: 1; z-index: 1;
padding: 8px 16px; padding: 8px 16px;
border-radius: 6px; border-radius: 6px;
font-family: inherit; font-family: inherit;
color: var(--foreground); color: var(--foreground);
background: var(--primary-background); background: var(--primary-background);
transition: 0.2s ease background-color; transition: 0.2s ease background-color;
border: none; border: none;
outline: 2px solid transparent; outline: 2px solid transparent;
transition: outline-color 0.2s ease-in-out; transition: outline-color 0.2s ease-in-out;
&:hover { &:hover {
background: var(--secondary-background); background: var(--secondary-background);
} }
&:focus { &:focus {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
} }
${(props) => ${(props) =>
props.contrast && props.contrast &&
css` css`
color: var(--secondary-foreground); color: var(--secondary-foreground);
background: var(--secondary-background); background: var(--secondary-background);
&:hover { &:hover {
background: var(--hover); background: var(--hover);
} }
`} `}
`; `;

View file

@ -1,9 +1,9 @@
import styled from "styled-components"; import styled from "styled-components";
export default styled.div` export default styled.div`
height: 0px; height: 0px;
opacity: 0.6; opacity: 0.6;
flex-shrink: 0; flex-shrink: 0;
margin: 8px 10px; margin: 8px 10px;
border-top: 1px solid var(--tertiary-foreground); border-top: 1px solid var(--tertiary-foreground);
`; `;

View file

@ -1,22 +1,22 @@
// This file must be imported and used at least once for SVG masks. // This file must be imported and used at least once for SVG masks.
export default function Masks() { export default function Masks() {
return ( return (
<svg width={0} height={0} style={{ position: 'fixed' }}> <svg width={0} height={0} style={{ position: "fixed" }}>
<defs> <defs>
<mask id="server"> <mask id="server">
<rect x="0" y="0" width="32" height="32" fill="white" /> <rect x="0" y="0" width="32" height="32" fill="white" />
<circle cx="27" cy="5" r="7" fill={"black"} /> <circle cx="27" cy="5" r="7" fill={"black"} />
</mask> </mask>
<mask id="user"> <mask id="user">
<rect x="0" y="0" width="32" height="32" fill="white" /> <rect x="0" y="0" width="32" height="32" fill="white" />
<circle cx="27" cy="27" r="7" fill={"black"} /> <circle cx="27" cy="27" r="7" fill={"black"} />
</mask> </mask>
<mask id="overlap"> <mask id="overlap">
<rect x="0" y="0" width="32" height="32" fill="white" /> <rect x="0" y="0" width="32" height="32" fill="white" />
<circle cx="32" cy="16" r="18" fill={"black"} /> <circle cx="32" cy="16" r="18" fill={"black"} />
</mask> </mask>
</defs> </defs>
</svg> </svg>
) );
} }

View file

@ -1,9 +1,11 @@
import Button from "./Button";
import classNames from "classnames";
import { Children } from "../../types/Preact";
import { createPortal, useEffect } from "preact/compat";
import styled, { css, keyframes } from "styled-components"; import styled, { css, keyframes } from "styled-components";
import classNames from "classnames";
import { createPortal, useEffect } from "preact/compat";
import { Children } from "../../types/Preact";
import Button from "./Button";
const open = keyframes` const open = keyframes`
0% {opacity: 0;} 0% {opacity: 0;}
70% {opacity: 0;} 70% {opacity: 0;}
@ -17,168 +19,181 @@ const zoomIn = keyframes`
`; `;
const ModalBase = styled.div` const ModalBase = styled.div`
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 9999; z-index: 9999;
position: fixed; position: fixed;
max-height: 100%; max-height: 100%;
user-select: none; user-select: none;
animation-name: ${open}; animation-name: ${open};
animation-duration: 0.2s; animation-duration: 0.2s;
display: grid; display: grid;
overflow-y: auto; overflow-y: auto;
place-items: center; place-items: center;
color: var(--foreground); color: var(--foreground);
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
`; `;
const ModalContainer = styled.div` const ModalContainer = styled.div`
overflow: hidden; overflow: hidden;
border-radius: 8px; border-radius: 8px;
max-width: calc(100vw - 20px); max-width: calc(100vw - 20px);
animation-name: ${zoomIn}; animation-name: ${zoomIn};
animation-duration: 0.25s; animation-duration: 0.25s;
animation-timing-function: cubic-bezier(.3,.3,.18,1.1); animation-timing-function: cubic-bezier(0.3, 0.3, 0.18, 1.1);
`; `;
const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'border' | 'padding']?: boolean }>` const ModalContent = styled.div<
border-radius: 8px; { [key in "attachment" | "noBackground" | "border" | "padding"]?: boolean }
text-overflow: ellipsis; >`
border-radius: 8px;
text-overflow: ellipsis;
h3 { h3 {
margin-top: 0; margin-top: 0;
} }
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
${ props => !props.noBackground && css` ${(props) =>
background: var(--secondary-header); !props.noBackground &&
` } css`
background: var(--secondary-header);
`}
${ props => props.padding && css` ${(props) =>
padding: 1.5em; props.padding &&
` } css`
padding: 1.5em;
`}
${ props => props.attachment && css` ${(props) =>
border-radius: 8px 8px 0 0; props.attachment &&
` } css`
border-radius: 8px 8px 0 0;
`}
${ props => props.border && css` ${(props) =>
border-radius: 10px; props.border &&
border: 2px solid var(--secondary-background); css`
` } border-radius: 10px;
border: 2px solid var(--secondary-background);
`}
`; `;
const ModalActions = styled.div` const ModalActions = styled.div`
gap: 8px; gap: 8px;
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: row-reverse;
padding: 1em 1.5em; padding: 1em 1.5em;
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;
background: var(--secondary-background); background: var(--secondary-background);
`; `;
export interface Action { export interface Action {
text: Children; text: Children;
onClick: () => void; onClick: () => void;
confirmation?: boolean; confirmation?: boolean;
contrast?: boolean; contrast?: boolean;
error?: boolean; error?: boolean;
} }
interface Props { interface Props {
children?: Children; children?: Children;
title?: Children; title?: Children;
disallowClosing?: boolean; disallowClosing?: boolean;
noBackground?: boolean; noBackground?: boolean;
dontModal?: boolean; dontModal?: boolean;
padding?: boolean; padding?: boolean;
onClose: () => void; onClose: () => void;
actions?: Action[]; actions?: Action[];
disabled?: boolean; disabled?: boolean;
border?: boolean; border?: boolean;
visible: boolean; visible: boolean;
} }
export default function Modal(props: Props) { export default function Modal(props: Props) {
if (!props.visible) return null; if (!props.visible) return null;
let content = ( let content = (
<ModalContent <ModalContent
attachment={!!props.actions} attachment={!!props.actions}
noBackground={props.noBackground} noBackground={props.noBackground}
border={props.border} border={props.border}
padding={props.padding ?? !props.dontModal}> padding={props.padding ?? !props.dontModal}>
{props.title && <h3>{props.title}</h3>} {props.title && <h3>{props.title}</h3>}
{props.children} {props.children}
</ModalContent> </ModalContent>
); );
if (props.dontModal) { if (props.dontModal) {
return content; return content;
} }
useEffect(() => { useEffect(() => {
if (props.disallowClosing) return; if (props.disallowClosing) return;
function keyDown(e: KeyboardEvent) { function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") { if (e.key === "Escape") {
props.onClose(); props.onClose();
} }
} }
document.body.addEventListener("keydown", keyDown); document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown); return () => document.body.removeEventListener("keydown", keyDown);
}, [ props.disallowClosing, props.onClose ]); }, [props.disallowClosing, props.onClose]);
let confirmationAction = props.actions?.find(action => action.confirmation); let confirmationAction = props.actions?.find(
useEffect(() => { (action) => action.confirmation,
if (!confirmationAction) return; );
useEffect(() => {
if (!confirmationAction) return;
// ! FIXME: this may be done better if we // ! FIXME: this may be done better if we
// ! can focus the button although that // ! can focus the button although that
// ! doesn't seem to work... // ! doesn't seem to work...
function keyDown(e: KeyboardEvent) { function keyDown(e: KeyboardEvent) {
if (e.key === "Enter") { if (e.key === "Enter") {
confirmationAction!.onClick(); confirmationAction!.onClick();
} }
} }
document.body.addEventListener("keydown", keyDown); document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown); return () => document.body.removeEventListener("keydown", keyDown);
}, [ confirmationAction ]); }, [confirmationAction]);
return createPortal( return createPortal(
<ModalBase onClick={(!props.disallowClosing && props.onClose) || undefined}> <ModalBase
<ModalContainer onClick={e => (e.cancelBubble = true)}> onClick={(!props.disallowClosing && props.onClose) || undefined}>
{content} <ModalContainer onClick={(e) => (e.cancelBubble = true)}>
{props.actions && ( {content}
<ModalActions> {props.actions && (
{props.actions.map(x => ( <ModalActions>
<Button {props.actions.map((x) => (
contrast={x.contrast ?? true} <Button
error={x.error ?? false} contrast={x.contrast ?? true}
onClick={x.onClick} error={x.error ?? false}
disabled={props.disabled}> onClick={x.onClick}
{x.text} disabled={props.disabled}>
</Button> {x.text}
))} </Button>
</ModalActions> ))}
)} </ModalActions>
</ModalContainer> )}
</ModalBase>, </ModalContainer>
document.body </ModalBase>,
); document.body,
);
} }

View file

@ -1,58 +1,64 @@
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { Children } from "../../types/Preact";
import { Text } from 'preact-i18n';
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as'> & { import { Text } from "preact-i18n";
error?: string;
block?: boolean; import { Children } from "../../types/Preact";
spaced?: boolean;
children?: Children; type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, "children" | "as"> & {
type?: "default" | "subtle" | "error"; error?: string;
} block?: boolean;
spaced?: boolean;
children?: Children;
type?: "default" | "subtle" | "error";
};
const OverlineBase = styled.div<Omit<Props, "children" | "error">>` const OverlineBase = styled.div<Omit<Props, "children" | "error">>`
display: inline; display: inline;
margin: 0.4em 0; margin: 0.4em 0;
${ props => props.spaced && css` ${(props) =>
margin-top: 0.8em; props.spaced &&
` } css`
margin-top: 0.8em;
`}
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--foreground); color: var(--foreground);
text-transform: uppercase; text-transform: uppercase;
${(props) =>
props.type === "subtle" &&
css`
font-size: 12px;
color: var(--secondary-foreground);
`}
${(props) =>
props.type === "error" &&
css`
font-size: 12px;
font-weight: 400;
color: var(--error);
`}
${(props) => ${(props) =>
props.type === "subtle" && props.block &&
css` css`
font-size: 12px; display: block;
color: var(--secondary-foreground); `}
`}
${(props) =>
props.type === "error" &&
css`
font-size: 12px;
font-weight: 400;
color: var(--error);
`}
${(props) =>
props.block &&
css`
display: block;
`}
`; `;
export default function Overline(props: Props) { export default function Overline(props: Props) {
return ( return (
<OverlineBase {...props}> <OverlineBase {...props}>
{props.children} {props.children}
{props.children && props.error && <> &middot; </>} {props.children && props.error && <> &middot; </>}
{props.error && <Overline type="error"> {props.error && (
<Text id={`error.${props.error}`}>{props.error}</Text> <Overline type="error">
</Overline>} <Text id={`error.${props.error}`}>{props.error}</Text>
</OverlineBase> </Overline>
); )}
</OverlineBase>
);
} }

View file

@ -21,83 +21,83 @@ const prRing = keyframes`
`; `;
const PreloaderBase = styled.div` const PreloaderBase = styled.div`
width: 100%; width: 100%;
height: 100%; height: 100%;
display: grid; display: grid;
place-items: center; place-items: center;
.spinner { .spinner {
width: 58px; width: 58px;
display: flex; display: flex;
text-align: center; text-align: center;
margin: 100px auto 0; margin: 100px auto 0;
justify-content: space-between; justify-content: space-between;
} }
.spinner > div { .spinner > div {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: var(--tertiary-foreground); background-color: var(--tertiary-foreground);
border-radius: 100%; border-radius: 100%;
display: inline-block; display: inline-block;
animation: ${skSpinner} 1.4s infinite ease-in-out both; animation: ${skSpinner} 1.4s infinite ease-in-out both;
} }
.spinner div:nth-child(1) { .spinner div:nth-child(1) {
animation-delay: -0.32s; animation-delay: -0.32s;
} }
.spinner div:nth-child(2) { .spinner div:nth-child(2) {
animation-delay: -0.16s; animation-delay: -0.16s;
} }
.ring { .ring {
display: inline-block; display: inline-block;
position: relative; position: relative;
width: 48px; width: 48px;
height: 52px; height: 52px;
} }
.ring div { .ring div {
width: 32px; width: 32px;
margin: 8px; margin: 8px;
height: 32px; height: 32px;
display: block; display: block;
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
box-sizing: border-box; box-sizing: border-box;
border: 2px solid #fff; border: 2px solid #fff;
animation: ${prRing} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; animation: ${prRing} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent; border-color: #fff transparent transparent transparent;
} }
.ring div:nth-child(1) { .ring div:nth-child(1) {
animation-delay: -0.45s; animation-delay: -0.45s;
} }
.ring div:nth-child(2) { .ring div:nth-child(2) {
animation-delay: -0.3s; animation-delay: -0.3s;
} }
.ring div:nth-child(3) { .ring div:nth-child(3) {
animation-delay: -0.15s; animation-delay: -0.15s;
} }
`; `;
interface Props { interface Props {
type: 'spinner' | 'ring' type: "spinner" | "ring";
} }
export default function Preloader({ type }: Props) { export default function Preloader({ type }: Props) {
return ( return (
<PreloaderBase> <PreloaderBase>
<div class={type}> <div class={type}>
<div /> <div />
<div /> <div />
<div /> <div />
</div> </div>
</PreloaderBase> </PreloaderBase>
); );
} }

View file

@ -1,111 +1,111 @@
import { Children } from "../../types/Preact";
import styled, { css } from "styled-components";
import { Circle } from "@styled-icons/boxicons-regular"; import { Circle } from "@styled-icons/boxicons-regular";
import styled, { css } from "styled-components";
import { Children } from "../../types/Preact";
interface Props { interface Props {
children: Children; children: Children;
description?: Children; description?: Children;
checked: boolean; checked: boolean;
disabled?: boolean; disabled?: boolean;
onSelect: () => void; onSelect: () => void;
} }
interface BaseProps { interface BaseProps {
selected: boolean; selected: boolean;
} }
const RadioBase = styled.label<BaseProps>` const RadioBase = styled.label<BaseProps>`
gap: 4px; gap: 4px;
z-index: 1; z-index: 1;
padding: 4px; padding: 4px;
display: flex; display: flex;
cursor: pointer; cursor: pointer;
align-items: center; align-items: center;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
user-select: none; user-select: none;
border-radius: 4px; border-radius: 4px;
transition: 0.2s ease all; transition: 0.2s ease all;
&:hover { &:hover {
background: var(--hover); background: var(--hover);
} }
> input { > input {
display: none; display: none;
} }
> div { > div {
margin: 4px; margin: 4px;
width: 24px; width: 24px;
height: 24px; height: 24px;
display: grid; display: grid;
border-radius: 50%; border-radius: 50%;
place-items: center; place-items: center;
background: var(--foreground); background: var(--foreground);
svg { svg {
color: var(--foreground); color: var(--foreground);
/*stroke-width: 2;*/ /*stroke-width: 2;*/
} }
} }
${(props) => ${(props) =>
props.selected && props.selected &&
css` css`
color: white; color: white;
cursor: default; cursor: default;
background: var(--accent); background: var(--accent);
> div { > div {
background: white; background: white;
} }
> div svg { > div svg {
color: var(--accent); color: var(--accent);
} }
&:hover { &:hover {
background: var(--accent); background: var(--accent);
} }
`} `}
`; `;
const RadioDescription = styled.span<BaseProps>` const RadioDescription = styled.span<BaseProps>`
font-size: 0.8em; font-size: 0.8em;
font-weight: 400; font-weight: 400;
color: var(--secondary-foreground); color: var(--secondary-foreground);
${(props) => ${(props) =>
props.selected && props.selected &&
css` css`
color: white; color: white;
`} `}
`; `;
export default function Radio(props: Props) { export default function Radio(props: Props) {
return ( return (
<RadioBase <RadioBase
selected={props.checked} selected={props.checked}
disabled={props.disabled} disabled={props.disabled}
onClick={() => onClick={() =>
!props.disabled && props.onSelect && props.onSelect() !props.disabled && props.onSelect && props.onSelect()
} }>
> <div>
<div> <Circle size={12} />
<Circle size={12} /> </div>
</div> <input type="radio" checked={props.checked} />
<input type="radio" checked={props.checked} /> <span>
<span> <span>{props.children}</span>
<span>{props.children}</span> {props.description && (
{props.description && ( <RadioDescription selected={props.checked}>
<RadioDescription selected={props.checked}> {props.description}
{props.description} </RadioDescription>
</RadioDescription> )}
)} </span>
</span> </RadioBase>
</RadioBase> );
);
} }

View file

@ -1,10 +1,10 @@
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
export interface TextAreaProps { export interface TextAreaProps {
code?: boolean; code?: boolean;
padding?: number; padding?: number;
lineHeight?: number; lineHeight?: number;
hideBorder?: boolean; hideBorder?: boolean;
} }
export const TEXT_AREA_BORDER_WIDTH = 2; export const TEXT_AREA_BORDER_WIDTH = 2;
@ -12,37 +12,46 @@ export const DEFAULT_TEXT_AREA_PADDING = 16;
export const DEFAULT_LINE_HEIGHT = 20; export const DEFAULT_LINE_HEIGHT = 20;
export default styled.textarea<TextAreaProps>` export default styled.textarea<TextAreaProps>`
width: 100%; width: 100%;
resize: none; resize: none;
display: block; display: block;
color: var(--foreground); color: var(--foreground);
background: var(--secondary-background); background: var(--secondary-background);
padding: ${ props => props.padding ?? DEFAULT_TEXT_AREA_PADDING }px; padding: ${(props) => props.padding ?? DEFAULT_TEXT_AREA_PADDING}px;
line-height: ${ props => props.lineHeight ?? DEFAULT_LINE_HEIGHT }px; line-height: ${(props) => props.lineHeight ?? DEFAULT_LINE_HEIGHT}px;
${ props => props.hideBorder && css` ${(props) =>
border: none; props.hideBorder &&
` } css`
border: none;
`}
${ props => !props.hideBorder && css` ${(props) =>
border-radius: 4px; !props.hideBorder &&
transition: border-color .2s ease-in-out; css`
border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent; border-radius: 4px;
` } transition: border-color 0.2s ease-in-out;
border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent;
`}
&:focus { &:focus {
outline: none; outline: none;
${ props => !props.hideBorder && css` ${(props) =>
border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent); !props.hideBorder &&
` } css`
} border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent);
`}
}
${ props => props.code ? css` ${(props) =>
font-family: var(--monoscape-font-font), monospace; props.code
` : css` ? css`
font-family: inherit; font-family: var(--monoscape-font-font), monospace;
` } `
: css`
font-family: inherit;
`}
font-variant-ligatures: var(--ligatures); font-variant-ligatures: var(--ligatures);
`; `;

View file

@ -1,65 +1,69 @@
import { Children } from "../../types/Preact";
import styled, { css } from "styled-components";
import { InfoCircle } from "@styled-icons/boxicons-regular"; import { InfoCircle } from "@styled-icons/boxicons-regular";
import styled, { css } from "styled-components";
import { Children } from "../../types/Preact";
interface Props { interface Props {
warning?: boolean warning?: boolean;
error?: boolean error?: boolean;
} }
export const Separator = styled.div<Props>` export const Separator = styled.div<Props>`
height: 1px; height: 1px;
width: calc(100% - 10px); width: calc(100% - 10px);
background: var(--secondary-header); background: var(--secondary-header);
margin: 18px auto; margin: 18px auto;
`; `;
export const TipBase = styled.div<Props>` export const TipBase = styled.div<Props>`
display: flex; display: flex;
padding: 12px; padding: 12px;
overflow: hidden; overflow: hidden;
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
border-radius: 7px; border-radius: 7px;
background: var(--primary-header); background: var(--primary-header);
border: 2px solid var(--secondary-header); border: 2px solid var(--secondary-header);
a { a {
cursor: pointer; cursor: pointer;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
svg { svg {
flex-shrink: 0; flex-shrink: 0;
margin-inline-end: 10px; margin-inline-end: 10px;
} }
${ props => props.warning && css` ${(props) =>
color: var(--warning); props.warning &&
border: 2px solid var(--warning); css`
background: var(--secondary-header); color: var(--warning);
` } border: 2px solid var(--warning);
background: var(--secondary-header);
`}
${ props => props.error && css` ${(props) =>
color: var(--error); props.error &&
border: 2px solid var(--error); css`
background: var(--secondary-header); color: var(--error);
` } border: 2px solid var(--error);
background: var(--secondary-header);
`}
`; `;
export default function Tip(props: Props & { children: Children }) { export default function Tip(props: Props & { children: Children }) {
const { children, ...tipProps } = props; const { children, ...tipProps } = props;
return ( return (
<> <>
<Separator /> <Separator />
<TipBase {...tipProps}> <TipBase {...tipProps}>
<InfoCircle size={20} /> <InfoCircle size={20} />
<span>{props.children}</span> <span>{props.children}</span>
</TipBase> </TipBase>
</> </>
);
);
} }

View file

@ -1,186 +1,217 @@
import { IntlProvider } from "preact-i18n";
import defaultsDeep from "lodash.defaultsdeep";
import { connectState } from "../redux/connector";
import { useEffect, useState } from "preact/hooks";
import definition from "../../external/lang/en.json";
import dayjs from "dayjs"; import dayjs from "dayjs";
import calendar from "dayjs/plugin/calendar"; import calendar from "dayjs/plugin/calendar";
import update from "dayjs/plugin/updateLocale";
import format from "dayjs/plugin/localizedFormat"; import format from "dayjs/plugin/localizedFormat";
import update from "dayjs/plugin/updateLocale";
import defaultsDeep from "lodash.defaultsdeep";
import { IntlProvider } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import { connectState } from "../redux/connector";
import definition from "../../external/lang/en.json";
dayjs.extend(calendar); dayjs.extend(calendar);
dayjs.extend(format); dayjs.extend(format);
dayjs.extend(update); dayjs.extend(update);
export enum Language { export enum Language {
ENGLISH = "en", ENGLISH = "en",
ARABIC = "ar", ARABIC = "ar",
AZERBAIJANI = "az", AZERBAIJANI = "az",
CZECH = "cs", CZECH = "cs",
GERMAN = "de", GERMAN = "de",
SPANISH = "es", SPANISH = "es",
FINNISH = "fi", FINNISH = "fi",
FRENCH = "fr", FRENCH = "fr",
HINDI = "hi", HINDI = "hi",
CROATIAN = "hr", CROATIAN = "hr",
HUNGARIAN = "hu", HUNGARIAN = "hu",
INDONESIAN = "id", INDONESIAN = "id",
LITHUANIAN = "lt", LITHUANIAN = "lt",
MACEDONIAN = "mk", MACEDONIAN = "mk",
DUTCH = "nl", DUTCH = "nl",
POLISH = "pl", POLISH = "pl",
PORTUGUESE_BRAZIL = "pt_BR", PORTUGUESE_BRAZIL = "pt_BR",
ROMANIAN = "ro", ROMANIAN = "ro",
RUSSIAN = "ru", RUSSIAN = "ru",
SERBIAN = "sr", SERBIAN = "sr",
SWEDISH = "sv", SWEDISH = "sv",
TURKISH = "tr", TURKISH = "tr",
UKRANIAN = "uk", UKRANIAN = "uk",
CHINESE_SIMPLIFIED = "zh_Hans", CHINESE_SIMPLIFIED = "zh_Hans",
OWO = "owo", OWO = "owo",
PIRATE = "pr", PIRATE = "pr",
BOTTOM = "bottom", BOTTOM = "bottom",
PIGLATIN = "piglatin", PIGLATIN = "piglatin",
} }
export interface LanguageEntry { export interface LanguageEntry {
display: string; display: string;
emoji: string; emoji: string;
i18n: string; i18n: string;
dayjs?: string; dayjs?: string;
rtl?: boolean; rtl?: boolean;
alt?: boolean; alt?: boolean;
} }
export const Languages: { [key in Language]: LanguageEntry } = { export const Languages: { [key in Language]: LanguageEntry } = {
en: { en: {
display: "English (Traditional)", display: "English (Traditional)",
emoji: "🇬🇧", emoji: "🇬🇧",
i18n: "en", i18n: "en",
dayjs: "en-gb", dayjs: "en-gb",
}, },
ar: { display: "عربي", emoji: "🇸🇦", i18n: "ar", rtl: true }, ar: { display: "عربي", emoji: "🇸🇦", i18n: "ar", rtl: true },
az: { display: "Azərbaycan dili", emoji: "🇦🇿", i18n: "az" }, az: { display: "Azərbaycan dili", emoji: "🇦🇿", i18n: "az" },
cs: { display: "Čeština", emoji: "🇨🇿", i18n: "cs" }, cs: { display: "Čeština", emoji: "🇨🇿", i18n: "cs" },
de: { display: "Deutsch", emoji: "🇩🇪", i18n: "de" }, de: { display: "Deutsch", emoji: "🇩🇪", i18n: "de" },
es: { display: "Español", emoji: "🇪🇸", i18n: "es" }, es: { display: "Español", emoji: "🇪🇸", i18n: "es" },
fi: { display: "suomi", emoji: "🇫🇮", i18n: "fi" }, fi: { display: "suomi", emoji: "🇫🇮", i18n: "fi" },
fr: { display: "Français", emoji: "🇫🇷", i18n: "fr" }, fr: { display: "Français", emoji: "🇫🇷", i18n: "fr" },
hi: { display: "हिन्दी", emoji: "🇮🇳", i18n: "hi" }, hi: { display: "हिन्दी", emoji: "🇮🇳", i18n: "hi" },
hr: { display: "Hrvatski", emoji: "🇭🇷", i18n: "hr" }, hr: { display: "Hrvatski", emoji: "🇭🇷", i18n: "hr" },
hu: { display: "magyar", emoji: "🇭🇺", i18n: "hu" }, hu: { display: "magyar", emoji: "🇭🇺", i18n: "hu" },
id: { display: "bahasa Indonesia", emoji: "🇮🇩", i18n: "id" }, id: { display: "bahasa Indonesia", emoji: "🇮🇩", i18n: "id" },
lt: { display: "Lietuvių", emoji: "🇱🇹", i18n: "lt" }, lt: { display: "Lietuvių", emoji: "🇱🇹", i18n: "lt" },
mk: { display: "Македонски", emoji: "🇲🇰", i18n: "mk" }, mk: { display: "Македонски", emoji: "🇲🇰", i18n: "mk" },
nl: { display: "Nederlands", emoji: "🇳🇱", i18n: "nl" }, nl: { display: "Nederlands", emoji: "🇳🇱", i18n: "nl" },
pl: { display: "Polski", emoji: "🇵🇱", i18n: "pl" }, pl: { display: "Polski", emoji: "🇵🇱", i18n: "pl" },
pt_BR: { pt_BR: {
display: "Português (do Brasil)", display: "Português (do Brasil)",
emoji: "🇧🇷", emoji: "🇧🇷",
i18n: "pt_BR", i18n: "pt_BR",
dayjs: "pt-br", dayjs: "pt-br",
}, },
ro: { display: "Română", emoji: "🇷🇴", i18n: "ro" }, ro: { display: "Română", emoji: "🇷🇴", i18n: "ro" },
ru: { display: "Русский", emoji: "🇷🇺", i18n: "ru" }, ru: { display: "Русский", emoji: "🇷🇺", i18n: "ru" },
sr: { display: "Српски", emoji: "🇷🇸", i18n: "sr" }, sr: { display: "Српски", emoji: "🇷🇸", i18n: "sr" },
sv: { display: "Svenska", emoji: "🇸🇪", i18n: "sv" }, sv: { display: "Svenska", emoji: "🇸🇪", i18n: "sv" },
tr: { display: "Türkçe", emoji: "🇹🇷", i18n: "tr" }, tr: { display: "Türkçe", emoji: "🇹🇷", i18n: "tr" },
uk: { display: "Українська", emoji: "🇺🇦", i18n: "uk" }, uk: { display: "Українська", emoji: "🇺🇦", i18n: "uk" },
zh_Hans: { zh_Hans: {
display: "中文 (简体)", display: "中文 (简体)",
emoji: "🇨🇳", emoji: "🇨🇳",
i18n: "zh_Hans", i18n: "zh_Hans",
dayjs: "zh", dayjs: "zh",
}, },
owo: { display: "OwO", emoji: "🐱", i18n: "owo", dayjs: "en-gb", alt: true }, owo: {
pr: { display: "Pirate", emoji: "🏴‍☠️", i18n: "pr", dayjs: "en-gb", alt: true }, display: "OwO",
bottom: { display: "Bottom", emoji: "🥺", i18n: "bottom", dayjs: "en-gb", alt: true }, emoji: "🐱",
piglatin: { i18n: "owo",
display: "Pig Latin", dayjs: "en-gb",
emoji: "🐖", alt: true,
i18n: "piglatin", },
dayjs: "en-gb", pr: {
alt: true display: "Pirate",
}, emoji: "🏴‍☠️",
i18n: "pr",
dayjs: "en-gb",
alt: true,
},
bottom: {
display: "Bottom",
emoji: "🥺",
i18n: "bottom",
dayjs: "en-gb",
alt: true,
},
piglatin: {
display: "Pig Latin",
emoji: "🐖",
i18n: "piglatin",
dayjs: "en-gb",
alt: true,
},
}; };
interface Props { interface Props {
children: JSX.Element | JSX.Element[]; children: JSX.Element | JSX.Element[];
locale: Language; locale: Language;
} }
function Locale({ children, locale }: Props) { function Locale({ children, locale }: Props) {
// TODO: create and use LanguageDefinition type here // TODO: create and use LanguageDefinition type here
const [defns, setDefinition] = useState<Record<string, unknown>>(definition); const [defns, setDefinition] =
const lang = Languages[locale]; useState<Record<string, unknown>>(definition);
const lang = Languages[locale];
// TODO: clean this up and use the built in Intl API // TODO: clean this up and use the built in Intl API
function transformLanguage(source: { [key: string]: any }) { function transformLanguage(source: { [key: string]: any }) {
const obj = defaultsDeep(source, definition); const obj = defaultsDeep(source, definition);
const dayjs = obj.dayjs; const dayjs = obj.dayjs;
const defaults = dayjs.defaults; const defaults = dayjs.defaults;
const twelvehour = defaults?.twelvehour === 'yes' || true; const twelvehour = defaults?.twelvehour === "yes" || true;
const separator: '/' | '-' | '.' = defaults?.date_separator ?? '/'; const separator: "/" | "-" | "." = defaults?.date_separator ?? "/";
const date: 'traditional' | 'simplified' | 'ISO8601' = defaults?.date_format ?? 'traditional'; const date: "traditional" | "simplified" | "ISO8601" =
defaults?.date_format ?? "traditional";
const DATE_FORMATS = { const DATE_FORMATS = {
traditional: `DD${separator}MM${separator}YYYY`, traditional: `DD${separator}MM${separator}YYYY`,
simplified: `MM${separator}DD${separator}YYYY`, simplified: `MM${separator}DD${separator}YYYY`,
ISO8601: 'YYYY-MM-DD' ISO8601: "YYYY-MM-DD",
} };
dayjs['sameElse'] = DATE_FORMATS[date]; dayjs["sameElse"] = DATE_FORMATS[date];
Object.keys(dayjs) Object.keys(dayjs)
.filter(k => k !== 'defaults') .filter((k) => k !== "defaults")
.forEach(k => dayjs[k] = dayjs[k].replace(/{{time}}/g, twelvehour ? 'LT' : 'HH:mm')); .forEach(
(k) =>
(dayjs[k] = dayjs[k].replace(
/{{time}}/g,
twelvehour ? "LT" : "HH:mm",
)),
);
return obj; return obj;
} }
useEffect(() => { useEffect(() => {
if (locale === "en") { if (locale === "en") {
const defn = transformLanguage(definition); const defn = transformLanguage(definition);
setDefinition(defn); setDefinition(defn);
dayjs.locale("en"); dayjs.locale("en");
dayjs.updateLocale('en', { calendar: defn.dayjs }); dayjs.updateLocale("en", { calendar: defn.dayjs });
return; return;
} }
import(`../../external/lang/${lang.i18n}.json`).then( import(`../../external/lang/${lang.i18n}.json`).then(
async (lang_file) => { async (lang_file) => {
const defn = transformLanguage(lang_file.default); const defn = transformLanguage(lang_file.default);
const target = lang.dayjs ?? lang.i18n; const target = lang.dayjs ?? lang.i18n;
const dayjs_locale = await import(`../../node_modules/dayjs/esm/locale/${target}.js`); const dayjs_locale = await import(
`../../node_modules/dayjs/esm/locale/${target}.js`
);
if (defn.dayjs) { if (defn.dayjs) {
dayjs.updateLocale(target, { calendar: defn.dayjs }); dayjs.updateLocale(target, { calendar: defn.dayjs });
} }
dayjs.locale(dayjs_locale.default); dayjs.locale(dayjs_locale.default);
setDefinition(defn); setDefinition(defn);
} },
); );
}, [locale, lang]); }, [locale, lang]);
useEffect(() => { useEffect(() => {
document.body.style.direction = lang.rtl ? "rtl" : ""; document.body.style.direction = lang.rtl ? "rtl" : "";
}, [lang.rtl]); }, [lang.rtl]);
return <IntlProvider definition={defns}>{children}</IntlProvider>; return <IntlProvider definition={defns}>{children}</IntlProvider>;
} }
export default connectState<Omit<Props, "locale">>( export default connectState<Omit<Props, "locale">>(
Locale, Locale,
(state) => { (state) => {
return { return {
locale: state.locale, locale: state.locale,
}; };
}, },
true true,
); );

View file

@ -6,44 +6,56 @@
// if it does cause problems though. // if it does cause problems though.
// //
// This now also supports Audio stuff. // This now also supports Audio stuff.
import { DEFAULT_SOUNDS, Settings, SoundOptions } from "../redux/reducers/settings";
import { playSound, Sounds } from "../assets/sounds/Audio";
import { connectState } from "../redux/connector";
import defaultsDeep from "lodash.defaultsdeep"; import defaultsDeep from "lodash.defaultsdeep";
import { Children } from "../types/Preact";
import { createContext } from "preact"; import { createContext } from "preact";
import { useMemo } from "preact/hooks"; import { useMemo } from "preact/hooks";
import { connectState } from "../redux/connector";
import {
DEFAULT_SOUNDS,
Settings,
SoundOptions,
} from "../redux/reducers/settings";
import { playSound, Sounds } from "../assets/sounds/Audio";
import { Children } from "../types/Preact";
export const SettingsContext = createContext<Settings>({}); export const SettingsContext = createContext<Settings>({});
export const SoundContext = createContext<((sound: Sounds) => void)>(null!); export const SoundContext = createContext<(sound: Sounds) => void>(null!);
interface Props { interface Props {
children?: Children, children?: Children;
settings: Settings settings: Settings;
} }
function Settings({ settings, children }: Props) { function SettingsProvider({ settings, children }: Props) {
const play = useMemo(() => { const play = useMemo(() => {
const enabled: SoundOptions = defaultsDeep(settings.notification?.sounds ?? {}, DEFAULT_SOUNDS); const enabled: SoundOptions = defaultsDeep(
return (sound: Sounds) => { settings.notification?.sounds ?? {},
if (enabled[sound]) { DEFAULT_SOUNDS,
playSound(sound); );
} return (sound: Sounds) => {
}; if (enabled[sound]) {
}, [ settings.notification ]); playSound(sound);
}
};
}, [settings.notification]);
return ( return (
<SettingsContext.Provider value={settings}> <SettingsContext.Provider value={settings}>
<SoundContext.Provider value={play}> <SoundContext.Provider value={play}>
{ children } {children}
</SoundContext.Provider> </SoundContext.Provider>
</SettingsContext.Provider> </SettingsContext.Provider>
) );
} }
export default connectState<Omit<Props, 'settings'>>(Settings, state => { export default connectState<Omit<Props, "settings">>(
return { SettingsProvider,
settings: state.settings (state) => {
} return {
}); settings: state.settings,
};
},
);

View file

@ -1,335 +1,360 @@
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import { createGlobalStyle } from "styled-components";
import { connectState } from "../redux/connector";
import { Children } from "../types/Preact";
import { useEffect } from "preact/hooks";
import { createContext } from "preact";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { createGlobalStyle } from "styled-components";
import { createContext } from "preact";
import { useEffect } from "preact/hooks";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import { connectState } from "../redux/connector";
import { Children } from "../types/Preact";
export type Variables = export type Variables =
| "accent" | "accent"
| "background" | "background"
| "foreground" | "foreground"
| "block" | "block"
| "message-box" | "message-box"
| "mention" | "mention"
| "success" | "success"
| "warning" | "warning"
| "error" | "error"
| "hover" | "hover"
| "scrollbar-thumb" | "scrollbar-thumb"
| "scrollbar-track" | "scrollbar-track"
| "primary-background" | "primary-background"
| "primary-header" | "primary-header"
| "secondary-background" | "secondary-background"
| "secondary-foreground" | "secondary-foreground"
| "secondary-header" | "secondary-header"
| "tertiary-background" | "tertiary-background"
| "tertiary-foreground" | "tertiary-foreground"
| "status-online" | "status-online"
| "status-away" | "status-away"
| "status-busy" | "status-busy"
| "status-streaming" | "status-streaming"
| "status-invisible" | "status-invisible";
// While this isn't used, it'd be good to keep this up to date as a reference or for future use // While this isn't used, it'd be good to keep this up to date as a reference or for future use
export type HiddenVariables = export type HiddenVariables =
| "font" | "font"
| "ligatures" | "ligatures"
| "app-height" | "app-height"
| "sidebar-active" | "sidebar-active"
| "monospace-font" | "monospace-font";
export type Fonts = 'Open Sans' | 'Inter' | 'Atkinson Hyperlegible' | 'Roboto' | 'Noto Sans' | 'Lato' | 'Bree Serif' | 'Montserrat' | 'Poppins' | 'Raleway' | 'Ubuntu' | 'Comic Neue'; export type Fonts =
export type MonoscapeFonts = 'Fira Code' | 'Roboto Mono' | 'Source Code Pro' | 'Space Mono' | 'Ubuntu Mono'; | "Open Sans"
| "Inter"
| "Atkinson Hyperlegible"
| "Roboto"
| "Noto Sans"
| "Lato"
| "Bree Serif"
| "Montserrat"
| "Poppins"
| "Raleway"
| "Ubuntu"
| "Comic Neue";
export type MonoscapeFonts =
| "Fira Code"
| "Roboto Mono"
| "Source Code Pro"
| "Space Mono"
| "Ubuntu Mono";
export type Theme = { export type Theme = {
[variable in Variables]: string; [variable in Variables]: string;
} & { } & {
light?: boolean; light?: boolean;
font?: Fonts; font?: Fonts;
css?: string; css?: string;
monoscapeFont?: MonoscapeFonts; monoscapeFont?: MonoscapeFonts;
}; };
export interface ThemeOptions { export interface ThemeOptions {
preset?: string; preset?: string;
ligatures?: boolean; ligatures?: boolean;
custom?: Partial<Theme>; custom?: Partial<Theme>;
} }
// import aaa from "@fontsource/open-sans/300.css?raw"; // import aaa from "@fontsource/open-sans/300.css?raw";
// console.info(aaa); // console.info(aaa);
export const FONTS: Record<Fonts, { name: string, load: () => void }> = { export const FONTS: Record<Fonts, { name: string; load: () => void }> = {
"Open Sans": { "Open Sans": {
name: "Open Sans", name: "Open Sans",
load: async () => { load: async () => {
await import("@fontsource/open-sans/300.css"); await import("@fontsource/open-sans/300.css");
await import("@fontsource/open-sans/400.css"); await import("@fontsource/open-sans/400.css");
await import("@fontsource/open-sans/600.css"); await import("@fontsource/open-sans/600.css");
await import("@fontsource/open-sans/700.css"); await import("@fontsource/open-sans/700.css");
await import("@fontsource/open-sans/400-italic.css"); await import("@fontsource/open-sans/400-italic.css");
} },
}, },
Inter: { Inter: {
name: "Inter", name: "Inter",
load: async () => { load: async () => {
await import("@fontsource/inter/300.css"); await import("@fontsource/inter/300.css");
await import("@fontsource/inter/400.css"); await import("@fontsource/inter/400.css");
await import("@fontsource/inter/600.css"); await import("@fontsource/inter/600.css");
await import("@fontsource/inter/700.css"); await import("@fontsource/inter/700.css");
} },
}, },
"Atkinson Hyperlegible": { "Atkinson Hyperlegible": {
name: "Atkinson Hyperlegible", name: "Atkinson Hyperlegible",
load: async () => { load: async () => {
await import("@fontsource/atkinson-hyperlegible/400.css"); await import("@fontsource/atkinson-hyperlegible/400.css");
await import("@fontsource/atkinson-hyperlegible/700.css"); await import("@fontsource/atkinson-hyperlegible/700.css");
await import("@fontsource/atkinson-hyperlegible/400-italic.css"); await import("@fontsource/atkinson-hyperlegible/400-italic.css");
} },
}, },
"Roboto": { Roboto: {
name: "Roboto", name: "Roboto",
load: async () => { load: async () => {
await import("@fontsource/roboto/400.css"); await import("@fontsource/roboto/400.css");
await import("@fontsource/roboto/700.css"); await import("@fontsource/roboto/700.css");
await import("@fontsource/roboto/400-italic.css"); await import("@fontsource/roboto/400-italic.css");
} },
}, },
"Noto Sans": { "Noto Sans": {
name: "Noto Sans", name: "Noto Sans",
load: async () => { load: async () => {
await import("@fontsource/noto-sans/400.css"); await import("@fontsource/noto-sans/400.css");
await import("@fontsource/noto-sans/700.css"); await import("@fontsource/noto-sans/700.css");
await import("@fontsource/noto-sans/400-italic.css"); await import("@fontsource/noto-sans/400-italic.css");
} },
}, },
"Bree Serif": { "Bree Serif": {
name: "Bree Serif", name: "Bree Serif",
load: () => import("@fontsource/bree-serif/400.css") load: () => import("@fontsource/bree-serif/400.css"),
}, },
"Lato": { Lato: {
name: "Lato", name: "Lato",
load: async () => { load: async () => {
await import("@fontsource/lato/300.css"); await import("@fontsource/lato/300.css");
await import("@fontsource/lato/400.css"); await import("@fontsource/lato/400.css");
await import("@fontsource/lato/700.css"); await import("@fontsource/lato/700.css");
await import("@fontsource/lato/400-italic.css"); await import("@fontsource/lato/400-italic.css");
} },
}, },
"Montserrat": { Montserrat: {
name: "Montserrat", name: "Montserrat",
load: async () => { load: async () => {
await import("@fontsource/montserrat/300.css"); await import("@fontsource/montserrat/300.css");
await import("@fontsource/montserrat/400.css"); await import("@fontsource/montserrat/400.css");
await import("@fontsource/montserrat/600.css"); await import("@fontsource/montserrat/600.css");
await import("@fontsource/montserrat/700.css"); await import("@fontsource/montserrat/700.css");
await import("@fontsource/montserrat/400-italic.css"); await import("@fontsource/montserrat/400-italic.css");
} },
}, },
"Poppins": { Poppins: {
name: "Poppins", name: "Poppins",
load: async () => { load: async () => {
await import("@fontsource/poppins/300.css"); await import("@fontsource/poppins/300.css");
await import("@fontsource/poppins/400.css"); await import("@fontsource/poppins/400.css");
await import("@fontsource/poppins/600.css"); await import("@fontsource/poppins/600.css");
await import("@fontsource/poppins/700.css"); await import("@fontsource/poppins/700.css");
await import("@fontsource/poppins/400-italic.css"); await import("@fontsource/poppins/400-italic.css");
} },
}, },
"Raleway": { Raleway: {
name: "Raleway", name: "Raleway",
load: async () => { load: async () => {
await import("@fontsource/raleway/300.css"); await import("@fontsource/raleway/300.css");
await import("@fontsource/raleway/400.css"); await import("@fontsource/raleway/400.css");
await import("@fontsource/raleway/600.css"); await import("@fontsource/raleway/600.css");
await import("@fontsource/raleway/700.css"); await import("@fontsource/raleway/700.css");
await import("@fontsource/raleway/400-italic.css"); await import("@fontsource/raleway/400-italic.css");
} },
}, },
"Ubuntu": { Ubuntu: {
name: "Ubuntu", name: "Ubuntu",
load: async () => { load: async () => {
await import("@fontsource/ubuntu/300.css"); await import("@fontsource/ubuntu/300.css");
await import("@fontsource/ubuntu/400.css"); await import("@fontsource/ubuntu/400.css");
await import("@fontsource/ubuntu/500.css"); await import("@fontsource/ubuntu/500.css");
await import("@fontsource/ubuntu/700.css"); await import("@fontsource/ubuntu/700.css");
await import("@fontsource/ubuntu/400-italic.css"); await import("@fontsource/ubuntu/400-italic.css");
} },
}, },
"Comic Neue": { "Comic Neue": {
name: "Comic Neue", name: "Comic Neue",
load: async () => { load: async () => {
await import("@fontsource/comic-neue/300.css"); await import("@fontsource/comic-neue/300.css");
await import("@fontsource/comic-neue/400.css"); await import("@fontsource/comic-neue/400.css");
await import("@fontsource/comic-neue/700.css"); await import("@fontsource/comic-neue/700.css");
await import("@fontsource/comic-neue/400-italic.css"); await import("@fontsource/comic-neue/400-italic.css");
} },
} },
}; };
export const MONOSCAPE_FONTS: Record<MonoscapeFonts, { name: string, load: () => void }> = { export const MONOSCAPE_FONTS: Record<
"Fira Code": { MonoscapeFonts,
name: "Fira Code", { name: string; load: () => void }
load: () => import("@fontsource/fira-code/400.css") > = {
}, "Fira Code": {
"Roboto Mono": { name: "Fira Code",
name: "Roboto Mono", load: () => import("@fontsource/fira-code/400.css"),
load: () => import("@fontsource/roboto-mono/400.css") },
}, "Roboto Mono": {
"Source Code Pro": { name: "Roboto Mono",
name: "Source Code Pro", load: () => import("@fontsource/roboto-mono/400.css"),
load: () => import("@fontsource/source-code-pro/400.css") },
}, "Source Code Pro": {
"Space Mono": { name: "Source Code Pro",
name: "Space Mono", load: () => import("@fontsource/source-code-pro/400.css"),
load: () => import("@fontsource/space-mono/400.css") },
}, "Space Mono": {
"Ubuntu Mono": { name: "Space Mono",
name: "Ubuntu Mono", load: () => import("@fontsource/space-mono/400.css"),
load: () => import("@fontsource/ubuntu-mono/400.css") },
} "Ubuntu Mono": {
name: "Ubuntu Mono",
load: () => import("@fontsource/ubuntu-mono/400.css"),
},
}; };
export const FONT_KEYS = Object.keys(FONTS).sort(); export const FONT_KEYS = Object.keys(FONTS).sort();
export const MONOSCAPE_FONT_KEYS = Object.keys(MONOSCAPE_FONTS).sort(); export const MONOSCAPE_FONT_KEYS = Object.keys(MONOSCAPE_FONTS).sort();
export const DEFAULT_FONT = 'Open Sans'; export const DEFAULT_FONT = "Open Sans";
export const DEFAULT_MONO_FONT = 'Fira Code'; export const DEFAULT_MONO_FONT = "Fira Code";
// Generated from https://gitlab.insrt.uk/revolt/community/themes // Generated from https://gitlab.insrt.uk/revolt/community/themes
export const PRESETS: Record<string, Theme> = { export const PRESETS: Record<string, Theme> = {
light: { light: {
light: true, light: true,
accent: "#FD6671", accent: "#FD6671",
background: "#F6F6F6", background: "#F6F6F6",
foreground: "#101010", foreground: "#101010",
block: "#414141", block: "#414141",
"message-box": "#F1F1F1", "message-box": "#F1F1F1",
mention: "rgba(251, 255, 0, 0.40)", mention: "rgba(251, 255, 0, 0.40)",
success: "#65E572", success: "#65E572",
warning: "#FAA352", warning: "#FAA352",
error: "#F06464", error: "#F06464",
hover: "rgba(0, 0, 0, 0.2)", hover: "rgba(0, 0, 0, 0.2)",
"scrollbar-thumb": "#CA525A", "scrollbar-thumb": "#CA525A",
"scrollbar-track": "transparent", "scrollbar-track": "transparent",
"primary-background": "#FFFFFF", "primary-background": "#FFFFFF",
"primary-header": "#F1F1F1", "primary-header": "#F1F1F1",
"secondary-background": "#F1F1F1", "secondary-background": "#F1F1F1",
"secondary-foreground": "#888888", "secondary-foreground": "#888888",
"secondary-header": "#F1F1F1", "secondary-header": "#F1F1F1",
"tertiary-background": "#4D4D4D", "tertiary-background": "#4D4D4D",
"tertiary-foreground": "#646464", "tertiary-foreground": "#646464",
"status-online": "#3ABF7E", "status-online": "#3ABF7E",
"status-away": "#F39F00", "status-away": "#F39F00",
"status-busy": "#F84848", "status-busy": "#F84848",
"status-streaming": "#977EFF", "status-streaming": "#977EFF",
"status-invisible": "#A5A5A5" "status-invisible": "#A5A5A5",
}, },
dark: { dark: {
light: false, light: false,
accent: "#FD6671", accent: "#FD6671",
background: "#191919", background: "#191919",
foreground: "#F6F6F6", foreground: "#F6F6F6",
block: "#2D2D2D", block: "#2D2D2D",
"message-box": "#363636", "message-box": "#363636",
mention: "rgba(251, 255, 0, 0.06)", mention: "rgba(251, 255, 0, 0.06)",
success: "#65E572", success: "#65E572",
warning: "#FAA352", warning: "#FAA352",
error: "#F06464", error: "#F06464",
hover: "rgba(0, 0, 0, 0.1)", hover: "rgba(0, 0, 0, 0.1)",
"scrollbar-thumb": "#CA525A", "scrollbar-thumb": "#CA525A",
"scrollbar-track": "transparent", "scrollbar-track": "transparent",
"primary-background": "#242424", "primary-background": "#242424",
"primary-header": "#363636", "primary-header": "#363636",
"secondary-background": "#1E1E1E", "secondary-background": "#1E1E1E",
"secondary-foreground": "#C8C8C8", "secondary-foreground": "#C8C8C8",
"secondary-header": "#2D2D2D", "secondary-header": "#2D2D2D",
"tertiary-background": "#4D4D4D", "tertiary-background": "#4D4D4D",
"tertiary-foreground": "#848484", "tertiary-foreground": "#848484",
"status-online": "#3ABF7E", "status-online": "#3ABF7E",
"status-away": "#F39F00", "status-away": "#F39F00",
"status-busy": "#F84848", "status-busy": "#F84848",
"status-streaming": "#977EFF", "status-streaming": "#977EFF",
"status-invisible": "#A5A5A5" "status-invisible": "#A5A5A5",
}, },
}; };
const keys = Object.keys(PRESETS.dark); const keys = Object.keys(PRESETS.dark);
const GlobalTheme = createGlobalStyle<{ theme: Theme }>` const GlobalTheme = createGlobalStyle<{ theme: Theme }>`
:root { :root {
${(props) => ${(props) =>
(Object.keys(props.theme) as Variables[]).map((key) => { (Object.keys(props.theme) as Variables[]).map((key) => {
if (!keys.includes(key)) return; if (!keys.includes(key)) return;
return `--${key}: ${props.theme[key]};`; return `--${key}: ${props.theme[key]};`;
})} })}
} }
`; `;
// Load the default default them and apply extras later // Load the default default them and apply extras later
export const ThemeContext = createContext<Theme>(PRESETS['dark']); export const ThemeContext = createContext<Theme>(PRESETS["dark"]);
interface Props { interface Props {
children: Children; children: Children;
options?: ThemeOptions; options?: ThemeOptions;
} }
function Theme({ children, options }: Props) { function Theme({ children, options }: Props) {
const theme: Theme = { const theme: Theme = {
...PRESETS["dark"], ...PRESETS["dark"],
...PRESETS[options?.preset ?? ''], ...PRESETS[options?.preset ?? ""],
...options?.custom ...options?.custom,
}; };
const root = document.documentElement.style; const root = document.documentElement.style;
useEffect(() => { useEffect(() => {
const font = theme.font ?? DEFAULT_FONT; const font = theme.font ?? DEFAULT_FONT;
root.setProperty('--font', `"${font}"`); root.setProperty("--font", `"${font}"`);
FONTS[font].load(); FONTS[font].load();
}, [ theme.font ]); }, [theme.font]);
useEffect(() => { useEffect(() => {
const font = theme.monoscapeFont ?? DEFAULT_MONO_FONT; const font = theme.monoscapeFont ?? DEFAULT_MONO_FONT;
root.setProperty('--monoscape-font', `"${font}"`); root.setProperty("--monoscape-font", `"${font}"`);
MONOSCAPE_FONTS[font].load(); MONOSCAPE_FONTS[font].load();
}, [ theme.monoscapeFont ]); }, [theme.monoscapeFont]);
useEffect(() => { useEffect(() => {
root.setProperty('--ligatures', options?.ligatures ? 'normal' : 'none'); root.setProperty("--ligatures", options?.ligatures ? "normal" : "none");
}, [ options?.ligatures ]); }, [options?.ligatures]);
useEffect(() => { useEffect(() => {
const resize = () => root.setProperty('--app-height', `${window.innerHeight}px`); const resize = () =>
resize(); root.setProperty("--app-height", `${window.innerHeight}px`);
resize();
window.addEventListener('resize', resize); window.addEventListener("resize", resize);
return () => window.removeEventListener('resize', resize); return () => window.removeEventListener("resize", resize);
}, []); }, []);
return ( return (
<ThemeContext.Provider value={theme}> <ThemeContext.Provider value={theme}>
<Helmet> <Helmet>
<meta <meta
name="theme-color" name="theme-color"
content={ content={
isTouchscreenDevice isTouchscreenDevice
? theme["primary-header"] ? theme["primary-header"]
: theme["background"] : theme["background"]
} }
/> />
</Helmet> </Helmet>
<GlobalTheme theme={theme} /> <GlobalTheme theme={theme} />
{theme.css && ( {theme.css && (
<style dangerouslySetInnerHTML={{ __html: theme.css }} /> <style dangerouslySetInnerHTML={{ __html: theme.css }} />
)} )}
{ children } {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );
} }
export default connectState<{ children: Children }>(Theme, state => { export default connectState<{ children: Children }>(Theme, (state) => {
return { return {
options: state.settings.theme options: state.settings.theme,
}; };
}); });

View file

@ -1,36 +1,38 @@
import { createContext } from "preact"; import { createContext } from "preact";
import { Children } from "../types/Preact";
import { useForceUpdate } from "./revoltjs/hooks";
import { AppContext } from "./revoltjs/RevoltClient";
import type VoiceClient from "../lib/vortex/VoiceClient";
import type { ProduceType, VoiceUser } from "../lib/vortex/Types";
import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { ProduceType, VoiceUser } from "../lib/vortex/Types";
import type VoiceClient from "../lib/vortex/VoiceClient";
import { Children } from "../types/Preact";
import { SoundContext } from "./Settings"; import { SoundContext } from "./Settings";
import { AppContext } from "./revoltjs/RevoltClient";
import { useForceUpdate } from "./revoltjs/hooks";
export enum VoiceStatus { export enum VoiceStatus {
LOADING = 0, LOADING = 0,
UNAVAILABLE, UNAVAILABLE,
ERRORED, ERRORED,
READY = 3, READY = 3,
CONNECTING = 4, CONNECTING = 4,
AUTHENTICATING, AUTHENTICATING,
RTC_CONNECTING, RTC_CONNECTING,
CONNECTED CONNECTED,
// RECONNECTING // RECONNECTING
} }
export interface VoiceOperations { export interface VoiceOperations {
connect: (channelId: string) => Promise<void>; connect: (channelId: string) => Promise<void>;
disconnect: () => void; disconnect: () => void;
isProducing: (type: ProduceType) => boolean; isProducing: (type: ProduceType) => boolean;
startProducing: (type: ProduceType) => Promise<void>; startProducing: (type: ProduceType) => Promise<void>;
stopProducing: (type: ProduceType) => Promise<void> | undefined; stopProducing: (type: ProduceType) => Promise<void> | undefined;
} }
export interface VoiceState { export interface VoiceState {
roomId?: string; roomId?: string;
status: VoiceStatus; status: VoiceStatus;
participants?: Readonly<Map<string, VoiceUser>>; participants?: Readonly<Map<string, VoiceUser>>;
} }
// They should be present from first render. - insert's words // They should be present from first render. - insert's words
@ -38,166 +40,168 @@ export const VoiceContext = createContext<VoiceState>(null!);
export const VoiceOperationsContext = createContext<VoiceOperations>(null!); export const VoiceOperationsContext = createContext<VoiceOperations>(null!);
type Props = { type Props = {
children: Children; children: Children;
}; };
export default function Voice({ children }: Props) { export default function Voice({ children }: Props) {
const revoltClient = useContext(AppContext); const revoltClient = useContext(AppContext);
const [client, setClient] = useState<VoiceClient | undefined>(undefined); const [client, setClient] = useState<VoiceClient | undefined>(undefined);
const [state, setState] = useState<VoiceState>({ const [state, setState] = useState<VoiceState>({
status: VoiceStatus.LOADING, status: VoiceStatus.LOADING,
participants: new Map() participants: new Map(),
}); });
function setStatus(status: VoiceStatus, roomId?: string) { function setStatus(status: VoiceStatus, roomId?: string) {
setState({ setState({
status, status,
roomId: roomId ?? client?.roomId, roomId: roomId ?? client?.roomId,
participants: client?.participants ?? new Map(), participants: client?.participants ?? new Map(),
}); });
} }
useEffect(() => { useEffect(() => {
import('../lib/vortex/VoiceClient') import("../lib/vortex/VoiceClient")
.then(({ default: VoiceClient }) => { .then(({ default: VoiceClient }) => {
const client = new VoiceClient(); const client = new VoiceClient();
setClient(client); setClient(client);
if (!client?.supported()) { if (!client?.supported()) {
setStatus(VoiceStatus.UNAVAILABLE); setStatus(VoiceStatus.UNAVAILABLE);
} else { } else {
setStatus(VoiceStatus.READY); setStatus(VoiceStatus.READY);
} }
}) })
.catch(err => { .catch((err) => {
console.error('Failed to load voice library!', err); console.error("Failed to load voice library!", err);
setStatus(VoiceStatus.UNAVAILABLE); setStatus(VoiceStatus.UNAVAILABLE);
}) });
}, []); }, []);
const isConnecting = useRef(false); const isConnecting = useRef(false);
const operations: VoiceOperations = useMemo(() => { const operations: VoiceOperations = useMemo(() => {
return { return {
connect: async channelId => { connect: async (channelId) => {
if (!client?.supported()) if (!client?.supported()) throw new Error("RTC is unavailable");
throw new Error("RTC is unavailable");
isConnecting.current = true; isConnecting.current = true;
setStatus(VoiceStatus.CONNECTING, channelId); setStatus(VoiceStatus.CONNECTING, channelId);
try { try {
const call = await revoltClient.channels.joinCall( const call = await revoltClient.channels.joinCall(
channelId channelId,
); );
if (!isConnecting.current) { if (!isConnecting.current) {
setStatus(VoiceStatus.READY); setStatus(VoiceStatus.READY);
return; return;
} }
// ! FIXME: use configuration to check if voso is enabled // ! FIXME: use configuration to check if voso is enabled
// await client.connect("wss://voso.revolt.chat/ws"); // await client.connect("wss://voso.revolt.chat/ws");
await client.connect("wss://voso.revolt.chat/ws", channelId); await client.connect(
"wss://voso.revolt.chat/ws",
channelId,
);
setStatus(VoiceStatus.AUTHENTICATING); setStatus(VoiceStatus.AUTHENTICATING);
await client.authenticate(call.token); await client.authenticate(call.token);
setStatus(VoiceStatus.RTC_CONNECTING); setStatus(VoiceStatus.RTC_CONNECTING);
await client.initializeTransports(); await client.initializeTransports();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setStatus(VoiceStatus.READY); setStatus(VoiceStatus.READY);
return; return;
} }
setStatus(VoiceStatus.CONNECTED); setStatus(VoiceStatus.CONNECTED);
isConnecting.current = false; isConnecting.current = false;
}, },
disconnect: () => { disconnect: () => {
if (!client?.supported()) if (!client?.supported()) throw new Error("RTC is unavailable");
throw new Error("RTC is unavailable");
// if (status <= VoiceStatus.READY) return; // if (status <= VoiceStatus.READY) return;
// this will not update in this context // this will not update in this context
isConnecting.current = false; isConnecting.current = false;
client.disconnect(); client.disconnect();
setStatus(VoiceStatus.READY); setStatus(VoiceStatus.READY);
}, },
isProducing: (type: ProduceType) => { isProducing: (type: ProduceType) => {
switch (type) { switch (type) {
case "audio": case "audio":
return client?.audioProducer !== undefined; return client?.audioProducer !== undefined;
} }
}, },
startProducing: async (type: ProduceType) => { startProducing: async (type: ProduceType) => {
switch (type) { switch (type) {
case "audio": { case "audio": {
if (client?.audioProducer !== undefined) return console.log('No audio producer.'); // ! FIXME: let the user know if (client?.audioProducer !== undefined)
if (navigator.mediaDevices === undefined) return console.log('No media devices.'); // ! FIXME: let the user know return console.log("No audio producer."); // ! FIXME: let the user know
const mediaStream = await navigator.mediaDevices.getUserMedia( if (navigator.mediaDevices === undefined)
{ return console.log("No media devices."); // ! FIXME: let the user know
audio: true const mediaStream =
} await navigator.mediaDevices.getUserMedia({
); audio: true,
});
await client?.startProduce( await client?.startProduce(
mediaStream.getAudioTracks()[0], mediaStream.getAudioTracks()[0],
"audio" "audio",
); );
return; return;
} }
} }
}, },
stopProducing: (type: ProduceType) => { stopProducing: (type: ProduceType) => {
return client?.stopProduce(type); return client?.stopProduce(type);
} },
} };
}, [ client ]); }, [client]);
const { forceUpdate } = useForceUpdate(); const { forceUpdate } = useForceUpdate();
const playSound = useContext(SoundContext); const playSound = useContext(SoundContext);
useEffect(() => { useEffect(() => {
if (!client?.supported()) return; if (!client?.supported()) return;
// ! FIXME: message for fatal: // ! FIXME: message for fatal:
// ! get rid of these force updates // ! get rid of these force updates
// ! handle it through state or smth // ! handle it through state or smth
client.on("startProduce", forceUpdate); client.on("startProduce", forceUpdate);
client.on("stopProduce", forceUpdate); client.on("stopProduce", forceUpdate);
client.on("userJoined", () => { client.on("userJoined", () => {
playSound('call_join'); playSound("call_join");
forceUpdate(); forceUpdate();
}); });
client.on("userLeft", () => { client.on("userLeft", () => {
playSound('call_leave'); playSound("call_leave");
forceUpdate(); forceUpdate();
}); });
client.on("userStartProduce", forceUpdate); client.on("userStartProduce", forceUpdate);
client.on("userStopProduce", forceUpdate); client.on("userStopProduce", forceUpdate);
client.on("close", forceUpdate); client.on("close", forceUpdate);
return () => { return () => {
client.removeListener("startProduce", forceUpdate); client.removeListener("startProduce", forceUpdate);
client.removeListener("stopProduce", forceUpdate); client.removeListener("stopProduce", forceUpdate);
client.removeListener("userJoined", forceUpdate); client.removeListener("userJoined", forceUpdate);
client.removeListener("userLeft", forceUpdate); client.removeListener("userLeft", forceUpdate);
client.removeListener("userStartProduce", forceUpdate); client.removeListener("userStartProduce", forceUpdate);
client.removeListener("userStopProduce", forceUpdate); client.removeListener("userStopProduce", forceUpdate);
client.removeListener("close", forceUpdate); client.removeListener("close", forceUpdate);
}; };
}, [ client, state ]); }, [client, state]);
return ( return (
<VoiceContext.Provider value={state}> <VoiceContext.Provider value={state}>
<VoiceOperationsContext.Provider value={operations}> <VoiceOperationsContext.Provider value={operations}>
{ children } {children}
</VoiceOperationsContext.Provider> </VoiceOperationsContext.Provider>
</VoiceContext.Provider> </VoiceContext.Provider>
); );
} }

View file

@ -1,32 +1,31 @@
import State from "../redux/State";
import { Children } from "../types/Preact";
import { BrowserRouter as Router } from "react-router-dom"; import { BrowserRouter as Router } from "react-router-dom";
import Intermediate from './intermediate/Intermediate'; import State from "../redux/State";
import Client from './revoltjs/RevoltClient';
import Settings from "./Settings"; import { Children } from "../types/Preact";
import Locale from "./Locale"; import Locale from "./Locale";
import Voice from "./Voice"; import Settings from "./Settings";
import Theme from "./Theme"; import Theme from "./Theme";
import Voice from "./Voice";
import Intermediate from "./intermediate/Intermediate";
import Client from "./revoltjs/RevoltClient";
export default function Context({ children }: { children: Children }) { export default function Context({ children }: { children: Children }) {
return ( return (
<Router> <Router>
<State> <State>
<Theme> <Theme>
<Settings> <Settings>
<Locale> <Locale>
<Intermediate> <Intermediate>
<Client> <Client>
<Voice> <Voice>{children}</Voice>
{children} </Client>
</Voice> </Intermediate>
</Client> </Locale>
</Intermediate> </Settings>
</Locale> </Theme>
</Settings> </State>
</Theme> </Router>
</State> );
</Router>
);
} }

View file

@ -1,141 +1,179 @@
import { Attachment, Channels, EmbedImage, Servers, Users } from "revolt.js/dist/api/objects";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { internalSubscribe } from "../../lib/eventEmitter";
import { Action } from "../../components/ui/Modal";
import { useHistory } from "react-router-dom";
import { Children } from "../../types/Preact";
import { createContext } from "preact";
import { Prompt } from "react-router"; import { Prompt } from "react-router";
import Modals from './Modals'; import { useHistory } from "react-router-dom";
import {
Attachment,
Channels,
EmbedImage,
Servers,
Users,
} from "revolt.js/dist/api/objects";
import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { internalSubscribe } from "../../lib/eventEmitter";
import { Action } from "../../components/ui/Modal";
import { Children } from "../../types/Preact";
import Modals from "./Modals";
export type Screen = export type Screen =
| { id: "none" } | { id: "none" }
// Modals // Modals
| { id: "signed_out" } | { id: "signed_out" }
| { id: "error"; error: string } | { id: "error"; error: string }
| { id: "clipboard"; text: string } | { id: "clipboard"; text: string }
| { id: "_prompt"; question: Children; content?: Children; actions: Action[] } | {
| ({ id: "special_prompt" } & ( id: "_prompt";
{ type: "leave_group", target: Channels.GroupChannel } | question: Children;
{ type: "close_dm", target: Channels.DirectMessageChannel } | content?: Children;
{ type: "leave_server", target: Servers.Server } | actions: Action[];
{ type: "delete_server", target: Servers.Server } | }
{ type: "delete_channel", target: Channels.TextChannel } | | ({ id: "special_prompt" } & (
{ type: "delete_message", target: Channels.Message } | | { type: "leave_group"; target: Channels.GroupChannel }
{ type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } | | { type: "close_dm"; target: Channels.DirectMessageChannel }
{ type: "kick_member", target: Servers.Server, user: string } | | { type: "leave_server"; target: Servers.Server }
{ type: "ban_member", target: Servers.Server, user: string } | | { type: "delete_server"; target: Servers.Server }
{ type: "unfriend_user", target: Users.User } | | { type: "delete_channel"; target: Channels.TextChannel }
{ type: "block_user", target: Users.User } | | { type: "delete_message"; target: Channels.Message }
{ type: "create_channel", target: Servers.Server } | {
)) | type: "create_invite";
({ id: "special_input" } & ( target: Channels.TextChannel | Channels.GroupChannel;
{ type: "create_group" | "create_server" | "set_custom_status" | "add_friend" } | }
{ type: "create_role", server: string, callback: (id: string) => void } | { type: "kick_member"; target: Servers.Server; user: string }
)) | { type: "ban_member"; target: Servers.Server; user: string }
| { | { type: "unfriend_user"; target: Users.User }
id: "_input"; | { type: "block_user"; target: Users.User }
question: Children; | { type: "create_channel"; target: Servers.Server }
field: Children; ))
defaultValue?: string; | ({ id: "special_input" } & (
callback: (value: string) => Promise<void>; | {
} type:
| { | "create_group"
id: "onboarding"; | "create_server"
callback: ( | "set_custom_status"
username: string, | "add_friend";
loginAfterSuccess?: true }
) => Promise<void>; | {
} type: "create_role";
server: string;
callback: (id: string) => void;
}
))
| {
id: "_input";
question: Children;
field: Children;
defaultValue?: string;
callback: (value: string) => Promise<void>;
}
| {
id: "onboarding";
callback: (
username: string,
loginAfterSuccess?: true,
) => Promise<void>;
}
// Pop-overs // Pop-overs
| { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage; } | { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage }
| { id: "modify_account"; field: "username" | "email" | "password" } | { id: "modify_account"; field: "username" | "email" | "password" }
| { id: "profile"; user_id: string } | { id: "profile"; user_id: string }
| { id: "channel_info"; channel_id: string } | { id: "channel_info"; channel_id: string }
| { id: "pending_requests"; users: string[] } | { id: "pending_requests"; users: string[] }
| { | {
id: "user_picker"; id: "user_picker";
omit?: string[]; omit?: string[];
callback: (users: string[]) => Promise<void>; callback: (users: string[]) => Promise<void>;
}; };
export const IntermediateContext = createContext({ export const IntermediateContext = createContext({
screen: { id: "none" } as Screen, screen: { id: "none" } as Screen,
focusTaken: false focusTaken: false,
}); });
export const IntermediateActionsContext = createContext({ export const IntermediateActionsContext = createContext({
openScreen: (screen: Screen) => {}, openScreen: (screen: Screen) => {},
writeClipboard: (text: string) => {} writeClipboard: (text: string) => {},
}); });
interface Props { interface Props {
children: Children; children: Children;
} }
export default function Intermediate(props: Props) { export default function Intermediate(props: Props) {
const [screen, openScreen] = useState<Screen>({ id: "none" }); const [screen, openScreen] = useState<Screen>({ id: "none" });
const history = useHistory(); const history = useHistory();
const value = { const value = {
screen, screen,
focusTaken: screen.id !== 'none' focusTaken: screen.id !== "none",
}; };
const actions = useMemo(() => { const actions = useMemo(() => {
return { return {
openScreen: (screen: Screen) => openScreen(screen), openScreen: (screen: Screen) => openScreen(screen),
writeClipboard: (text: string) => { writeClipboard: (text: string) => {
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
} else { } else {
actions.openScreen({ id: "clipboard", text }); actions.openScreen({ id: "clipboard", text });
} }
} },
} };
}, []); }, []);
useEffect(() => { useEffect(() => {
const openProfile = (user_id: string) => openScreen({ id: "profile", user_id }); const openProfile = (user_id: string) =>
const navigate = (path: string) => history.push(path); openScreen({ id: "profile", user_id });
const navigate = (path: string) => history.push(path);
const subs = [ const subs = [
internalSubscribe("Intermediate", "openProfile", openProfile), internalSubscribe("Intermediate", "openProfile", openProfile),
internalSubscribe("Intermediate", "navigate", navigate) internalSubscribe("Intermediate", "navigate", navigate),
] ];
return () => subs.map(unsub => unsub()); return () => subs.map((unsub) => unsub());
}, []); }, []);
return ( return (
<IntermediateContext.Provider value={value}> <IntermediateContext.Provider value={value}>
<IntermediateActionsContext.Provider value={actions}> <IntermediateActionsContext.Provider value={actions}>
{ screen.id !== 'onboarding' && props.children } {screen.id !== "onboarding" && props.children}
<Modals <Modals
{...value} {...value}
{...actions} {...actions}
key={ key={
screen.id screen.id
} /** By specifying a key, we reset state whenever switching screen. */ } /** By specifying a key, we reset state whenever switching screen. */
/> />
<Prompt <Prompt
when={[ 'modify_account', 'special_prompt', 'special_input', 'image_viewer', 'profile', 'channel_info', 'pending_requests', 'user_picker' ].includes(screen.id)} when={[
message={(_, action) => { "modify_account",
if (action === 'POP') { "special_prompt",
openScreen({ id: 'none' }); "special_input",
setTimeout(() => history.push(history.location), 0); "image_viewer",
"profile",
"channel_info",
"pending_requests",
"user_picker",
].includes(screen.id)}
message={(_, action) => {
if (action === "POP") {
openScreen({ id: "none" });
setTimeout(() => history.push(history.location), 0);
return false; return false;
} }
return true; return true;
}} }}
/> />
</IntermediateActionsContext.Provider> </IntermediateActionsContext.Provider>
</IntermediateContext.Provider> </IntermediateContext.Provider>
); );
} }
export const useIntermediate = () => useContext(IntermediateActionsContext); export const useIntermediate = () => useContext(IntermediateActionsContext);

View file

@ -1,34 +1,33 @@
import { Screen } from "./Intermediate"; import { Screen } from "./Intermediate";
import { ClipboardModal } from "./modals/Clipboard";
import { ErrorModal } from "./modals/Error"; import { ErrorModal } from "./modals/Error";
import { InputModal } from "./modals/Input"; import { InputModal } from "./modals/Input";
import { OnboardingModal } from "./modals/Onboarding";
import { PromptModal } from "./modals/Prompt"; import { PromptModal } from "./modals/Prompt";
import { SignedOutModal } from "./modals/SignedOut"; import { SignedOutModal } from "./modals/SignedOut";
import { ClipboardModal } from "./modals/Clipboard";
import { OnboardingModal } from "./modals/Onboarding";
export interface Props { export interface Props {
screen: Screen; screen: Screen;
openScreen: (id: any) => void; openScreen: (id: any) => void;
} }
export default function Modals({ screen, openScreen }: Props) { export default function Modals({ screen, openScreen }: Props) {
const onClose = () => openScreen({ id: "none" }); const onClose = () => openScreen({ id: "none" });
switch (screen.id) { switch (screen.id) {
case "_prompt": case "_prompt":
return <PromptModal onClose={onClose} {...screen} />; return <PromptModal onClose={onClose} {...screen} />;
case "_input": case "_input":
return <InputModal onClose={onClose} {...screen} />; return <InputModal onClose={onClose} {...screen} />;
case "error": case "error":
return <ErrorModal onClose={onClose} {...screen} />; return <ErrorModal onClose={onClose} {...screen} />;
case "signed_out": case "signed_out":
return <SignedOutModal onClose={onClose} {...screen} />; return <SignedOutModal onClose={onClose} {...screen} />;
case "clipboard": case "clipboard":
return <ClipboardModal onClose={onClose} {...screen} />; return <ClipboardModal onClose={onClose} {...screen} />;
case "onboarding": case "onboarding":
return <OnboardingModal onClose={onClose} {...screen} />; return <OnboardingModal onClose={onClose} {...screen} />;
} }
return null; return null;
} }

View file

@ -1,39 +1,39 @@
import { IntermediateContext, useIntermediate } from "./Intermediate";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { UserPicker } from "./popovers/UserPicker"; import { IntermediateContext, useIntermediate } from "./Intermediate";
import { SpecialInputModal } from "./modals/Input"; import { SpecialInputModal } from "./modals/Input";
import { SpecialPromptModal } from "./modals/Prompt"; import { SpecialPromptModal } from "./modals/Prompt";
import { UserProfile } from "./popovers/UserProfile";
import { ImageViewer } from "./popovers/ImageViewer";
import { ChannelInfo } from "./popovers/ChannelInfo"; import { ChannelInfo } from "./popovers/ChannelInfo";
import { PendingRequests } from "./popovers/PendingRequests"; import { ImageViewer } from "./popovers/ImageViewer";
import { ModifyAccountModal } from "./popovers/ModifyAccount"; import { ModifyAccountModal } from "./popovers/ModifyAccount";
import { PendingRequests } from "./popovers/PendingRequests";
import { UserPicker } from "./popovers/UserPicker";
import { UserProfile } from "./popovers/UserProfile";
export default function Popovers() { export default function Popovers() {
const { screen } = useContext(IntermediateContext); const { screen } = useContext(IntermediateContext);
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const onClose = () => openScreen({ id: "none" }); const onClose = () => openScreen({ id: "none" });
switch (screen.id) { switch (screen.id) {
case "profile": case "profile":
return <UserProfile {...screen} onClose={onClose} />; return <UserProfile {...screen} onClose={onClose} />;
case "user_picker": case "user_picker":
return <UserPicker {...screen} onClose={onClose} />; return <UserPicker {...screen} onClose={onClose} />;
case "image_viewer": case "image_viewer":
return <ImageViewer {...screen} onClose={onClose} />; return <ImageViewer {...screen} onClose={onClose} />;
case "channel_info": case "channel_info":
return <ChannelInfo {...screen} onClose={onClose} />; return <ChannelInfo {...screen} onClose={onClose} />;
case "pending_requests": case "pending_requests":
return <PendingRequests {...screen} onClose={onClose} />; return <PendingRequests {...screen} onClose={onClose} />;
case "modify_account": case "modify_account":
return <ModifyAccountModal onClose={onClose} {...screen} />; return <ModifyAccountModal onClose={onClose} {...screen} />;
case "special_prompt": case "special_prompt":
return <SpecialPromptModal onClose={onClose} {...screen} />; return <SpecialPromptModal onClose={onClose} {...screen} />;
case "special_input": case "special_input":
return <SpecialInputModal onClose={onClose} {...screen} />; return <SpecialInputModal onClose={onClose} {...screen} />;
} }
return null; return null;
} }

View file

@ -1,32 +1,32 @@
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal"; import Modal from "../../../components/ui/Modal";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
text: string; text: string;
} }
export function ClipboardModal({ onClose, text }: Props) { export function ClipboardModal({ onClose, text }: Props) {
return ( return (
<Modal <Modal
visible={true} visible={true}
onClose={onClose} onClose={onClose}
title={<Text id="app.special.modals.clipboard.unavailable" />} title={<Text id="app.special.modals.clipboard.unavailable" />}
actions={[ actions={[
{ {
onClick: onClose, onClick: onClose,
confirmation: true, confirmation: true,
text: <Text id="app.special.modals.actions.close" /> text: <Text id="app.special.modals.actions.close" />,
} },
]} ]}>
> {location.protocol !== "https:" && (
{location.protocol !== "https:" && ( <p>
<p> <Text id="app.special.modals.clipboard.https" />
<Text id="app.special.modals.clipboard.https" /> </p>
</p> )}
)} <Text id="app.special.modals.clipboard.copy" />{" "}
<Text id="app.special.modals.clipboard.copy" />{" "} <code style={{ userSelect: "all" }}>{text}</code>
<code style={{ userSelect: "all" }}>{text}</code> </Modal>
</Modal> );
);
} }

View file

@ -1,30 +1,30 @@
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal"; import Modal from "../../../components/ui/Modal";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
error: string; error: string;
} }
export function ErrorModal({ onClose, error }: Props) { export function ErrorModal({ onClose, error }: Props) {
return ( return (
<Modal <Modal
visible={true} visible={true}
onClose={() => false} onClose={() => false}
title={<Text id="app.special.modals.error" />} title={<Text id="app.special.modals.error" />}
actions={[ actions={[
{ {
onClick: onClose, onClick: onClose,
confirmation: true, confirmation: true,
text: <Text id="app.special.modals.actions.ok" /> text: <Text id="app.special.modals.actions.ok" />,
}, },
{ {
onClick: () => location.reload(), onClick: () => location.reload(),
text: <Text id="app.special.modals.actions.reload" /> text: <Text id="app.special.modals.actions.reload" />,
} },
]} ]}>
> <Text id={`error.${error}`}>{error}</Text>
<Text id={`error.${error}`}>{error}</Text> </Modal>
</Modal> );
);
} }

View file

@ -1,154 +1,176 @@
import { ulid } from "ulid";
import { Text } from "preact-i18n";
import { useHistory } from "react-router"; import { useHistory } from "react-router";
import Modal from "../../../components/ui/Modal"; import { ulid } from "ulid";
import { Children } from "../../../types/Preact";
import { takeError } from "../../revoltjs/util"; import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks"; import { useContext, useState } from "preact/hooks";
import Overline from '../../../components/ui/Overline';
import InputBox from '../../../components/ui/InputBox'; import InputBox from "../../../components/ui/InputBox";
import Modal from "../../../components/ui/Modal";
import Overline from "../../../components/ui/Overline";
import { Children } from "../../../types/Preact";
import { AppContext } from "../../revoltjs/RevoltClient"; import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
question: Children; question: Children;
field?: Children; field?: Children;
defaultValue?: string; defaultValue?: string;
callback: (value: string) => Promise<void>; callback: (value: string) => Promise<void>;
} }
export function InputModal({ export function InputModal({
onClose, onClose,
question, question,
field, field,
defaultValue, defaultValue,
callback callback,
}: Props) { }: Props) {
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [value, setValue] = useState(defaultValue ?? ""); const [value, setValue] = useState(defaultValue ?? "");
const [error, setError] = useState<undefined | string>(undefined); const [error, setError] = useState<undefined | string>(undefined);
return ( return (
<Modal <Modal
visible={true} visible={true}
title={question} title={question}
disabled={processing} disabled={processing}
actions={[ actions={[
{ {
confirmation: true, confirmation: true,
text: <Text id="app.special.modals.actions.ok" />, text: <Text id="app.special.modals.actions.ok" />,
onClick: () => { onClick: () => {
setProcessing(true); setProcessing(true);
callback(value) callback(value)
.then(onClose) .then(onClose)
.catch(err => { .catch((err) => {
setError(takeError(err)); setError(takeError(err));
setProcessing(false) setProcessing(false);
}) });
} },
}, },
{ {
text: <Text id="app.special.modals.actions.cancel" />, text: <Text id="app.special.modals.actions.cancel" />,
onClick: onClose onClick: onClose,
} },
]} ]}
onClose={onClose} onClose={onClose}>
> <form>
<form> {field ? (
{ field ? <Overline error={error} block> <Overline error={error} block>
{field} {field}
</Overline> : (error && <Overline error={error} type="error" block />) } </Overline>
<InputBox ) : (
value={value} error && <Overline error={error} type="error" block />
onChange={e => setValue(e.currentTarget.value)} )}
/> <InputBox
</form> value={value}
</Modal> onChange={(e) => setValue(e.currentTarget.value)}
); />
</form>
</Modal>
);
} }
type SpecialProps = { onClose: () => void } & ( type SpecialProps = { onClose: () => void } & (
{ type: "create_group" | "create_server" | "set_custom_status" | "add_friend" } | | {
{ type: "create_role", server: string, callback: (id: string) => void } type:
) | "create_group"
| "create_server"
| "set_custom_status"
| "add_friend";
}
| { type: "create_role"; server: string; callback: (id: string) => void }
);
export function SpecialInputModal(props: SpecialProps) { export function SpecialInputModal(props: SpecialProps) {
const history = useHistory(); const history = useHistory();
const client = useContext(AppContext); const client = useContext(AppContext);
const { onClose } = props; const { onClose } = props;
switch (props.type) { switch (props.type) {
case "create_group": { case "create_group": {
return <InputModal return (
onClose={onClose} <InputModal
question={<Text id="app.main.groups.create" />} onClose={onClose}
field={<Text id="app.main.groups.name" />} question={<Text id="app.main.groups.create" />}
callback={async name => { field={<Text id="app.main.groups.name" />}
const group = await client.channels.createGroup( callback={async (name) => {
{ const group = await client.channels.createGroup({
name, name,
nonce: ulid(), nonce: ulid(),
users: [] users: [],
} });
);
history.push(`/channel/${group._id}`); history.push(`/channel/${group._id}`);
}} }}
/>; />
} );
case "create_server": { }
return <InputModal case "create_server": {
onClose={onClose} return (
question={<Text id="app.main.servers.create" />} <InputModal
field={<Text id="app.main.servers.name" />} onClose={onClose}
callback={async name => { question={<Text id="app.main.servers.create" />}
const server = await client.servers.createServer( field={<Text id="app.main.servers.name" />}
{ callback={async (name) => {
name, const server = await client.servers.createServer({
nonce: ulid() name,
} nonce: ulid(),
); });
history.push(`/server/${server._id}`); history.push(`/server/${server._id}`);
}} }}
/>; />
} );
case "create_role": { }
return <InputModal case "create_role": {
onClose={onClose} return (
question={<Text id="app.settings.permissions.create_role" />} <InputModal
field={<Text id="app.settings.permissions.role_name" />} onClose={onClose}
callback={async name => { question={
const role = await client.servers.createRole(props.server, name); <Text id="app.settings.permissions.create_role" />
props.callback(role.id); }
}} field={<Text id="app.settings.permissions.role_name" />}
/>; callback={async (name) => {
} const role = await client.servers.createRole(
case "set_custom_status": { props.server,
return <InputModal name,
onClose={onClose} );
question={<Text id="app.context_menu.set_custom_status" />} props.callback(role.id);
field={<Text id="app.context_menu.custom_status" />} }}
defaultValue={client.user?.status?.text} />
callback={text => );
client.users.editUser({ }
status: { case "set_custom_status": {
...client.user?.status, return (
text: text.trim().length > 0 ? text : undefined <InputModal
} onClose={onClose}
}) question={<Text id="app.context_menu.set_custom_status" />}
} field={<Text id="app.context_menu.custom_status" />}
/>; defaultValue={client.user?.status?.text}
} callback={(text) =>
case "add_friend": { client.users.editUser({
return <InputModal status: {
onClose={onClose} ...client.user?.status,
question={"Add Friend"} text: text.trim().length > 0 ? text : undefined,
callback={username => },
client.users.addFriend(username) })
} }
/>; />
} );
default: return null; }
} case "add_friend": {
return (
<InputModal
onClose={onClose}
question={"Add Friend"}
callback={(username) => client.users.addFriend(username)}
/>
);
}
default:
return null;
}
} }

View file

@ -1,71 +1,78 @@
import { SubmitHandler, useForm } from "react-hook-form";
import styles from "./Onboarding.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { SubmitHandler, useForm } from "react-hook-form";
import styles from "./Onboarding.module.scss"; import wideSVG from "../../../assets/wide.svg";
import { takeError } from "../../revoltjs/util";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import FormField from "../../../pages/login/FormField";
import Preloader from "../../../components/ui/Preloader"; import Preloader from "../../../components/ui/Preloader";
import wideSVG from '../../../assets/wide.svg'; import FormField from "../../../pages/login/FormField";
import { takeError } from "../../revoltjs/util";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
callback: (username: string, loginAfterSuccess?: true) => Promise<void>; callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
} }
interface FormInputs { interface FormInputs {
username: string username: string;
} }
export function OnboardingModal({ onClose, callback }: Props) { export function OnboardingModal({ onClose, callback }: Props) {
const { handleSubmit, register } = useForm<FormInputs>(); const { handleSubmit, register } = useForm<FormInputs>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const onSubmit: SubmitHandler<FormInputs> = ({ username }) => { const onSubmit: SubmitHandler<FormInputs> = ({ username }) => {
setLoading(true); setLoading(true);
callback(username, true) callback(username, true)
.then(onClose) .then(onClose)
.catch((err: any) => { .catch((err: any) => {
setError(takeError(err)); setError(takeError(err));
setLoading(false); setLoading(false);
}); });
} };
return ( return (
<div className={styles.onboarding}> <div className={styles.onboarding}>
<div className={styles.header}> <div className={styles.header}>
<h1> <h1>
<Text id="app.special.modals.onboarding.welcome" /> <Text id="app.special.modals.onboarding.welcome" />
<img src={wideSVG} /> <img src={wideSVG} />
</h1> </h1>
</div> </div>
<div className={styles.form}> <div className={styles.form}>
{loading ? ( {loading ? (
<Preloader type="spinner" /> <Preloader type="spinner" />
) : ( ) : (
<> <>
<p> <p>
<Text id="app.special.modals.onboarding.pick" /> <Text id="app.special.modals.onboarding.pick" />
</p> </p>
<form onSubmit={handleSubmit(onSubmit) as JSX.GenericEventHandler<HTMLFormElement>}> <form
<div> onSubmit={
<FormField handleSubmit(
type="username" onSubmit,
register={register} ) as JSX.GenericEventHandler<HTMLFormElement>
showOverline }>
error={error} <div>
/> <FormField
</div> type="username"
<Button type="submit"> register={register}
<Text id="app.special.modals.actions.continue" /> showOverline
</Button> error={error}
</form> />
</> </div>
)} <Button type="submit">
</div> <Text id="app.special.modals.actions.continue" />
<div /> </Button>
</div> </form>
); </>
)}
</div>
<div />
</div>
);
} }

View file

@ -1,346 +1,473 @@
import { ulid } from "ulid";
import { Text } from "preact-i18n";
import styles from './Prompt.module.scss';
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import Radio from "../../../components/ui/Radio";
import { Children } from "../../../types/Preact";
import { useIntermediate } from "../Intermediate";
import InputBox from "../../../components/ui/InputBox";
import Overline from "../../../components/ui/Overline";
import { AppContext } from "../../revoltjs/RevoltClient";
import { mapMessage, takeError } from "../../revoltjs/util";
import Modal, { Action } from "../../../components/ui/Modal";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects"; import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import { ulid } from "ulid";
import styles from "./Prompt.module.scss";
import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import UserIcon from "../../../components/common/user/UserIcon";
import Message from "../../../components/common/messaging/Message";
import { TextReact } from "../../../lib/i18n"; import { TextReact } from "../../../lib/i18n";
import Message from "../../../components/common/messaging/Message";
import UserIcon from "../../../components/common/user/UserIcon";
import InputBox from "../../../components/ui/InputBox";
import Modal, { Action } from "../../../components/ui/Modal";
import Overline from "../../../components/ui/Overline";
import Radio from "../../../components/ui/Radio";
import { Children } from "../../../types/Preact";
import { AppContext } from "../../revoltjs/RevoltClient";
import { mapMessage, takeError } from "../../revoltjs/util";
import { useIntermediate } from "../Intermediate";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
question: Children; question: Children;
content?: Children; content?: Children;
disabled?: boolean; disabled?: boolean;
actions: Action[]; actions: Action[];
error?: string; error?: string;
} }
export function PromptModal({ onClose, question, content, actions, disabled, error }: Props) { export function PromptModal({
return ( onClose,
<Modal question,
visible={true} content,
title={question} actions,
actions={actions} disabled,
onClose={onClose} error,
disabled={disabled}> }: Props) {
{ error && <Overline error={error} type="error" /> } return (
{ content } <Modal
</Modal> visible={true}
); title={question}
actions={actions}
onClose={onClose}
disabled={disabled}>
{error && <Overline error={error} type="error" />}
{content}
</Modal>
);
} }
type SpecialProps = { onClose: () => void } & ( type SpecialProps = { onClose: () => void } & (
{ type: "leave_group", target: Channels.GroupChannel } | | { type: "leave_group"; target: Channels.GroupChannel }
{ type: "close_dm", target: Channels.DirectMessageChannel } | | { type: "close_dm"; target: Channels.DirectMessageChannel }
{ type: "leave_server", target: Servers.Server } | | { type: "leave_server"; target: Servers.Server }
{ type: "delete_server", target: Servers.Server } | | { type: "delete_server"; target: Servers.Server }
{ type: "delete_channel", target: Channels.TextChannel } | | { type: "delete_channel"; target: Channels.TextChannel }
{ type: "delete_message", target: Channels.Message } | | { type: "delete_message"; target: Channels.Message }
{ type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } | | {
{ type: "kick_member", target: Servers.Server, user: string } | type: "create_invite";
{ type: "ban_member", target: Servers.Server, user: string } | target: Channels.TextChannel | Channels.GroupChannel;
{ type: "unfriend_user", target: Users.User } | }
{ type: "block_user", target: Users.User } | | { type: "kick_member"; target: Servers.Server; user: string }
{ type: "create_channel", target: Servers.Server } | { type: "ban_member"; target: Servers.Server; user: string }
) | { type: "unfriend_user"; target: Users.User }
| { type: "block_user"; target: Users.User }
| { type: "create_channel"; target: Servers.Server }
);
export function SpecialPromptModal(props: SpecialProps) { export function SpecialPromptModal(props: SpecialProps) {
const client = useContext(AppContext); const client = useContext(AppContext);
const [ processing, setProcessing ] = useState(false); const [processing, setProcessing] = useState(false);
const [ error, setError ] = useState<undefined | string>(undefined); const [error, setError] = useState<undefined | string>(undefined);
const { onClose } = props; const { onClose } = props;
switch (props.type) { switch (props.type) {
case 'leave_group': case "leave_group":
case 'close_dm': case "close_dm":
case 'leave_server': case "leave_server":
case 'delete_server': case "delete_server":
case 'delete_channel': case "delete_channel":
case 'unfriend_user': case "unfriend_user":
case 'block_user': { case "block_user": {
const EVENTS = { const EVENTS = {
'close_dm': ['confirm_close_dm', 'close'], close_dm: ["confirm_close_dm", "close"],
'delete_server': ['confirm_delete', 'delete'], delete_server: ["confirm_delete", "delete"],
'delete_channel': ['confirm_delete', 'delete'], delete_channel: ["confirm_delete", "delete"],
'leave_group': ['confirm_leave', 'leave'], leave_group: ["confirm_leave", "leave"],
'leave_server': ['confirm_leave', 'leave'], leave_server: ["confirm_leave", "leave"],
'unfriend_user': ['unfriend_user', 'remove'], unfriend_user: ["unfriend_user", "remove"],
'block_user': ['block_user', 'block'] block_user: ["block_user", "block"],
}; };
let event = EVENTS[props.type]; let event = EVENTS[props.type];
let name; let name;
switch (props.type) { switch (props.type) {
case 'unfriend_user': case "unfriend_user":
case 'block_user': name = props.target.username; break; case "block_user":
case 'close_dm': name = client.users.get(client.channels.getRecipient(props.target._id))?.username; break; name = props.target.username;
default: name = props.target.name; break;
} case "close_dm":
name = client.users.get(
client.channels.getRecipient(props.target._id),
)?.username;
break;
default:
name = props.target.name;
}
return ( return (
<PromptModal <PromptModal
onClose={onClose} onClose={onClose}
question={<Text question={
id={`app.special.modals.prompt.${event[0]}`} <Text
fields={{ name }} id={`app.special.modals.prompt.${event[0]}`}
/>} fields={{ name }}
actions={[ />
{ }
confirmation: true, actions={[
contrast: true, {
error: true, confirmation: true,
text: <Text id={`app.special.modals.actions.${event[1]}`} />, contrast: true,
onClick: async () => { error: true,
setProcessing(true); text: (
<Text
id={`app.special.modals.actions.${event[1]}`}
/>
),
onClick: async () => {
setProcessing(true);
try { try {
switch (props.type) { switch (props.type) {
case 'unfriend_user': case "unfriend_user":
await client.users.removeFriend(props.target._id); break; await client.users.removeFriend(
case 'block_user': props.target._id,
await client.users.blockUser(props.target._id); break; );
case 'leave_group': break;
case 'close_dm': case "block_user":
case 'delete_channel': await client.users.blockUser(
await client.channels.delete(props.target._id); break; props.target._id,
case 'leave_server': );
case 'delete_server': break;
await client.servers.delete(props.target._id); break; case "leave_group":
} case "close_dm":
case "delete_channel":
await client.channels.delete(
props.target._id,
);
break;
case "leave_server":
case "delete_server":
await client.servers.delete(
props.target._id,
);
break;
}
onClose(); onClose();
} catch (err) { } catch (err) {
setError(takeError(err)); setError(takeError(err));
setProcessing(false); setProcessing(false);
} }
} },
}, },
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose } {
]} text: (
content={<TextReact id={`app.special.modals.prompt.${event[0]}_long`} fields={{ name: <b>{ name }</b> }} />} <Text id="app.special.modals.actions.cancel" />
disabled={processing} ),
error={error} onClick: onClose,
/> },
) ]}
} content={
case 'delete_message': { <TextReact
return ( id={`app.special.modals.prompt.${event[0]}_long`}
<PromptModal fields={{ name: <b>{name}</b> }}
onClose={onClose} />
question={<Text id={'app.context_menu.delete_message'} />} }
actions={[ disabled={processing}
{ error={error}
confirmation: true, />
contrast: true, );
error: true, }
text: <Text id="app.special.modals.actions.delete" />, case "delete_message": {
onClick: async () => { return (
setProcessing(true); <PromptModal
onClose={onClose}
question={<Text id={"app.context_menu.delete_message"} />}
actions={[
{
confirmation: true,
contrast: true,
error: true,
text: (
<Text id="app.special.modals.actions.delete" />
),
onClick: async () => {
setProcessing(true);
try { try {
await client.channels.deleteMessage(props.target.channel, props.target._id); await client.channels.deleteMessage(
props.target.channel,
props.target._id,
);
onClose(); onClose();
} catch (err) { } catch (err) {
setError(takeError(err)); setError(takeError(err));
setProcessing(false); setProcessing(false);
} }
} },
}, },
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose } {
]} text: (
content={<> <Text id="app.special.modals.actions.cancel" />
<Text id={`app.special.modals.prompt.confirm_delete_message_long`} /> ),
<Message message={mapMessage(props.target)} head={true} contrast /> onClick: onClose,
</>} },
disabled={processing} ]}
error={error} content={
/> <>
) <Text
} id={`app.special.modals.prompt.confirm_delete_message_long`}
case "create_invite": { />
const [ code, setCode ] = useState('abcdef'); <Message
const { writeClipboard } = useIntermediate(); message={mapMessage(props.target)}
head={true}
contrast
/>
</>
}
disabled={processing}
error={error}
/>
);
}
case "create_invite": {
const [code, setCode] = useState("abcdef");
const { writeClipboard } = useIntermediate();
useEffect(() => { useEffect(() => {
setProcessing(true); setProcessing(true);
client.channels.createInvite(props.target._id) client.channels
.then(code => setCode(code)) .createInvite(props.target._id)
.catch(err => setError(takeError(err))) .then((code) => setCode(code))
.finally(() => setProcessing(false)); .catch((err) => setError(takeError(err)))
}, []); .finally(() => setProcessing(false));
}, []);
return ( return (
<PromptModal <PromptModal
onClose={onClose} onClose={onClose}
question={<Text id={`app.context_menu.create_invite`} />} question={<Text id={`app.context_menu.create_invite`} />}
actions={[ actions={[
{ {
text: <Text id="app.special.modals.actions.ok" />, text: <Text id="app.special.modals.actions.ok" />,
confirmation: true, confirmation: true,
onClick: onClose onClick: onClose,
}, },
{ {
text: <Text id="app.context_menu.copy_link" />, text: <Text id="app.context_menu.copy_link" />,
onClick: () => writeClipboard(`${window.location.protocol}//${window.location.host}/invite/${code}`) onClick: () =>
} writeClipboard(
]} `${window.location.protocol}//${window.location.host}/invite/${code}`,
content={ ),
processing ? },
<Text id="app.special.modals.prompt.create_invite_generate" /> ]}
: <div className={styles.invite}> content={
<Text id="app.special.modals.prompt.create_invite_created" /> processing ? (
<code>{code}</code> <Text id="app.special.modals.prompt.create_invite_generate" />
</div> ) : (
} <div className={styles.invite}>
disabled={processing} <Text id="app.special.modals.prompt.create_invite_created" />
error={error} <code>{code}</code>
/> </div>
) )
} }
case "kick_member": { disabled={processing}
const user = client.users.get(props.user); error={error}
/>
);
}
case "kick_member": {
const user = client.users.get(props.user);
return ( return (
<PromptModal <PromptModal
onClose={onClose} onClose={onClose}
question={<Text id={`app.context_menu.kick_member`} />} question={<Text id={`app.context_menu.kick_member`} />}
actions={[ actions={[
{ {
text: <Text id="app.special.modals.actions.kick" />, text: <Text id="app.special.modals.actions.kick" />,
contrast: true, contrast: true,
error: true, error: true,
confirmation: true, confirmation: true,
onClick: async () => { onClick: async () => {
setProcessing(true); setProcessing(true);
try { try {
await client.servers.members.kickMember(props.target._id, props.user); await client.servers.members.kickMember(
onClose(); props.target._id,
} catch (err) { props.user,
setError(takeError(err)); );
setProcessing(false); onClose();
} } catch (err) {
} setError(takeError(err));
}, setProcessing(false);
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose } }
]} },
content={<div className={styles.column}> },
<UserIcon target={user} size={64} /> {
<Text text: (
id="app.special.modals.prompt.confirm_kick" <Text id="app.special.modals.actions.cancel" />
fields={{ name: user?.username }} /> ),
</div>} onClick: onClose,
disabled={processing} },
error={error} ]}
/> content={
) <div className={styles.column}>
} <UserIcon target={user} size={64} />
case "ban_member": { <Text
const [ reason, setReason ] = useState<string | undefined>(undefined); id="app.special.modals.prompt.confirm_kick"
const user = client.users.get(props.user); fields={{ name: user?.username }}
/>
</div>
}
disabled={processing}
error={error}
/>
);
}
case "ban_member": {
const [reason, setReason] = useState<string | undefined>(undefined);
const user = client.users.get(props.user);
return ( return (
<PromptModal <PromptModal
onClose={onClose} onClose={onClose}
question={<Text id={`app.context_menu.ban_member`} />} question={<Text id={`app.context_menu.ban_member`} />}
actions={[ actions={[
{ {
text: <Text id="app.special.modals.actions.ban" />, text: <Text id="app.special.modals.actions.ban" />,
contrast: true, contrast: true,
error: true, error: true,
confirmation: true, confirmation: true,
onClick: async () => { onClick: async () => {
setProcessing(true); setProcessing(true);
try { try {
await client.servers.banUser(props.target._id, props.user, { reason }); await client.servers.banUser(
onClose(); props.target._id,
} catch (err) { props.user,
setError(takeError(err)); { reason },
setProcessing(false); );
} onClose();
} } catch (err) {
}, setError(takeError(err));
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose } setProcessing(false);
]} }
content={<div className={styles.column}> },
<UserIcon target={user} size={64} /> },
<Text {
id="app.special.modals.prompt.confirm_ban" text: (
fields={{ name: user?.username }} /> <Text id="app.special.modals.actions.cancel" />
<Overline><Text id="app.special.modals.prompt.confirm_ban_reason" /></Overline> ),
<InputBox value={reason ?? ''} onChange={e => setReason(e.currentTarget.value)} /> onClick: onClose,
</div>} },
disabled={processing} ]}
error={error} content={
/> <div className={styles.column}>
) <UserIcon target={user} size={64} />
} <Text
case 'create_channel': { id="app.special.modals.prompt.confirm_ban"
const [ name, setName ] = useState(''); fields={{ name: user?.username }}
const [ type, setType ] = useState<'Text' | 'Voice'>('Text'); />
const history = useHistory(); <Overline>
<Text id="app.special.modals.prompt.confirm_ban_reason" />
</Overline>
<InputBox
value={reason ?? ""}
onChange={(e) =>
setReason(e.currentTarget.value)
}
/>
</div>
}
disabled={processing}
error={error}
/>
);
}
case "create_channel": {
const [name, setName] = useState("");
const [type, setType] = useState<"Text" | "Voice">("Text");
const history = useHistory();
return ( return (
<PromptModal <PromptModal
onClose={onClose} onClose={onClose}
question={<Text id="app.context_menu.create_channel" />} question={<Text id="app.context_menu.create_channel" />}
actions={[ actions={[
{ {
confirmation: true, confirmation: true,
contrast: true, contrast: true,
text: <Text id="app.special.modals.actions.create" />, text: (
onClick: async () => { <Text id="app.special.modals.actions.create" />
setProcessing(true); ),
onClick: async () => {
setProcessing(true);
try { try {
const channel = await client.servers.createChannel( const channel =
props.target._id, await client.servers.createChannel(
{ props.target._id,
type, {
name, type,
nonce: ulid() name,
} nonce: ulid(),
); },
);
history.push(`/server/${props.target._id}/channel/${channel._id}`); history.push(
onClose(); `/server/${props.target._id}/channel/${channel._id}`,
} catch (err) { );
setError(takeError(err)); onClose();
setProcessing(false); } catch (err) {
} setError(takeError(err));
} setProcessing(false);
}, }
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose } },
]} },
content={<> {
<Overline block type="subtle"><Text id="app.main.servers.channel_type" /></Overline> text: (
<Radio checked={type === 'Text'} onSelect={() => setType('Text')}> <Text id="app.special.modals.actions.cancel" />
<Text id="app.main.servers.text_channel" /></Radio> ),
<Radio checked={type === 'Voice'} onSelect={() => setType('Voice')}> onClick: onClose,
<Text id="app.main.servers.voice_channel" /></Radio> },
<Overline block type="subtle"><Text id="app.main.servers.channel_name" /></Overline> ]}
<InputBox content={
value={name} <>
onChange={e => setName(e.currentTarget.value)} /> <Overline block type="subtle">
</>} <Text id="app.main.servers.channel_type" />
disabled={processing} </Overline>
error={error} <Radio
/> checked={type === "Text"}
) onSelect={() => setType("Text")}>
} <Text id="app.main.servers.text_channel" />
default: return null; </Radio>
} <Radio
checked={type === "Voice"}
onSelect={() => setType("Voice")}>
<Text id="app.main.servers.voice_channel" />
</Radio>
<Overline block type="subtle">
<Text id="app.main.servers.channel_name" />
</Overline>
<InputBox
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
</>
}
disabled={processing}
error={error}
/>
);
}
default:
return null;
}
} }

View file

@ -1,23 +1,24 @@
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal"; import Modal from "../../../components/ui/Modal";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
} }
export function SignedOutModal({ onClose }: Props) { export function SignedOutModal({ onClose }: Props) {
return ( return (
<Modal <Modal
visible={true} visible={true}
onClose={onClose} onClose={onClose}
title={<Text id="app.special.modals.signed_out" />} title={<Text id="app.special.modals.signed_out" />}
actions={[ actions={[
{ {
onClick: onClose, onClick: onClose,
confirmation: true, confirmation: true,
text: <Text id="app.special.modals.actions.ok" /> text: <Text id="app.special.modals.actions.ok" />,
} },
]} ]}
/> />
); );
} }

View file

@ -1,38 +1,44 @@
import { X } from "@styled-icons/boxicons-regular"; import { X } from "@styled-icons/boxicons-regular";
import styles from "./ChannelInfo.module.scss"; import styles from "./ChannelInfo.module.scss";
import Modal from "../../../components/ui/Modal"; import Modal from "../../../components/ui/Modal";
import { getChannelName } from "../../revoltjs/util";
import Markdown from "../../../components/markdown/Markdown"; import Markdown from "../../../components/markdown/Markdown";
import { useChannel, useForceUpdate } from "../../revoltjs/hooks"; import { useChannel, useForceUpdate } from "../../revoltjs/hooks";
import { getChannelName } from "../../revoltjs/util";
interface Props { interface Props {
channel_id: string; channel_id: string;
onClose: () => void; onClose: () => void;
} }
export function ChannelInfo({ channel_id, onClose }: Props) { export function ChannelInfo({ channel_id, onClose }: Props) {
const ctx = useForceUpdate(); const ctx = useForceUpdate();
const channel = useChannel(channel_id, ctx); const channel = useChannel(channel_id, ctx);
if (!channel) return null; if (!channel) return null;
if (channel.channel_type === "DirectMessage" || channel.channel_type === 'SavedMessages') { if (
onClose(); channel.channel_type === "DirectMessage" ||
return null; channel.channel_type === "SavedMessages"
} ) {
onClose();
return null;
}
return ( return (
<Modal visible={true} onClose={onClose}> <Modal visible={true} onClose={onClose}>
<div className={styles.info}> <div className={styles.info}>
<div className={styles.header}> <div className={styles.header}>
<h1>{ getChannelName(ctx.client, channel, true) }</h1> <h1>{getChannelName(ctx.client, channel, true)}</h1>
<div onClick={onClose}> <div onClick={onClose}>
<X size={36} /> <X size={36} />
</div> </div>
</div> </div>
<p> <p>
<Markdown content={channel.description} /> <Markdown content={channel.description} />
</p> </p>
</div> </div>
</Modal> </Modal>
); );
} }

View file

@ -1,43 +1,46 @@
import styles from "./ImageViewer.module.scss";
import Modal from "../../../components/ui/Modal";
import { useContext, useEffect } from "preact/hooks";
import { AppContext } from "../../revoltjs/RevoltClient";
import { Attachment, EmbedImage } from "revolt.js/dist/api/objects"; import { Attachment, EmbedImage } from "revolt.js/dist/api/objects";
import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
import styles from "./ImageViewer.module.scss";
import { useContext, useEffect } from "preact/hooks";
import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions"; import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions";
import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
import Modal from "../../../components/ui/Modal";
import { AppContext } from "../../revoltjs/RevoltClient";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
embed?: EmbedImage; embed?: EmbedImage;
attachment?: Attachment; attachment?: Attachment;
} }
export function ImageViewer({ attachment, embed, onClose }: Props) { export function ImageViewer({ attachment, embed, onClose }: Props) {
// ! FIXME: temp code // ! FIXME: temp code
// ! add proxy function to client // ! add proxy function to client
function proxyImage(url: string) { function proxyImage(url: string) {
return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url); return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
} }
if (attachment && attachment.metadata.type !== "Image") return null; if (attachment && attachment.metadata.type !== "Image") return null;
const client = useContext(AppContext); const client = useContext(AppContext);
return ( return (
<Modal visible={true} onClose={onClose} noBackground> <Modal visible={true} onClose={onClose} noBackground>
<div className={styles.viewer}> <div className={styles.viewer}>
{ attachment && {attachment && (
<> <>
<img src={client.generateFileURL(attachment)} /> <img src={client.generateFileURL(attachment)} />
<AttachmentActions attachment={attachment} /> <AttachmentActions attachment={attachment} />
</> </>
} )}
{ embed && {embed && (
<> <>
<img src={proxyImage(embed.url)} /> <img src={proxyImage(embed.url)} />
<EmbedMediaActions embed={embed} /> <EmbedMediaActions embed={embed} />
</> </>
} )}
</div> </div>
</Modal> </Modal>
); );
} }

View file

@ -1,127 +1,134 @@
import { Text } from "preact-i18n";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import Modal from "../../../components/ui/Modal";
import { takeError } from "../../revoltjs/util"; import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks"; import { useContext, useState } from "preact/hooks";
import FormField from '../../../pages/login/FormField';
import Modal from "../../../components/ui/Modal";
import Overline from "../../../components/ui/Overline"; import Overline from "../../../components/ui/Overline";
import FormField from "../../../pages/login/FormField";
import { AppContext } from "../../revoltjs/RevoltClient"; import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
field: "username" | "email" | "password"; field: "username" | "email" | "password";
} }
interface FormInputs { interface FormInputs {
password: string, password: string;
new_email: string, new_email: string;
new_username: string, new_username: string;
new_password: string, new_password: string;
// TODO: figure out if this is correct or not // TODO: figure out if this is correct or not
// it wasn't in the types before this was typed but the element itself was there // it wasn't in the types before this was typed but the element itself was there
current_password?: string current_password?: string;
} }
export function ModifyAccountModal({ onClose, field }: Props) { export function ModifyAccountModal({ onClose, field }: Props) {
const client = useContext(AppContext); const client = useContext(AppContext);
const { handleSubmit, register, errors } = useForm<FormInputs>(); const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const onSubmit: SubmitHandler<FormInputs> = async ({ const onSubmit: SubmitHandler<FormInputs> = async ({
password, password,
new_username, new_username,
new_email, new_email,
new_password new_password,
}) => { }) => {
try { try {
if (field === "email") { if (field === "email") {
await client.req("POST", "/auth/change/email", { await client.req("POST", "/auth/change/email", {
password, password,
new_email new_email,
}); });
onClose(); onClose();
} else if (field === "password") { } else if (field === "password") {
await client.req("POST", "/auth/change/password", { await client.req("POST", "/auth/change/password", {
password, password,
new_password new_password,
}); });
onClose(); onClose();
} else if (field === "username") { } else if (field === "username") {
await client.req("PATCH", "/users/id/username", { await client.req("PATCH", "/users/id/username", {
username: new_username, username: new_username,
password password,
}); });
onClose(); onClose();
} }
} catch (err) { } catch (err) {
setError(takeError(err)); setError(takeError(err));
} }
} };
return ( return (
<Modal <Modal
visible={true} visible={true}
onClose={onClose} onClose={onClose}
title={<Text id={`app.special.modals.account.change.${field}`} />} title={<Text id={`app.special.modals.account.change.${field}`} />}
actions={[ actions={[
{ {
confirmation: true, confirmation: true,
onClick: handleSubmit(onSubmit), onClick: handleSubmit(onSubmit),
text: text:
field === "email" ? ( field === "email" ? (
<Text id="app.special.modals.actions.send_email" /> <Text id="app.special.modals.actions.send_email" />
) : ( ) : (
<Text id="app.special.modals.actions.update" /> <Text id="app.special.modals.actions.update" />
) ),
}, },
{ {
onClick: onClose, onClick: onClose,
text: <Text id="app.special.modals.actions.close" /> text: <Text id="app.special.modals.actions.close" />,
} },
]} ]}>
> {/* Preact / React typing incompatabilities */}
{/* Preact / React typing incompatabilities */} <form
<form onSubmit={handleSubmit(onSubmit) as JSX.GenericEventHandler<HTMLFormElement>}> onSubmit={
{field === "email" && ( handleSubmit(
<FormField onSubmit,
type="email" ) as JSX.GenericEventHandler<HTMLFormElement>
name="new_email" }>
register={register} {field === "email" && (
showOverline <FormField
error={errors.new_email?.message} type="email"
/> name="new_email"
)} register={register}
{field === "password" && ( showOverline
<FormField error={errors.new_email?.message}
type="password" />
name="new_password" )}
register={register} {field === "password" && (
showOverline <FormField
error={errors.new_password?.message} type="password"
/> name="new_password"
)} register={register}
{field === "username" && ( showOverline
<FormField error={errors.new_password?.message}
type="username" />
name="new_username" )}
register={register} {field === "username" && (
showOverline <FormField
error={errors.new_username?.message} type="username"
/> name="new_username"
)} register={register}
<FormField showOverline
type="current_password" error={errors.new_username?.message}
register={register} />
showOverline )}
error={errors.current_password?.message} <FormField
/> type="current_password"
{error && ( register={register}
<Overline type="error" error={error}> showOverline
<Text id="app.special.modals.account.failed" /> error={errors.current_password?.message}
</Overline> />
)} {error && (
</form> <Overline type="error" error={error}>
</Modal> <Text id="app.special.modals.account.failed" />
); </Overline>
)}
</form>
</Modal>
);
} }

View file

@ -1,27 +1,31 @@
import { Text } from "preact-i18n";
import styles from "./UserPicker.module.scss"; import styles from "./UserPicker.module.scss";
import { useUsers } from "../../revoltjs/hooks"; import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal"; import Modal from "../../../components/ui/Modal";
import { Friend } from "../../../pages/friends/Friend"; import { Friend } from "../../../pages/friends/Friend";
import { useUsers } from "../../revoltjs/hooks";
interface Props { interface Props {
users: string[]; users: string[];
onClose: () => void; onClose: () => void;
} }
export function PendingRequests({ users: ids, onClose }: Props) { export function PendingRequests({ users: ids, onClose }: Props) {
const users = useUsers(ids); const users = useUsers(ids);
return ( return (
<Modal <Modal
visible={true} visible={true}
title={<Text id="app.special.friends.pending" />} title={<Text id="app.special.friends.pending" />}
onClose={onClose}> onClose={onClose}>
<div className={styles.list}> <div className={styles.list}>
{ users {users
.filter(x => typeof x !== 'undefined') .filter((x) => typeof x !== "undefined")
.map(x => <Friend user={x!} key={x!._id} />) } .map((x) => (
</div> <Friend user={x!} key={x!._id} />
</Modal> ))}
); </div>
</Modal>
);
} }

View file

@ -1,64 +1,68 @@
import { User, Users } from "revolt.js/dist/api/objects";
import styles from "./UserPicker.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import styles from "./UserPicker.module.scss";
import { useUsers } from "../../revoltjs/hooks";
import Modal from "../../../components/ui/Modal";
import { User, Users } from "revolt.js/dist/api/objects";
import UserCheckbox from "../../../components/common/user/UserCheckbox"; import UserCheckbox from "../../../components/common/user/UserCheckbox";
import Modal from "../../../components/ui/Modal";
import { useUsers } from "../../revoltjs/hooks";
interface Props { interface Props {
omit?: string[]; omit?: string[];
onClose: () => void; onClose: () => void;
callback: (users: string[]) => Promise<void>; callback: (users: string[]) => Promise<void>;
} }
export function UserPicker(props: Props) { export function UserPicker(props: Props) {
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const omit = [...(props.omit || []), "00000000000000000000000000"]; const omit = [...(props.omit || []), "00000000000000000000000000"];
const users = useUsers(); const users = useUsers();
return ( return (
<Modal <Modal
visible={true} visible={true}
title={<Text id="app.special.popovers.user_picker.select" />} title={<Text id="app.special.popovers.user_picker.select" />}
onClose={props.onClose} onClose={props.onClose}
actions={[ actions={[
{ {
text: <Text id="app.special.modals.actions.ok" />, text: <Text id="app.special.modals.actions.ok" />,
onClick: () => props.callback(selected).then(props.onClose) onClick: () => props.callback(selected).then(props.onClose),
} },
]} ]}>
> <div className={styles.list}>
<div className={styles.list}> {(
{(users.filter( users.filter(
x => (x) =>
x && x &&
x.relationship === Users.Relationship.Friend && x.relationship === Users.Relationship.Friend &&
!omit.includes(x._id) !omit.includes(x._id),
) as User[]) ) as User[]
.map(x => { )
return { .map((x) => {
...x, return {
selected: selected.includes(x._id) ...x,
}; selected: selected.includes(x._id),
}) };
.map(x => ( })
<UserCheckbox .map((x) => (
user={x} <UserCheckbox
checked={x.selected} user={x}
onChange={v => { checked={x.selected}
if (v) { onChange={(v) => {
setSelected([...selected, x._id]); if (v) {
} else { setSelected([...selected, x._id]);
setSelected( } else {
selected.filter(y => y !== x._id) setSelected(
); selected.filter((y) => y !== x._id),
} );
}} }
/> }}
))} />
</div> ))}
</Modal> </div>
); </Modal>
);
} }

View file

@ -1,331 +1,362 @@
import { decodeTime } from "ulid"; import {
Envelope,
Edit,
UserPlus,
Shield,
Money,
} from "@styled-icons/boxicons-regular";
import { Link, useHistory } from "react-router-dom"; import { Link, useHistory } from "react-router-dom";
import { Localizer, Text } from "preact-i18n";
import styles from "./UserProfile.module.scss";
import Modal from "../../../components/ui/Modal";
import { Route } from "revolt.js/dist/api/routes";
import { Users } from "revolt.js/dist/api/objects"; import { Users } from "revolt.js/dist/api/objects";
import { useIntermediate } from "../Intermediate";
import Preloader from "../../../components/ui/Preloader";
import Tooltip from '../../../components/common/Tooltip';
import IconButton from "../../../components/ui/IconButton";
import Markdown from '../../../components/markdown/Markdown';
import { UserPermission } from "revolt.js/dist/api/permissions"; import { UserPermission } from "revolt.js/dist/api/permissions";
import UserIcon from '../../../components/common/user/UserIcon'; import { Route } from "revolt.js/dist/api/routes";
import ChannelIcon from '../../../components/common/ChannelIcon'; import { decodeTime } from "ulid";
import UserStatus from '../../../components/common/user/UserStatus';
import { Envelope, Edit, UserPlus, Shield, Money } from "@styled-icons/boxicons-regular"; import styles from "./UserProfile.module.scss";
import { Localizer, Text } from "preact-i18n";
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks"; import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks";
import { AppContext, ClientStatus, StatusContext } from "../../revoltjs/RevoltClient";
import { useChannels, useForceUpdate, useUserPermission, useUsers } from "../../revoltjs/hooks"; import ChannelIcon from "../../../components/common/ChannelIcon";
import Tooltip from "../../../components/common/Tooltip";
import UserIcon from "../../../components/common/user/UserIcon";
import UserStatus from "../../../components/common/user/UserStatus";
import IconButton from "../../../components/ui/IconButton";
import Modal from "../../../components/ui/Modal";
import Preloader from "../../../components/ui/Preloader";
import Markdown from "../../../components/markdown/Markdown";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../../revoltjs/RevoltClient";
import {
useChannels,
useForceUpdate,
useUserPermission,
useUsers,
} from "../../revoltjs/hooks";
import { useIntermediate } from "../Intermediate";
interface Props { interface Props {
user_id: string; user_id: string;
dummy?: boolean; dummy?: boolean;
onClose: () => void; onClose: () => void;
dummyProfile?: Users.Profile; dummyProfile?: Users.Profile;
} }
enum Badges { enum Badges {
Developer = 1, Developer = 1,
Translator = 2, Translator = 2,
Supporter = 4, Supporter = 4,
ResponsibleDisclosure = 8, ResponsibleDisclosure = 8,
EarlyAdopter = 256 EarlyAdopter = 256,
} }
export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) { export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) {
const { openScreen, writeClipboard } = useIntermediate(); const { openScreen, writeClipboard } = useIntermediate();
const [profile, setProfile] = useState<undefined | null | Users.Profile>( const [profile, setProfile] = useState<undefined | null | Users.Profile>(
undefined undefined,
); );
const [mutual, setMutual] = useState< const [mutual, setMutual] = useState<
undefined | null | Route<"GET", "/users/id/mutual">["response"] undefined | null | Route<"GET", "/users/id/mutual">["response"]
>(undefined); >(undefined);
const history = useHistory(); const history = useHistory();
const client = useContext(AppContext); const client = useContext(AppContext);
const status = useContext(StatusContext); const status = useContext(StatusContext);
const [tab, setTab] = useState("profile"); const [tab, setTab] = useState("profile");
const ctx = useForceUpdate(); const ctx = useForceUpdate();
const all_users = useUsers(undefined, ctx); const all_users = useUsers(undefined, ctx);
const channels = useChannels(undefined, ctx); const channels = useChannels(undefined, ctx);
const user = all_users.find(x => x!._id === user_id); const user = all_users.find((x) => x!._id === user_id);
const users = mutual?.users ? all_users.filter(x => mutual.users.includes(x!._id)) : undefined; const users = mutual?.users
? all_users.filter((x) => mutual.users.includes(x!._id))
: undefined;
if (!user) { if (!user) {
useEffect(onClose, []); useEffect(onClose, []);
return null; return null;
} }
const permissions = useUserPermission(user!._id, ctx); const permissions = useUserPermission(user!._id, ctx);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!user_id) return; if (!user_id) return;
if (typeof profile !== 'undefined') setProfile(undefined); if (typeof profile !== "undefined") setProfile(undefined);
if (typeof mutual !== 'undefined') setMutual(undefined); if (typeof mutual !== "undefined") setMutual(undefined);
}, [user_id]); }, [user_id]);
if (dummy) { if (dummy) {
useLayoutEffect(() => { useLayoutEffect(() => {
setProfile(dummyProfile); setProfile(dummyProfile);
}, [dummyProfile]); }, [dummyProfile]);
} }
useEffect(() => { useEffect(() => {
if (dummy) return; if (dummy) return;
if ( if (status === ClientStatus.ONLINE && typeof mutual === "undefined") {
status === ClientStatus.ONLINE && setMutual(null);
typeof mutual === "undefined" client.users.fetchMutual(user_id).then((data) => setMutual(data));
) { }
setMutual(null); }, [mutual, status]);
client.users
.fetchMutual(user_id)
.then(data => setMutual(data));
}
}, [mutual, status]);
useEffect(() => { useEffect(() => {
if (dummy) return; if (dummy) return;
if ( if (status === ClientStatus.ONLINE && typeof profile === "undefined") {
status === ClientStatus.ONLINE && setProfile(null);
typeof profile === "undefined"
) {
setProfile(null);
if (permissions & UserPermission.ViewProfile) { if (permissions & UserPermission.ViewProfile) {
client.users client.users
.fetchProfile(user_id) .fetchProfile(user_id)
.then(data => setProfile(data)) .then((data) => setProfile(data))
.catch(() => {}); .catch(() => {});
} }
} }
}, [profile, status]); }, [profile, status]);
const mutualGroups = channels.filter( const mutualGroups = channels.filter(
channel => (channel) =>
channel?.channel_type === "Group" && channel?.channel_type === "Group" &&
channel.recipients.includes(user_id) channel.recipients.includes(user_id),
); );
const backgroundURL = profile && client.users.getBackgroundURL(profile, { width: 1000 }, true); const backgroundURL =
const badges = (user.badges ?? 0) | (decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0); profile &&
client.users.getBackgroundURL(profile, { width: 1000 }, true);
const badges =
(user.badges ?? 0) |
(decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0);
return ( return (
<Modal visible <Modal
border={dummy} visible
padding={false} border={dummy}
onClose={onClose} padding={false}
dontModal={dummy}> onClose={onClose}
<div dontModal={dummy}>
className={styles.header} <div
data-force={ className={styles.header}
profile?.background data-force={profile?.background ? "light" : undefined}
? "light" style={{
: undefined backgroundImage:
} backgroundURL &&
style={{ `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`,
backgroundImage: backgroundURL && `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')` }}>
}} <div className={styles.profile}>
> <UserIcon size={80} target={user} status />
<div className={styles.profile}> <div className={styles.details}>
<UserIcon size={80} target={user} status /> <Localizer>
<div className={styles.details}> <span
<Localizer> className={styles.username}
<span onClick={() => writeClipboard(user.username)}>
className={styles.username} @{user.username}
onClick={() => writeClipboard(user.username)}> </span>
@{user.username} </Localizer>
</span> {user.status?.text && (
</Localizer> <span className={styles.status}>
{user.status?.text && ( <UserStatus user={user} />
<span className={styles.status}> </span>
<UserStatus user={user} /> )}
</span> </div>
)} {user.relationship === Users.Relationship.Friend && (
</div> <Localizer>
{user.relationship === Users.Relationship.Friend && ( <Tooltip
<Localizer> content={
<Tooltip <Text id="app.context_menu.message_user" />
content={ }>
<Text id="app.context_menu.message_user" /> <IconButton
} onClick={() => {
> onClose();
<IconButton history.push(`/open/${user_id}`);
onClick={() => { }}>
onClose(); <Envelope size={30} />
history.push(`/open/${user_id}`); </IconButton>
}}> </Tooltip>
<Envelope size={30} /> </Localizer>
</IconButton> )}
</Tooltip> {user.relationship === Users.Relationship.User && (
</Localizer> <IconButton
)} onClick={() => {
{user.relationship === Users.Relationship.User && ( onClose();
<IconButton if (dummy) return;
onClick={() => { history.push(`/settings/profile`);
onClose(); }}>
if (dummy) return; <Edit size={28} />
history.push(`/settings/profile`); </IconButton>
}}> )}
<Edit size={28} /> {(user.relationship === Users.Relationship.Incoming ||
</IconButton> user.relationship === Users.Relationship.None) && (
)} <IconButton
{(user.relationship === Users.Relationship.Incoming || onClick={() =>
user.relationship === Users.Relationship.None) && ( client.users.addFriend(user.username)
<IconButton onClick={() => client.users.addFriend(user.username)}> }>
<UserPlus size={28} /> <UserPlus size={28} />
</IconButton> </IconButton>
)} )}
</div> </div>
<div className={styles.tabs}> <div className={styles.tabs}>
<div <div
data-active={tab === "profile"} data-active={tab === "profile"}
onClick={() => setTab("profile")} onClick={() => setTab("profile")}>
> <Text id="app.special.popovers.user_profile.profile" />
<Text id="app.special.popovers.user_profile.profile" /> </div>
</div> {user.relationship !== Users.Relationship.User && (
{ user.relationship !== Users.Relationship.User && <>
<> <div
<div data-active={tab === "friends"}
data-active={tab === "friends"} onClick={() => setTab("friends")}>
onClick={() => setTab("friends")} <Text id="app.special.popovers.user_profile.mutual_friends" />
> </div>
<Text id="app.special.popovers.user_profile.mutual_friends" /> <div
</div> data-active={tab === "groups"}
<div onClick={() => setTab("groups")}>
data-active={tab === "groups"} <Text id="app.special.popovers.user_profile.mutual_groups" />
onClick={() => setTab("groups")} </div>
> </>
<Text id="app.special.popovers.user_profile.mutual_groups" /> )}
</div> </div>
</> </div>
} <div className={styles.content}>
</div> {tab === "profile" && (
</div> <div>
<div className={styles.content}> {!(profile?.content || badges > 0) && (
{tab === "profile" && <div className={styles.empty}>
<div> <Text id="app.special.popovers.user_profile.empty" />
{ !(profile?.content || (badges > 0)) && </div>
<div className={styles.empty}><Text id="app.special.popovers.user_profile.empty" /></div> } )}
{ (badges > 0) && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.badges" /></div> } {badges > 0 && (
{ (badges > 0) && ( <div className={styles.category}>
<div className={styles.badges}> <Text id="app.special.popovers.user_profile.sub.badges" />
<Localizer> </div>
{badges & Badges.Developer ? ( )}
<Tooltip {badges > 0 && (
content={ <div className={styles.badges}>
<Text id="app.navigation.tabs.dev" /> <Localizer>
} {badges & Badges.Developer ? (
> <Tooltip
<img src="/assets/badges/developer.svg" /> content={
</Tooltip> <Text id="app.navigation.tabs.dev" />
) : ( }>
<></> <img src="/assets/badges/developer.svg" />
)} </Tooltip>
{badges & Badges.Translator ? ( ) : (
<Tooltip <></>
content={ )}
<Text id="app.special.popovers.user_profile.badges.translator" /> {badges & Badges.Translator ? (
} <Tooltip
> content={
<img src="/assets/badges/translator.svg" /> <Text id="app.special.popovers.user_profile.badges.translator" />
</Tooltip> }>
) : ( <img src="/assets/badges/translator.svg" />
<></> </Tooltip>
)} ) : (
{badges & Badges.EarlyAdopter ? ( <></>
<Tooltip )}
content={ {badges & Badges.EarlyAdopter ? (
<Text id="app.special.popovers.user_profile.badges.early_adopter" /> <Tooltip
} content={
> <Text id="app.special.popovers.user_profile.badges.early_adopter" />
<img src="/assets/badges/early_adopter.svg" /> }>
</Tooltip> <img src="/assets/badges/early_adopter.svg" />
) : ( </Tooltip>
<></> ) : (
)} <></>
{badges & Badges.Supporter ? ( )}
<Tooltip {badges & Badges.Supporter ? (
content={ <Tooltip
<Text id="app.special.popovers.user_profile.badges.supporter" /> content={
} <Text id="app.special.popovers.user_profile.badges.supporter" />
> }>
<Money size={32} color="#efab44" /> <Money size={32} color="#efab44" />
</Tooltip> </Tooltip>
) : ( ) : (
<></> <></>
)} )}
{badges & Badges.ResponsibleDisclosure ? ( {badges & Badges.ResponsibleDisclosure ? (
<Tooltip <Tooltip
content={ content={
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" /> <Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
} }>
> <Shield size={32} color="gray" />
<Shield size={32} color="gray" /> </Tooltip>
</Tooltip> ) : (
) : ( <></>
<></> )}
)} </Localizer>
</Localizer> </div>
</div> )}
)} {profile?.content && (
{ profile?.content && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.information" /></div> } <div className={styles.category}>
<Markdown content={profile?.content} /> <Text id="app.special.popovers.user_profile.sub.information" />
{/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/} </div>
</div>} )}
{tab === "friends" && <Markdown content={profile?.content} />
(users ? ( {/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/}
<div className={styles.entries}> </div>
{users.length === 0 ? ( )}
<div className={styles.empty}> {tab === "friends" &&
<Text id="app.special.popovers.user_profile.no_users" /> (users ? (
</div> <div className={styles.entries}>
) : ( {users.length === 0 ? (
users.map( <div className={styles.empty}>
x => <Text id="app.special.popovers.user_profile.no_users" />
x && ( </div>
<div onClick={() => openScreen({ id: 'profile', user_id: x._id })} ) : (
className={styles.entry} users.map(
key={x._id}> (x) =>
<UserIcon size={32} target={x} /> x && (
<span>{x.username}</span> <div
</div> onClick={() =>
) openScreen({
) id: "profile",
)} user_id: x._id,
</div> })
) : ( }
<Preloader type="ring" /> className={styles.entry}
))} key={x._id}>
{tab === "groups" && ( <UserIcon
<div className={styles.entries}> size={32}
{mutualGroups.length === 0 ? ( target={x}
<div className={styles.empty}> />
<Text id="app.special.popovers.user_profile.no_groups" /> <span>{x.username}</span>
</div> </div>
) : ( ),
mutualGroups.map( )
x => )}
x?.channel_type === "Group" && ( </div>
<Link to={`/channel/${x._id}`}> ) : (
<div <Preloader type="ring" />
className={styles.entry} ))}
key={x._id} {tab === "groups" && (
> <div className={styles.entries}>
<ChannelIcon target={x} size={32} /> {mutualGroups.length === 0 ? (
<span>{x.name}</span> <div className={styles.empty}>
</div> <Text id="app.special.popovers.user_profile.no_groups" />
</Link> </div>
) ) : (
) mutualGroups.map(
)} (x) =>
</div> x?.channel_type === "Group" && (
)} <Link to={`/channel/${x._id}`}>
</div> <div
</Modal> className={styles.entry}
); key={x._id}>
<ChannelIcon
target={x}
size={32}
/>
<span>{x.name}</span>
</div>
</Link>
),
)
)}
</div>
)}
</div>
</Modal>
);
} }

View file

@ -1,22 +1,23 @@
import { useContext } from "preact/hooks";
import { Redirect } from "react-router-dom"; import { Redirect } from "react-router-dom";
import { Children } from "../../types/Preact";
import { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { OperationsContext } from "./RevoltClient"; import { OperationsContext } from "./RevoltClient";
interface Props { interface Props {
auth?: boolean; auth?: boolean;
children: Children; children: Children;
} }
export const CheckAuth = (props: Props) => { export const CheckAuth = (props: Props) => {
const operations = useContext(OperationsContext); const operations = useContext(OperationsContext);
if (props.auth && !operations.ready()) { if (props.auth && !operations.ready()) {
return <Redirect to="/login" />; return <Redirect to="/login" />;
} else if (!props.auth && operations.ready()) { } else if (!props.auth && operations.ready()) {
return <Redirect to="/" />; return <Redirect to="/" />;
} }
return <>{props.children}</>; return <>{props.children}</>;
}; };

View file

@ -1,221 +1,292 @@
import { Text } from "preact-i18n";
import { takeError } from "./util";
import classNames from "classnames";
import { AppContext } from "./RevoltClient";
import styles from './FileUploads.module.scss';
import Axios, { AxiosRequestConfig } from "axios";
import { useContext, useEffect, useState } from "preact/hooks";
import Preloader from "../../components/ui/Preloader";
import { determineFileSize } from "../../lib/fileSize";
import IconButton from '../../components/ui/IconButton';
import { Plus, X, XCircle } from "@styled-icons/boxicons-regular"; import { Plus, X, XCircle } from "@styled-icons/boxicons-regular";
import { Pencil } from "@styled-icons/boxicons-solid"; import { Pencil } from "@styled-icons/boxicons-solid";
import Axios, { AxiosRequestConfig } from "axios";
import styles from "./FileUploads.module.scss";
import classNames from "classnames";
import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks";
import { determineFileSize } from "../../lib/fileSize";
import IconButton from "../../components/ui/IconButton";
import Preloader from "../../components/ui/Preloader";
import { useIntermediate } from "../intermediate/Intermediate"; import { useIntermediate } from "../intermediate/Intermediate";
import { AppContext } from "./RevoltClient";
import { takeError } from "./util";
type Props = { type Props = {
maxFileSize: number maxFileSize: number;
remove: () => Promise<void> remove: () => Promise<void>;
fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners' fileType: "backgrounds" | "icons" | "avatars" | "attachments" | "banners";
} & ( } & (
{ behaviour: 'ask', onChange: (file: File) => void } | | { behaviour: "ask"; onChange: (file: File) => void }
{ behaviour: 'upload', onUpload: (id: string) => Promise<void> } | | { behaviour: "upload"; onUpload: (id: string) => Promise<void> }
{ behaviour: 'multi', onChange: (files: File[]) => void, append?: (files: File[]) => void } | {
) & ( behaviour: "multi";
{ style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } | onChange: (files: File[]) => void;
{ style: 'attachment', attached: boolean, uploading: boolean, cancel: () => void, size?: number } append?: (files: File[]) => void;
) }
) &
(
| {
style: "icon" | "banner";
defaultPreview?: string;
previewURL?: string;
width?: number;
height?: number;
}
| {
style: "attachment";
attached: boolean;
uploading: boolean;
cancel: () => void;
size?: number;
}
);
export async function uploadFile(autumnURL: string, tag: string, file: File, config?: AxiosRequestConfig) { export async function uploadFile(
const formData = new FormData(); autumnURL: string,
formData.append("file", file); tag: string,
file: File,
config?: AxiosRequestConfig,
) {
const formData = new FormData();
formData.append("file", file);
const res = await Axios.post(autumnURL + "/" + tag, formData, { const res = await Axios.post(autumnURL + "/" + tag, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data" "Content-Type": "multipart/form-data",
}, },
...config ...config,
}); });
return res.data.id; return res.data.id;
} }
export function grabFiles(maxFileSize: number, cb: (files: File[]) => void, tooLarge: () => void, multiple?: boolean) { export function grabFiles(
const input = document.createElement("input"); maxFileSize: number,
input.type = "file"; cb: (files: File[]) => void,
input.multiple = multiple ?? false; tooLarge: () => void,
multiple?: boolean,
) {
const input = document.createElement("input");
input.type = "file";
input.multiple = multiple ?? false;
input.onchange = async (e) => { input.onchange = async (e) => {
const files = (e.currentTarget as HTMLInputElement)?.files; const files = (e.currentTarget as HTMLInputElement)?.files;
if (!files) return; if (!files) return;
for (let file of files) { for (let file of files) {
if (file.size > maxFileSize) { if (file.size > maxFileSize) {
return tooLarge(); return tooLarge();
} }
} }
cb(Array.from(files)); cb(Array.from(files));
}; };
input.click(); input.click();
} }
export function FileUploader(props: Props) { export function FileUploader(props: Props) {
const { fileType, maxFileSize, remove } = props; const { fileType, maxFileSize, remove } = props;
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const client = useContext(AppContext); const client = useContext(AppContext);
const [ uploading, setUploading ] = useState(false); const [uploading, setUploading] = useState(false);
function onClick() { function onClick() {
if (uploading) return; if (uploading) return;
grabFiles(maxFileSize, async files => { grabFiles(
setUploading(true); maxFileSize,
async (files) => {
setUploading(true);
try { try {
if (props.behaviour === 'multi') { if (props.behaviour === "multi") {
props.onChange(files); props.onChange(files);
} else if (props.behaviour === 'ask') { } else if (props.behaviour === "ask") {
props.onChange(files[0]); props.onChange(files[0]);
} else { } else {
await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, files[0])); await props.onUpload(
} await uploadFile(
} catch (err) { client.configuration!.features.autumn.url,
return openScreen({ id: "error", error: takeError(err) }); fileType,
} finally { files[0],
setUploading(false); ),
} );
}, () => }
openScreen({ id: "error", error: "FileTooLarge" }), } catch (err) {
props.behaviour === 'multi'); return openScreen({ id: "error", error: takeError(err) });
} } finally {
setUploading(false);
}
},
() => openScreen({ id: "error", error: "FileTooLarge" }),
props.behaviour === "multi",
);
}
function removeOrUpload() { function removeOrUpload() {
if (uploading) return; if (uploading) return;
if (props.style === 'attachment') { if (props.style === "attachment") {
if (props.attached) { if (props.attached) {
props.remove(); props.remove();
} else { } else {
onClick(); onClick();
} }
} else { } else {
if (props.previewURL) { if (props.previewURL) {
props.remove(); props.remove();
} else { } else {
onClick(); onClick();
} }
} }
} }
if (props.behaviour === 'multi' && props.append) { if (props.behaviour === "multi" && props.append) {
useEffect(() => { useEffect(() => {
// File pasting. // File pasting.
function paste(e: ClipboardEvent) { function paste(e: ClipboardEvent) {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
if (typeof items === "undefined") return; if (typeof items === "undefined") return;
if (props.behaviour !== 'multi' || !props.append) return; if (props.behaviour !== "multi" || !props.append) return;
let files = []; let files = [];
for (const item of items) { for (const item of items) {
if (!item.type.startsWith("text/")) { if (!item.type.startsWith("text/")) {
const blob = item.getAsFile(); const blob = item.getAsFile();
if (blob) { if (blob) {
if (blob.size > props.maxFileSize) { if (blob.size > props.maxFileSize) {
openScreen({ id: 'error', error: 'FileTooLarge' }); openScreen({
} id: "error",
error: "FileTooLarge",
});
}
files.push(blob); files.push(blob);
} }
} }
} }
props.append(files); props.append(files);
} }
// Let the browser know we can drop files. // Let the browser know we can drop files.
function dragover(e: DragEvent) { function dragover(e: DragEvent) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
} }
// File dropping. // File dropping.
function drop(e: DragEvent) { function drop(e: DragEvent) {
e.preventDefault(); e.preventDefault();
if (props.behaviour !== 'multi' || !props.append) return; if (props.behaviour !== "multi" || !props.append) return;
const dropped = e.dataTransfer?.files; const dropped = e.dataTransfer?.files;
if (dropped) { if (dropped) {
let files = []; let files = [];
for (const item of dropped) { for (const item of dropped) {
if (item.size > props.maxFileSize) { if (item.size > props.maxFileSize) {
openScreen({ id: 'error', error: 'FileTooLarge' }); openScreen({ id: "error", error: "FileTooLarge" });
} }
files.push(item); files.push(item);
} }
props.append(files); props.append(files);
} }
} }
document.addEventListener("paste", paste); document.addEventListener("paste", paste);
document.addEventListener("dragover", dragover); document.addEventListener("dragover", dragover);
document.addEventListener("drop", drop); document.addEventListener("drop", drop);
return () => { return () => {
document.removeEventListener("paste", paste); document.removeEventListener("paste", paste);
document.removeEventListener("dragover", dragover); document.removeEventListener("dragover", dragover);
document.removeEventListener("drop", drop); document.removeEventListener("drop", drop);
}; };
}, [ props.append ]); }, [props.append]);
} }
if (props.style === 'icon' || props.style === 'banner') { if (props.style === "icon" || props.style === "banner") {
const { style, previewURL, defaultPreview, width, height } = props; const { style, previewURL, defaultPreview, width, height } = props;
return ( return (
<div className={classNames(styles.uploader, <div
{ [styles.icon]: style === 'icon', className={classNames(styles.uploader, {
[styles.banner]: style === 'banner' })} [styles.icon]: style === "icon",
data-uploading={uploading}> [styles.banner]: style === "banner",
<div className={styles.image} })}
style={{ backgroundImage: data-uploading={uploading}>
style === 'icon' ? `url('${previewURL ?? defaultPreview}')` : <div
(previewURL ? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')` : 'black'), className={styles.image}
width, height style={{
}} backgroundImage:
onClick={onClick}> style === "icon"
{ uploading ? ? `url('${previewURL ?? defaultPreview}')`
<div className={styles.uploading}> : previewURL
<Preloader type="ring" /> ? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')`
</div> : : "black",
<div className={styles.edit}> width,
<Pencil size={30} /> height,
</div> } }}
</div> onClick={onClick}>
<div className={styles.modify}> {uploading ? (
<span onClick={removeOrUpload}>{ <div className={styles.uploading}>
uploading ? <Text id="app.main.channel.uploading_file" /> : <Preloader type="ring" />
props.previewURL ? <Text id="app.settings.actions.remove" /> : </div>
<Text id="app.settings.actions.upload" /> }</span> ) : (
<span className={styles.small}><Text id="app.settings.actions.max_filesize" fields={{ filesize: determineFileSize(maxFileSize) }} /></span> <div className={styles.edit}>
</div> <Pencil size={30} />
</div> </div>
) )}
} else if (props.style === 'attachment') { </div>
const { attached, uploading, cancel, size } = props; <div className={styles.modify}>
return ( <span onClick={removeOrUpload}>
<IconButton {uploading ? (
onClick={() => { <Text id="app.main.channel.uploading_file" />
if (uploading) return cancel(); ) : props.previewURL ? (
if (attached) return remove(); <Text id="app.settings.actions.remove" />
onClick(); ) : (
}}> <Text id="app.settings.actions.upload" />
{ uploading ? <XCircle size={size} /> : attached ? <X size={size} /> : <Plus size={size} />} )}
</IconButton> </span>
) <span className={styles.small}>
} <Text
id="app.settings.actions.max_filesize"
fields={{
filesize: determineFileSize(maxFileSize),
}}
/>
</span>
</div>
</div>
);
} else if (props.style === "attachment") {
const { attached, uploading, cancel, size } = props;
return (
<IconButton
onClick={() => {
if (uploading) return cancel();
if (attached) return remove();
onClick();
}}>
{uploading ? (
<XCircle size={size} />
) : attached ? (
<X size={size} />
) : (
<Plus size={size} />
)}
</IconButton>
);
}
return null; return null;
} }

View file

@ -1,247 +1,286 @@
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { Message, SYSTEM_USER_ID, User } from "revolt.js";
import { Users } from "revolt.js/dist/api/objects";
import { decodeTime } from "ulid"; import { decodeTime } from "ulid";
import { useContext, useEffect } from "preact/hooks";
import { useTranslation } from "../../lib/i18n";
import { connectState } from "../../redux/connector";
import {
getNotificationState,
Notifications,
shouldNotify,
} from "../../redux/reducers/notifications";
import { NotificationOptions } from "../../redux/reducers/settings";
import { SoundContext } from "../Settings"; import { SoundContext } from "../Settings";
import { AppContext } from "./RevoltClient"; import { AppContext } from "./RevoltClient";
import { useTranslation } from "../../lib/i18n";
import { Users } from "revolt.js/dist/api/objects";
import { useContext, useEffect } from "preact/hooks";
import { connectState } from "../../redux/connector";
import { Message, SYSTEM_USER_ID, User } from "revolt.js";
import { NotificationOptions } from "../../redux/reducers/settings";
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { getNotificationState, Notifications, shouldNotify } from "../../redux/reducers/notifications";
interface Props { interface Props {
options?: NotificationOptions; options?: NotificationOptions;
notifs: Notifications; notifs: Notifications;
} }
const notifications: { [key: string]: Notification } = {}; const notifications: { [key: string]: Notification } = {};
async function createNotification(title: string, options: globalThis.NotificationOptions) { async function createNotification(
try { title: string,
return new Notification(title, options); options: globalThis.NotificationOptions,
} catch (err) { ) {
let sw = await navigator.serviceWorker.getRegistration(); try {
sw?.showNotification(title, options); return new Notification(title, options);
} } catch (err) {
let sw = await navigator.serviceWorker.getRegistration();
sw?.showNotification(title, options);
}
} }
function Notifier({ options, notifs }: Props) { function Notifier({ options, notifs }: Props) {
const translate = useTranslation(); const translate = useTranslation();
const showNotification = options?.desktopEnabled ?? false; const showNotification = options?.desktopEnabled ?? false;
const client = useContext(AppContext); const client = useContext(AppContext);
const { guild: guild_id, channel: channel_id } = useParams<{ const { guild: guild_id, channel: channel_id } = useParams<{
guild: string; guild: string;
channel: string; channel: string;
}>(); }>();
const history = useHistory(); const history = useHistory();
const playSound = useContext(SoundContext); const playSound = useContext(SoundContext);
async function message(msg: Message) { async function message(msg: Message) {
if (msg.author === client.user!._id) return; if (msg.author === client.user!._id) return;
if (msg.channel === channel_id && document.hasFocus()) return; if (msg.channel === channel_id && document.hasFocus()) return;
if (client.user!.status?.presence === Users.Presence.Busy) return; if (client.user!.status?.presence === Users.Presence.Busy) return;
const channel = client.channels.get(msg.channel); const channel = client.channels.get(msg.channel);
const author = client.users.get(msg.author); const author = client.users.get(msg.author);
if (!channel) return; if (!channel) return;
if (author?.relationship === Users.Relationship.Blocked) return; if (author?.relationship === Users.Relationship.Blocked) return;
const notifState = getNotificationState(notifs, channel); const notifState = getNotificationState(notifs, channel);
if (!shouldNotify(notifState, msg, client.user!._id)) return; if (!shouldNotify(notifState, msg, client.user!._id)) return;
playSound('message'); playSound("message");
if (!showNotification) return; if (!showNotification) return;
let title; let title;
switch (channel.channel_type) { switch (channel.channel_type) {
case "SavedMessages": case "SavedMessages":
return; return;
case "DirectMessage": case "DirectMessage":
title = `@${author?.username}`; title = `@${author?.username}`;
break; break;
case "Group": case "Group":
if (author?._id === SYSTEM_USER_ID) { if (author?._id === SYSTEM_USER_ID) {
title = channel.name; title = channel.name;
} else { } else {
title = `@${author?.username} - ${channel.name}`; title = `@${author?.username} - ${channel.name}`;
} }
break; break;
case "TextChannel": case "TextChannel":
const server = client.servers.get(channel.server); const server = client.servers.get(channel.server);
title = `@${author?.username} (#${channel.name}, ${server?.name})`; title = `@${author?.username} (#${channel.name}, ${server?.name})`;
break; break;
default: default:
title = msg.channel; title = msg.channel;
break; break;
} }
let image; let image;
if (msg.attachments) { if (msg.attachments) {
let imageAttachment = msg.attachments.find(x => x.metadata.type === 'Image'); let imageAttachment = msg.attachments.find(
if (imageAttachment) { (x) => x.metadata.type === "Image",
image = client.generateFileURL(imageAttachment, { max_side: 720 }); );
} if (imageAttachment) {
} image = client.generateFileURL(imageAttachment, {
max_side: 720,
});
}
}
let body, icon; let body, icon;
if (typeof msg.content === "string") { if (typeof msg.content === "string") {
body = client.markdownToText(msg.content); body = client.markdownToText(msg.content);
icon = client.users.getAvatarURL(msg.author, { max_side: 256 }); icon = client.users.getAvatarURL(msg.author, { max_side: 256 });
} else { } else {
let users = client.users; let users = client.users;
switch (msg.content.type) { switch (msg.content.type) {
case "user_added": case "user_added":
case "user_remove": case "user_remove":
body = translate( body = translate(
`app.main.channel.system.${msg.content.type === 'user_added' ? 'added_by' : 'removed_by'}`, `app.main.channel.system.${
{ user: users.get(msg.content.id)?.username, other_user: users.get(msg.content.by)?.username } msg.content.type === "user_added"
); ? "added_by"
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); : "removed_by"
break; }`,
case "user_joined": {
case "user_left": user: users.get(msg.content.id)?.username,
case "user_kicked": other_user: users.get(msg.content.by)?.username,
case "user_banned": },
body = translate( );
`app.main.channel.system.${msg.content.type}`, icon = client.users.getAvatarURL(msg.content.id, {
{ user: users.get(msg.content.id)?.username } max_side: 256,
); });
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); break;
break; case "user_joined":
case "channel_renamed": case "user_left":
body = translate( case "user_kicked":
`app.main.channel.system.channel_renamed`, case "user_banned":
{ user: users.get(msg.content.by)?.username, name: msg.content.name } body = translate(
); `app.main.channel.system.${msg.content.type}`,
icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 }); { user: users.get(msg.content.id)?.username },
break; );
case "channel_description_changed": icon = client.users.getAvatarURL(msg.content.id, {
case "channel_icon_changed": max_side: 256,
body = translate( });
`app.main.channel.system.${msg.content.type}`, break;
{ user: users.get(msg.content.by)?.username } case "channel_renamed":
); body = translate(
icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 }); `app.main.channel.system.channel_renamed`,
break; {
} user: users.get(msg.content.by)?.username,
} name: msg.content.name,
},
);
icon = client.users.getAvatarURL(msg.content.by, {
max_side: 256,
});
break;
case "channel_description_changed":
case "channel_icon_changed":
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.by)?.username },
);
icon = client.users.getAvatarURL(msg.content.by, {
max_side: 256,
});
break;
}
}
let notif = await createNotification(title, { let notif = await createNotification(title, {
icon, icon,
image, image,
body, body,
timestamp: decodeTime(msg._id), timestamp: decodeTime(msg._id),
tag: msg.channel, tag: msg.channel,
badge: '/assets/icons/android-chrome-512x512.png', badge: "/assets/icons/android-chrome-512x512.png",
silent: true silent: true,
}); });
if (notif) { if (notif) {
notif.addEventListener("click", () => { notif.addEventListener("click", () => {
window.focus(); window.focus();
const id = msg.channel; const id = msg.channel;
if (id !== channel_id) { if (id !== channel_id) {
let channel = client.channels.get(id); let channel = client.channels.get(id);
if (channel) { if (channel) {
if (channel.channel_type === 'TextChannel') { if (channel.channel_type === "TextChannel") {
history.push(`/server/${channel.server}/channel/${id}`); history.push(
} else { `/server/${channel.server}/channel/${id}`,
history.push(`/channel/${id}`); );
} } else {
} history.push(`/channel/${id}`);
} }
}); }
}
});
notifications[msg.channel] = notif; notifications[msg.channel] = notif;
notif.addEventListener( notif.addEventListener(
"close", "close",
() => delete notifications[msg.channel] () => delete notifications[msg.channel],
); );
} }
} }
async function relationship(user: User, property: string) { async function relationship(user: User, property: string) {
if (client.user?.status?.presence === Users.Presence.Busy) return; if (client.user?.status?.presence === Users.Presence.Busy) return;
if (property !== "relationship") return; if (property !== "relationship") return;
if (!showNotification) return; if (!showNotification) return;
let event; let event;
switch (user.relationship) { switch (user.relationship) {
case Users.Relationship.Incoming: case Users.Relationship.Incoming:
event = translate("notifications.sent_request", { person: user.username }); event = translate("notifications.sent_request", {
break; person: user.username,
case Users.Relationship.Friend: });
event = translate("notifications.now_friends", { person: user.username }); break;
break; case Users.Relationship.Friend:
default: event = translate("notifications.now_friends", {
return; person: user.username,
} });
break;
default:
return;
}
let notif = await createNotification(event, { let notif = await createNotification(event, {
icon: client.users.getAvatarURL(user._id, { max_side: 256 }), icon: client.users.getAvatarURL(user._id, { max_side: 256 }),
badge: '/assets/icons/android-chrome-512x512.png', badge: "/assets/icons/android-chrome-512x512.png",
timestamp: +new Date() timestamp: +new Date(),
}); });
notif?.addEventListener("click", () => { notif?.addEventListener("click", () => {
history.push(`/friends`); history.push(`/friends`);
}); });
} }
useEffect(() => { useEffect(() => {
client.addListener("message", message); client.addListener("message", message);
client.users.addListener("mutation", relationship); client.users.addListener("mutation", relationship);
return () => { return () => {
client.removeListener("message", message); client.removeListener("message", message);
client.users.removeListener("mutation", relationship); client.users.removeListener("mutation", relationship);
}; };
}, [client, playSound, guild_id, channel_id, showNotification, notifs]); }, [client, playSound, guild_id, channel_id, showNotification, notifs]);
useEffect(() => { useEffect(() => {
function visChange() { function visChange() {
if (document.visibilityState === "visible") { if (document.visibilityState === "visible") {
if (notifications[channel_id]) { if (notifications[channel_id]) {
notifications[channel_id].close(); notifications[channel_id].close();
} }
} }
} }
visChange(); visChange();
document.addEventListener("visibilitychange", visChange); document.addEventListener("visibilitychange", visChange);
return () => return () =>
document.removeEventListener("visibilitychange", visChange); document.removeEventListener("visibilitychange", visChange);
}, [guild_id, channel_id]); }, [guild_id, channel_id]);
return null; return null;
} }
const NotifierComponent = connectState( const NotifierComponent = connectState(
Notifier, Notifier,
state => { (state) => {
return { return {
options: state.settings.notification, options: state.settings.notification,
notifs: state.notifications notifs: state.notifications,
}; };
}, },
true true,
); );
export default function Notifications() { export default function NotificationsComponent() {
return ( return (
<Switch> <Switch>
<Route path="/server/:server/channel/:channel"> <Route path="/server/:server/channel/:channel">
<NotifierComponent /> <NotifierComponent />
</Route> </Route>
<Route path="/channel/:channel"> <Route path="/channel/:channel">
<NotifierComponent /> <NotifierComponent />
</Route> </Route>
<Route path="/"> <Route path="/">
<NotifierComponent /> <NotifierComponent />
</Route> </Route>
</Switch> </Switch>
); );
} }

View file

@ -1,44 +1,47 @@
import { Text } from "preact-i18n";
import styled from "styled-components";
import { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { WifiOff } from "@styled-icons/boxicons-regular"; import { WifiOff } from "@styled-icons/boxicons-regular";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import Preloader from "../../components/ui/Preloader"; import Preloader from "../../components/ui/Preloader";
import { Children } from "../../types/Preact";
import { ClientStatus, StatusContext } from "./RevoltClient"; import { ClientStatus, StatusContext } from "./RevoltClient";
interface Props { interface Props {
children: Children; children: Children;
} }
const Base = styled.div` const Base = styled.div`
gap: 16px; gap: 16px;
padding: 1em; padding: 1em;
display: flex; display: flex;
user-select: none; user-select: none;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
background: var(--secondary-header); background: var(--secondary-header);
> div { > div {
font-size: 18px; font-size: 18px;
} }
`; `;
export default function RequiresOnline(props: Props) { export default function RequiresOnline(props: Props) {
const status = useContext(StatusContext); const status = useContext(StatusContext);
if (status === ClientStatus.CONNECTING) return <Preloader type="ring" />; if (status === ClientStatus.CONNECTING) return <Preloader type="ring" />;
if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY) if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY)
return ( return (
<Base> <Base>
<WifiOff size={16} /> <WifiOff size={16} />
<div> <div>
<Text id="app.special.requires_online" /> <Text id="app.special.requires_online" />
</div> </div>
</Base> </Base>
); );
return <>{ props.children }</>; return <>{props.children}</>;
} }

View file

@ -1,37 +1,42 @@
import { openDB } from 'idb'; import { openDB } from "idb";
import { useHistory } from "react-router-dom";
import { Client } from "revolt.js"; import { Client } from "revolt.js";
import { takeError } from "./util";
import { createContext } from "preact";
import { dispatch } from '../../redux';
import { Children } from "../../types/Preact";
import { useHistory } from 'react-router-dom';
import { Route } from "revolt.js/dist/api/routes"; import { Route } from "revolt.js/dist/api/routes";
import { connectState } from "../../redux/connector";
import Preloader from "../../components/ui/Preloader"; import { createContext } from "preact";
import { AuthState } from "../../redux/reducers/auth";
import { useEffect, useMemo, useState } from "preact/hooks"; import { useEffect, useMemo, useState } from "preact/hooks";
import { useIntermediate } from '../intermediate/Intermediate';
import { SingletonMessageRenderer } from "../../lib/renderer/Singleton";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import { AuthState } from "../../redux/reducers/auth";
import Preloader from "../../components/ui/Preloader";
import { Children } from "../../types/Preact";
import { useIntermediate } from "../intermediate/Intermediate";
import { registerEvents, setReconnectDisallowed } from "./events"; import { registerEvents, setReconnectDisallowed } from "./events";
import { SingletonMessageRenderer } from '../../lib/renderer/Singleton'; import { takeError } from "./util";
export enum ClientStatus { export enum ClientStatus {
INIT, INIT,
LOADING, LOADING,
READY, READY,
OFFLINE, OFFLINE,
DISCONNECTED, DISCONNECTED,
CONNECTING, CONNECTING,
RECONNECTING, RECONNECTING,
ONLINE, ONLINE,
} }
export interface ClientOperations { export interface ClientOperations {
login: (data: Route<"POST", "/auth/login">["data"]) => Promise<void>; login: (data: Route<"POST", "/auth/login">["data"]) => Promise<void>;
logout: (shouldRequest?: boolean) => Promise<void>; logout: (shouldRequest?: boolean) => Promise<void>;
loggedIn: () => boolean; loggedIn: () => boolean;
ready: () => boolean; ready: () => boolean;
openDM: (user_id: string) => Promise<string>; openDM: (user_id: string) => Promise<string>;
} }
// By the time they are used, they should all be initialized. // By the time they are used, they should all be initialized.
@ -42,189 +47,195 @@ export const StatusContext = createContext<ClientStatus>(null!);
export const OperationsContext = createContext<ClientOperations>(null!); export const OperationsContext = createContext<ClientOperations>(null!);
type Props = { type Props = {
auth: AuthState; auth: AuthState;
children: Children; children: Children;
}; };
function Context({ auth, children }: Props) { function Context({ auth, children }: Props) {
const history = useHistory(); const history = useHistory();
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const [status, setStatus] = useState(ClientStatus.INIT); const [status, setStatus] = useState(ClientStatus.INIT);
const [client, setClient] = useState<Client>(undefined as unknown as Client); const [client, setClient] = useState<Client>(
undefined as unknown as Client,
);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
let db; let db;
try { try {
// Match sw.ts#L23 // Match sw.ts#L23
db = await openDB('state', 3, { db = await openDB("state", 3, {
upgrade(db) { upgrade(db) {
for (let store of [ "channels", "servers", "users", "members" ]) { for (let store of [
db.createObjectStore(store, { "channels",
keyPath: '_id' "servers",
}); "users",
} "members",
}, ]) {
}); db.createObjectStore(store, {
} catch (err) { keyPath: "_id",
console.error('Failed to open IndexedDB store, continuing without.'); });
} }
},
});
} catch (err) {
console.error(
"Failed to open IndexedDB store, continuing without.",
);
}
const client = new Client({ const client = new Client({
autoReconnect: false, autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL, apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV, debug: import.meta.env.DEV,
db db,
}); });
setClient(client); setClient(client);
SingletonMessageRenderer.subscribe(client); SingletonMessageRenderer.subscribe(client);
setStatus(ClientStatus.LOADING); setStatus(ClientStatus.LOADING);
})(); })();
}, [ ]); }, []);
if (status === ClientStatus.INIT) return null; if (status === ClientStatus.INIT) return null;
const operations: ClientOperations = useMemo(() => { const operations: ClientOperations = useMemo(() => {
return { return {
login: async data => { login: async (data) => {
setReconnectDisallowed(true); setReconnectDisallowed(true);
try { try {
const onboarding = await client.login(data); const onboarding = await client.login(data);
setReconnectDisallowed(false); setReconnectDisallowed(false);
const login = () => const login = () =>
dispatch({ dispatch({
type: "LOGIN", type: "LOGIN",
session: client.session! // This [null assertion] is ok, we should have a session by now. - insert's words session: client.session!, // This [null assertion] is ok, we should have a session by now. - insert's words
}); });
if (onboarding) { if (onboarding) {
openScreen({ openScreen({
id: "onboarding", id: "onboarding",
callback: (username: string) => callback: (username: string) =>
onboarding(username, true).then(login) onboarding(username, true).then(login),
}); });
} else { } else {
login(); login();
} }
} catch (err) { } catch (err) {
setReconnectDisallowed(false); setReconnectDisallowed(false);
throw err; throw err;
} }
}, },
logout: async shouldRequest => { logout: async (shouldRequest) => {
dispatch({ type: "LOGOUT" }); dispatch({ type: "LOGOUT" });
client.reset(); client.reset();
dispatch({ type: "RESET" }); dispatch({ type: "RESET" });
openScreen({ id: "none" }); openScreen({ id: "none" });
setStatus(ClientStatus.READY); setStatus(ClientStatus.READY);
client.websocket.disconnect(); client.websocket.disconnect();
if (shouldRequest) { if (shouldRequest) {
try { try {
await client.logout(); await client.logout();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
}, },
loggedIn: () => typeof auth.active !== "undefined", loggedIn: () => typeof auth.active !== "undefined",
ready: () => ( ready: () =>
operations.loggedIn() && operations.loggedIn() && typeof client.user !== "undefined",
typeof client.user !== "undefined" openDM: async (user_id: string) => {
), let channel = await client.users.openDM(user_id);
openDM: async (user_id: string) => { history.push(`/channel/${channel!._id}`);
let channel = await client.users.openDM(user_id); return channel!._id;
history.push(`/channel/${channel!._id}`); },
return channel!._id; };
} }, [client, auth.active]);
}
}, [ client, auth.active ]);
useEffect(() => registerEvents({ operations }, setStatus, client), [ client ]); useEffect(
() => registerEvents({ operations }, setStatus, client),
[client],
);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (client.db) { if (client.db) {
await client.restore(); await client.restore();
} }
if (auth.active) { if (auth.active) {
dispatch({ type: "QUEUE_FAIL_ALL" }); dispatch({ type: "QUEUE_FAIL_ALL" });
const active = auth.accounts[auth.active]; const active = auth.accounts[auth.active];
client.user = client.users.get(active.session.user_id); client.user = client.users.get(active.session.user_id);
if (!navigator.onLine) { if (!navigator.onLine) {
return setStatus(ClientStatus.OFFLINE); return setStatus(ClientStatus.OFFLINE);
} }
if (operations.ready()) if (operations.ready()) setStatus(ClientStatus.CONNECTING);
setStatus(ClientStatus.CONNECTING);
if (navigator.onLine) { if (navigator.onLine) {
await client await client
.fetchConfiguration() .fetchConfiguration()
.catch(() => .catch(() =>
console.error("Failed to connect to API server.") console.error("Failed to connect to API server."),
); );
} }
try { try {
await client.fetchConfiguration(); await client.fetchConfiguration();
const callback = await client.useExistingSession( const callback = await client.useExistingSession(
active.session active.session,
); );
if (callback) { if (callback) {
openScreen({ id: "onboarding", callback }); openScreen({ id: "onboarding", callback });
} }
} catch (err) { } catch (err) {
setStatus(ClientStatus.DISCONNECTED); setStatus(ClientStatus.DISCONNECTED);
const error = takeError(err); const error = takeError(err);
if (error === "Forbidden" || error === "Unauthorized") { if (error === "Forbidden" || error === "Unauthorized") {
operations.logout(true); operations.logout(true);
openScreen({ id: "signed_out" }); openScreen({ id: "signed_out" });
} else { } else {
openScreen({ id: "error", error }); openScreen({ id: "error", error });
} }
} }
} else { } else {
try { try {
await client.fetchConfiguration() await client.fetchConfiguration();
} catch (err) { } catch (err) {
console.error("Failed to connect to API server."); console.error("Failed to connect to API server.");
} }
setStatus(ClientStatus.READY); setStatus(ClientStatus.READY);
} }
})(); })();
}, []); }, []);
if (status === ClientStatus.LOADING) { if (status === ClientStatus.LOADING) {
return <Preloader type="spinner" />; return <Preloader type="spinner" />;
} }
return ( return (
<AppContext.Provider value={client}> <AppContext.Provider value={client}>
<StatusContext.Provider value={status}> <StatusContext.Provider value={status}>
<OperationsContext.Provider value={operations}> <OperationsContext.Provider value={operations}>
{ children } {children}
</OperationsContext.Provider> </OperationsContext.Provider>
</StatusContext.Provider> </StatusContext.Provider>
</AppContext.Provider> </AppContext.Provider>
); );
} }
export default connectState<{ children: Children }>( export default connectState<{ children: Children }>(Context, (state) => {
Context, return {
state => { auth: state.auth,
return { sync: state.sync,
auth: state.auth, };
sync: state.sync });
};
}
);

View file

@ -1,77 +1,76 @@
/** /**
* This file monitors the message cache to delete any queued messages that have already sent. * This file monitors the message cache to delete any queued messages that have already sent.
*/ */
import { Message } from "revolt.js"; import { Message } from "revolt.js";
import { AppContext } from "./RevoltClient";
import { Typing } from "../../redux/reducers/typing";
import { useContext, useEffect } from "preact/hooks"; import { useContext, useEffect } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import { QueuedMessage } from "../../redux/reducers/queue"; import { QueuedMessage } from "../../redux/reducers/queue";
import { dispatch } from "../../redux"; import { Typing } from "../../redux/reducers/typing";
import { AppContext } from "./RevoltClient";
type Props = { type Props = {
messages: QueuedMessage[]; messages: QueuedMessage[];
typing: Typing typing: Typing;
}; };
function StateMonitor(props: Props) { function StateMonitor(props: Props) {
const client = useContext(AppContext); const client = useContext(AppContext);
useEffect(() => { useEffect(() => {
dispatch({ dispatch({
type: 'QUEUE_DROP_ALL' type: "QUEUE_DROP_ALL",
}); });
}, [ ]); }, []);
useEffect(() => { useEffect(() => {
function add(msg: Message) { function add(msg: Message) {
if (!msg.nonce) return; if (!msg.nonce) return;
if (!props.messages.find(x => x.id === msg.nonce)) return; if (!props.messages.find((x) => x.id === msg.nonce)) return;
dispatch({ dispatch({
type: 'QUEUE_REMOVE', type: "QUEUE_REMOVE",
nonce: msg.nonce nonce: msg.nonce,
}); });
} }
client.addListener('message', add); client.addListener("message", add);
return () => client.removeListener('message', add); return () => client.removeListener("message", add);
}, [ props.messages ]); }, [props.messages]);
useEffect(() => { useEffect(() => {
function removeOld() { function removeOld() {
if (!props.typing) return; if (!props.typing) return;
for (let channel of Object.keys(props.typing)) { for (let channel of Object.keys(props.typing)) {
let users = props.typing[channel]; let users = props.typing[channel];
for (let user of users) { for (let user of users) {
if (+ new Date() > user.started + 5000) { if (+new Date() > user.started + 5000) {
dispatch({ dispatch({
type: 'TYPING_STOP', type: "TYPING_STOP",
channel, channel,
user: user.id user: user.id,
}); });
} }
} }
} }
} }
removeOld(); removeOld();
let interval = setInterval(removeOld, 1000); let interval = setInterval(removeOld, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [ props.typing ]); }, [props.typing]);
return null; return null;
} }
export default connectState( export default connectState(StateMonitor, (state) => {
StateMonitor, return {
state => { messages: [...state.queue],
return { typing: state.typing,
messages: [...state.queue], };
typing: state.typing });
};
}
);

View file

@ -1,126 +1,146 @@
/** /**
* This file monitors changes to settings and syncs them to the server. * This file monitors changes to settings and syncs them to the server.
*/ */
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
import { Language } from "../Locale";
import { Sync } from "revolt.js/dist/api/objects"; import { Sync } from "revolt.js/dist/api/objects";
import { useContext, useEffect } from "preact/hooks";
import { connectState } from "../../redux/connector";
import { Settings } from "../../redux/reducers/settings";
import { Notifications } from "../../redux/reducers/notifications";
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { DEFAULT_ENABLED_SYNC, SyncData, SyncKeys, SyncOptions } from "../../redux/reducers/sync";
import { useContext, useEffect } from "preact/hooks";
import { dispatch } from "../../redux"; import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import { Notifications } from "../../redux/reducers/notifications";
import { Settings } from "../../redux/reducers/settings";
import {
DEFAULT_ENABLED_SYNC,
SyncData,
SyncKeys,
SyncOptions,
} from "../../redux/reducers/sync";
import { Language } from "../Locale";
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
type Props = { type Props = {
settings: Settings, settings: Settings;
locale: Language, locale: Language;
sync: SyncOptions, sync: SyncOptions;
notifications: Notifications notifications: Notifications;
}; };
var lastValues: { [key in SyncKeys]?: any } = { }; var lastValues: { [key in SyncKeys]?: any } = {};
export function mapSync(packet: Sync.UserSettings, revision?: Record<string, number>) { export function mapSync(
let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = {}; packet: Sync.UserSettings,
for (let key of Object.keys(packet)) { revision?: Record<string, number>,
let [ timestamp, obj ] = packet[key]; ) {
if (timestamp < (revision ?? {})[key] ?? 0) { let update: { [key in SyncKeys]?: [number, SyncData[key]] } = {};
continue; for (let key of Object.keys(packet)) {
} let [timestamp, obj] = packet[key];
if (timestamp < (revision ?? {})[key] ?? 0) {
continue;
}
let object; let object;
if (obj[0] === '{') { if (obj[0] === "{") {
object = JSON.parse(obj) object = JSON.parse(obj);
} else { } else {
object = obj; object = obj;
} }
lastValues[key as SyncKeys] = object; lastValues[key as SyncKeys] = object;
update[key as SyncKeys] = [ timestamp, object ]; update[key as SyncKeys] = [timestamp, object];
} }
return update; return update;
} }
function SyncManager(props: Props) { function SyncManager(props: Props) {
const client = useContext(AppContext); const client = useContext(AppContext);
const status = useContext(StatusContext); const status = useContext(StatusContext);
useEffect(() => { useEffect(() => {
if (status === ClientStatus.ONLINE) { if (status === ClientStatus.ONLINE) {
client client
.syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x))) .syncFetchSettings(
.then(data => { DEFAULT_ENABLED_SYNC.filter(
dispatch({ (x) => !props.sync?.disabled?.includes(x),
type: 'SYNC_UPDATE', ),
update: mapSync(data) )
}); .then((data) => {
}); dispatch({
type: "SYNC_UPDATE",
update: mapSync(data),
});
});
client client
.syncFetchUnreads() .syncFetchUnreads()
.then(unreads => dispatch({ type: 'UNREADS_SET', unreads })); .then((unreads) => dispatch({ type: "UNREADS_SET", unreads }));
} }
}, [ status ]); }, [status]);
function syncChange(key: SyncKeys, data: any) { function syncChange(key: SyncKeys, data: any) {
let timestamp = + new Date(); let timestamp = +new Date();
dispatch({ dispatch({
type: 'SYNC_SET_REVISION', type: "SYNC_SET_REVISION",
key, key,
timestamp timestamp,
}); });
client.syncSetSettings({ client.syncSetSettings(
[key]: data {
}, timestamp); [key]: data,
} },
timestamp,
);
}
let disabled = props.sync.disabled ?? []; let disabled = props.sync.disabled ?? [];
for (let [key, object] of [ ['appearance', props.settings.appearance], ['theme', props.settings.theme], ['locale', props.locale], ['notifications', props.notifications] ] as [SyncKeys, any][]) { for (let [key, object] of [
useEffect(() => { ["appearance", props.settings.appearance],
if (disabled.indexOf(key) === -1) { ["theme", props.settings.theme],
if (typeof lastValues[key] !== 'undefined') { ["locale", props.locale],
if (!isEqual(lastValues[key], object)) { ["notifications", props.notifications],
syncChange(key, object); ] as [SyncKeys, any][]) {
} useEffect(() => {
} if (disabled.indexOf(key) === -1) {
} if (typeof lastValues[key] !== "undefined") {
if (!isEqual(lastValues[key], object)) {
syncChange(key, object);
}
}
}
lastValues[key] = object; lastValues[key] = object;
}, [ disabled, object ]); }, [disabled, object]);
} }
useEffect(() => { useEffect(() => {
function onPacket(packet: ClientboundNotification) { function onPacket(packet: ClientboundNotification) {
if (packet.type === 'UserSettingsUpdate') { if (packet.type === "UserSettingsUpdate") {
let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = mapSync(packet.update, props.sync.revision); let update: { [key in SyncKeys]?: [number, SyncData[key]] } =
mapSync(packet.update, props.sync.revision);
dispatch({ dispatch({
type: 'SYNC_UPDATE', type: "SYNC_UPDATE",
update update,
}); });
} }
} }
client.addListener('packet', onPacket); client.addListener("packet", onPacket);
return () => client.removeListener('packet', onPacket); return () => client.removeListener("packet", onPacket);
}, [ disabled, props.sync ]); }, [disabled, props.sync]);
return null; return null;
} }
export default connectState( export default connectState(SyncManager, (state) => {
SyncManager, return {
state => { settings: state.settings,
return { locale: state.locale,
settings: state.settings, sync: state.sync,
locale: state.locale, notifications: state.notifications,
sync: state.sync, };
notifications: state.notifications });
};
}
);

View file

@ -1,148 +1,155 @@
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { Client, Message } from "revolt.js/dist"; import { Client, Message } from "revolt.js/dist";
import { import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
ClientOperations,
ClientStatus
} from "./RevoltClient";
import { StateUpdater } from "preact/hooks"; import { StateUpdater } from "preact/hooks";
import { dispatch } from "../../redux"; import { dispatch } from "../../redux";
import { ClientOperations, ClientStatus } from "./RevoltClient";
export var preventReconnect = false; export var preventReconnect = false;
let preventUntil = 0; let preventUntil = 0;
export function setReconnectDisallowed(allowed: boolean) { export function setReconnectDisallowed(allowed: boolean) {
preventReconnect = allowed; preventReconnect = allowed;
} }
export function registerEvents({ export function registerEvents(
operations { operations }: { operations: ClientOperations },
}: { operations: ClientOperations }, setStatus: StateUpdater<ClientStatus>, client: Client) { setStatus: StateUpdater<ClientStatus>,
function attemptReconnect() { client: Client,
if (preventReconnect) return; ) {
function reconnect() { function attemptReconnect() {
preventUntil = +new Date() + 2000; if (preventReconnect) return;
client.websocket.connect().catch(err => console.error(err)); function reconnect() {
} preventUntil = +new Date() + 2000;
client.websocket.connect().catch((err) => console.error(err));
}
if (+new Date() > preventUntil) { if (+new Date() > preventUntil) {
setTimeout(reconnect, 2000); setTimeout(reconnect, 2000);
} else { } else {
reconnect(); reconnect();
} }
} }
let listeners: Record<string, (...args: any[]) => void> = { let listeners: Record<string, (...args: any[]) => void> = {
connecting: () => connecting: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING), operations.ready() && setStatus(ClientStatus.CONNECTING),
dropped: () => { dropped: () => {
if (operations.ready()) { if (operations.ready()) {
setStatus(ClientStatus.DISCONNECTED); setStatus(ClientStatus.DISCONNECTED);
attemptReconnect(); attemptReconnect();
} }
}, },
packet: (packet: ClientboundNotification) => { packet: (packet: ClientboundNotification) => {
switch (packet.type) { switch (packet.type) {
case "ChannelStartTyping": { case "ChannelStartTyping": {
if (packet.user === client.user?._id) return; if (packet.user === client.user?._id) return;
dispatch({ dispatch({
type: "TYPING_START", type: "TYPING_START",
channel: packet.id, channel: packet.id,
user: packet.user user: packet.user,
}); });
break; break;
} }
case "ChannelStopTyping": { case "ChannelStopTyping": {
if (packet.user === client.user?._id) return; if (packet.user === client.user?._id) return;
dispatch({ dispatch({
type: "TYPING_STOP", type: "TYPING_STOP",
channel: packet.id, channel: packet.id,
user: packet.user user: packet.user,
}); });
break; break;
} }
case "ChannelAck": { case "ChannelAck": {
dispatch({ dispatch({
type: "UNREADS_MARK_READ", type: "UNREADS_MARK_READ",
channel: packet.id, channel: packet.id,
message: packet.message_id message: packet.message_id,
}); });
break; break;
} }
} }
}, },
message: (message: Message) => { message: (message: Message) => {
if (message.mentions?.includes(client.user!._id)) { if (message.mentions?.includes(client.user!._id)) {
dispatch({ dispatch({
type: "UNREADS_MENTION", type: "UNREADS_MENTION",
channel: message.channel, channel: message.channel,
message: message._id message: message._id,
}); });
} }
}, },
ready: () => setStatus(ClientStatus.ONLINE) ready: () => setStatus(ClientStatus.ONLINE),
}; };
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
listeners = new Proxy(listeners, { listeners = new Proxy(listeners, {
get: (target, listener, receiver) => (...args: unknown[]) => { get:
console.debug(`Calling ${listener.toString()} with`, args); (target, listener, receiver) =>
Reflect.get(target, listener)(...args) (...args: unknown[]) => {
} console.debug(`Calling ${listener.toString()} with`, args);
}) Reflect.get(target, listener)(...args);
} },
});
}
// TODO: clean this a bit and properly handle types // TODO: clean this a bit and properly handle types
for (const listener in listeners) { for (const listener in listeners) {
client.addListener(listener, listeners[listener]); client.addListener(listener, listeners[listener]);
} }
function logMutation(target: string, key: string) { function logMutation(target: string, key: string) {
console.log('(o) Object mutated', target, '\nChanged:', key); console.log("(o) Object mutated", target, "\nChanged:", key);
} }
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
client.users.addListener('mutation', logMutation); client.users.addListener("mutation", logMutation);
client.servers.addListener('mutation', logMutation); client.servers.addListener("mutation", logMutation);
client.channels.addListener('mutation', logMutation); client.channels.addListener("mutation", logMutation);
client.servers.members.addListener('mutation', logMutation); client.servers.members.addListener("mutation", logMutation);
} }
const online = () => { const online = () => {
if (operations.ready()) { if (operations.ready()) {
setStatus(ClientStatus.RECONNECTING); setStatus(ClientStatus.RECONNECTING);
setReconnectDisallowed(false); setReconnectDisallowed(false);
attemptReconnect(); attemptReconnect();
} }
}; };
const offline = () => { const offline = () => {
if (operations.ready()) { if (operations.ready()) {
setReconnectDisallowed(true); setReconnectDisallowed(true);
client.websocket.disconnect(); client.websocket.disconnect();
setStatus(ClientStatus.OFFLINE); setStatus(ClientStatus.OFFLINE);
} }
}; };
window.addEventListener("online", online); window.addEventListener("online", online);
window.addEventListener("offline", offline); window.addEventListener("offline", offline);
return () => { return () => {
for (const listener in listeners) { for (const listener in listeners) {
client.removeListener(listener, listeners[listener as keyof typeof listeners]); client.removeListener(
} listener,
listeners[listener as keyof typeof listeners],
);
}
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
client.users.removeListener('mutation', logMutation); client.users.removeListener("mutation", logMutation);
client.servers.removeListener('mutation', logMutation); client.servers.removeListener("mutation", logMutation);
client.channels.removeListener('mutation', logMutation); client.channels.removeListener("mutation", logMutation);
client.servers.members.removeListener('mutation', logMutation); client.servers.members.removeListener("mutation", logMutation);
} }
window.removeEventListener("online", online); window.removeEventListener("online", online);
window.removeEventListener("offline", offline); window.removeEventListener("offline", offline);
}; };
} }

View file

@ -1,177 +1,232 @@
import { useCallback, useContext, useEffect, useState } from "preact/hooks"; import { Client, PermissionCalculator } from "revolt.js";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects"; import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import { Client, PermissionCalculator } from 'revolt.js';
import { AppContext } from "./RevoltClient";
import Collection from "revolt.js/dist/maps/Collection"; import Collection from "revolt.js/dist/maps/Collection";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { AppContext } from "./RevoltClient";
export interface HookContext { export interface HookContext {
client: Client, client: Client;
forceUpdate: () => void forceUpdate: () => void;
} }
export function useForceUpdate(context?: HookContext): HookContext { export function useForceUpdate(context?: HookContext): HookContext {
const client = useContext(AppContext); const client = useContext(AppContext);
if (context) return context; if (context) return context;
const H = useState(0); const H = useState(0);
var updateState: (_: number) => void; var updateState: (_: number) => void;
if (Array.isArray(H)) { if (Array.isArray(H)) {
let [, u] = H; let [, u] = H;
updateState = u; updateState = u;
} else { } else {
console.warn('Failed to construct using useState.'); console.warn("Failed to construct using useState.");
updateState = ()=>{}; updateState = () => {};
} }
return { client, forceUpdate: () => updateState(Math.random()) }; return { client, forceUpdate: () => updateState(Math.random()) };
} }
// TODO: utils.d.ts maybe? // TODO: utils.d.ts maybe?
type PickProperties<T, U> = Pick<T, { type PickProperties<T, U> = Pick<
[K in keyof T]: T[K] extends U ? K : never T,
}[keyof T]> {
[K in keyof T]: T[K] extends U ? K : never;
}[keyof T]
>;
// The keys in Client that are an object // The keys in Client that are an object
// for some reason undefined keeps appearing despite there being no reason to so it's filtered out // for some reason undefined keeps appearing despite there being no reason to so it's filtered out
type ClientCollectionKey = Exclude<keyof PickProperties<Client, Collection<any>>, undefined>; type ClientCollectionKey = Exclude<
keyof PickProperties<Client, Collection<any>>,
undefined
>;
function useObject(type: ClientCollectionKey, id?: string | string[], context?: HookContext) { function useObject(
const ctx = useForceUpdate(context); type: ClientCollectionKey,
id?: string | string[],
context?: HookContext,
) {
const ctx = useForceUpdate(context);
function update(target: any) { function update(target: any) {
if (typeof id === 'string' ? target === id : if (
Array.isArray(id) ? id.includes(target) : true) { typeof id === "string"
ctx.forceUpdate(); ? target === id
} : Array.isArray(id)
} ? id.includes(target)
: true
) {
ctx.forceUpdate();
}
}
const map = ctx.client[type]; const map = ctx.client[type];
useEffect(() => { useEffect(() => {
map.addListener("update", update); map.addListener("update", update);
return () => map.removeListener("update", update); return () => map.removeListener("update", update);
}, [id]); }, [id]);
return typeof id === 'string' ? map.get(id) return typeof id === "string"
: Array.isArray(id) ? id.map(x => map.get(x)) ? map.get(id)
: map.toArray(); : Array.isArray(id)
? id.map((x) => map.get(x))
: map.toArray();
} }
export function useUser(id?: string, context?: HookContext) { export function useUser(id?: string, context?: HookContext) {
if (typeof id === "undefined") return; if (typeof id === "undefined") return;
return useObject('users', id, context) as Readonly<Users.User> | undefined; return useObject("users", id, context) as Readonly<Users.User> | undefined;
} }
export function useSelf(context?: HookContext) { export function useSelf(context?: HookContext) {
const ctx = useForceUpdate(context); const ctx = useForceUpdate(context);
return useUser(ctx.client.user!._id, ctx); return useUser(ctx.client.user!._id, ctx);
} }
export function useUsers(ids?: string[], context?: HookContext) { export function useUsers(ids?: string[], context?: HookContext) {
return useObject('users', ids, context) as (Readonly<Users.User> | undefined)[]; return useObject("users", ids, context) as (
| Readonly<Users.User>
| undefined
)[];
} }
export function useChannel(id?: string, context?: HookContext) { export function useChannel(id?: string, context?: HookContext) {
if (typeof id === "undefined") return; if (typeof id === "undefined") return;
return useObject('channels', id, context) as Readonly<Channels.Channel> | undefined; return useObject("channels", id, context) as
| Readonly<Channels.Channel>
| undefined;
} }
export function useChannels(ids?: string[], context?: HookContext) { export function useChannels(ids?: string[], context?: HookContext) {
return useObject('channels', ids, context) as (Readonly<Channels.Channel> | undefined)[]; return useObject("channels", ids, context) as (
| Readonly<Channels.Channel>
| undefined
)[];
} }
export function useServer(id?: string, context?: HookContext) { export function useServer(id?: string, context?: HookContext) {
if (typeof id === "undefined") return; if (typeof id === "undefined") return;
return useObject('servers', id, context) as Readonly<Servers.Server> | undefined; return useObject("servers", id, context) as
| Readonly<Servers.Server>
| undefined;
} }
export function useServers(ids?: string[], context?: HookContext) { export function useServers(ids?: string[], context?: HookContext) {
return useObject('servers', ids, context) as (Readonly<Servers.Server> | undefined)[]; return useObject("servers", ids, context) as (
| Readonly<Servers.Server>
| undefined
)[];
} }
export function useDMs(context?: HookContext) { export function useDMs(context?: HookContext) {
const ctx = useForceUpdate(context); const ctx = useForceUpdate(context);
function mutation(target: string) { function mutation(target: string) {
let channel = ctx.client.channels.get(target); let channel = ctx.client.channels.get(target);
if (channel) { if (channel) {
if (channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group') { if (
ctx.forceUpdate(); channel.channel_type === "DirectMessage" ||
} channel.channel_type === "Group"
} ) {
} ctx.forceUpdate();
}
}
}
const map = ctx.client.channels; const map = ctx.client.channels;
useEffect(() => { useEffect(() => {
map.addListener("update", mutation); map.addListener("update", mutation);
return () => map.removeListener("update", mutation); return () => map.removeListener("update", mutation);
}, []); }, []);
return map return map
.toArray() .toArray()
.filter(x => x.channel_type === 'DirectMessage' || x.channel_type === 'Group' || x.channel_type === 'SavedMessages') as (Channels.GroupChannel | Channels.DirectMessageChannel | Channels.SavedMessagesChannel)[]; .filter(
(x) =>
x.channel_type === "DirectMessage" ||
x.channel_type === "Group" ||
x.channel_type === "SavedMessages",
) as (
| Channels.GroupChannel
| Channels.DirectMessageChannel
| Channels.SavedMessagesChannel
)[];
} }
export function useUserPermission(id: string, context?: HookContext) { export function useUserPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context); const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate(); const mutation = (target: string) => target === id && ctx.forceUpdate();
useEffect(() => { useEffect(() => {
ctx.client.users.addListener("update", mutation); ctx.client.users.addListener("update", mutation);
return () => ctx.client.users.removeListener("update", mutation); return () => ctx.client.users.removeListener("update", mutation);
}, [id]); }, [id]);
let calculator = new PermissionCalculator(ctx.client); let calculator = new PermissionCalculator(ctx.client);
return calculator.forUser(id); return calculator.forUser(id);
} }
export function useChannelPermission(id: string, context?: HookContext) { export function useChannelPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context); const ctx = useForceUpdate(context);
const channel = ctx.client.channels.get(id); const channel = ctx.client.channels.get(id);
const server = (channel && (channel.channel_type === 'TextChannel' || channel.channel_type === 'VoiceChannel')) ? channel.server : undefined; const server =
channel &&
(channel.channel_type === "TextChannel" ||
channel.channel_type === "VoiceChannel")
? channel.server
: undefined;
const mutation = (target: string) => (target === id) && ctx.forceUpdate(); const mutation = (target: string) => target === id && ctx.forceUpdate();
const mutationServer = (target: string) => (target === server) && ctx.forceUpdate(); const mutationServer = (target: string) =>
const mutationMember = (target: string) => (target.substr(26) === ctx.client.user!._id) && ctx.forceUpdate(); target === server && ctx.forceUpdate();
const mutationMember = (target: string) =>
target.substr(26) === ctx.client.user!._id && ctx.forceUpdate();
useEffect(() => { useEffect(() => {
ctx.client.channels.addListener("update", mutation); ctx.client.channels.addListener("update", mutation);
if (server) { if (server) {
ctx.client.servers.addListener("update", mutationServer); ctx.client.servers.addListener("update", mutationServer);
ctx.client.servers.members.addListener("update", mutationMember); ctx.client.servers.members.addListener("update", mutationMember);
} }
return () => { return () => {
ctx.client.channels.removeListener("update", mutation); ctx.client.channels.removeListener("update", mutation);
if (server) { if (server) {
ctx.client.servers.removeListener("update", mutationServer); ctx.client.servers.removeListener("update", mutationServer);
ctx.client.servers.members.removeListener("update", mutationMember); ctx.client.servers.members.removeListener(
} "update",
} mutationMember,
}, [id]); );
}
};
}, [id]);
let calculator = new PermissionCalculator(ctx.client); let calculator = new PermissionCalculator(ctx.client);
return calculator.forChannel(id); return calculator.forChannel(id);
} }
export function useServerPermission(id: string, context?: HookContext) { export function useServerPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context); const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate(); const mutation = (target: string) => target === id && ctx.forceUpdate();
const mutationMember = (target: string) => (target.substr(26) === ctx.client.user!._id) && ctx.forceUpdate(); const mutationMember = (target: string) =>
target.substr(26) === ctx.client.user!._id && ctx.forceUpdate();
useEffect(() => { useEffect(() => {
ctx.client.servers.addListener("update", mutation); ctx.client.servers.addListener("update", mutation);
ctx.client.servers.members.addListener("update", mutationMember); ctx.client.servers.members.addListener("update", mutationMember);
return () => { return () => {
ctx.client.servers.removeListener("update", mutation); ctx.client.servers.removeListener("update", mutation);
ctx.client.servers.members.removeListener("update", mutationMember); ctx.client.servers.members.removeListener("update", mutationMember);
} };
}, [id]); }, [id]);
let calculator = new PermissionCalculator(ctx.client); let calculator = new PermissionCalculator(ctx.client);
return calculator.forServer(id); return calculator.forServer(id);
} }

View file

@ -1,48 +1,57 @@
import { Channel, Message, User } from "revolt.js/dist/api/objects";
import { Children } from "../../types/Preact";
import { Text } from "preact-i18n";
import { Client } from "revolt.js"; import { Client } from "revolt.js";
import { Channel, Message, User } from "revolt.js/dist/api/objects";
export function takeError( import { Text } from "preact-i18n";
error: any
): string {
const type = error?.response?.data?.type;
let id = type;
if (!type) {
if (error?.response?.status === 403) {
return "Unauthorized";
} else if (error && (!!error.isAxiosError && !error.response)) {
return "NetworkError";
}
console.error(error); import { Children } from "../../types/Preact";
return "UnknownError";
}
return id; export function takeError(error: any): string {
const type = error?.response?.data?.type;
let id = type;
if (!type) {
if (error?.response?.status === 403) {
return "Unauthorized";
} else if (error && !!error.isAxiosError && !error.response) {
return "NetworkError";
}
console.error(error);
return "UnknownError";
}
return id;
} }
export function getChannelName(client: Client, channel: Channel, prefixType?: boolean): Children { export function getChannelName(
if (channel.channel_type === "SavedMessages") client: Client,
return <Text id="app.navigation.tabs.saved" />; channel: Channel,
prefixType?: boolean,
): Children {
if (channel.channel_type === "SavedMessages")
return <Text id="app.navigation.tabs.saved" />;
if (channel.channel_type === "DirectMessage") { if (channel.channel_type === "DirectMessage") {
let uid = client.channels.getRecipient(channel._id); let uid = client.channels.getRecipient(channel._id);
return <>{prefixType && "@"}{client.users.get(uid)?.username}</>; return (
} <>
{prefixType && "@"}
{client.users.get(uid)?.username}
</>
);
}
if (channel.channel_type === "TextChannel" && prefixType) { if (channel.channel_type === "TextChannel" && prefixType) {
return <>#{channel.name}</>; return <>#{channel.name}</>;
} }
return <>{channel.name}</>; return <>{channel.name}</>;
} }
export type MessageObject = Omit<Message, "edited"> & { edited?: string }; export type MessageObject = Omit<Message, "edited"> & { edited?: string };
export function mapMessage(message: Partial<Message>) { export function mapMessage(message: Partial<Message>) {
const { edited, ...msg } = message; const { edited, ...msg } = message;
return { return {
...msg, ...msg,
edited: edited?.$date, edited: edited?.$date,
} as MessageObject; } as MessageObject;
} }

5
src/env.d.ts vendored
View file

@ -1,5 +1,4 @@
interface ImportMetaEnv { interface ImportMetaEnv {
VITE_API_URL: string; VITE_API_URL: string;
VITE_THEMES_URL: string; VITE_THEMES_URL: string;
} }

View file

@ -1,15 +1,16 @@
import { Link, LinkProps } from "react-router-dom"; import { Link, LinkProps } from "react-router-dom";
type Props = LinkProps & JSX.HTMLAttributes<HTMLAnchorElement> & { type Props = LinkProps &
active: boolean JSX.HTMLAttributes<HTMLAnchorElement> & {
}; active: boolean;
};
export default function ConditionalLink(props: Props) { export default function ConditionalLink(props: Props) {
const { active, ...linkProps } = props; const { active, ...linkProps } = props;
if (active) { if (active) {
return <a>{ props.children }</a>; return <a>{props.children}</a>;
} else { } else {
return <Link {...linkProps} />; return <Link {...linkProps} />;
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -2,17 +2,21 @@ import { useState } from "preact/hooks";
const counts: { [key: string]: number } = {}; const counts: { [key: string]: number } = {};
export default function PaintCounter({ small, always }: { small?: boolean, always?: boolean }) { export default function PaintCounter({
if (import.meta.env.PROD && !always) return null; small,
always,
}: {
small?: boolean;
always?: boolean;
}) {
if (import.meta.env.PROD && !always) return null;
const [uniqueId] = useState('' + Math.random()); const [uniqueId] = useState("" + Math.random());
const count = counts[uniqueId] ?? 0; const count = counts[uniqueId] ?? 0;
counts[uniqueId] = count + 1; counts[uniqueId] = count + 1;
return ( return (
<div style={{ textAlign: 'center', fontSize: '0.8em' }}> <div style={{ textAlign: "center", fontSize: "0.8em" }}>
{ small ? <>P: { count + 1 }</> : <> {small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>}
Painted {count + 1} time(s). </div>
</> } );
</div>
)
} }

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