diff --git a/.env b/.env index d72dbacc6671a3424eda0ec96f295e662c7bc28a..f0c2383776e2a8f7436bc915a2f571f40279aa63 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -VITE_API_URL=https://api.revolt.chat +VITE_API_URL=http://local.revolt.chat:8000 VITE_THEMES_URL=https://static.revolt.chat/themes diff --git a/external/lang b/external/lang index 9db39a2eecc5fbb7ed06d4598da60700e96e3274..210172de724fcd5adeacec221bd9da30350afc06 160000 --- a/external/lang +++ b/external/lang @@ -1 +1 @@ -Subproject commit 9db39a2eecc5fbb7ed06d4598da60700e96e3274 +Subproject commit 210172de724fcd5adeacec221bd9da30350afc06 diff --git a/package.json b/package.json index 6cc365000bf7bf3b9dc6d846cbde0e4c2faafb8e..102e828264ea76eab1adadb066947f3e63105bd9 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@types/prismjs": "^1.16.5", "@types/react-helmet": "^6.1.1", "@types/react-router-dom": "^5.1.7", + "@types/react-scroll": "^1.8.2", "@types/styled-components": "^5.1.10", "@types/twemoji": "^12.1.1", "@typescript-eslint/eslint-plugin": "^4.27.0", @@ -66,6 +67,7 @@ "react-overlapping-panels": "1.2.1", "react-redux": "^7.2.4", "react-router-dom": "^5.2.0", + "react-scroll": "^1.8.2", "react-tippy": "^1.4.0", "redux": "^4.1.0", "revolt.js": "4.3.0", @@ -76,6 +78,7 @@ "twemoji": "^13.1.0", "typescript": "^4.3.2", "ulid": "^2.3.0", + "use-resize-observer": "^7.0.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 db8ee8841cb851056838b2f65cc56982e413d29f..a79346249013b6ac1d035c8cc4974163f10e18c3 100644 --- a/src/components/common/ChannelIcon.tsx +++ b/src/components/common/ChannelIcon.tsx @@ -32,12 +32,6 @@ export default function ChannelIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLI height={size} aria-hidden="true" square={isServerChannel} - src={iconURL ?? fallback} - onError={ e => { - let el = e.currentTarget; - if (el.src !== fallback) { - el.src = fallback - } - }} /> + src={iconURL ?? fallback} /> ); } diff --git a/src/components/common/ServerIcon.tsx b/src/components/common/ServerIcon.tsx index 46befcbb63cbbf8704f31e61491435c302e2278e..e3db726322a2054517af8653ab9b920dfa3c19ff 100644 --- a/src/components/common/ServerIcon.tsx +++ b/src/components/common/ServerIcon.tsx @@ -42,12 +42,6 @@ export default function ServerIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLIm width={size} height={size} aria-hidden="true" - src={iconURL} - onError={ e => { - let el = e.currentTarget; - if (el.src !== fallback) { - el.src = fallback - } - }} /> + src={iconURL} /> ); } diff --git a/src/components/common/UserIcon.tsx b/src/components/common/UserIcon.tsx index a78416d8e094eed3066df430dbee965fb86d81c4..5e9243fdd66ec1acab4949cd67fa3ab5a5e58a03 100644 --- a/src/components/common/UserIcon.tsx +++ b/src/components/common/UserIcon.tsx @@ -54,7 +54,7 @@ export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGEle const { target, attachment, size, voice, status, animate, children, as, ...svgProps } = props; const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate) - ?? (target && client.users.getDefaultAvatarURL(target._id)); + ?? (target ? client.users.getDefaultAvatarURL(target._id) : fallback); return ( <IconBase {...svgProps} @@ -65,13 +65,7 @@ export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGEle <foreignObject x="0" y="0" width="32" height="32"> { <img src={iconURL} - draggable={false} - onError={ e => { - let el = e.currentTarget; - if (el.src !== fallback) { - el.src = fallback - } - }} /> + draggable={false} /> } </foreignObject> {props.status && ( diff --git a/src/components/common/UserShort.tsx b/src/components/common/UserShort.tsx new file mode 100644 index 0000000000000000000000000000000000000000..de16edf4dace6f8d2558713b96a359d2a330864b --- /dev/null +++ b/src/components/common/UserShort.tsx @@ -0,0 +1,14 @@ +import { User } from "revolt.js"; +import UserIcon from "./UserIcon"; +import { Text } from "preact-i18n"; + +export function Username({ user }: { user?: User }) { + return <b>{ user?.username ?? <Text id="app.main.channel.unknown_user" /> }</b>; +} + +export default function UserShort({ user }: { user?: User }) { + return <> + <UserIcon size={24} target={user} /> + <Username user={user} /> + </>; +} diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..03c13596de618cc0c024b0480bd5b1d0b2ecbf13 --- /dev/null +++ b/src/components/common/messaging/Message.tsx @@ -0,0 +1,37 @@ +import UserIcon from "../UserIcon"; +import { Username } from "../UserShort"; +import Markdown from "../../markdown/Markdown"; +import { Children } from "../../../types/Preact"; +import { attachContextMenu } from "preact-context-menu"; +import { useUser } from "../../../context/revoltjs/hooks"; +import { MessageObject } from "../../../context/revoltjs/util"; +import MessageBase, { MessageContent, MessageDetail, MessageInfo } from "./MessageBase"; + +interface Props { + attachContext?: boolean + message: MessageObject + contrast?: boolean + content?: Children + head?: boolean +} + +export default function Message({ attachContext, message, contrast, content, head }: Props) { + // TODO: Can improve re-renders here by providing a list + // TODO: of dependencies. We only need to update on u/avatar. + let user = useUser(message.author); + + return ( + <MessageBase contrast={contrast} + onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel }) : undefined}> + <MessageInfo> + { head ? + <UserIcon target={user} size={36} /> : + <MessageDetail message={message} /> } + </MessageInfo> + <MessageContent> + { head && <Username user={user} /> } + { content ?? <Markdown content={message.content as string} /> } + </MessageContent> + </MessageBase> + ) +} diff --git a/src/components/common/messaging/MessageBase.tsx b/src/components/common/messaging/MessageBase.tsx new file mode 100644 index 0000000000000000000000000000000000000000..209e6408beb80f259883b9202f087cd127776f30 --- /dev/null +++ b/src/components/common/messaging/MessageBase.tsx @@ -0,0 +1,111 @@ +import dayjs from "dayjs"; +import styled, { css } from "styled-components"; +import { decodeTime } from "ulid"; +import { MessageObject } from "../../../context/revoltjs/util"; + +export interface BaseMessageProps { + head?: boolean, + status?: boolean, + mention?: boolean, + blocked?: boolean, + sending?: boolean, + contrast?: boolean +} + +export default styled.div<BaseMessageProps>` + display: flex; + overflow-x: none; + padding: .125rem; + flex-direction: row; + padding-right: 16px; + + ${ props => props.contrast && css` + padding: .3rem; + border-radius: 4px; + background: var(--hover); + ` } + + ${ props => props.head && css` + margin-top: 12px; + ` } + + ${ props => props.mention && css` + background: var(--mention); + ` } + + ${ props => props.blocked && css` + filter: blur(4px); + transition: 0.2s ease filter; + + &:hover { + filter: none; + } + ` } + + ${ props => props.sending && css` + opacity: 0.8; + color: var(--tertiary-foreground); + ` } + + ${ props => props.status && css` + color: var(--error); + ` } + + .copy { + width: 0; + opacity: 0; + } + + &:hover { + background: var(--hover); + + time { + opacity: 1; + } + } +`; + +export const MessageInfo = styled.div` + width: 62px; + display: flex; + flex-shrink: 0; + padding-top: 2px; + flex-direction: row; + justify-content: center; + + ::selection { + background-color: transparent; + color: var(--tertiary-foreground); + } + + time { + opacity: 0; + cursor: default; + display: inline; + font-size: 10px; + padding-top: 1px; + color: var(--tertiary-foreground); + } +`; + +export const MessageContent = styled.div` + min-width: 0; + flex-grow: 1; + display: flex; + overflow: hidden; + font-size: 0.875rem; + flex-direction: column; + justify-content: center; +`; + +export function MessageDetail({ message }: { message: MessageObject }) { + return ( + <> + <time> + <i className="copy">[</i> + {dayjs(decodeTime(message._id)).format("H:mm")} + <i className="copy">]</i> + </time> + </> + ) +} diff --git a/src/components/common/messaging/SystemMessage.tsx b/src/components/common/messaging/SystemMessage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c867f915601fb21424cb697618537c7851906ab5 --- /dev/null +++ b/src/components/common/messaging/SystemMessage.tsx @@ -0,0 +1,152 @@ +import { User } from "revolt.js"; +import classNames from "classnames"; +import { attachContextMenu } from "preact-context-menu"; +import { MessageObject } from "../../../context/revoltjs/util"; +import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks"; +import { TextReact } from "../../../lib/i18n"; +import UserIcon from "../UserIcon"; +import Username from "../UserShort"; +import UserShort from "../UserShort"; +import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase"; +import styled from "styled-components"; + +const SystemContent = styled.div` + gap: 4px; + display: flex; + padding: 2px 0; + flex-wrap: wrap; + align-items: center; + flex-direction: row; +`; + +type SystemMessageParsed = + | { type: "text"; content: string } + | { type: "user_added"; user: User; by: User } + | { type: "user_remove"; user: User; by: User } + | { type: "user_joined"; user: User } + | { type: "user_left"; user: User } + | { type: "user_kicked"; user: User } + | { type: "user_banned"; user: User } + | { type: "channel_renamed"; name: string; by: User } + | { type: "channel_description_changed"; by: User } + | { type: "channel_icon_changed"; by: User }; + +interface Props { + attachContext?: boolean; + message: MessageObject; +} + +export function SystemMessage({ attachContext, message }: Props) { + const ctx = useForceUpdate(); + + let data: SystemMessageParsed; + let content = message.content; + if (typeof content === "object") { + switch (content.type) { + case "text": + data = content; + break; + case "user_added": + case "user_remove": + data = { + type: content.type, + user: useUser(content.id, ctx) as User, + by: useUser(content.by, ctx) as User + }; + break; + case "user_joined": + case "user_left": + case "user_kicked": + case "user_banned": + data = { + type: content.type, + user: useUser(content.id, ctx) as User + }; + break; + case "channel_renamed": + data = { + type: "channel_renamed", + name: content.name, + by: useUser(content.by, ctx) as User + }; + break; + case "channel_description_changed": + case "channel_icon_changed": + data = { + type: content.type, + by: useUser(content.by, ctx) as User + }; + break; + default: + data = { type: "text", content: JSON.stringify(content) }; + } + } else { + data = { type: "text", content }; + } + + let children; + switch (data.type) { + case "text": + children = <span>{data.content}</span>; + break; + case "user_added": + case "user_remove": + children = ( + <TextReact + id={`app.main.channel.system.${data.type === 'user_added' ? "added_by" : "removed_by"}`} + fields={{ + user: <UserShort user={data.user} />, + other_user: <UserShort user={data.by} /> + }} + /> + ); + break; + case "user_joined": + case "user_left": + case "user_kicked": + case "user_banned": + children = ( + <TextReact + id={`app.main.channel.system.${data.type}`} + fields={{ + user: <UserShort user={data.user} /> + }} + /> + ); + break; + case "channel_renamed": + children = ( + <TextReact + id={`app.main.channel.system.channel_renamed`} + fields={{ + user: <UserShort user={data.by} />, + name: <b>{data.name}</b> + }} + /> + ); + break; + case "channel_description_changed": + case "channel_icon_changed": + children = ( + <TextReact + id={`app.main.channel.system.${data.type}`} + fields={{ + user: <UserShort user={data.by} /> + }} + /> + ); + break; + } + + return ( + <MessageBase + onContextMenu={attachContext ? attachContextMenu('Menu', + { message, contextualChannel: message.channel } + ) : undefined}> + <MessageInfo> + <MessageDetail message={message} /> + </MessageInfo> + <SystemContent>{children}</SystemContent> + </MessageBase> + ); +} diff --git a/src/components/markdown/Markdown.tsx b/src/components/markdown/Markdown.tsx index b98e8280becce0c70d09521989712c7eb69aed64..1dffb1c07b61cb3ecee05003482494b3e3a04054 100644 --- a/src/components/markdown/Markdown.tsx +++ b/src/components/markdown/Markdown.tsx @@ -10,7 +10,7 @@ export interface MarkdownProps { export default function Markdown(props: MarkdownProps) { return ( // @ts-expect-error - <Suspense fallback="Getting ready to render Markdown..."> + <Suspense fallback={props.content}> <Renderer {...props} /> </Suspense> ) diff --git a/src/components/ui/DateDivider.tsx b/src/components/ui/DateDivider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f310753dec1639fb25faf30597bd76e9e162168e --- /dev/null +++ b/src/components/ui/DateDivider.tsx @@ -0,0 +1,48 @@ +import dayjs from "dayjs"; +import styled, { css } from "styled-components"; + +const Base = styled.div<{ unread?: boolean }>` + height: 0; + display: flex; + margin: 14px 10px; + user-select: none; + align-items: center; + border-top: thin solid var(--tertiary-foreground); + + time { + margin-top: -2px; + font-size: .6875rem; + line-height: .6875rem; + padding: 2px 5px 2px 0; + color: var(--tertiary-foreground); + background: var(--primary-background); + } + + ${ props => props.unread && css` + border-top: thin solid var(--accent); + ` } +`; + +const Unread = styled.div` + background: var(--accent); + color: white; + padding: 5px 8px; + border-radius: 60px; + font-weight: 600; +`; + +interface Props { + date: Date; + unread?: boolean; +} + +export default function DateDivider(props: Props) { + return ( + <Base unread={props.unread}> + { props.unread && <Unread>NEW</Unread> } + <time> + { dayjs(props.date).format("LL") } + </time> + </Base> + ); +} diff --git a/src/context/Locale.tsx b/src/context/Locale.tsx index 737eaff972b70cd6754b0ad91e6e437dbb0c1d28..df9b1eff74b282c4f85446e30a2ec4d710085485 100644 --- a/src/context/Locale.tsx +++ b/src/context/Locale.tsx @@ -1,7 +1,7 @@ import { IntlProvider } from "preact-i18n"; import { connectState } from "../redux/connector"; -import definition from "../../external/lang/en.json"; import { useEffect, useState } from "preact/hooks"; +import definition from "../../external/lang/en.json"; import dayjs from "dayjs"; import calendar from "dayjs/plugin/calendar"; diff --git a/src/context/intermediate/modals/Prompt.tsx b/src/context/intermediate/modals/Prompt.tsx index 645d395e30ca494cc5d9792932c765d5b50120f4..3a1dac547142e85598ddc51d80bec398311c0df0 100644 --- a/src/context/intermediate/modals/Prompt.tsx +++ b/src/context/intermediate/modals/Prompt.tsx @@ -9,7 +9,8 @@ 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"; +import { mapMessage, takeError } from "../../revoltjs/util"; +import Message from "../../../components/common/messaging/Message"; interface Props { onClose: () => void; @@ -57,26 +58,23 @@ export function SpecialPromptModal(props: SpecialProps) { 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; + let name = props.type === 'close_dm' ? client.users.get(client.channels.getRecipient(props.target._id))?.username : props.target.name; return ( <PromptModal onClose={onClose} question={<Text - id={props.type === 'delete_message' ? 'app.context_menu.delete_message' : `app.special.modals.prompt.${event}`} + id={`app.special.modals.prompt.${event}`} fields={{ name }} />} actions={[ @@ -91,8 +89,6 @@ export function SpecialPromptModal(props: SpecialProps) { 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); } @@ -112,6 +108,41 @@ export function SpecialPromptModal(props: SpecialProps) { /> ) } + case 'delete_message': { + return ( + <PromptModal + onClose={onClose} + question={<Text id={'app.context_menu.delete_message'} />} + actions={[ + { + confirmation: true, + contrast: true, + error: true, + text: <Text id="app.special.modals.actions.delete" />, + onClick: async () => { + setProcessing(true); + + try { + await client.channels.deleteMessage(props.target.channel, 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.confirm_delete_message_long`} /> + <Message message={mapMessage(props.target)} head={true} contrast /> + </>} + disabled={processing} + error={error} + /> + ) + } case "create_invite": { const [ code, setCode ] = useState('abcdef'); const { writeClipboard } = useIntermediate(); diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx index 8f86c82439dd115a732df63ba59a4287aaf73d68..5c2ee715c4eb2189355852353c916767250097de 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -4,13 +4,14 @@ import { takeError } from "./util"; import { createContext } from "preact"; import { Children } from "../../types/Preact"; import { Route } from "revolt.js/dist/api/routes"; -import { useEffect, useMemo, useState } from "preact/hooks"; import { connectState } from "../../redux/connector"; import Preloader from "../../components/ui/Preloader"; import { WithDispatcher } from "../../redux/reducers"; import { AuthState } from "../../redux/reducers/auth"; import { SyncOptions } from "../../redux/reducers/sync"; +import { useEffect, useMemo, useState } from "preact/hooks"; import { registerEvents, setReconnectDisallowed } from "./events"; +import { SingletonMessageRenderer } from '../../lib/renderer/Singleton'; export enum ClientStatus { INIT, @@ -61,13 +62,15 @@ function Context({ auth, sync, children, dispatcher }: Props) { console.error('Failed to open IndexedDB store, continuing without.'); } - setClient(new Client({ + const client = new Client({ autoReconnect: false, apiURL: import.meta.env.VITE_API_URL, debug: import.meta.env.DEV, db - })); + }); + setClient(client); + SingletonMessageRenderer.subscribe(client); setStatus(ClientStatus.LOADING); })(); }, [ ]); @@ -131,10 +134,7 @@ function Context({ auth, sync, children, dispatcher }: Props) { } }, [ client, auth.active ]); - useEffect( - () => registerEvents({ operations, dispatcher }, setStatus, client), - [ client ] - ); + useEffect(() => registerEvents({ operations, dispatcher }, setStatus, client), [ client ]); useEffect(() => { (async () => { diff --git a/src/context/revoltjs/util.tsx b/src/context/revoltjs/util.tsx index 71e760a68c74b6144bd487048932ca77d829a106..a611d3e1eac8d3c80cbc80e8cfbb2e249fb05605 100644 --- a/src/context/revoltjs/util.tsx +++ b/src/context/revoltjs/util.tsx @@ -22,14 +22,13 @@ export function takeError( return id; } -export function getChannelName(client: Client, channel: Channel, users: User[], prefixType?: boolean): Children { +export function getChannelName(client: Client, channel: Channel, 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}</>; + return <>{prefixType && "@"}{client.users.get(uid)?.username}</>; } if (channel.channel_type === "TextChannel" && prefixType) { diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5f270b2eac5c7524f0dfd58c0f7d6a781f3c0d17 --- /dev/null +++ b/src/lib/i18n.tsx @@ -0,0 +1,54 @@ +import { IntlContext } from "preact-i18n"; +import { useContext } from "preact/hooks"; +import { Children } from "../types/Preact"; + +interface Fields { + [key: string]: Children +} + +interface Props { + id: string; + fields: Fields +} + +export interface IntlType { + intl: { + dictionary: { + [key: string]: Object | string + } + } +} + +// This will exhibit O(2^n) behaviour. +function recursiveReplaceFields(input: string, fields: Fields) { + const key = Object.keys(fields)[0]; + if (key) { + const { [key]: field, ...restOfFields } = fields; + if (typeof field === 'undefined') return [ input ]; + + const values: (Children | string[])[] = input.split(`{{${key}}}`) + .map(v => recursiveReplaceFields(v, restOfFields)); + + for (let i=values.length - 1;i>0;i-=2) { + values.splice(i, 0, field); + } + + return values.flat(); + } else { + // base case + return [ input ]; + } +} + +export function TextReact({ id, fields }: Props) { + const { intl } = useContext(IntlContext) as unknown as IntlType; + + const path = id.split('.'); + let entry = intl.dictionary[path.shift()!]; + for (let key of path) { + // @ts-expect-error + entry = entry[key]; + } + + return <>{ recursiveReplaceFields(entry as string, fields) }</>; +} diff --git a/src/lib/renderer/Singleton.ts b/src/lib/renderer/Singleton.ts new file mode 100644 index 0000000000000000000000000000000000000000..da4acadbe0f3694499d8fd2a21ca1c3b6c0cb78a --- /dev/null +++ b/src/lib/renderer/Singleton.ts @@ -0,0 +1,192 @@ +import { RendererRoutines, RenderState, ScrollState } from "./types"; +import { SimpleRenderer } from "./simple/SimpleRenderer"; +import { useEffect, useState } from "preact/hooks"; +import EventEmitter3 from 'eventemitter3'; +import { Client, Message } from "revolt.js"; + +export const SMOOTH_SCROLL_ON_RECEIVE = false; + +export class SingletonRenderer extends EventEmitter3 { + client?: Client; + channel?: string; + state: RenderState; + currentRenderer: RendererRoutines; + + stale = false; + fetchingTop = false; + fetchingBottom = false; + + constructor() { + super(); + + this.receive = this.receive.bind(this); + this.edit = this.edit.bind(this); + this.delete = this.delete.bind(this); + + this.state = { type: 'LOADING' }; + this.currentRenderer = SimpleRenderer; + } + + private receive(message: Message) { + this.currentRenderer.receive(this, message); + } + + private edit(id: string, patch: Partial<Message>) { + this.currentRenderer.edit(this, id, patch); + } + + private delete(id: string) { + this.currentRenderer.delete(this, id); + } + + subscribe(client: Client) { + if (this.client) { + this.client.removeListener('message', this.receive); + this.client.removeListener('message/update', this.edit); + this.client.removeListener('message/delete', this.delete); + } + + this.client = client; + client.addListener('message', this.receive); + client.addListener('message/update', this.edit); + client.addListener('message/delete', this.delete); + } + + private setStateUnguarded(state: RenderState, scroll?: ScrollState) { + this.state = state; + this.emit('state', state); + + if (scroll) { + this.emit('scroll', scroll); + } + } + + setState(id: string, state: RenderState, scroll?: ScrollState) { + if (id !== this.channel) return; + this.setStateUnguarded(state, scroll); + } + + markStale() { + this.stale = true; + } + + async init(id: string) { + this.channel = id; + this.stale = false; + this.setStateUnguarded({ type: 'LOADING' }); + await this.currentRenderer.init(this, id); + } + + async reloadStale(id: string) { + if (this.stale) { + this.stale = false; + await this.init(id); + } + } + + async loadTop(ref?: HTMLDivElement) { + if (this.fetchingTop) return; + this.fetchingTop = true; + + function generateScroll(end: string): ScrollState { + if (ref) { + let heightRemoved = 0; + let messageContainer = ref.children[0]; + if (messageContainer) { + for (let child of Array.from(messageContainer.children)) { + // If this child has a ulid. + if (child.id?.length === 26) { + // Check whether it was removed. + if (child.id.localeCompare(end) === 1) { + heightRemoved += child.clientHeight + + // We also need to take into account the top margin of the container. + parseInt(window.getComputedStyle(child).marginTop.slice(0, -2)); + } + } + } + } + + return { + type: 'OffsetTop', + previousHeight: ref.scrollHeight - heightRemoved + } + } else { + return { + type: 'OffsetTop', + previousHeight: 0 + } + } + } + + await this.currentRenderer.loadTop(this, generateScroll); + + // Allow state updates to propagate. + setTimeout(() => this.fetchingTop = false, 0); + } + + async loadBottom(ref?: HTMLDivElement) { + if (this.fetchingBottom) return; + this.fetchingBottom = true; + + function generateScroll(start: string): ScrollState { + if (ref) { + let heightRemoved = 0; + let messageContainer = ref.children[0]; + if (messageContainer) { + for (let child of Array.from(messageContainer.children)) { + // If this child has a ulid. + if (child.id?.length === 26) { + // Check whether it was removed. + if (child.id.localeCompare(start) === -1) { + heightRemoved += child.clientHeight + + // We also need to take into account the top margin of the container. + parseInt(window.getComputedStyle(child).marginTop.slice(0, -2)); + } + } + } + } + + return { + type: 'ScrollTop', + y: ref.scrollTop - heightRemoved + } + } else { + return { + type: 'ScrollToBottom' + } + } + } + + await this.currentRenderer.loadBottom(this, generateScroll); + + // Allow state updates to propagate. + setTimeout(() => this.fetchingBottom = false, 0); + } + + async jumpToBottom(id: string, smooth: boolean) { + if (id !== this.channel) return; + if (this.state.type === 'RENDER' && this.state.atBottom) { + this.emit('scroll', { type: 'ScrollToBottom', smooth }); + } else { + await this.currentRenderer.init(this, id, true); + } + } +} + +export const SingletonMessageRenderer = new SingletonRenderer(); + +export function useRenderState(id: string) { + const [state, setState] = useState<Readonly<RenderState>>(SingletonMessageRenderer.state); + if (typeof id === "undefined") return; + + function render(state: RenderState) { + setState(state); + } + + useEffect(() => { + SingletonMessageRenderer.addListener("state", render); + return () => SingletonMessageRenderer.removeListener("state", render); + }, [id]); + + return state; +} diff --git a/src/lib/renderer/simple/SimpleRenderer.ts b/src/lib/renderer/simple/SimpleRenderer.ts new file mode 100644 index 0000000000000000000000000000000000000000..afd808886d2c48a878f9d465a861ed32e8be9c4f --- /dev/null +++ b/src/lib/renderer/simple/SimpleRenderer.ts @@ -0,0 +1,178 @@ +import { mapMessage } from "../../../context/revoltjs/util"; +import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton"; +import { RendererRoutines } from "../types"; + +export const SimpleRenderer: RendererRoutines = { + init: async (renderer, id, smooth) => { + if (renderer.client!.websocket.connected) { + renderer.client!.channels + .fetchMessagesWithUsers(id, { }, true) + .then(({ messages: data }) => { + data.reverse(); + let messages = data.map(x => mapMessage(x)); + renderer.setState( + id, + { + type: 'RENDER', + messages, + atTop: data.length < 50, + atBottom: true + }, + { type: 'ScrollToBottom', smooth } + ); + }); + } else { + renderer.setState(id, { type: 'WAITING_FOR_NETWORK' }); + } + }, + receive: async (renderer, message) => { + if (message.channel !== renderer.channel) return; + if (renderer.state.type !== 'RENDER') return; + if (renderer.state.messages.find(x => x._id === message._id)) return; + if (!renderer.state.atBottom) return; + + let messages = [ ...renderer.state.messages, mapMessage(message) ]; + let atTop = renderer.state.atTop; + if (messages.length > 150) { + messages = messages.slice(messages.length - 150); + atTop = false; + } + + renderer.setState( + message.channel, + { + ...renderer.state, + messages, + atTop + }, + { type: 'StayAtBottom', smooth: SMOOTH_SCROLL_ON_RECEIVE } + ); + }, + edit: async (renderer, id, patch) => { + const channel = renderer.channel; + if (!channel) return; + if (renderer.state.type !== 'RENDER') return; + + let messages = [ ...renderer.state.messages ]; + let index = messages.findIndex(x => x._id === id); + + if (index > -1) { + let message = { ...messages[index], ...mapMessage(patch) }; + messages.splice(index, 1, message); + + renderer.setState( + channel, + { + ...renderer.state, + messages + }, + { type: 'StayAtBottom' } + ); + } + }, + delete: async (renderer, id) => { + const channel = renderer.channel; + if (!channel) return; + if (renderer.state.type !== 'RENDER') return; + + let messages = [ ...renderer.state.messages ]; + let index = messages.findIndex(x => x._id === id); + + if (index > -1) { + messages.splice(index, 1); + + renderer.setState( + channel, + { + ...renderer.state, + messages + }, + { type: 'StayAtBottom' } + ); + } + }, + loadTop: async (renderer, generateScroll) => { + const channel = renderer.channel; + if (!channel) return; + + const state = renderer.state; + if (state.type !== 'RENDER') return; + if (state.atTop) return; + + const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, { + before: state.messages[0]._id + }, true); + + if (data.length === 0) { + return renderer.setState( + channel, + { + ...state, + atTop: true + } + ); + } + + data.reverse(); + let messages = [ ...data.map(x => mapMessage(x)), ...state.messages ]; + + let atTop = false; + if (data.length < 50) { + atTop = true; + } + + let atBottom = state.atBottom; + if (messages.length > 150) { + messages = messages.slice(0, 150); + atBottom = false; + } + + renderer.setState( + channel, + { ...state, atTop, atBottom, messages }, + generateScroll(messages[messages.length - 1]._id) + ); + }, + loadBottom: async (renderer, generateScroll) => { + const channel = renderer.channel; + if (!channel) return; + + const state = renderer.state; + if (state.type !== 'RENDER') return; + if (state.atBottom) return; + + const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, { + after: state.messages[state.messages.length - 1]._id, + sort: 'Oldest' + }, true); + + if (data.length === 0) { + return renderer.setState( + channel, + { + ...state, + atBottom: true + } + ); + } + + let messages = [ ...state.messages, ...data.map(x => mapMessage(x)) ]; + + let atBottom = false; + if (data.length < 50) { + atBottom = true; + } + + let atTop = state.atTop; + if (messages.length > 150) { + messages = messages.slice(messages.length - 150); + atTop = false; + } + + renderer.setState( + channel, + { ...state, atTop, atBottom, messages }, + generateScroll(messages[0]._id) + ); + } +}; diff --git a/src/lib/renderer/types.ts b/src/lib/renderer/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..da6709ff420d013c237f9ecaca0e8c67a7d0e010 --- /dev/null +++ b/src/lib/renderer/types.ts @@ -0,0 +1,32 @@ +import { Message } from "revolt.js"; +import { SingletonRenderer } from "./Singleton"; +import { MessageObject } from "../../context/revoltjs/util"; + +export type ScrollState = + | { type: "Free" } + | { type: "Bottom", scrollingUntil?: number } + | { type: "ScrollToBottom" | "StayAtBottom", smooth?: boolean } + | { type: "OffsetTop"; previousHeight: number } + | { type: "ScrollTop"; y: number }; + +export type RenderState = + | { + type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY"; + } + | { + type: "RENDER"; + atTop: boolean; + atBottom: boolean; + messages: MessageObject[]; + }; + +export interface RendererRoutines { + init: (renderer: SingletonRenderer, id: string, smooth?: boolean) => Promise<void> + + receive: (renderer: SingletonRenderer, message: Message) => Promise<void>; + edit: (renderer: SingletonRenderer, id: string, partial: Partial<Message>) => Promise<void>; + delete: (renderer: SingletonRenderer, id: string) => Promise<void>; + + loadTop: (renderer: SingletonRenderer, generateScroll: (end: string) => ScrollState) => Promise<void>; + loadBottom: (renderer: SingletonRenderer, generateScroll: (start: string) => ScrollState) => Promise<void>; +} diff --git a/src/pages/RevoltApp.tsx b/src/pages/RevoltApp.tsx index 88738fd37e4c1eca0817c8df3547b7f5fbc6ec2a..5bd4a856f9ffb2893b08ed45472995ac445f3a56 100644 --- a/src/pages/RevoltApp.tsx +++ b/src/pages/RevoltApp.tsx @@ -11,6 +11,7 @@ import RightSidebar from "../components/navigation/RightSidebar"; import Home from './home/Home'; import Friends from "./friends/Friends"; +import Channel from "./channels/Channel"; import Settings from './settings/Settings'; import Developer from "./developer/Developer"; import ServerSettings from "./settings/ServerSettings"; @@ -40,6 +41,11 @@ export default function App() { <Route path="/server/:server/settings" component={ServerSettings} /> <Route path="/channel/:channel/settings/:page" component={ChannelSettings} /> <Route path="/channel/:channel/settings" component={ChannelSettings} /> + + <Route path="/channel/:channel/message/:message" component={Channel} /> + <Route path="/server/:server/channel/:channel" component={Channel} /> + <Route path="/server/:server" /> + <Route path="/channel/:channel" component={Channel} /> <Route path="/settings/:page" component={Settings} /> <Route path="/settings" component={Settings} /> @@ -57,17 +63,7 @@ export default function App() { /** * - * <Route path="/channel/:channel/message/:message"> - <ChannelWrapper /> - </Route> - - <Route path="/server/:server/channel/:channel"> - <ChannelWrapper /> - </Route> - <Route path="/server/:server" /> - <Route path="/channel/:channel"> - <ChannelWrapper /> - </Route> + * <Route path="/open/:id"> <Open /> diff --git a/src/pages/channels/Channel.tsx b/src/pages/channels/Channel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4c731ce5453a40d1c8bd5f86d7bedb14c741b424 --- /dev/null +++ b/src/pages/channels/Channel.tsx @@ -0,0 +1,44 @@ +import styled from "styled-components"; +import { useParams } from "react-router-dom"; +import Header from "../../components/ui/Header"; +import { useRenderState } from "../../lib/renderer/Singleton"; +import { useChannel, useForceUpdate, useUsers } from "../../context/revoltjs/hooks"; +import { MessageArea } from "./messaging/MessageArea"; + +const ChannelMain = styled.div` + flex-grow: 1; + display: flex; + min-height: 0; + overflow: hidden; + flex-direction: row; +`; + +const ChannelContent = styled.div` + flex-grow: 1; + display: flex; + overflow: hidden; + flex-direction: column; +`; + +export default function Channel() { + const { channel: id } = useParams<{ channel: string }>(); + + const ctx = useForceUpdate(); + const channel = useChannel(id, ctx); + + if (!channel) return null; + // const view = useRenderState(id); + + return ( + <> + <Header placement="primary"> + Channel + </Header> + <ChannelMain> + <ChannelContent> + <MessageArea id={id} /> + </ChannelContent> + </ChannelMain> + </> + ) +} diff --git a/src/pages/channels/messaging/ConversationStart.tsx b/src/pages/channels/messaging/ConversationStart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21ecadb12b4372707919d2414f781536ebfd2c7f --- /dev/null +++ b/src/pages/channels/messaging/ConversationStart.tsx @@ -0,0 +1,38 @@ +import { Text } from "preact-i18n"; +import styled from "styled-components"; +import { getChannelName } from "../../../context/revoltjs/util"; +import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks"; + +const StartBase = styled.div` + margin: 18px 16px 10px 16px; + + h1 { + font-size: 23px; + margin: 0 0 8px 0; + } + + h4 { + font-weight: 400; + margin: 0; + font-size: 14px; + } +`; + +interface Props { + id: string; +} + +export default function ConversationStart({ id }: Props) { + const ctx = useForceUpdate(); + const channel = useChannel(id, ctx); + if (!channel) return null; + + return ( + <StartBase> + <h1>{ getChannelName(ctx.client, channel, true) }</h1> + <h4> + <Text id="app.main.channel.start.group" /> + </h4> + </StartBase> + ); +} diff --git a/src/pages/channels/messaging/MessageArea.tsx b/src/pages/channels/messaging/MessageArea.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b04122916ebab874da16888780a98f969b5df5d4 --- /dev/null +++ b/src/pages/channels/messaging/MessageArea.tsx @@ -0,0 +1,231 @@ +import styled from "styled-components"; +import { createContext } from "preact"; +import { animateScroll } from "react-scroll"; +import MessageRenderer from "./MessageRenderer"; +import ConversationStart from './ConversationStart'; +import useResizeObserver from "use-resize-observer"; +import Preloader from "../../../components/ui/Preloader"; +import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; +import { RenderState, ScrollState } from "../../../lib/renderer/types"; +import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton"; +import { IntermediateContext } from "../../../context/intermediate/Intermediate"; +import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; +import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; + +const Area = styled.div` + height: 100%; + flex-grow: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: scroll; + word-break: break-word; + + > div { + display: flex; + min-height: 100%; + flex-direction: column; + justify-content: flex-end; + } +`; + +interface Props { + id: string; +} + +export const MessageAreaWidthContext = createContext(0); +export const MESSAGE_AREA_PADDING = 82; + +export function MessageArea({ id }: Props) { + const status = useContext(StatusContext); + const { focusTaken } = useContext(IntermediateContext); + + // ? This is the scroll container. + const ref = useRef<HTMLDivElement>(null); + const { width, height } = useResizeObserver<HTMLDivElement>({ ref }); + + // ? Current channel state. + const [state, setState] = useState<RenderState>({ type: "LOADING" }); + + // ? Hook-based scrolling mechanism. + const [scrollState, setSS] = useState<ScrollState>({ + type: "Free" + }); + + const setScrollState = (v: ScrollState) => { + if (v.type === 'StayAtBottom') { + if (scrollState.type === 'Bottom' || atBottom()) { + setSS({ type: 'ScrollToBottom', smooth: v.smooth }); + } else { + setSS({ type: 'Free' }); + } + } else { + setSS(v); + } + } + + // ? Determine if we are at the bottom of the scroll container. + // -> https://stackoverflow.com/a/44893438 + // By default, we assume we are at the bottom, i.e. when we first load. + const atBottom = (offset = 0) => + ref.current + ? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) - + offset <= + ref.current.clientHeight + : true; + + const atTop = (offset = 0) => ref.current.scrollTop <= offset; + + // ? Handle events from renderer. + useEffect(() => { + SingletonMessageRenderer.addListener('state', setState); + return () => SingletonMessageRenderer.removeListener('state', setState); + }, [ ]); + + useEffect(() => { + SingletonMessageRenderer.addListener('scroll', setScrollState); + return () => SingletonMessageRenderer.removeListener('scroll', setScrollState); + }, [ scrollState ]); + + // ? Load channel initially. + useEffect(() => { + SingletonMessageRenderer.init(id); + }, [ id ]); + + // ? If we are waiting for network, try again. + useEffect(() => { + switch (status) { + case ClientStatus.ONLINE: + if (state.type === 'WAITING_FOR_NETWORK') { + SingletonMessageRenderer.init(id); + } else { + SingletonMessageRenderer.reloadStale(id); + } + + break; + case ClientStatus.OFFLINE: + case ClientStatus.DISCONNECTED: + case ClientStatus.CONNECTING: + SingletonMessageRenderer.markStale(); + break; + } + }, [ status, state ]); + + // ? Scroll to the bottom before the browser paints. + useLayoutEffect(() => { + if (scrollState.type === "ScrollToBottom") { + setScrollState({ type: "Bottom", scrollingUntil: + new Date() + 150 }); + + animateScroll.scrollToBottom({ + container: ref.current, + duration: scrollState.smooth ? 150 : 0 + }); + } else if (scrollState.type === "OffsetTop") { + animateScroll.scrollTo( + Math.max( + 101, + ref.current.scrollTop + + (ref.current.scrollHeight - scrollState.previousHeight) + ), + { + container: ref.current, + duration: 0 + } + ); + + setScrollState({ type: "Free" }); + } else if (scrollState.type === "ScrollTop") { + animateScroll.scrollTo(scrollState.y, { + container: ref.current, + duration: 0 + }); + + setScrollState({ type: "Free" }); + } + }, [scrollState]); + + // ? When the container is scrolled. + // ? Also handle StayAtBottom + useEffect(() => { + async function onScroll() { + if (scrollState.type === "Free" && atBottom()) { + setScrollState({ type: "Bottom" }); + } else if (scrollState.type === "Bottom" && !atBottom()) { + if (scrollState.scrollingUntil && scrollState.scrollingUntil > + new Date()) return; + setScrollState({ type: "Free" }); + } + } + + ref.current.addEventListener("scroll", onScroll); + return () => ref.current.removeEventListener("scroll", onScroll); + }, [ref, scrollState]); + + // ? Top and bottom loaders. + useEffect(() => { + async function onScroll() { + if (atTop(100)) { + SingletonMessageRenderer.loadTop(ref.current); + } + + if (atBottom(100)) { + SingletonMessageRenderer.loadBottom(ref.current); + } + } + + ref.current.addEventListener("scroll", onScroll); + return () => ref.current.removeEventListener("scroll", onScroll); + }, [ref]); + + // ? Scroll down whenever the message area resizes. + function stbOnResize() { + if (!atBottom() && scrollState.type === "Bottom") { + animateScroll.scrollToBottom({ + container: ref.current, + duration: 0 + }); + + setScrollState({ type: "Bottom" }); + } + } + + // ? Scroll down when container resized. + useLayoutEffect(() => { + stbOnResize(); + }, [height]); + + // ? Scroll down whenever the window resizes. + useLayoutEffect(() => { + document.addEventListener("resize", stbOnResize); + return () => document.removeEventListener("resize", stbOnResize); + }, [ref, scrollState]); + + // ? Scroll to bottom when pressing 'Escape'. + useEffect(() => { + function keyUp(e: KeyboardEvent) { + if (e.key === "Escape" && !focusTaken) { + SingletonMessageRenderer.jumpToBottom(id, true); + } + } + + document.body.addEventListener("keyup", keyUp); + return () => document.body.removeEventListener("keyup", keyUp); + }, [ref, focusTaken]); + + return ( + <MessageAreaWidthContext.Provider value={(width ?? 0) - MESSAGE_AREA_PADDING}> + <Area ref={ref}> + <div> + {state.type === "LOADING" && <Preloader />} + {state.type === "WAITING_FOR_NETWORK" && ( + <RequiresOnline> + <Preloader /> + </RequiresOnline> + )} + {state.type === "RENDER" && ( + <MessageRenderer id={id} state={state} /> + )} + {state.type === "EMPTY" && <ConversationStart id={id} />} + </div> + </Area> + </MessageAreaWidthContext.Provider> + ); +} diff --git a/src/pages/channels/messaging/MessageRenderer.tsx b/src/pages/channels/messaging/MessageRenderer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..205ecb31ed0c22b128801459f158bb2509d1c33b --- /dev/null +++ b/src/pages/channels/messaging/MessageRenderer.tsx @@ -0,0 +1,179 @@ +import { decodeTime } from "ulid"; +import { useEffect, useState } from "preact/hooks"; +import ConversationStart from "./ConversationStart"; +import { connectState } from "../../../redux/connector"; +import Preloader from "../../../components/ui/Preloader"; +import { RenderState } from "../../../lib/renderer/types"; +import DateDivider from "../../../components/ui/DateDivider"; +import { QueuedMessage } from "../../../redux/reducers/queue"; +import { MessageObject } from "../../../context/revoltjs/util"; +import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; +import { useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; +import { Children } from "../../../types/Preact"; +import { SystemMessage } from "../../../components/common/messaging/SystemMessage"; +import Message from "../../../components/common/messaging/Message"; + +interface Props { + id: string; + state: RenderState; + queue: QueuedMessage[]; +} + +function MessageRenderer({ id, state, queue }: Props) { + if (state.type !== 'RENDER') return null; + + const ctx = useForceUpdate(); + const users = useUsers(); + const userId = ctx.client.user!._id; + + /* + const view = useView(id);*/ + + const [editing, setEditing] = useState<string | undefined>(undefined); + const stopEditing = () => { + setEditing(undefined); + // InternalEventEmitter.emit("focus_textarea", "message"); + }; + useEffect(() => { + function editLast() { + if (state.type !== 'RENDER') return; + for (let i = state.messages.length - 1; i >= 0; i--) { + if (state.messages[i].author === userId) { + setEditing(state.messages[i]._id); + return; + } + } + } + + // InternalEventEmitter.addListener("edit_last", editLast); + // InternalEventEmitter.addListener("edit_message", setEditing); + + return () => { + // InternalEventEmitter.removeListener("edit_last", editLast); + // InternalEventEmitter.removeListener("edit_message", setEditing); + }; + }, [state.messages]); + + let render: Children[] = [], + previous: MessageObject | undefined; + + if (state.atTop) { + render.push(<ConversationStart id={id} />); + } else { + render.push( + <RequiresOnline> + <Preloader /> + </RequiresOnline> + ); + } + + let head = true; + function compare( + current: string, + curAuthor: string, + previous: string, + prevAuthor: string + ) { + const atime = decodeTime(current), + adate = new Date(atime), + btime = decodeTime(previous), + bdate = new Date(btime); + + if ( + adate.getFullYear() !== bdate.getFullYear() || + adate.getMonth() !== bdate.getMonth() || + adate.getDate() !== bdate.getDate() + ) { + render.push(<DateDivider date={adate} />); + } + + head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000; + } + + for (const message of state.messages) { + if (previous) { + compare( + message._id, + message.author, + previous._id, + previous.author + ); + } + + if (message.author === "00000000000000000000000000") { + render.push(<SystemMessage key={message._id} message={message} attachContext />); + } else { + render.push( + <Message message={message} + key={message._id} + head={head} + attachContext /> + ); + /*render.push( + <Message + editing={editing === message._id ? stopEditing : undefined} + user={users.find(x => x?._id === message.author)} + message={message} + key={message._id} + head={head} + /> + );*/ + } + + previous = message; + } + + const nonces = state.messages.map(x => x.nonce); + if (state.atBottom) { + for (const msg of queue) { + if (msg.channel !== id) continue; + if (nonces.includes(msg.id)) continue; + + if (previous) { + compare( + msg.id, + userId as string, + previous._id, + previous.author + ); + + previous = { + _id: msg.id, + data: { author: userId as string } + } as any; + } + + /*render.push( + <Message + user={users.find(x => x?._id === userId)} + message={msg.data} + queued={msg} + key={msg.id} + head={head} + /> + );*/ + render.push( + <Message message={msg.data} + key={msg.id} + head={head} + attachContext /> + ); + } + + render.push(<div>end</div>); + } else { + render.push( + <RequiresOnline> + <Preloader /> + </RequiresOnline> + ); + } + + return <>{ render }</>; +} + +export default connectState<Omit<Props, 'queue'>>(MessageRenderer, state => { + return { + queue: state.queue + }; +}); diff --git a/src/pages/developer/Developer.tsx b/src/pages/developer/Developer.tsx index e7a5cbc013aeaf395fd7e8872b5639c37f4c3bcd..1a392bc0119055c4afd45a451ab4ff725d93c889 100644 --- a/src/pages/developer/Developer.tsx +++ b/src/pages/developer/Developer.tsx @@ -1,4 +1,5 @@ import { useContext } from "preact/hooks"; +import { TextReact } from "../../lib/i18n"; import Header from "../../components/ui/Header"; import PaintCounter from "../../lib/PaintCounter"; import { AppContext } from "../../context/revoltjs/RevoltClient"; @@ -19,6 +20,9 @@ export default function Developer() { <b>User ID:</b> {client.user!._id} <br/> <b>Permission against self:</b> {userPermission} <br/> </div> + <div style={{ padding: "16px" }}> + <TextReact id="login.open_mail_provider" fields={{ provider: <b>GAMING!</b> }} /> + </div> <div style={{ padding: "16px" }}> {/*<span> <b>Voice Status:</b> {VoiceStatus[voice.status]} diff --git a/src/pages/settings/ChannelSettings.tsx b/src/pages/settings/ChannelSettings.tsx index 0f7cf15d422fe38adbb997895fd5b856422385b5..cd4b0f4773a58a716e4d86c96bfc6e35cb089871 100644 --- a/src/pages/settings/ChannelSettings.tsx +++ b/src/pages/settings/ChannelSettings.tsx @@ -28,7 +28,7 @@ export default function ChannelSettings() { <GenericSettings pages={[ { - category: <Category variant="uniform" text={getChannelName(ctx.client, channel, [], true)} />, + category: <Category variant="uniform" text={getChannelName(ctx.client, channel, true)} />, id: 'overview', icon: <List size={20} strokeWidth={2} />, title: <Text id="app.settings.channel_pages.overview.title" /> diff --git a/src/pages/settings/channel/Overview.tsx b/src/pages/settings/channel/Overview.tsx index 1efaf59b80e7f512272ce4948e16e0ed660483ce..22eedca28beb3aa67f0b729e2ad48d4cc26fa900 100644 --- a/src/pages/settings/channel/Overview.tsx +++ b/src/pages/settings/channel/Overview.tsx @@ -81,7 +81,7 @@ export function Overview({ channel }: Props) { if (!changed) setChanged(true) }} /> - <Button onClick={save} style="contrast" disabled={!changed}> + <Button onClick={save} contrast disabled={!changed}> <Text id="app.special.modals.actions.save" /> </Button> </div> diff --git a/src/pages/settings/server/Overview.tsx b/src/pages/settings/server/Overview.tsx index a6d4234d2f442c5306f616a1b48c4ca0abe5a75e..d3ba3a3e9f7628266e649983548ce08f5f120dd4 100644 --- a/src/pages/settings/server/Overview.tsx +++ b/src/pages/settings/server/Overview.tsx @@ -76,7 +76,7 @@ export function Overview({ server }: Props) { if (!changed) setChanged(true) }} /> - <Button onClick={save} style="contrast" disabled={!changed}> + <Button onClick={save} contrast disabled={!changed}> <Text id="app.special.modals.actions.save" /> </Button> diff --git a/yarn.lock b/yarn.lock index 0bf73154372bf83958e186465d2f6e28f5d2a038..ce8812d6fed5c943ad29d38d2f1dfbbc7cfb7ac7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1242,6 +1242,13 @@ "@types/history" "*" "@types/react" "*" +"@types/react-scroll@^1.8.2": + version "1.8.2" + resolved "https://registry.yarnpkg.com/@types/react-scroll/-/react-scroll-1.8.2.tgz#44bbbadabb9014517eb865d6fa47937535a2234a" + integrity sha512-oavV6BZLfaIghX4JSmrm6mJkeVayQlmsFx1Rz8ffGjMngHAI/juZkRZM/zV/H5D0pGqjzACvBmKYUU4YBecwLg== + dependencies: + "@types/react" "*" + "@types/react@*": version "17.0.11" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451" @@ -2745,6 +2752,11 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= +lodash.throttle@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" + integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= + lodash.truncate@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" @@ -3209,6 +3221,14 @@ react-router@5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-scroll@^1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/react-scroll/-/react-scroll-1.8.2.tgz#68e35b74ae296c88e7863393c9fd49f05afa29f5" + integrity sha512-f2ZEG5fsPbPTySI9ekcFpETCcNlqbmwbQj9hhzYK8tkgv+PA8APatSt66o/q0KSkDZxyT98ONTtXp9x0lyowEw== + dependencies: + lodash.throttle "^4.1.1" + prop-types "^15.7.2" + react-side-effect@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.1.tgz#66c5701c3e7560ab4822a4ee2742dee215d72eb3" @@ -3301,6 +3321,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -3855,6 +3880,13 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +use-resize-observer@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-7.0.0.tgz#15f0efbd5a4e08a8cc51901f21a89ba836f2116e" + integrity sha512-+RjrQsk/mL8aKy4TGBDiPkUv6whyeoGDMIZYk0gOGHOlnrsjImC+jG6lfAFcBCKAG9epGRL419adhDNdkDCQkA== + dependencies: + resize-observer-polyfill "^1.5.1" + v8-compile-cache@^2.0.3: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"