diff --git a/package.json b/package.json index 1048e99a..e2c051e9 100644 --- a/package.json +++ b/package.json @@ -24,16 +24,22 @@ "preact": "^10.5.13" }, "devDependencies": { + "@fontsource/fira-mono": "^4.4.5", "@fontsource/open-sans": "^4.4.5", "@hcaptcha/react-hcaptcha": "^0.3.6", "@preact/preset-vite": "^2.0.0", "@styled-icons/bootstrap": "^10.34.0", "@styled-icons/feather": "^10.34.0", + "@traptitech/markdown-it-katex": "^3.4.3", + "@traptitech/markdown-it-spoiler": "^1.1.6", + "@types/markdown-it": "^12.0.2", "@types/node": "^15.12.4", "@types/preact-i18n": "^2.3.0", + "@types/prismjs": "^1.16.5", "@types/react-helmet": "^6.1.1", "@types/react-router-dom": "^5.1.7", "@types/styled-components": "^5.1.10", + "@types/twemoji": "^12.1.1", "@typescript-eslint/eslint-plugin": "^4.27.0", "@typescript-eslint/parser": "^4.27.0", "classnames": "^2.3.1", @@ -41,22 +47,31 @@ "detect-browser": "^5.2.0", "eslint": "^7.28.0", "eslint-config-preact": "^1.1.4", + "highlight.js": "^11.0.1", "idb": "^6.1.2", "localforage": "^1.9.0", + "markdown-it": "^12.0.6", + "markdown-it-emoji": "^2.0.0", + "markdown-it-sub": "^1.0.0", + "markdown-it-sup": "^1.0.0", "preact-i18n": "^2.4.0-preactx", "prettier": "^2.3.1", + "prismjs": "^1.23.0", "react-device-detect": "^1.17.0", "react-helmet": "^6.1.0", "react-hook-form": "6.3.0", "react-overlapping-panels": "1.2.1", "react-redux": "^7.2.4", "react-router-dom": "^5.2.0", + "react-tippy": "^1.4.0", "redux": "^4.1.0", "revolt.js": "4.3.0", "rimraf": "^3.0.2", "sass": "^1.35.1", "styled-components": "^5.3.0", + "twemoji": "^13.1.0", "typescript": "^4.3.2", + "ulid": "^2.3.0", "vite": "^2.3.7", "vite-plugin-pwa": "^0.8.1" } diff --git a/src/components/common/ChannelIcon.tsx b/src/components/common/ChannelIcon.tsx index af245c3f..2611edb6 100644 --- a/src/components/common/ChannelIcon.tsx +++ b/src/components/common/ChannelIcon.tsx @@ -10,7 +10,7 @@ interface Props extends IconBaseProps, 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); diff --git a/src/components/common/ServerIcon.tsx b/src/components/common/ServerIcon.tsx index b9ea8118..46befcbb 100644 --- a/src/components/common/ServerIcon.tsx +++ b/src/components/common/ServerIcon.tsx @@ -20,7 +20,7 @@ const ServerText = styled.div` const fallback = '/assets/group.png'; export default function ServerIcon(props: Props & Omit, 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); diff --git a/src/components/common/Tooltip.tsx b/src/components/common/Tooltip.tsx new file mode 100644 index 00000000..db4a6c7b --- /dev/null +++ b/src/components/common/Tooltip.tsx @@ -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 & { + 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 ( + {props.content}} /> + ); +} diff --git a/src/components/common/UserCheckbox.tsx b/src/components/common/UserCheckbox.tsx new file mode 100644 index 00000000..35577ebc --- /dev/null +++ b/src/components/common/UserCheckbox.tsx @@ -0,0 +1,14 @@ +import { User } from "revolt.js"; +import UserIcon from "./UserIcon"; +import Checkbox, { CheckboxProps } from "../ui/Checkbox"; + +type UserProps = Omit & { user: User }; + +export default function UserCheckbox({ user, ...props }: UserProps) { + return ( + + + {user.username} + + ); +} diff --git a/src/components/common/UserIcon.tsx b/src/components/common/UserIcon.tsx index 124d7571..7aa5e379 100644 --- a/src/components/common/UserIcon.tsx +++ b/src/components/common/UserIcon.tsx @@ -49,11 +49,11 @@ const VoiceIndicator = styled.div<{ status: VoiceStatus }>` const fallback = '/assets/user.png'; export default function UserIcon(props: Props & Omit, 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 ( + ) +} + +export function generateEmoji(emoji: string) { + return `${emoji}`; +} diff --git a/src/components/markdown/Markdown.module.scss b/src/components/markdown/Markdown.module.scss new file mode 100644 index 00000000..2f13352a --- /dev/null +++ b/src/components/markdown/Markdown.module.scss @@ -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; + } +} diff --git a/src/components/markdown/Markdown.tsx b/src/components/markdown/Markdown.tsx new file mode 100644 index 00000000..b98e8280 --- /dev/null +++ b/src/components/markdown/Markdown.tsx @@ -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 + + + + ) +} diff --git a/src/components/markdown/Renderer.tsx b/src/components/markdown/Renderer.tsx new file mode 100644 index 00000000..35dbef8d --- /dev/null +++ b/src/components/markdown/Renderer.tsx @@ -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 `
${lang}
${out}
`; + } + + return `
${md.utils.escapeHtml(str)}
`; + } +}) +.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 ( + { + if (ev.target) { + let element: Element = ev.target as any; + if (element.classList.contains("spoiler")) { + element.classList.add("shown"); + } + } + }} + /> + ); +} diff --git a/src/components/navigation/SidebarBase.tsx b/src/components/navigation/SidebarBase.tsx index 677c9145..cfd4ee29 100644 --- a/src/components/navigation/SidebarBase.tsx +++ b/src/components/navigation/SidebarBase.tsx @@ -5,4 +5,5 @@ export default styled.div` display: flex; user-select: none; flex-direction: row; + align-items: stretch; `; diff --git a/src/components/navigation/items/ConnectionStatus.tsx b/src/components/navigation/items/ConnectionStatus.tsx index 88a886c7..7b7a9b08 100644 --- a/src/components/navigation/items/ConnectionStatus.tsx +++ b/src/components/navigation/items/ConnectionStatus.tsx @@ -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 ( diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx index 4e767e3a..cc4d6dcb 100644 --- a/src/components/navigation/left/HomeSidebar.tsx +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -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); diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx index 432b1f6c..fb9adf31 100644 --- a/src/components/ui/Checkbox.tsx +++ b/src/components/ui/Checkbox.tsx @@ -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 ( diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 00000000..65a93960 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -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 = ( + + {props.title &&

{props.title}

} + {props.children} +
+ ); + + 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( + + (e.cancelBubble = true)}> + {content} + {props.actions && ( + + {props.actions.map(x => ( + + ))} + + )} + + , + document.body + ); +} diff --git a/src/context/index.tsx b/src/context/index.tsx index cf025690..fde2bfae 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -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 ( - - - - - {children} - - - - + + + + + + {children} + + + + + ); } diff --git a/src/context/intermediate/Intermediate.tsx b/src/context/intermediate/Intermediate.tsx new file mode 100644 index 00000000..a623d9f0 --- /dev/null +++ b/src/context/intermediate/Intermediate.tsx @@ -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; + } +| { + id: "onboarding"; + callback: ( + username: string, + loginAfterSuccess?: true + ) => Promise; + } + +// 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; + }; + +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({ 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 ( + + + {props.children} + + {/* { + openScreen({ id: 'none' }); + setTimeout(() => history.push(history.location), 0); + + return false; + }} + />*/} + + + ); +} + +export const useIntermediate = () => useContext(IntermediateActionsContext); diff --git a/src/context/intermediate/Modals.tsx b/src/context/intermediate/Modals.tsx new file mode 100644 index 00000000..a70c1370 --- /dev/null +++ b/src/context/intermediate/Modals.tsx @@ -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 ; + case "special_prompt": + return ; + case "_input": + return ; + case "special_input": + return ; + case "error": + return ; + case "signed_out": + return ; + case "clipboard": + return ; + case "modify_account": + return ; + case "onboarding": + return ; + } + + return null; +} diff --git a/src/context/intermediate/Popovers.tsx b/src/context/intermediate/Popovers.tsx new file mode 100644 index 00000000..565d762b --- /dev/null +++ b/src/context/intermediate/Popovers.tsx @@ -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 ; + case "user_picker": + return ; + case "image_viewer": + return ; + case "channel_info": + return ; + } + + return null; +} diff --git a/src/context/intermediate/modals/Clipboard.tsx b/src/context/intermediate/modals/Clipboard.tsx new file mode 100644 index 00000000..f0b12bad --- /dev/null +++ b/src/context/intermediate/modals/Clipboard.tsx @@ -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 ( + } + actions={[ + { + onClick: onClose, + confirmation: true, + text: + } + ]} + > + {location.protocol !== "https:" && ( +

+ +

+ )} + {" "} + {text} +
+ ); +} diff --git a/src/context/intermediate/modals/Error.tsx b/src/context/intermediate/modals/Error.tsx new file mode 100644 index 00000000..5b3cc170 --- /dev/null +++ b/src/context/intermediate/modals/Error.tsx @@ -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 ( + false} + title={} + actions={[ + { + onClick: onClose, + confirmation: true, + text: + }, + { + onClick: () => location.reload(), + text: + } + ]} + > + {error} + + ); +} diff --git a/src/context/intermediate/modals/Input.tsx b/src/context/intermediate/modals/Input.tsx new file mode 100644 index 00000000..4aa68f9b --- /dev/null +++ b/src/context/intermediate/modals/Input.tsx @@ -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; +} + +export function InputModal({ + onClose, + question, + field, + defaultValue, + callback +}: Props) { + const [processing, setProcessing] = useState(false); + const [value, setValue] = useState(defaultValue ?? ""); + const [error, setError] = useState(undefined); + + return ( + , + onClick: () => { + setProcessing(true); + callback(value) + .then(onClose) + .catch(err => { + setError(takeError(err)); + setProcessing(false) + }) + } + }, + { + text: , + onClick: onClose + } + ]} + onClose={onClose} + > + + {field} + + setValue(e.currentTarget.value)} + /> + + ); +} + +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 } + field={} + callback={async name => { + const group = await client.channels.createGroup( + { + name, + nonce: ulid(), + users: [] + } + ); + + history.push(`/channel/${group._id}`); + }} + />; + } + case "create_server": { + return } + field={} + callback={async name => { + const server = await client.servers.createServer( + { + name, + nonce: ulid() + } + ); + + history.push(`/server/${server._id}`); + }} + />; + } + case "create_channel": { + return } + field={} + 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 } + field={} + defaultValue={client.user?.status?.text} + callback={text => + client.users.editUser({ + status: { + ...client.user?.status, + text + } + }) + } + />; + } + default: return null; + } +} diff --git a/src/context/intermediate/modals/ModifyAccount.tsx b/src/context/intermediate/modals/ModifyAccount.tsx new file mode 100644 index 00000000..2deb2b8c --- /dev/null +++ b/src/context/intermediate/modals/ModifyAccount.tsx @@ -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(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 ( + } + actions={[ + { + confirmation: true, + onClick: handleSubmit(onSubmit), + text: + field === "email" ? ( + + ) : ( + + ) + }, + { + onClick: onClose, + text: + } + ]} + > +
+ {field === "email" && ( + + )} + {field === "password" && ( + + )} + {field === "username" && ( + + )} + + {error && ( + + + + )} + +
+ ); +} diff --git a/src/context/intermediate/modals/Onboarding.module.scss b/src/context/intermediate/modals/Onboarding.module.scss new file mode 100644 index 00000000..f3ed2dde --- /dev/null +++ b/src/context/intermediate/modals/Onboarding.module.scss @@ -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; + } + } + } +} diff --git a/src/context/intermediate/modals/Onboarding.tsx b/src/context/intermediate/modals/Onboarding.tsx new file mode 100644 index 00000000..b1741e2a --- /dev/null +++ b/src/context/intermediate/modals/Onboarding.tsx @@ -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; +} + +export function OnboardingModal({ onClose, callback }: Props) { + const { handleSubmit, register } = useForm(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + + async function onSubmit({ username }: { username: string }) { + setLoading(true); + callback(username, true) + .then(onClose) + .catch((err: any) => { + setError(takeError(err)); + setLoading(false); + }); + } + + return ( +
+
+

+ +

+
+
+ {loading ? ( + + ) : ( + <> +

+ +

+
+
+ +
+ +
+ + )} +
+
+
+ ); +} diff --git a/src/context/intermediate/modals/Prompt.module.scss b/src/context/intermediate/modals/Prompt.module.scss new file mode 100644 index 00000000..a107fcf6 --- /dev/null +++ b/src/context/intermediate/modals/Prompt.module.scss @@ -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; +} diff --git a/src/context/intermediate/modals/Prompt.tsx b/src/context/intermediate/modals/Prompt.tsx new file mode 100644 index 00000000..c5aedfa9 --- /dev/null +++ b/src/context/intermediate/modals/Prompt.tsx @@ -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 ( + + { error && } + { content } + + ); +} + +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); + + 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 ( + } + actions={[ + { + confirmation: true, + style: 'contrast-error', + text: , + 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: , onClick: onClose } + ]} + content={} + 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 ( + } + actions={[ + { + text: , + confirmation: true, + onClick: onClose + }, + { + text: , + onClick: () => writeClipboard(`${window.location.protocol}//${window.location.host}/invite/${code}`) + } + ]} + content={ + processing ? + + :
+ + {code} +
+ } + disabled={processing} + error={error} + /> + ) + } + case "kick_member": { + const user = client.users.get(props.user); + + return ( + } + actions={[ + { + text: , + 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: , onClick: onClose } + ]} + content={
+ + +
} + disabled={processing} + error={error} + /> + ) + } + case "ban_member": { + const [ reason, setReason ] = useState(undefined); + const user = client.users.get(props.user); + + return ( + } + actions={[ + { + text: , + 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: , onClick: onClose } + ]} + content={
+ + + + setReason(e.currentTarget.value)} /> +
} + disabled={processing} + error={error} + /> + ) + } + default: return null; + } +} diff --git a/src/context/intermediate/modals/SignedOut.tsx b/src/context/intermediate/modals/SignedOut.tsx new file mode 100644 index 00000000..b56f6385 --- /dev/null +++ b/src/context/intermediate/modals/SignedOut.tsx @@ -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 ( + } + actions={[ + { + onClick: onClose, + confirmation: true, + text: + } + ]} + /> + ); +} diff --git a/src/context/intermediate/popovers/ChannelInfo.module.scss b/src/context/intermediate/popovers/ChannelInfo.module.scss new file mode 100644 index 00000000..ff37d169 --- /dev/null +++ b/src/context/intermediate/popovers/ChannelInfo.module.scss @@ -0,0 +1,16 @@ +.info { + .header { + display: flex; + align-items: center; + flex-direction: row; + + h1 { + margin: 0; + flex-grow: 1; + } + + div { + cursor: pointer; + } + } +} diff --git a/src/context/intermediate/popovers/ChannelInfo.tsx b/src/context/intermediate/popovers/ChannelInfo.tsx new file mode 100644 index 00000000..574943c8 --- /dev/null +++ b/src/context/intermediate/popovers/ChannelInfo.tsx @@ -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 ( + +
+
+

{ getChannelName(ctx.client, channel, [ ], true) }

+
+ +
+
+

+ +

+
+
+ ); +} diff --git a/src/context/intermediate/popovers/ImageViewer.module.scss b/src/context/intermediate/popovers/ImageViewer.module.scss new file mode 100644 index 00000000..0a9f360e --- /dev/null +++ b/src/context/intermediate/popovers/ImageViewer.module.scss @@ -0,0 +1,6 @@ +.viewer { + img { + max-width: 90vw; + max-height: 90vh; + } +} diff --git a/src/context/intermediate/popovers/ImageViewer.tsx b/src/context/intermediate/popovers/ImageViewer.tsx new file mode 100644 index 00000000..ae6754e2 --- /dev/null +++ b/src/context/intermediate/popovers/ImageViewer.tsx @@ -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 ( + +
+ { attachment && + <> + + {/**/} + + } + { embed && + <> + {/**/} + {/**/} + + } +
+
+ ); +} diff --git a/src/context/intermediate/popovers/UserPicker.module.scss b/src/context/intermediate/popovers/UserPicker.module.scss new file mode 100644 index 00000000..56afcf32 --- /dev/null +++ b/src/context/intermediate/popovers/UserPicker.module.scss @@ -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; + } + } + } +} \ No newline at end of file diff --git a/src/context/intermediate/popovers/UserPicker.tsx b/src/context/intermediate/popovers/UserPicker.tsx new file mode 100644 index 00000000..9c2a8e60 --- /dev/null +++ b/src/context/intermediate/popovers/UserPicker.tsx @@ -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; +} + +export function UserPicker(props: Props) { + const [selected, setSelected] = useState([]); + const omit = [...(props.omit || []), "00000000000000000000000000"]; + + const users = useUsers(); + + return ( + } + onClose={props.onClose} + actions={[ + { + text: , + onClick: () => props.callback(selected).then(props.onClose) + } + ]} + > +
+ {(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 => ( + { + if (v) { + setSelected([...selected, x._id]); + } else { + setSelected( + selected.filter(y => y !== x._id) + ); + } + }} + /> + ))} +
+
+ ); +} diff --git a/src/context/intermediate/popovers/UserProfile.module.scss b/src/context/intermediate/popovers/UserProfile.module.scss new file mode 100644 index 00000000..448595ab --- /dev/null +++ b/src/context/intermediate/popovers/UserProfile.module.scss @@ -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; + } + } +} diff --git a/src/context/intermediate/popovers/UserProfile.tsx b/src/context/intermediate/popovers/UserProfile.tsx new file mode 100644 index 00000000..0a11cfd8 --- /dev/null +++ b/src/context/intermediate/popovers/UserProfile.tsx @@ -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 + ); + 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 ( + +
+
+ +
+ + writeClipboard(user.username)}> + @{user.username} + + + {user.status?.text && ( + + + + )} +
+ {user.relationship === Users.Relationship.Friend && ( + + + } + > + {/* { + onClose(); + history.push(`/open/${user_id}`); + }} + >*/} + + {/**/} + + + )} + {user.relationship === Users.Relationship.User && ( + /* { + onClose(); + if (dummy) return; + history.push(`/settings/profile`); + }} + >*/ + + /**/ + )} + {(user.relationship === Users.Relationship.Incoming || + user.relationship === Users.Relationship.None) && ( + /* client.users.addFriend(user.username)} + >*/ + + /**/ + )} +
+
+
setTab("profile")} + > + +
+ { user.relationship !== Users.Relationship.User && + <> +
setTab("friends")} + > + +
+
setTab("groups")} + > + +
+ + } +
+
+
+ {tab === "profile" && +
+ { !(profile?.content || (badges > 0)) && +
} + { (badges > 0) &&
} + { (badges > 0) && ( +
+ + {badges & Badges.Developer ? ( + + } + > + + + ) : ( + <> + )} + {badges & Badges.Translator ? ( + + } + > + + + ) : ( + <> + )} + {badges & Badges.EarlyAdopter ? ( + + } + > + + + ) : ( + <> + )} + {badges & Badges.Supporter ? ( + + } + > + + + ) : ( + <> + )} + {badges & Badges.ResponsibleDisclosure ? ( + + } + > + + + ) : ( + <> + )} + +
+ )} + { profile?.content &&
} + + {/*
*/} +
} + {tab === "friends" && + (users ? ( +
+ {users.length === 0 ? ( +
+ +
+ ) : ( + users.map( + x => + x && ( + // +
+ + {x.username} +
+ //
+ ) + ) + )} +
+ ) : ( + + ))} + {tab === "groups" && ( +
+ {mutualGroups.length === 0 ? ( +
+ +
+ ) : ( + mutualGroups.map( + x => + x?.channel_type === "Group" && ( + +
+ + {x.name} +
+ + ) + ) + )} +
+ )} +
+
+ ); +} diff --git a/src/context/revoltjs/CheckAuth.tsx b/src/context/revoltjs/CheckAuth.tsx index 3933e913..f084e933 100644 --- a/src/context/revoltjs/CheckAuth.tsx +++ b/src/context/revoltjs/CheckAuth.tsx @@ -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 ; diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx index c93b95a1..d489dee8 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -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(undefined as any); +export const AppContext = createContext(undefined as any); +export const StatusContext = createContext(undefined as any); +export const OperationsContext = createContext(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 ( - - { children } + + + + { children } + + ); } diff --git a/src/context/revoltjs/error.ts b/src/context/revoltjs/error.ts deleted file mode 100644 index 0c44c77c..00000000 --- a/src/context/revoltjs/error.ts +++ /dev/null @@ -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; -} diff --git a/src/context/revoltjs/events.ts b/src/context/revoltjs/events.ts index 0a10c538..fea22e7f 100644 --- a/src/context/revoltjs/events.ts +++ b/src/context/revoltjs/events.ts @@ -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, client: Client) { +}: { operations: ClientOperations } & WithDispatcher, setStatus: StateUpdater, client: Client) { const listeners = { connecting: () => operations.ready() && setStatus(ClientStatus.CONNECTING), diff --git a/src/context/revoltjs/hooks.ts b/src/context/revoltjs/hooks.ts index 00f8eb70..e8ccb64f 100644 --- a/src/context/revoltjs/hooks.ts +++ b/src/context/revoltjs/hooks.ts @@ -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({}), []) }; diff --git a/src/context/revoltjs/messages.ts b/src/context/revoltjs/messages.ts deleted file mode 100644 index 44f52c40..00000000 --- a/src/context/revoltjs/messages.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Message } from "revolt.js/dist/api/objects"; - -export type MessageObject = Omit & { edited?: string }; -export function mapMessage(message: Partial) { - const { edited, ...msg } = message; - return { - ...msg, - edited: edited?.$date, - } as MessageObject; -} diff --git a/src/context/revoltjs/util.tsx b/src/context/revoltjs/util.tsx new file mode 100644 index 00000000..71e760a6 --- /dev/null +++ b/src/context/revoltjs/util.tsx @@ -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 ; + + 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 & { edited?: string }; +export function mapMessage(message: Partial) { + const { edited, ...msg } = message; + return { + ...msg, + edited: edited?.$date, + } as MessageObject; +} diff --git a/src/lib/PaintCounter.tsx b/src/lib/PaintCounter.tsx index e5ca3084..668ce682 100644 --- a/src/lib/PaintCounter.tsx +++ b/src/lib/PaintCounter.tsx @@ -9,10 +9,10 @@ export default function PaintCounter({ small }: { small?: boolean }) { const count = counts[uniqueId] ?? 0; counts[uniqueId] = count + 1; return ( - +
{ small ? <>P: { count + 1 } : <> Painted {count + 1} time(s). } - +
) } diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 484a73dd..ebe37fe5 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -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() { + ); }; diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx index 4ae75dbd..9ccb04f0 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -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 ( - <> -

Nested component

- - @{ user.username } is { user.online ? 'online' : 'offline' }

- -

UserIcon Tests

- - - - - - - -

Channels

- { channels.map(channel => - channel && - channel.channel_type !== 'SavedMessages' && - channel.channel_type !== 'DirectMessage' && - - ) } - -

Servers

- { servers.map(server => - server && - - ) } - -

-

{ 'test long paragraph'.repeat(2000) }

- - ) -} - export default function Home() { return ( -
-

HOME

+
-
); } diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx index 2334a174..33235932 100644 --- a/src/pages/login/Login.tsx +++ b/src/pages/login/Login.tsx @@ -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 (
diff --git a/src/pages/login/forms/CaptchaBlock.tsx b/src/pages/login/forms/CaptchaBlock.tsx index 0ba486a2..e55b0980 100644 --- a/src/pages/login/forms/CaptchaBlock.tsx +++ b/src/pages/login/forms/CaptchaBlock.tsx @@ -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) { diff --git a/src/pages/login/forms/Form.tsx b/src/pages/login/forms/Form.tsx index 56cdfadf..3239e395 100644 --- a/src/pages/login/forms/Form.tsx +++ b/src/pages/login/forms/Form.tsx @@ -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(undefined); diff --git a/src/pages/login/forms/FormCreate.tsx b/src/pages/login/forms/FormCreate.tsx index ca00a0c7..4336acd2 100644 --- a/src/pages/login/forms/FormCreate.tsx +++ b/src/pages/login/forms/FormCreate.tsx @@ -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 (
diff --git a/src/pages/login/forms/FormResend.tsx b/src/pages/login/forms/FormResend.tsx index 9d52b20f..643767d6 100644 --- a/src/pages/login/forms/FormResend.tsx +++ b/src/pages/login/forms/FormResend.tsx @@ -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 ( (); - const { client } = useContext(AppContext); + const client = useContext(AppContext); const history = useHistory(); return ( diff --git a/src/redux/reducers/queue.ts b/src/redux/reducers/queue.ts index 9ed4cfd4..abf78f9b 100644 --- a/src/redux/reducers/queue.ts +++ b/src/redux/reducers/queue.ts @@ -1,4 +1,4 @@ -import { MessageObject } from "../../context/revoltjs/messages"; +import { MessageObject } from "../../context/revoltjs/util"; export enum QueueStatus { SENDING = "sending", diff --git a/src/styles/_fonts.scss b/src/styles/_fonts.scss index c290de23..9552f22f 100644 --- a/src/styles/_fonts.scss +++ b/src/styles/_fonts.scss @@ -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"; diff --git a/src/types/Preact.ts b/src/types/Preact.ts index b7b47798..80ac4895 100644 --- a/src/types/Preact.ts +++ b/src/types/Preact.ts @@ -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[]; diff --git a/yarn.lock b/yarn.lock index 3906148e..16f513a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -912,6 +912,11 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@fontsource/fira-mono@^4.4.5": + version "4.4.5" + resolved "https://registry.yarnpkg.com/@fontsource/fira-mono/-/fira-mono-4.4.5.tgz#ceac70967cd3c4262195603aba567cd4582493f8" + integrity sha512-LWbsPhTr1JRV3zUgvMrOxQDn1BG9F4R0FPeBkqWP8/oqPxvVYAhEepg1DN9M1k6L9sRN2I2HWHBpt4QVbDGXpw== + "@fontsource/open-sans@^4.4.5": version "4.4.5" resolved "https://registry.yarnpkg.com/@fontsource/open-sans/-/open-sans-4.4.5.tgz#07b31617e62ed753c94cabcf552ebaed4de497ce" @@ -1109,11 +1114,28 @@ ejs "^2.6.1" magic-string "^0.25.0" +"@traptitech/markdown-it-katex@^3.4.3": + version "3.4.3" + resolved "https://registry.yarnpkg.com/@traptitech/markdown-it-katex/-/markdown-it-katex-3.4.3.tgz#23dacbd276ac748409a189550e0ecd764cfde8cf" + integrity sha512-ZUG8iapT1xL035NWKYvG8/2AczS40G6JkCf+7Ju5G1aKnCbBIwyuoM+AnwJ+j9WdSGzPRYUG2sNels8a8//uPg== + dependencies: + katex "^0.13.9" + +"@traptitech/markdown-it-spoiler@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@traptitech/markdown-it-spoiler/-/markdown-it-spoiler-1.1.6.tgz#973e92045699551e2c9fb39bbd673ee48bc90b83" + integrity sha512-tH/Fk1WMsnSuLpuRsXw8iHtdivoCEI5V08hQ7doVm6WmzAnBf/cUzyH9+GbOldPq9Hwv9v9tuy5t/MxmdNAGXg== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/highlight.js@^9.7.0": + version "9.12.4" + resolved "https://registry.yarnpkg.com/@types/highlight.js/-/highlight.js-9.12.4.tgz#8c3496bd1b50cc04aeefd691140aa571d4dbfa34" + integrity sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww== + "@types/history@*": version "4.7.8" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" @@ -1132,6 +1154,25 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== +"@types/linkify-it@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.1.tgz#4d26a9efe3aa2caf829234ec5a39580fc88b6001" + integrity sha512-pQv3Sygwxxh6jYQzXaiyWDAHevJqWtqDUv6t11Sa9CPGiXny66II7Pl6PR8QO5OVysD6HYOkHMeBgIjLnk9SkQ== + +"@types/markdown-it@^12.0.2": + version "12.0.2" + resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.0.2.tgz#153e5477970ed2a47b2f619ed4ab66f870de8a04" + integrity sha512-p4DIfLMmGN0iLSbMxknDXeSm8W2ZRqQeN/1EAwVxVqJietzgp3WeP1UQjCKWDXWBcEbUa1ECx8YAfdpQdDQmZQ== + dependencies: + "@types/highlight.js" "^9.7.0" + "@types/linkify-it" "*" + "@types/mdurl" "*" + +"@types/mdurl@*": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9" + integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA== + "@types/node@*": version "15.12.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.3.tgz#2817bf5f25bc82f56579018c53f7d41b1830b1af" @@ -1149,6 +1190,11 @@ dependencies: preact "^10.0.0" +"@types/prismjs@^1.16.5": + version "1.16.5" + resolved "https://registry.yarnpkg.com/@types/prismjs/-/prismjs-1.16.5.tgz#378f491ff02304ce50924b05283111d4a286ecba" + integrity sha512-nSU7U6FQDJJCraFNwaHmH5YDsd/VA9rTnJ7B7AGFdn+m+VSt3FjLWN7+AbqxZ67dbFazqtrDFUto3HK4ljrHIg== + "@types/prop-types@*": version "15.7.3" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" @@ -1218,6 +1264,11 @@ "@types/react" "*" csstype "^3.0.2" +"@types/twemoji@^12.1.1": + version "12.1.1" + resolved "https://registry.yarnpkg.com/@types/twemoji/-/twemoji-12.1.1.tgz#34c5dcecff438b5be173889a6ee8ad51ba90445f" + integrity sha512-dW1B1WHTfrWmEzXb/tp8xsZqQHAyMB9JwLwbBqkIQVzmNUI02R7lJqxUpKFM114ygNZHKA1r74oPugCAiYHt1A== + "@typescript-eslint/eslint-plugin@^4.27.0": version "4.27.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.27.0.tgz#0b7fc974e8bc9b2b5eb98ed51427b0be529b4ad0" @@ -1380,6 +1431,11 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + array-includes@^3.1.2, array-includes@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a" @@ -1602,6 +1658,15 @@ classnames@^2.3.1: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== +clipboard@^2.0.0: + version "2.0.8" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.8.tgz#ffc6c103dd2967a83005f3f61976aa4655a4cdba" + integrity sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ== + dependencies: + good-listener "^1.2.2" + select "^1.1.2" + tiny-emitter "^2.0.0" + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -1636,6 +1701,11 @@ commander@^2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@^6.0.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + common-tags@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" @@ -1742,6 +1812,11 @@ define-properties@^1.1.3: dependencies: object-keys "^1.0.12" +delegate@^3.1.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" + integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== + detect-browser@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/detect-browser/-/detect-browser-5.2.0.tgz#c9cd5afa96a6a19fda0bbe9e9be48a6b6e1e9c97" @@ -1800,6 +1875,11 @@ enquirer@^2.3.5: dependencies: ansi-colors "^4.1.1" +entities@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" + integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== + es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2, es-abstract@^1.18.2: version "1.18.3" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.3.tgz#25c4c3380a27aa203c44b2b685bba94da31b63e0" @@ -2145,6 +2225,15 @@ from@~0: resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" integrity sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4= +fs-extra@^8.0.1: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + fs-extra@^9.0.1: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" @@ -2237,6 +2326,13 @@ globby@^11.0.3: merge2 "^1.3.0" slash "^3.0.0" +good-listener@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" + integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA= + dependencies: + delegate "^3.1.2" + graceful-fs@^4.1.6, graceful-fs@^4.2.0: version "4.2.6" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" @@ -2269,6 +2365,11 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +highlight.js@^11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.0.1.tgz#a78bafccd9aa297978799fe5eed9beb7ee1ef887" + integrity sha512-EqYpWyTF2s8nMfttfBA2yLKPNoZCO33pLS4MnbXQ4hECf1TKujCt1Kq7QAdrio7roL4+CqsfjqwYj4tYgq0pJQ== + history@^4.9.0: version "4.10.1" resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" @@ -2520,6 +2621,22 @@ json5@^2.1.2: dependencies: minimist "^1.2.5" +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= + optionalDependencies: + graceful-fs "^4.1.6" + +jsonfile@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-5.0.0.tgz#e6b718f73da420d612823996fdf14a03f6ff6922" + integrity sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w== + dependencies: + universalify "^0.1.2" + optionalDependencies: + graceful-fs "^4.1.6" + jsonfile@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" @@ -2537,6 +2654,13 @@ jsonfile@^6.0.1: array-includes "^3.1.2" object.assign "^4.1.2" +katex@^0.13.9: + version "0.13.11" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.13.11.tgz#66138ebf173f25ef130cd3a3ea3ea1d12a3f1362" + integrity sha512-yJBHVIgwlAaapzlbvTpVF/ZOs8UkTj/sd46Fl8+qAf2/UiituPYVeapVD8ADZtqyRg/qNWUKt7gJoyYVWLrcXw== + dependencies: + commander "^6.0.0" + kolorist@^1.2.10: version "1.4.1" resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.4.1.tgz#5ce60d5fefa23ca55a7e3203e16f7b9ed5b0556a" @@ -2557,6 +2681,13 @@ lie@3.1.1: dependencies: immediate "~3.0.5" +linkify-it@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.2.tgz#f55eeb8bc1d3ae754049e124ab3bb56d97797fb8" + integrity sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ== + dependencies: + uc.micro "^1.0.1" + localforage@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.9.0.tgz#f3e4d32a8300b362b4634cc4e066d9d00d2f09d1" @@ -2642,6 +2773,37 @@ map-stream@~0.1.0: resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" integrity sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ= +markdown-it-emoji@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/markdown-it-emoji/-/markdown-it-emoji-2.0.0.tgz#3164ad4c009efd946e98274f7562ad611089a231" + integrity sha512-39j7/9vP/CPCKbEI44oV8yoPJTpvfeReTn/COgRhSpNrjWF3PfP/JUxxB0hxV6ynOY8KH8Y8aX9NMDdo6z+6YQ== + +markdown-it-sub@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/markdown-it-sub/-/markdown-it-sub-1.0.0.tgz#375fd6026eae7ddcb012497f6411195ea1e3afe8" + integrity sha1-N1/WAm6ufdywEkl/ZBEZXqHjr+g= + +markdown-it-sup@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/markdown-it-sup/-/markdown-it-sup-1.0.0.tgz#cb9c9ff91a5255ac08f3fd3d63286e15df0a1fc3" + integrity sha1-y5yf+RpSVawI8/09YyhuFd8KH8M= + +markdown-it@^12.0.6: + version "12.0.6" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.0.6.tgz#adcc8e5fe020af292ccbdf161fe84f1961516138" + integrity sha512-qv3sVLl4lMT96LLtR7xeRJX11OUFjsaD5oVat2/SNBIb21bJXwal2+SklcRbTwGwqWpWH/HRtYavOoJE+seL8w== + dependencies: + argparse "^2.0.1" + entities "~2.1.0" + linkify-it "^3.0.1" + mdurl "^1.0.1" + uc.micro "^1.0.5" + +mdurl@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" + integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4= + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -2857,6 +3019,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== +popper.js@^1.11.1: + version "1.16.1" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" + integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== + postcss-value-parser@^4.0.2: version "4.1.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" @@ -2904,6 +3071,13 @@ pretty-bytes@^5.3.0, pretty-bytes@^5.6.0: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== +prismjs@^1.23.0: + version "1.23.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" + integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== + optionalDependencies: + clipboard "^2.0.0" + progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -3025,6 +3199,13 @@ react-side-effect@^2.1.0: resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.1.tgz#66c5701c3e7560ab4822a4ee2742dee215d72eb3" integrity sha512-2FoTQzRNTncBVtnzxFOk2mCpcfxQpenBMbk5kSVBg5UcPqV9fRbgY2zhb7GTWWOlpFmAxhClBDlIq8Rsubz1yQ== +react-tippy@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/react-tippy/-/react-tippy-1.4.0.tgz#e8a8b4085ec985e5c94fe128918b733b588a1465" + integrity sha512-r/hM5XK9Ztr2ZY7IWKuRmISTlUPS/R6ddz6PO2EuxCgW+4JBcGZRPU06XcVPRDCOIiio8ryBQFrXMhFMhsuaHA== + dependencies: + popper.js "^1.11.1" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -3199,6 +3380,11 @@ sass@^1.35.1: dependencies: chokidar ">=3.0.0 <4.0.0" +select@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" + integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= + semver@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" @@ -3478,6 +3664,11 @@ through@2, through@~2.3, through@~2.3.1: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= +tiny-emitter@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" + integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== + tiny-invariant@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" @@ -3530,6 +3721,21 @@ tsutils@^3.17.1, tsutils@^3.21.0: dependencies: tslib "^1.8.1" +twemoji-parser@13.1.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-13.1.0.tgz#65e7e449c59258791b22ac0b37077349127e3ea4" + integrity sha512-AQOzLJpYlpWMy8n+0ATyKKZzWlZBJN+G0C+5lhX7Ftc2PeEVdUU/7ns2Pn2vVje26AIZ/OHwFoUbdv6YYD/wGg== + +twemoji@^13.1.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/twemoji/-/twemoji-13.1.0.tgz#65bb71e966dae56f0d42c30176f04cbdae109913" + integrity sha512-e3fZRl2S9UQQdBFLYXtTBT6o4vidJMnpWUAhJA+yLGR+kaUTZAt3PixC0cGvvxWSuq2MSz/o0rJraOXrWw/4Ew== + dependencies: + fs-extra "^8.0.1" + jsonfile "^5.0.0" + twemoji-parser "13.1.0" + universalify "^0.1.2" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -3557,6 +3763,11 @@ ua-parser-js@^0.7.24: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31" integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g== +uc.micro@^1.0.1, uc.micro@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" + integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== + ulid@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/ulid/-/ulid-2.3.0.tgz#93063522771a9774121a84d126ecd3eb9804071f" @@ -3602,6 +3813,11 @@ unique-string@^2.0.0: dependencies: crypto-random-string "^2.0.0" +universalify@^0.1.0, universalify@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + universalify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"