mirror of
https://github.com/revoltchat/revite.git
synced 2024-11-22 07:00:58 -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
|
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/prismjs": "^1.16.5",
|
||||||
"@types/react-helmet": "^6.1.1",
|
"@types/react-helmet": "^6.1.1",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"@types/react-router-dom": "^5.1.7",
|
||||||
|
"@types/react-scroll": "^1.8.2",
|
||||||
"@types/styled-components": "^5.1.10",
|
"@types/styled-components": "^5.1.10",
|
||||||
"@types/twemoji": "^12.1.1",
|
"@types/twemoji": "^12.1.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
||||||
|
@ -66,6 +67,7 @@
|
||||||
"react-overlapping-panels": "1.2.1",
|
"react-overlapping-panels": "1.2.1",
|
||||||
"react-redux": "^7.2.4",
|
"react-redux": "^7.2.4",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
|
"react-scroll": "^1.8.2",
|
||||||
"react-tippy": "^1.4.0",
|
"react-tippy": "^1.4.0",
|
||||||
"redux": "^4.1.0",
|
"redux": "^4.1.0",
|
||||||
"revolt.js": "4.3.0",
|
"revolt.js": "4.3.0",
|
||||||
|
@ -76,6 +78,7 @@
|
||||||
"twemoji": "^13.1.0",
|
"twemoji": "^13.1.0",
|
||||||
"typescript": "^4.3.2",
|
"typescript": "^4.3.2",
|
||||||
"ulid": "^2.3.0",
|
"ulid": "^2.3.0",
|
||||||
|
"use-resize-observer": "^7.0.0",
|
||||||
"vite": "^2.3.7",
|
"vite": "^2.3.7",
|
||||||
"vite-plugin-pwa": "^0.8.1"
|
"vite-plugin-pwa": "^0.8.1"
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,12 +32,6 @@ export default function ChannelIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLI
|
||||||
height={size}
|
height={size}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
square={isServerChannel}
|
square={isServerChannel}
|
||||||
src={iconURL ?? fallback}
|
src={iconURL ?? fallback} />
|
||||||
onError={ e => {
|
|
||||||
let el = e.currentTarget;
|
|
||||||
if (el.src !== fallback) {
|
|
||||||
el.src = fallback
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,12 +42,6 @@ export default function ServerIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLIm
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
src={iconURL}
|
src={iconURL} />
|
||||||
onError={ e => {
|
|
||||||
let el = e.currentTarget;
|
|
||||||
if (el.src !== fallback) {
|
|
||||||
el.src = fallback
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { target, attachment, size, voice, status, animate, children, as, ...svgProps } = props;
|
||||||
const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate)
|
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 (
|
return (
|
||||||
<IconBase {...svgProps}
|
<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">
|
<foreignObject x="0" y="0" width="32" height="32">
|
||||||
{
|
{
|
||||||
<img src={iconURL}
|
<img src={iconURL}
|
||||||
draggable={false}
|
draggable={false} />
|
||||||
onError={ e => {
|
|
||||||
let el = e.currentTarget;
|
|
||||||
if (el.src !== fallback) {
|
|
||||||
el.src = fallback
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
}
|
}
|
||||||
</foreignObject>
|
</foreignObject>
|
||||||
{props.status && (
|
{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) {
|
export default function Markdown(props: MarkdownProps) {
|
||||||
return (
|
return (
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
<Suspense fallback="Getting ready to render Markdown...">
|
<Suspense fallback={props.content}>
|
||||||
<Renderer {...props} />
|
<Renderer {...props} />
|
||||||
</Suspense>
|
</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 { IntlProvider } from "preact-i18n";
|
||||||
import { connectState } from "../redux/connector";
|
import { connectState } from "../redux/connector";
|
||||||
import definition from "../../external/lang/en.json";
|
|
||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import definition from "../../external/lang/en.json";
|
||||||
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import calendar from "dayjs/plugin/calendar";
|
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 { Channels, Servers } from "revolt.js/dist/api/objects";
|
||||||
import { useContext, useEffect, useState } from "preact/hooks";
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
import { AppContext } from "../../revoltjs/RevoltClient";
|
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 {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
@ -57,26 +58,23 @@ export function SpecialPromptModal(props: SpecialProps) {
|
||||||
case 'close_dm':
|
case 'close_dm':
|
||||||
case 'leave_server':
|
case 'leave_server':
|
||||||
case 'delete_server':
|
case 'delete_server':
|
||||||
case 'delete_message':
|
|
||||||
case 'delete_channel': {
|
case 'delete_channel': {
|
||||||
const EVENTS = {
|
const EVENTS = {
|
||||||
'close_dm': 'confirm_close_dm',
|
'close_dm': 'confirm_close_dm',
|
||||||
'delete_server': 'confirm_delete',
|
'delete_server': 'confirm_delete',
|
||||||
'delete_channel': 'confirm_delete',
|
'delete_channel': 'confirm_delete',
|
||||||
'delete_message': 'confirm_delete_message',
|
|
||||||
'leave_group': 'confirm_leave',
|
'leave_group': 'confirm_leave',
|
||||||
'leave_server': 'confirm_leave'
|
'leave_server': 'confirm_leave'
|
||||||
};
|
};
|
||||||
|
|
||||||
let event = EVENTS[props.type];
|
let event = EVENTS[props.type];
|
||||||
let name = props.type === 'close_dm' ? client.users.get(client.channels.getRecipient(props.target._id))?.username :
|
let name = props.type === 'close_dm' ? client.users.get(client.channels.getRecipient(props.target._id))?.username : props.target.name;
|
||||||
props.type === 'delete_message' ? undefined : props.target.name;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PromptModal
|
<PromptModal
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
question={<Text
|
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 }}
|
fields={{ name }}
|
||||||
/>}
|
/>}
|
||||||
actions={[
|
actions={[
|
||||||
|
@ -91,8 +89,6 @@ export function SpecialPromptModal(props: SpecialProps) {
|
||||||
try {
|
try {
|
||||||
if (props.type === 'leave_group' || props.type === 'close_dm' || props.type === 'delete_channel') {
|
if (props.type === 'leave_group' || props.type === 'close_dm' || props.type === 'delete_channel') {
|
||||||
await client.channels.delete(props.target._id);
|
await client.channels.delete(props.target._id);
|
||||||
} else if (props.type === 'delete_message') {
|
|
||||||
await client.channels.deleteMessage(props.target.channel, props.target._id);
|
|
||||||
} else {
|
} else {
|
||||||
await client.servers.delete(props.target._id);
|
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": {
|
case "create_invite": {
|
||||||
const [ code, setCode ] = useState('abcdef');
|
const [ code, setCode ] = useState('abcdef');
|
||||||
const { writeClipboard } = useIntermediate();
|
const { writeClipboard } = useIntermediate();
|
||||||
|
|
|
@ -4,13 +4,14 @@ import { takeError } from "./util";
|
||||||
import { createContext } from "preact";
|
import { createContext } from "preact";
|
||||||
import { Children } from "../../types/Preact";
|
import { Children } from "../../types/Preact";
|
||||||
import { Route } from "revolt.js/dist/api/routes";
|
import { Route } from "revolt.js/dist/api/routes";
|
||||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
|
||||||
import { connectState } from "../../redux/connector";
|
import { connectState } from "../../redux/connector";
|
||||||
import Preloader from "../../components/ui/Preloader";
|
import Preloader from "../../components/ui/Preloader";
|
||||||
import { WithDispatcher } from "../../redux/reducers";
|
import { WithDispatcher } from "../../redux/reducers";
|
||||||
import { AuthState } from "../../redux/reducers/auth";
|
import { AuthState } from "../../redux/reducers/auth";
|
||||||
import { SyncOptions } from "../../redux/reducers/sync";
|
import { SyncOptions } from "../../redux/reducers/sync";
|
||||||
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||||
import { registerEvents, setReconnectDisallowed } from "./events";
|
import { registerEvents, setReconnectDisallowed } from "./events";
|
||||||
|
import { SingletonMessageRenderer } from '../../lib/renderer/Singleton';
|
||||||
|
|
||||||
export enum ClientStatus {
|
export enum ClientStatus {
|
||||||
INIT,
|
INIT,
|
||||||
|
@ -61,13 +62,15 @@ function Context({ auth, sync, children, dispatcher }: Props) {
|
||||||
console.error('Failed to open IndexedDB store, continuing without.');
|
console.error('Failed to open IndexedDB store, continuing without.');
|
||||||
}
|
}
|
||||||
|
|
||||||
setClient(new Client({
|
const client = new Client({
|
||||||
autoReconnect: false,
|
autoReconnect: false,
|
||||||
apiURL: import.meta.env.VITE_API_URL,
|
apiURL: import.meta.env.VITE_API_URL,
|
||||||
debug: import.meta.env.DEV,
|
debug: import.meta.env.DEV,
|
||||||
db
|
db
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
setClient(client);
|
||||||
|
SingletonMessageRenderer.subscribe(client);
|
||||||
setStatus(ClientStatus.LOADING);
|
setStatus(ClientStatus.LOADING);
|
||||||
})();
|
})();
|
||||||
}, [ ]);
|
}, [ ]);
|
||||||
|
@ -131,10 +134,7 @@ function Context({ auth, sync, children, dispatcher }: Props) {
|
||||||
}
|
}
|
||||||
}, [ client, auth.active ]);
|
}, [ client, auth.active ]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => registerEvents({ operations, dispatcher }, setStatus, client), [ client ]);
|
||||||
() => registerEvents({ operations, dispatcher }, setStatus, client),
|
|
||||||
[ client ]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
|
@ -22,14 +22,13 @@ export function takeError(
|
||||||
return id;
|
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")
|
if (channel.channel_type === "SavedMessages")
|
||||||
return <Text id="app.navigation.tabs.saved" />;
|
return <Text id="app.navigation.tabs.saved" />;
|
||||||
|
|
||||||
if (channel.channel_type === "DirectMessage") {
|
if (channel.channel_type === "DirectMessage") {
|
||||||
let uid = client.channels.getRecipient(channel._id);
|
let uid = client.channels.getRecipient(channel._id);
|
||||||
|
return <>{prefixType && "@"}{client.users.get(uid)?.username}</>;
|
||||||
return <>{prefixType && "@"}{users.find(x => x._id === uid)?.username}</>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel.channel_type === "TextChannel" && prefixType) {
|
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 Home from './home/Home';
|
||||||
import Friends from "./friends/Friends";
|
import Friends from "./friends/Friends";
|
||||||
|
import Channel from "./channels/Channel";
|
||||||
import Settings from './settings/Settings';
|
import Settings from './settings/Settings';
|
||||||
import Developer from "./developer/Developer";
|
import Developer from "./developer/Developer";
|
||||||
import ServerSettings from "./settings/ServerSettings";
|
import ServerSettings from "./settings/ServerSettings";
|
||||||
|
@ -40,6 +41,11 @@ export default function App() {
|
||||||
<Route path="/server/:server/settings" component={ServerSettings} />
|
<Route path="/server/:server/settings" component={ServerSettings} />
|
||||||
<Route path="/channel/:channel/settings/:page" component={ChannelSettings} />
|
<Route path="/channel/:channel/settings/:page" component={ChannelSettings} />
|
||||||
<Route path="/channel/:channel/settings" 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/:page" component={Settings} />
|
||||||
<Route path="/settings" 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">
|
<Route path="/open/:id">
|
||||||
<Open />
|
<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 { useContext } from "preact/hooks";
|
||||||
|
import { TextReact } from "../../lib/i18n";
|
||||||
import Header from "../../components/ui/Header";
|
import Header from "../../components/ui/Header";
|
||||||
import PaintCounter from "../../lib/PaintCounter";
|
import PaintCounter from "../../lib/PaintCounter";
|
||||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||||
|
@ -19,6 +20,9 @@ export default function Developer() {
|
||||||
<b>User ID:</b> {client.user!._id} <br/>
|
<b>User ID:</b> {client.user!._id} <br/>
|
||||||
<b>Permission against self:</b> {userPermission} <br/>
|
<b>Permission against self:</b> {userPermission} <br/>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ padding: "16px" }}>
|
||||||
|
<TextReact id="login.open_mail_provider" fields={{ provider: <b>GAMING!</b> }} />
|
||||||
|
</div>
|
||||||
<div style={{ padding: "16px" }}>
|
<div style={{ padding: "16px" }}>
|
||||||
{/*<span>
|
{/*<span>
|
||||||
<b>Voice Status:</b> {VoiceStatus[voice.status]}
|
<b>Voice Status:</b> {VoiceStatus[voice.status]}
|
||||||
|
|
|
@ -28,7 +28,7 @@ export default function ChannelSettings() {
|
||||||
<GenericSettings
|
<GenericSettings
|
||||||
pages={[
|
pages={[
|
||||||
{
|
{
|
||||||
category: <Category variant="uniform" text={getChannelName(ctx.client, channel, [], true)} />,
|
category: <Category variant="uniform" text={getChannelName(ctx.client, channel, true)} />,
|
||||||
id: 'overview',
|
id: 'overview',
|
||||||
icon: <List size={20} strokeWidth={2} />,
|
icon: <List size={20} strokeWidth={2} />,
|
||||||
title: <Text id="app.settings.channel_pages.overview.title" />
|
title: <Text id="app.settings.channel_pages.overview.title" />
|
||||||
|
|
|
@ -81,7 +81,7 @@ export function Overview({ channel }: Props) {
|
||||||
if (!changed) setChanged(true)
|
if (!changed) setChanged(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={save} style="contrast" disabled={!changed}>
|
<Button onClick={save} contrast disabled={!changed}>
|
||||||
<Text id="app.special.modals.actions.save" />
|
<Text id="app.special.modals.actions.save" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -76,7 +76,7 @@ export function Overview({ server }: Props) {
|
||||||
if (!changed) setChanged(true)
|
if (!changed) setChanged(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={save} style="contrast" disabled={!changed}>
|
<Button onClick={save} contrast disabled={!changed}>
|
||||||
<Text id="app.special.modals.actions.save" />
|
<Text id="app.special.modals.actions.save" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
32
yarn.lock
32
yarn.lock
|
@ -1242,6 +1242,13 @@
|
||||||
"@types/history" "*"
|
"@types/history" "*"
|
||||||
"@types/react" "*"
|
"@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@*":
|
"@types/react@*":
|
||||||
version "17.0.11"
|
version "17.0.11"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451"
|
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"
|
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
|
||||||
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
|
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:
|
lodash.truncate@^4.4.2:
|
||||||
version "4.4.2"
|
version "4.4.2"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
|
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-invariant "^1.0.2"
|
||||||
tiny-warning "^1.0.0"
|
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:
|
react-side-effect@^2.1.0:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.1.tgz#66c5701c3e7560ab4822a4ee2742dee215d72eb3"
|
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"
|
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==
|
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:
|
resolve-from@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||||
|
@ -3855,6 +3880,13 @@ uri-js@^4.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode "^2.1.0"
|
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:
|
v8-compile-cache@^2.0.3:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
|
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
|
||||||
|
|
Loading…
Reference in a new issue