mirror of
https://github.com/revoltchat/revite.git
synced 2024-12-23 22:22:07 -05:00
Work on channels, render content of messages.
This commit is contained in:
parent
89f8ab2694
commit
d0b9cf9090
30 changed files with 1415 additions and 58 deletions
2
.env
2
.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
|
||||
|
|
2
external/lang
vendored
2
external/lang
vendored
|
@ -1 +1 @@
|
|||
Subproject commit 9db39a2eecc5fbb7ed06d4598da60700e96e3274
|
||||
Subproject commit 210172de724fcd5adeacec221bd9da30350afc06
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 && (
|
||||
|
|
14
src/components/common/UserShort.tsx
Normal file
14
src/components/common/UserShort.tsx
Normal file
|
@ -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} />
|
||||
</>;
|
||||
}
|
37
src/components/common/messaging/Message.tsx
Normal file
37
src/components/common/messaging/Message.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
111
src/components/common/messaging/MessageBase.tsx
Normal file
111
src/components/common/messaging/MessageBase.tsx
Normal file
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
152
src/components/common/messaging/SystemMessage.tsx
Normal file
152
src/components/common/messaging/SystemMessage.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
|
|
48
src/components/ui/DateDivider.tsx
Normal file
48
src/components/ui/DateDivider.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
54
src/lib/i18n.tsx
Normal file
54
src/lib/i18n.tsx
Normal file
|
@ -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) }</>;
|
||||
}
|
192
src/lib/renderer/Singleton.ts
Normal file
192
src/lib/renderer/Singleton.ts
Normal file
|
@ -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;
|
||||
}
|
178
src/lib/renderer/simple/SimpleRenderer.ts
Normal file
178
src/lib/renderer/simple/SimpleRenderer.ts
Normal file
|
@ -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)
|
||||
);
|
||||
}
|
||||
};
|
32
src/lib/renderer/types.ts
Normal file
32
src/lib/renderer/types.ts
Normal file
|
@ -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>;
|
||||
}
|
|
@ -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 />
|
||||
|
|
44
src/pages/channels/Channel.tsx
Normal file
44
src/pages/channels/Channel.tsx
Normal file
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
38
src/pages/channels/messaging/ConversationStart.tsx
Normal file
38
src/pages/channels/messaging/ConversationStart.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
231
src/pages/channels/messaging/MessageArea.tsx
Normal file
231
src/pages/channels/messaging/MessageArea.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
179
src/pages/channels/messaging/MessageRenderer.tsx
Normal file
179
src/pages/channels/messaging/MessageRenderer.tsx
Normal file
|
@ -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
|
||||
};
|
||||
});
|
|
@ -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]}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
32
yarn.lock
32
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"
|
||||
|
|
Loading…
Reference in a new issue