Work on channels, render content of messages.

This commit is contained in:
Paul 2021-06-20 17:31:53 +01:00
parent 89f8ab2694
commit d0b9cf9090
30 changed files with 1415 additions and 58 deletions

2
.env
View file

@ -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

@ -1 +1 @@
Subproject commit 9db39a2eecc5fbb7ed06d4598da60700e96e3274
Subproject commit 210172de724fcd5adeacec221bd9da30350afc06

View file

@ -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"
}

View file

@ -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} />
);
}

View file

@ -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} />
);
}

View file

@ -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 && (

View 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} />
</>;
}

View 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>
)
}

View 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>
</>
)
}

View 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>
);
}

View file

@ -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>
)

View 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>
);
}

View file

@ -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";

View file

@ -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();

View file

@ -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 () => {

View file

@ -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
View 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) }</>;
}

View 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;
}

View 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
View 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>;
}

View file

@ -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 />

View 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>
</>
)
}

View 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>
);
}

View 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>
);
}

View 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
};
});

View file

@ -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]}

View file

@ -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" />

View file

@ -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>

View file

@ -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>

View file

@ -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"