Port modal / popover context.

This commit is contained in:
Paul
2021-06-19 18:46:05 +01:00
parent 5b77ed439f
commit 9706dd75f3
57 changed files with 2562 additions and 140 deletions

View File

@@ -10,7 +10,7 @@ interface Props extends IconBaseProps<Channels.GroupChannel | Channels.TextChann
const fallback = '/assets/group.png';
export default function ChannelIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) {
const { client } = useContext(AppContext);
const client = useContext(AppContext);
const { size, target, attachment, isServerChannel: server, animate, children, as, ...imgProps } = props;
const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate);

View File

@@ -20,7 +20,7 @@ const ServerText = styled.div`
const fallback = '/assets/group.png';
export default function ServerIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) {
const { client } = useContext(AppContext);
const client = useContext(AppContext);
const { target, attachment, size, animate, server_name, children, as, ...imgProps } = props;
const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate);

View File

@@ -0,0 +1,26 @@
import styled from "styled-components";
import { Children } from "../../types/Preact";
import { Position, Tooltip as TooltipCore, TooltipProps } from "react-tippy";
type Props = Omit<TooltipProps, 'html'> & {
position?: Position;
children: Children;
content: Children;
}
const TooltipBase = styled.div`
padding: 8px;
font-size: 12px;
border-radius: 4px;
color: var(--foreground);
background: var(--secondary-background);
`;
export default function Tooltip(props: Props) {
return (
<TooltipCore
{...props}
// @ts-expect-error
html={<TooltipBase>{props.content}</TooltipBase>} />
);
}

View File

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

View File

@@ -49,11 +49,11 @@ const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
const fallback = '/assets/user.png';
export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>) {
const { client } = useContext(AppContext);
const client = useContext(AppContext);
const { target, attachment, size, voice, status, animate, children, as, ...svgProps } = props;
const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate)
?? client.users.getDefaultAvatarURL(target!._id);
?? (target && client.users.getDefaultAvatarURL(target._id));
return (
<IconBase {...svgProps}

View File

@@ -0,0 +1,41 @@
import twemoji from 'twemoji';
var EMOJI_PACK = 'mutant';
const REVISION = 3;
/*export function setEmojiPack(pack: EmojiPacks) {
EMOJI_PACK = pack;
}*/
// Taken from Twemoji source code.
// scripts/build.js#344
// grabTheRightIcon(rawText);
const UFE0Fg = /\uFE0F/g;
const U200D = String.fromCharCode(0x200D);
function toCodePoint(emoji: string) {
return twemoji.convert.toCodePoint(emoji.indexOf(U200D) < 0 ?
emoji.replace(UFE0Fg, '') :
emoji
);
}
function parseEmoji(emoji: string) {
let codepoint = toCodePoint(emoji);
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
}
export function Emoji({ emoji, size }: { emoji: string, size?: number }) {
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) {
return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(emoji)}" />`;
}

View File

@@ -0,0 +1,202 @@
@import "@fontsource/fira-mono/400.css";
.markdown {
:global(.emoji) {
height: 1.25em;
width: 1.25em;
margin: 0 0.05em 0 0.1em;
vertical-align: -0.2em;
}
&[data-large-emojis="true"] :global(.emoji) {
width: 3rem;
height: 3rem;
margin-bottom: 0;
margin-top: 1px;
margin-right: 2px;
vertical-align: -.3em;
}
p,
pre {
margin: 0;
}
a {
text-decoration: none;
&[data-type="mention"] {
padding: 0 6px;
font-weight: 600;
border-radius: 12px;
display: inline-block;
background: var(--secondary-background);
&:hover {
text-decoration: none;
}
}
&:hover {
text-decoration: underline;
}
}
h1,
h2,
h3,
h4,
h5,
h6,
ul,
ol,
blockquote {
margin: 0;
}
ul,
ol {
list-style-position: inside;
padding-left: 10px;
}
blockquote {
margin: 2px 0;
padding: 2px 0;
border-radius: 4px;
background: var(--hover);
border-inline-start: 4px solid var(--tertiary-background);
> * {
margin: 0 8px;
}
}
pre {
padding: 1em;
border-radius: 4px;
overflow-x: scroll;
border-radius: 3px;
background: var(--block) !important;
}
p > code {
padding: 1px 4px;
}
code {
color: white;
font-size: 90%;
border-radius: 4px;
background: var(--block);
font-family: "Fira Mono", monospace;
}
input[type="checkbox"] {
margin-right: 4px;
pointer-events: none;
}
table {
border-collapse: collapse;
th,
td {
padding: 6px;
border: 1px solid var(--tertiary-foreground);
}
}
:global(.katex-block) {
overflow-x: auto;
}
:global(.spoiler) {
padding: 0 2px;
cursor: pointer;
user-select: none;
color: transparent;
border-radius: 4px;
background: #151515;
&:global(.shown) {
cursor: auto;
user-select: all;
color: var(--foreground);
background: var(--secondary-background);
}
}
:global(.code) {
font-family: "Fira Mono", monospace;
:global(.lang) {
// height: 8px;
// position: relative;
div {
// margin-left: -5px;
// margin-top: -16px;
// position: absolute;
color: #111;
cursor: pointer;
padding: 2px 6px;
font-weight: 600;
user-select: none;
display: inline-block;
background: var(--accent);
font-size: 10px;
border-radius: 2px;
text-transform: uppercase;
box-shadow: 0 2px #787676;
&:active {
transform: translateY(1px);
box-shadow: 0 1px #787676;
}
}
// ! FIXME: had to change this temporarily due to overflow
width: fit-content;
padding-bottom: 8px;
}
}
input[type="checkbox"] {
width: 0;
opacity: 0;
pointer-events: none;
}
label {
pointer-events: none;
}
input[type="checkbox"] + label:before {
width: 12px;
height: 12px;
content: 'a';
font-size: 10px;
margin-right: 6px;
line-height: 12px;
position: relative;
border-radius: 4px;
background: white;
display: inline-block;
}
input[type="checkbox"][checked="true"] + label:before {
content: '';
align-items: center;
display: inline-flex;
justify-content: center;
background: var(--accent);
}
input[type="checkbox"] + label {
line-height: 12px;
position: relative;
}
}

View File

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

View File

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

View File

@@ -5,4 +5,5 @@ export default styled.div`
display: flex;
user-select: none;
flex-direction: row;
align-items: stretch;
`;

View File

@@ -1,10 +1,10 @@
import { Text } from "preact-i18n";
import Banner from "../../ui/Banner";
import { useContext } from "preact/hooks";
import { AppContext, ClientStatus } from "../../../context/revoltjs/RevoltClient";
import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
export default function ConnectionStatus() {
const { status } = useContext(AppContext);
const status = useContext(StatusContext);
if (status === ClientStatus.OFFLINE) {
return (

View File

@@ -48,7 +48,7 @@ const HomeList = styled.div`
function HomeSidebar(props: Props) {
const { pathname } = useLocation();
const { client } = useContext(AppContext);
const client = useContext(AppContext);
const { channel } = useParams<{ channel: string }>();
// const { openScreen, writeClipboard } = useContext(IntermediateContext);

View File

@@ -60,7 +60,7 @@ const Checkmark = styled.div<{ checked: boolean }>`
`}
`;
interface Props {
export interface CheckboxProps {
checked: boolean;
disabled?: boolean;
className?: string;
@@ -69,7 +69,7 @@ interface Props {
onChange: (state: boolean) => void;
}
export default function Checkbox(props: Props) {
export default function Checkbox(props: CheckboxProps) {
return (
<CheckboxBase disabled={props.disabled}>
<CheckboxContent>

138
src/components/ui/Modal.tsx Normal file
View File

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

View File

@@ -2,20 +2,23 @@ import State from "../redux/State";
import { Children } from "../types/Preact";
import { BrowserRouter } from "react-router-dom";
import Intermediate from './intermediate/Intermediate';
import ClientContext from './revoltjs/RevoltClient';
import Locale from "./Locale";
import Theme from "./Theme";
export default function Context({ children }: { children: Children }) {
return (
<BrowserRouter>
<State>
<ClientContext>
<Locale>
<Theme>{children}</Theme>
</Locale>
</ClientContext>
</State>
</BrowserRouter>
<State>
<Locale>
<Intermediate>
<BrowserRouter>
<ClientContext>
<Theme>{children}</Theme>
</ClientContext>
</BrowserRouter>
</Intermediate>
</Locale>
</State>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
import { IntermediateContext, useIntermediate } from "./Intermediate";
import { useContext } from "preact/hooks";
import { UserPicker } from "./popovers/UserPicker";
import { UserProfile } from "./popovers/UserProfile";
import { ImageViewer } from "./popovers/ImageViewer";
import { ChannelInfo } from "./popovers/ChannelInfo";
export default function Popovers() {
const { screen } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const onClose = () => openScreen({ id: "none" });
switch (screen.id) {
case "profile":
return <UserProfile {...screen} onClose={onClose} />;
case "user_picker":
return <UserPicker {...screen} onClose={onClose} />;
case "image_viewer":
return <ImageViewer {...screen} onClose={onClose} />;
case "channel_info":
return <ChannelInfo {...screen} onClose={onClose} />;
}
return null;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
import { Text } from "preact-i18n";
import { useForm } from "react-hook-form";
import Modal from "../../../components/ui/Modal";
import { takeError } from "../../revoltjs/util";
import { useContext, useState } from "preact/hooks";
import FormField from '../../../pages/login/FormField';
import Overline from "../../../components/ui/Overline";
import { AppContext } from "../../revoltjs/RevoltClient";
interface Props {
onClose: () => void;
field: "username" | "email" | "password";
}
export function ModifyAccountModal({ onClose, field }: Props) {
const client = useContext(AppContext);
const { handleSubmit, register, errors } = useForm();
const [error, setError] = useState<string | undefined>(undefined);
async function onSubmit({
password,
new_username,
new_email,
new_password
}: {
password: string;
new_username: string;
new_email: string;
new_password: string;
}) {
try {
if (field === "email") {
await client.req("POST", "/auth/change/email", {
password,
new_email
});
onClose();
} else if (field === "password") {
await client.req("POST", "/auth/change/password", {
password,
new_password
});
onClose();
} else if (field === "username") {
await client.req("PATCH", "/users/id/username", {
username: new_username,
password
});
onClose();
}
} catch (err) {
setError(takeError(err));
}
}
return (
<Modal
visible={true}
onClose={onClose}
title={<Text id={`app.special.modals.account.change.${field}`} />}
actions={[
{
confirmation: true,
onClick: handleSubmit(onSubmit),
text:
field === "email" ? (
<Text id="app.special.modals.actions.send_email" />
) : (
<Text id="app.special.modals.actions.update" />
)
},
{
onClick: onClose,
text: <Text id="app.special.modals.actions.close" />
}
]}
>
<form onSubmit={handleSubmit(onSubmit) as any}>
{field === "email" && (
<FormField
type="email"
name="new_email"
register={register}
showOverline
error={errors.new_email?.message}
/>
)}
{field === "password" && (
<FormField
type="password"
name="new_password"
register={register}
showOverline
error={errors.new_password?.message}
/>
)}
{field === "username" && (
<FormField
type="username"
name="new_username"
register={register}
showOverline
error={errors.new_username?.message}
/>
)}
<FormField
type="current_password"
register={register}
showOverline
error={errors.current_password?.message}
/>
{error && (
<Overline type="error" error={error}>
<Text id="app.special.modals.account.failed" />
</Overline>
)}
</form>
</Modal>
);
}

View File

@@ -0,0 +1,40 @@
.onboarding {
display: flex;
align-items: center;
flex-direction: column;
div {
flex: 1;
&.header {
padding: 3em;
text-align: center;
h1 {
margin: 0;
}
}
&.form {
flex-grow: 1;
max-width: 420px;
img {
margin: auto;
display: block;
max-height: 420px;
border-radius: 8px;
}
input {
width: 100%;
}
button {
display: block;
margin: 24px 0;
margin-left: auto;
}
}
}
}

View File

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

View File

@@ -0,0 +1,18 @@
.invite {
display: flex;
flex-direction: column;
code {
padding: 1em;
user-select: all;
font-size: 1.4em;
text-align: center;
font-family: "Fira Mono";
}
}
.column {
display: flex;
align-items: center;
flex-direction: column;
}

View File

@@ -0,0 +1,234 @@
import { Text } from "preact-i18n";
import styles from './Prompt.module.scss';
import { Children } from "../../../types/Preact";
import { IntermediateContext, useIntermediate } from "../Intermediate";
import InputBox from "../../../components/ui/InputBox";
import Overline from "../../../components/ui/Overline";
import UserIcon from "../../../components/common/UserIcon";
import Modal, { Action } from "../../../components/ui/Modal";
import { Channels, Servers } from "revolt.js/dist/api/objects";
import { useContext, useEffect, useState } from "preact/hooks";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
question: Children;
content?: Children;
disabled?: boolean;
actions: Action[];
error?: string;
}
export function PromptModal({ onClose, question, content, actions, disabled, error }: Props) {
return (
<Modal
visible={true}
title={question}
actions={actions}
onClose={onClose}
disabled={disabled}>
{ error && <Overline error={error} type="error" /> }
{ content }
</Modal>
);
}
type SpecialProps = { onClose: () => void } & (
{ type: "leave_group", target: Channels.GroupChannel } |
{ type: "close_dm", target: Channels.DirectMessageChannel } |
{ type: "leave_server", target: Servers.Server } |
{ type: "delete_server", target: Servers.Server } |
{ type: "delete_channel", target: Channels.TextChannel } |
{ type: "delete_message", target: Channels.Message } |
{ type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } |
{ type: "kick_member", target: Servers.Server, user: string } |
{ type: "ban_member", target: Servers.Server, user: string }
)
export function SpecialPromptModal(props: SpecialProps) {
const client = useContext(AppContext);
const [ processing, setProcessing ] = useState(false);
const [ error, setError ] = useState<undefined | string>(undefined);
const { onClose } = props;
switch (props.type) {
case 'leave_group':
case 'close_dm':
case 'leave_server':
case 'delete_server':
case 'delete_message':
case 'delete_channel': {
const EVENTS = {
'close_dm': 'confirm_close_dm',
'delete_server': 'confirm_delete',
'delete_channel': 'confirm_delete',
'delete_message': 'confirm_delete_message',
'leave_group': 'confirm_leave',
'leave_server': 'confirm_leave'
};
let event = EVENTS[props.type];
let name = props.type === 'close_dm' ? client.users.get(client.channels.getRecipient(props.target._id))?.username :
props.type === 'delete_message' ? undefined : props.target.name;
return (
<PromptModal
onClose={onClose}
question={<Text
id={props.type === 'delete_message' ? 'app.context_menu.delete_message' : `app.special.modals.prompt.${event}`}
fields={{ name }}
/>}
actions={[
{
confirmation: true,
style: 'contrast-error',
text: <Text id="app.special.modals.actions.delete" />,
onClick: async () => {
setProcessing(true);
try {
if (props.type === 'leave_group' || props.type === 'close_dm' || props.type === 'delete_channel') {
await client.channels.delete(props.target._id);
} else if (props.type === 'delete_message') {
await client.channels.deleteMessage(props.target.channel, props.target._id);
} else {
await client.servers.delete(props.target._id);
}
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}
},
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
]}
content={<Text id={`app.special.modals.prompt.${event}_long`} />}
disabled={processing}
error={error}
/>
)
}
case "create_invite": {
const [ code, setCode ] = useState('abcdef');
const { writeClipboard } = useIntermediate();
useEffect(() => {
setProcessing(true);
client.channels.createInvite(props.target._id)
.then(code => setCode(code))
.catch(err => setError(takeError(err)))
.finally(() => setProcessing(false));
}, []);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.create_invite`} />}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
confirmation: true,
onClick: onClose
},
{
text: <Text id="app.context_menu.copy_link" />,
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}>
<Text id="app.special.modals.prompt.create_invite_created" />
<code>{code}</code>
</div>
}
disabled={processing}
error={error}
/>
)
}
case "kick_member": {
const user = client.users.get(props.user);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.kick_member`} />}
actions={[
{
text: <Text id="app.special.modals.actions.kick" />,
style: 'contrast-error',
confirmation: true,
onClick: async () => {
setProcessing(true);
try {
await client.servers.members.kickMember(props.target._id, props.user);
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
id="app.special.modals.prompt.confirm_kick"
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 (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.ban_member`} />}
actions={[
{
text: <Text id="app.special.modals.actions.ban" />,
style: 'contrast-error',
confirmation: true,
onClick: async () => {
setProcessing(true);
try {
await client.servers.banUser(props.target._id, props.user, { reason });
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
id="app.special.modals.prompt.confirm_ban"
fields={{ name: user?.username }} />
<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}
/>
)
}
default: return null;
}
}

View File

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

View File

@@ -0,0 +1,16 @@
.info {
.header {
display: flex;
align-items: center;
flex-direction: row;
h1 {
margin: 0;
flex-grow: 1;
}
div {
cursor: pointer;
}
}
}

View File

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

View File

@@ -0,0 +1,6 @@
.viewer {
img {
max-width: 90vw;
max-height: 90vh;
}
}

View File

@@ -0,0 +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";
interface Props {
onClose: () => void;
embed?: EmbedImage;
attachment?: Attachment;
}
export function ImageViewer({ attachment, embed, onClose }: Props) {
if (attachment && attachment.metadata.type !== "Image") return null;
const client = useContext(AppContext);
useEffect(() => {
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, []);
return (
<Modal visible={true} onClose={onClose} noBackground>
<div className={styles.viewer}>
{ attachment &&
<>
<img src={client.generateFileURL(attachment)} />
{/*<AttachmentActions attachment={attachment} />*/}
</>
}
{ embed &&
<>
{/*<img src={proxyImage(embed.url)} />*/}
{/*<EmbedMediaActions embed={embed} />*/}
</>
}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,21 @@
.list {
width: 400px;
max-width: 100%;
max-height: 360px;
overflow-y: scroll;
// ! FIXME: very temporary code
> label {
> span {
align-items: flex-start !important;
> span {
display: flex;
padding: 4px;
flex-direction: row;
gap: 10px;
justify-content: flex-start;
align-items: center;
}
}
}
}

View File

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

View File

@@ -0,0 +1,165 @@
.modal {
height: 460px;
display: flex;
padding: 0 !important;
flex-direction: column;
}
.header {
background-size: cover;
border-radius: 8px 8px 0 0;
background-position: center;
&[data-force="light"] {
color: white;
}
&[data-force="dark"] {
color: black;
}
}
.profile {
gap: 16px;
width: 560px;
display: flex;
padding: 20px;
max-width: 100%;
align-items: center;
flex-direction: row;
.details {
flex-grow: 1;
min-width: 0;
display: flex;
flex-direction: column;
> * {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.username {
font-size: 22px;
font-weight: 600;
}
.status {
font-size: 13px;
}
}
}
.tabs {
gap: 8px;
display: flex;
padding: 0 1.5em;
font-size: .875rem;
> div {
padding: 8px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: border-bottom .3s;
&[data-active="true"] {
border-bottom: 2px solid var(--foreground);
cursor: default;
}
&:hover:not([data-active="true"]) {
border-bottom: 2px solid var(--tertiary-foreground);
}
}
}
.content {
gap: 8px;
height: 100%;
display: flex;
padding: 1em 1.5em;
max-width: 560px;
overflow-y: auto;
flex-direction: column;
background: var(--primary-background);
border-radius: 0 0 8px 8px;
.empty {
display: flex;
justify-content: center;
align-items: center;
}
.category {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--tertiary-foreground);
margin-bottom: 8px;
&:not(:first-child) {
margin-top: 8px;
}
}
> div {
> span {
font-size: 15px;
}
}
}
.badges {
gap: 8px;
display: flex;
margin-top: 4px;
flex-direction: row;
img {
width: 32px;
height: 32px;
cursor: pointer;
}
}
.entries {
gap: 8px;
display: flex;
flex-direction: column;
a {
min-width: 0;
}
.entry {
gap: 8px;
min-width: 0;
padding: 12px;
display: flex;
cursor: pointer;
border-radius: 4px;
align-items: center;
color: var(--secondary-foreground);
background-color: var(--secondary-background);
transition: background-color .1s;
&:hover {
background-color: var(--primary-background);
}
img {
width: 32px;
height: 32px;
border-radius: 50%;
}
span {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}

View File

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

View File

@@ -2,7 +2,7 @@ import { ReactNode } from "react";
import { useContext } from "preact/hooks";
import { Redirect } from "react-router-dom";
import { AppContext } from "./RevoltClient";
import { OperationsContext } from "./RevoltClient";
interface Props {
auth?: boolean;
@@ -10,7 +10,7 @@ interface Props {
}
export const CheckAuth = (props: Props) => {
const { operations } = useContext(AppContext);
const operations = useContext(OperationsContext);
if (props.auth && !operations.ready()) {
return <Redirect to="/login" />;

View File

@@ -1,10 +1,10 @@
import { openDB } from 'idb';
import { Client } from "revolt.js";
import { takeError } from "./error";
import { takeError } from "./util";
import { createContext } from "preact";
import { Children } from "../../types/Preact";
import { Route } from "revolt.js/dist/api/routes";
import { useEffect, useState } from "preact/hooks";
import { useEffect, useMemo, useState } from "preact/hooks";
import { connectState } from "../../redux/connector";
import Preloader from "../../components/ui/Preloader";
import { WithDispatcher } from "../../redux/reducers";
@@ -30,13 +30,9 @@ export interface ClientOperations {
ready: () => boolean;
}
export interface AppState {
client: Client;
status: ClientStatus;
operations: ClientOperations;
}
export const AppContext = createContext<AppState>(undefined as any);
export const AppContext = createContext<Client>(undefined as any);
export const StatusContext = createContext<ClientStatus>(undefined as any);
export const OperationsContext = createContext<ClientOperations>(undefined as any);
type Props = WithDispatcher & {
auth: AuthState;
@@ -78,10 +74,8 @@ function Context({ auth, sync, children, dispatcher }: Props) {
if (status === ClientStatus.INIT) return null;
const value: AppState = {
client,
status,
operations: {
const operations: ClientOperations = useMemo(() => {
return {
login: async data => {
setReconnectDisallowed(true);
@@ -131,14 +125,14 @@ function Context({ auth, sync, children, dispatcher }: Props) {
},
loggedIn: () => typeof auth.active !== "undefined",
ready: () => (
value.operations.loggedIn() &&
operations.loggedIn() &&
typeof client.user !== "undefined"
)
}
};
}, [ client, auth.active ]);
useEffect(
() => registerEvents({ ...value, dispatcher }, setStatus, client),
() => registerEvents({ operations, dispatcher }, setStatus, client),
[ client ]
);
@@ -155,7 +149,7 @@ function Context({ auth, sync, children, dispatcher }: Props) {
return setStatus(ClientStatus.OFFLINE);
}
if (value.operations.ready())
if (operations.ready())
setStatus(ClientStatus.CONNECTING);
if (navigator.onLine) {
@@ -194,7 +188,7 @@ function Context({ auth, sync, children, dispatcher }: Props) {
setStatus(ClientStatus.DISCONNECTED);
const error = takeError(err);
if (error === "Forbidden") {
value.operations.logout(true);
operations.logout(true);
// openScreen({ id: "signed_out" });
} else {
// openScreen({ id: "error", error });
@@ -217,8 +211,12 @@ function Context({ auth, sync, children, dispatcher }: Props) {
}
return (
<AppContext.Provider value={value}>
{ children }
<AppContext.Provider value={client}>
<StatusContext.Provider value={status}>
<OperationsContext.Provider value={operations}>
{ children }
</OperationsContext.Provider>
</StatusContext.Provider>
</AppContext.Provider>
);
}

View File

@@ -1,18 +0,0 @@
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;
}

View File

@@ -2,7 +2,7 @@ import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"
import { WithDispatcher } from "../../redux/reducers";
import { Client, Message } from "revolt.js/dist";
import {
AppState,
ClientOperations,
ClientStatus
} from "./RevoltClient";
import { StateUpdater } from "preact/hooks";
@@ -17,7 +17,7 @@ export function setReconnectDisallowed(allowed: boolean) {
export function registerEvents({
operations,
dispatcher
}: AppState & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) {
}: { operations: ClientOperations } & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) {
const listeners = {
connecting: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING),

View File

@@ -9,7 +9,7 @@ export interface HookContext {
}
export function useForceUpdate(context?: HookContext): HookContext {
const { client } = useContext(AppContext);
const client = useContext(AppContext);
if (context) return context;
const [, updateState] = useState({});
return { client, forceUpdate: useCallback(() => updateState({}), []) };

View File

@@ -1,10 +0,0 @@
import { Message } from "revolt.js/dist/api/objects";
export type MessageObject = Omit<Message, "edited"> & { edited?: string };
export function mapMessage(message: Partial<Message>) {
const { edited, ...msg } = message;
return {
...msg,
edited: edited?.$date,
} as MessageObject;
}

View File

@@ -0,0 +1,49 @@
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";
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, users: User[], prefixType?: boolean): Children {
if (channel.channel_type === "SavedMessages")
return <Text id="app.navigation.tabs.saved" />;
if (channel.channel_type === "DirectMessage") {
let uid = client.channels.getRecipient(channel._id);
return <>{prefixType && "@"}{users.find(x => x._id === uid)?.username}</>;
}
if (channel.channel_type === "TextChannel" && prefixType) {
return <>#{channel.name}</>;
}
return <>{channel.name}</>;
}
export type MessageObject = Omit<Message, "edited"> & { edited?: string };
export function mapMessage(message: Partial<Message>) {
const { edited, ...msg } = message;
return {
...msg,
edited: edited?.$date,
} as MessageObject;
}

View File

@@ -9,10 +9,10 @@ export default function PaintCounter({ small }: { small?: boolean }) {
const count = counts[uniqueId] ?? 0;
counts[uniqueId] = count + 1;
return (
<span>
<div style={{ textAlign: 'center', fontSize: '0.8em' }}>
{ small ? <>P: { count + 1 }</> : <>
Painted {count + 1} time(s).
</> }
</span>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import LeftSidebar from "../components/navigation/LeftSidebar";
import RightSidebar from "../components/navigation/RightSidebar";
import Home from './home/Home';
import Popovers from "../context/intermediate/Popovers";
export default function App() {
return (
@@ -20,6 +21,7 @@ export default function App() {
<Home />
</Route>
</Switch>
<Popovers />
</OverlappingPanels>
);
};

View File

@@ -1,59 +1,9 @@
import { useChannels, useForceUpdate, useServers, useUser } from "../../context/revoltjs/hooks";
import ChannelIcon from "../../components/common/ChannelIcon";
import ServerIcon from "../../components/common/ServerIcon";
import UserIcon from "../../components/common/UserIcon";
import PaintCounter from "../../lib/PaintCounter";
export function Nested() {
const ctx = useForceUpdate();
let user = useUser('01EX2NCWQ0CHS3QJF0FEQS1GR4', ctx)!;
let user2 = useUser('01EX40TVKYNV114H8Q8VWEGBWQ', ctx)!;
let user3 = useUser('01F5GV44HTXP3MTCD2VPV42DPE', ctx)!;
let channels = useChannels(undefined, ctx);
let servers = useServers(undefined, ctx);
return (
<>
<h3>Nested component</h3>
<PaintCounter />
@{ user.username } is { user.online ? 'online' : 'offline' }<br/><br/>
<h3>UserIcon Tests</h3>
<UserIcon size={64} target={user} />
<UserIcon size={64} target={user} status />
<UserIcon size={64} target={user} voice='muted' />
<UserIcon size={64} attachment={user2.avatar} />
<UserIcon size={64} attachment={user3.avatar} />
<UserIcon size={64} attachment={user3.avatar} animate />
<h3>Channels</h3>
{ channels.map(channel =>
channel &&
channel.channel_type !== 'SavedMessages' &&
channel.channel_type !== 'DirectMessage' &&
<ChannelIcon size={48} target={channel} />
) }
<h3>Servers</h3>
{ servers.map(server =>
server &&
<ServerIcon size={48} target={server} />
) }
<br/><br/>
<p>{ 'test long paragraph'.repeat(2000) }</p>
</>
)
}
export default function Home() {
return (
<div style={{ overflowY: 'scroll', height: '100vh' }}>
<h1>HOME</h1>
<div>
<PaintCounter />
<Nested />
</div>
);
}

View File

@@ -17,7 +17,7 @@ import { FormReset, FormSendReset } from "./forms/FormReset";
export default function Login() {
const theme = useContext(ThemeContext);
const { client } = useContext(AppContext);
const client = useContext(AppContext);
return (
<div className={styles.login}>

View File

@@ -11,7 +11,7 @@ export interface CaptchaProps {
}
export function CaptchaBlock(props: CaptchaProps) {
const { client } = useContext(AppContext);
const client = useContext(AppContext);
useEffect(() => {
if (!client.configuration?.features.captcha.enabled) {

View File

@@ -7,7 +7,7 @@ import { MailProvider } from "./MailProvider";
import { useContext, useState } from "preact/hooks";
import { CheckCircle, Mail } from "@styled-icons/feather";
import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock";
import { takeError } from "../../../context/revoltjs/error";
import { takeError } from "../../../context/revoltjs/util";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import FormField from "../FormField";
@@ -34,7 +34,7 @@ function getInviteCode() {
}
export function Form({ page, callback }: Props) {
const { client } = useContext(AppContext);
const client = useContext(AppContext);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | undefined>(undefined);

View File

@@ -3,7 +3,7 @@ import { useContext } from "preact/hooks";
import { Form } from "./Form";
export function FormCreate() {
const { client } = useContext(AppContext);
const client = useContext(AppContext);
return (
<Form

View File

@@ -2,10 +2,10 @@ import { Form } from "./Form";
import { useContext } from "preact/hooks";
import { useHistory } from "react-router-dom";
import { deviceDetect } from "react-device-detect";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { OperationsContext } from "../../../context/revoltjs/RevoltClient";
export function FormLogin() {
const { operations } = useContext(AppContext);
const { login } = useContext(OperationsContext);
const history = useHistory();
return (
@@ -21,7 +21,7 @@ export function FormLogin() {
device_name = "Unknown Device";
}
await operations.login({ ...data, device_name });
await login({ ...data, device_name });
history.push("/");
}}
/>

View File

@@ -3,7 +3,7 @@ import { useContext } from "preact/hooks";
import { Form } from "./Form";
export function FormResend() {
const { client } = useContext(AppContext);
const client = useContext(AppContext);
return (
<Form

View File

@@ -4,7 +4,7 @@ import { useHistory, useParams } from "react-router-dom";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
export function FormSendReset() {
const { client } = useContext(AppContext);
const client = useContext(AppContext);
return (
<Form
@@ -18,7 +18,7 @@ export function FormSendReset() {
export function FormReset() {
const { token } = useParams<{ token: string }>();
const { client } = useContext(AppContext);
const client = useContext(AppContext);
const history = useHistory();
return (

View File

@@ -1,4 +1,4 @@
import { MessageObject } from "../../context/revoltjs/messages";
import { MessageObject } from "../../context/revoltjs/util";
export enum QueueStatus {
SENDING = "sending",

View File

@@ -3,7 +3,4 @@
@import "@fontsource/open-sans/600.css";
@import "@fontsource/open-sans/700.css";
@import "@fontsource/open-sans/300-italic.css";
@import "@fontsource/open-sans/400-italic.css";
@import "@fontsource/open-sans/600-italic.css";
@import "@fontsource/open-sans/700-italic.css";

View File

@@ -1,3 +1,4 @@
import { VNode } from "preact";
export type Children = VNode | (VNode | string)[] | string;
export type Child = VNode | string | false | undefined;
export type Children = Child | Child[] | Children[];