mirror of
https://github.com/stoatchat/for-legacy-web.git
synced 2026-03-06 08:38:37 +00:00
Work on channels, render content of messages.
This commit is contained in:
@@ -11,6 +11,7 @@ import RightSidebar from "../components/navigation/RightSidebar";
|
||||
|
||||
import Home from './home/Home';
|
||||
import Friends from "./friends/Friends";
|
||||
import Channel from "./channels/Channel";
|
||||
import Settings from './settings/Settings';
|
||||
import Developer from "./developer/Developer";
|
||||
import ServerSettings from "./settings/ServerSettings";
|
||||
@@ -40,6 +41,11 @@ export default function App() {
|
||||
<Route path="/server/:server/settings" component={ServerSettings} />
|
||||
<Route path="/channel/:channel/settings/:page" component={ChannelSettings} />
|
||||
<Route path="/channel/:channel/settings" component={ChannelSettings} />
|
||||
|
||||
<Route path="/channel/:channel/message/:message" component={Channel} />
|
||||
<Route path="/server/:server/channel/:channel" component={Channel} />
|
||||
<Route path="/server/:server" />
|
||||
<Route path="/channel/:channel" component={Channel} />
|
||||
|
||||
<Route path="/settings/:page" component={Settings} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
@@ -57,17 +63,7 @@ export default function App() {
|
||||
|
||||
/**
|
||||
*
|
||||
* <Route path="/channel/:channel/message/:message">
|
||||
<ChannelWrapper />
|
||||
</Route>
|
||||
|
||||
<Route path="/server/:server/channel/:channel">
|
||||
<ChannelWrapper />
|
||||
</Route>
|
||||
<Route path="/server/:server" />
|
||||
<Route path="/channel/:channel">
|
||||
<ChannelWrapper />
|
||||
</Route>
|
||||
*
|
||||
|
||||
<Route path="/open/:id">
|
||||
<Open />
|
||||
|
||||
44
src/pages/channels/Channel.tsx
Normal file
44
src/pages/channels/Channel.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import styled from "styled-components";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Header from "../../components/ui/Header";
|
||||
import { useRenderState } from "../../lib/renderer/Singleton";
|
||||
import { useChannel, useForceUpdate, useUsers } from "../../context/revoltjs/hooks";
|
||||
import { MessageArea } from "./messaging/MessageArea";
|
||||
|
||||
const ChannelMain = styled.div`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const ChannelContent = styled.div`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export default function Channel() {
|
||||
const { channel: id } = useParams<{ channel: string }>();
|
||||
|
||||
const ctx = useForceUpdate();
|
||||
const channel = useChannel(id, ctx);
|
||||
|
||||
if (!channel) return null;
|
||||
// const view = useRenderState(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header placement="primary">
|
||||
Channel
|
||||
</Header>
|
||||
<ChannelMain>
|
||||
<ChannelContent>
|
||||
<MessageArea id={id} />
|
||||
</ChannelContent>
|
||||
</ChannelMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
38
src/pages/channels/messaging/ConversationStart.tsx
Normal file
38
src/pages/channels/messaging/ConversationStart.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Text } from "preact-i18n";
|
||||
import styled from "styled-components";
|
||||
import { getChannelName } from "../../../context/revoltjs/util";
|
||||
import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks";
|
||||
|
||||
const StartBase = styled.div`
|
||||
margin: 18px 16px 10px 16px;
|
||||
|
||||
h1 {
|
||||
font-size: 23px;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default function ConversationStart({ id }: Props) {
|
||||
const ctx = useForceUpdate();
|
||||
const channel = useChannel(id, ctx);
|
||||
if (!channel) return null;
|
||||
|
||||
return (
|
||||
<StartBase>
|
||||
<h1>{ getChannelName(ctx.client, channel, true) }</h1>
|
||||
<h4>
|
||||
<Text id="app.main.channel.start.group" />
|
||||
</h4>
|
||||
</StartBase>
|
||||
);
|
||||
}
|
||||
231
src/pages/channels/messaging/MessageArea.tsx
Normal file
231
src/pages/channels/messaging/MessageArea.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import styled from "styled-components";
|
||||
import { createContext } from "preact";
|
||||
import { animateScroll } from "react-scroll";
|
||||
import MessageRenderer from "./MessageRenderer";
|
||||
import ConversationStart from './ConversationStart';
|
||||
import useResizeObserver from "use-resize-observer";
|
||||
import Preloader from "../../../components/ui/Preloader";
|
||||
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
|
||||
import { RenderState, ScrollState } from "../../../lib/renderer/types";
|
||||
import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton";
|
||||
import { IntermediateContext } from "../../../context/intermediate/Intermediate";
|
||||
import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
|
||||
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
const Area = styled.div`
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
word-break: break-word;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const MessageAreaWidthContext = createContext(0);
|
||||
export const MESSAGE_AREA_PADDING = 82;
|
||||
|
||||
export function MessageArea({ id }: Props) {
|
||||
const status = useContext(StatusContext);
|
||||
const { focusTaken } = useContext(IntermediateContext);
|
||||
|
||||
// ? This is the scroll container.
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
|
||||
|
||||
// ? Current channel state.
|
||||
const [state, setState] = useState<RenderState>({ type: "LOADING" });
|
||||
|
||||
// ? Hook-based scrolling mechanism.
|
||||
const [scrollState, setSS] = useState<ScrollState>({
|
||||
type: "Free"
|
||||
});
|
||||
|
||||
const setScrollState = (v: ScrollState) => {
|
||||
if (v.type === 'StayAtBottom') {
|
||||
if (scrollState.type === 'Bottom' || atBottom()) {
|
||||
setSS({ type: 'ScrollToBottom', smooth: v.smooth });
|
||||
} else {
|
||||
setSS({ type: 'Free' });
|
||||
}
|
||||
} else {
|
||||
setSS(v);
|
||||
}
|
||||
}
|
||||
|
||||
// ? Determine if we are at the bottom of the scroll container.
|
||||
// -> https://stackoverflow.com/a/44893438
|
||||
// By default, we assume we are at the bottom, i.e. when we first load.
|
||||
const atBottom = (offset = 0) =>
|
||||
ref.current
|
||||
? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) -
|
||||
offset <=
|
||||
ref.current.clientHeight
|
||||
: true;
|
||||
|
||||
const atTop = (offset = 0) => ref.current.scrollTop <= offset;
|
||||
|
||||
// ? Handle events from renderer.
|
||||
useEffect(() => {
|
||||
SingletonMessageRenderer.addListener('state', setState);
|
||||
return () => SingletonMessageRenderer.removeListener('state', setState);
|
||||
}, [ ]);
|
||||
|
||||
useEffect(() => {
|
||||
SingletonMessageRenderer.addListener('scroll', setScrollState);
|
||||
return () => SingletonMessageRenderer.removeListener('scroll', setScrollState);
|
||||
}, [ scrollState ]);
|
||||
|
||||
// ? Load channel initially.
|
||||
useEffect(() => {
|
||||
SingletonMessageRenderer.init(id);
|
||||
}, [ id ]);
|
||||
|
||||
// ? If we are waiting for network, try again.
|
||||
useEffect(() => {
|
||||
switch (status) {
|
||||
case ClientStatus.ONLINE:
|
||||
if (state.type === 'WAITING_FOR_NETWORK') {
|
||||
SingletonMessageRenderer.init(id);
|
||||
} else {
|
||||
SingletonMessageRenderer.reloadStale(id);
|
||||
}
|
||||
|
||||
break;
|
||||
case ClientStatus.OFFLINE:
|
||||
case ClientStatus.DISCONNECTED:
|
||||
case ClientStatus.CONNECTING:
|
||||
SingletonMessageRenderer.markStale();
|
||||
break;
|
||||
}
|
||||
}, [ status, state ]);
|
||||
|
||||
// ? Scroll to the bottom before the browser paints.
|
||||
useLayoutEffect(() => {
|
||||
if (scrollState.type === "ScrollToBottom") {
|
||||
setScrollState({ type: "Bottom", scrollingUntil: + new Date() + 150 });
|
||||
|
||||
animateScroll.scrollToBottom({
|
||||
container: ref.current,
|
||||
duration: scrollState.smooth ? 150 : 0
|
||||
});
|
||||
} else if (scrollState.type === "OffsetTop") {
|
||||
animateScroll.scrollTo(
|
||||
Math.max(
|
||||
101,
|
||||
ref.current.scrollTop +
|
||||
(ref.current.scrollHeight - scrollState.previousHeight)
|
||||
),
|
||||
{
|
||||
container: ref.current,
|
||||
duration: 0
|
||||
}
|
||||
);
|
||||
|
||||
setScrollState({ type: "Free" });
|
||||
} else if (scrollState.type === "ScrollTop") {
|
||||
animateScroll.scrollTo(scrollState.y, {
|
||||
container: ref.current,
|
||||
duration: 0
|
||||
});
|
||||
|
||||
setScrollState({ type: "Free" });
|
||||
}
|
||||
}, [scrollState]);
|
||||
|
||||
// ? When the container is scrolled.
|
||||
// ? Also handle StayAtBottom
|
||||
useEffect(() => {
|
||||
async function onScroll() {
|
||||
if (scrollState.type === "Free" && atBottom()) {
|
||||
setScrollState({ type: "Bottom" });
|
||||
} else if (scrollState.type === "Bottom" && !atBottom()) {
|
||||
if (scrollState.scrollingUntil && scrollState.scrollingUntil > + new Date()) return;
|
||||
setScrollState({ type: "Free" });
|
||||
}
|
||||
}
|
||||
|
||||
ref.current.addEventListener("scroll", onScroll);
|
||||
return () => ref.current.removeEventListener("scroll", onScroll);
|
||||
}, [ref, scrollState]);
|
||||
|
||||
// ? Top and bottom loaders.
|
||||
useEffect(() => {
|
||||
async function onScroll() {
|
||||
if (atTop(100)) {
|
||||
SingletonMessageRenderer.loadTop(ref.current);
|
||||
}
|
||||
|
||||
if (atBottom(100)) {
|
||||
SingletonMessageRenderer.loadBottom(ref.current);
|
||||
}
|
||||
}
|
||||
|
||||
ref.current.addEventListener("scroll", onScroll);
|
||||
return () => ref.current.removeEventListener("scroll", onScroll);
|
||||
}, [ref]);
|
||||
|
||||
// ? Scroll down whenever the message area resizes.
|
||||
function stbOnResize() {
|
||||
if (!atBottom() && scrollState.type === "Bottom") {
|
||||
animateScroll.scrollToBottom({
|
||||
container: ref.current,
|
||||
duration: 0
|
||||
});
|
||||
|
||||
setScrollState({ type: "Bottom" });
|
||||
}
|
||||
}
|
||||
|
||||
// ? Scroll down when container resized.
|
||||
useLayoutEffect(() => {
|
||||
stbOnResize();
|
||||
}, [height]);
|
||||
|
||||
// ? Scroll down whenever the window resizes.
|
||||
useLayoutEffect(() => {
|
||||
document.addEventListener("resize", stbOnResize);
|
||||
return () => document.removeEventListener("resize", stbOnResize);
|
||||
}, [ref, scrollState]);
|
||||
|
||||
// ? Scroll to bottom when pressing 'Escape'.
|
||||
useEffect(() => {
|
||||
function keyUp(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !focusTaken) {
|
||||
SingletonMessageRenderer.jumpToBottom(id, true);
|
||||
}
|
||||
}
|
||||
|
||||
document.body.addEventListener("keyup", keyUp);
|
||||
return () => document.body.removeEventListener("keyup", keyUp);
|
||||
}, [ref, focusTaken]);
|
||||
|
||||
return (
|
||||
<MessageAreaWidthContext.Provider value={(width ?? 0) - MESSAGE_AREA_PADDING}>
|
||||
<Area ref={ref}>
|
||||
<div>
|
||||
{state.type === "LOADING" && <Preloader />}
|
||||
{state.type === "WAITING_FOR_NETWORK" && (
|
||||
<RequiresOnline>
|
||||
<Preloader />
|
||||
</RequiresOnline>
|
||||
)}
|
||||
{state.type === "RENDER" && (
|
||||
<MessageRenderer id={id} state={state} />
|
||||
)}
|
||||
{state.type === "EMPTY" && <ConversationStart id={id} />}
|
||||
</div>
|
||||
</Area>
|
||||
</MessageAreaWidthContext.Provider>
|
||||
);
|
||||
}
|
||||
179
src/pages/channels/messaging/MessageRenderer.tsx
Normal file
179
src/pages/channels/messaging/MessageRenderer.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { decodeTime } from "ulid";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import ConversationStart from "./ConversationStart";
|
||||
import { connectState } from "../../../redux/connector";
|
||||
import Preloader from "../../../components/ui/Preloader";
|
||||
import { RenderState } from "../../../lib/renderer/types";
|
||||
import DateDivider from "../../../components/ui/DateDivider";
|
||||
import { QueuedMessage } from "../../../redux/reducers/queue";
|
||||
import { MessageObject } from "../../../context/revoltjs/util";
|
||||
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
|
||||
import { useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
|
||||
import { Children } from "../../../types/Preact";
|
||||
import { SystemMessage } from "../../../components/common/messaging/SystemMessage";
|
||||
import Message from "../../../components/common/messaging/Message";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
state: RenderState;
|
||||
queue: QueuedMessage[];
|
||||
}
|
||||
|
||||
function MessageRenderer({ id, state, queue }: Props) {
|
||||
if (state.type !== 'RENDER') return null;
|
||||
|
||||
const ctx = useForceUpdate();
|
||||
const users = useUsers();
|
||||
const userId = ctx.client.user!._id;
|
||||
|
||||
/*
|
||||
const view = useView(id);*/
|
||||
|
||||
const [editing, setEditing] = useState<string | undefined>(undefined);
|
||||
const stopEditing = () => {
|
||||
setEditing(undefined);
|
||||
// InternalEventEmitter.emit("focus_textarea", "message");
|
||||
};
|
||||
useEffect(() => {
|
||||
function editLast() {
|
||||
if (state.type !== 'RENDER') return;
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
if (state.messages[i].author === userId) {
|
||||
setEditing(state.messages[i]._id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InternalEventEmitter.addListener("edit_last", editLast);
|
||||
// InternalEventEmitter.addListener("edit_message", setEditing);
|
||||
|
||||
return () => {
|
||||
// InternalEventEmitter.removeListener("edit_last", editLast);
|
||||
// InternalEventEmitter.removeListener("edit_message", setEditing);
|
||||
};
|
||||
}, [state.messages]);
|
||||
|
||||
let render: Children[] = [],
|
||||
previous: MessageObject | undefined;
|
||||
|
||||
if (state.atTop) {
|
||||
render.push(<ConversationStart id={id} />);
|
||||
} else {
|
||||
render.push(
|
||||
<RequiresOnline>
|
||||
<Preloader />
|
||||
</RequiresOnline>
|
||||
);
|
||||
}
|
||||
|
||||
let head = true;
|
||||
function compare(
|
||||
current: string,
|
||||
curAuthor: string,
|
||||
previous: string,
|
||||
prevAuthor: string
|
||||
) {
|
||||
const atime = decodeTime(current),
|
||||
adate = new Date(atime),
|
||||
btime = decodeTime(previous),
|
||||
bdate = new Date(btime);
|
||||
|
||||
if (
|
||||
adate.getFullYear() !== bdate.getFullYear() ||
|
||||
adate.getMonth() !== bdate.getMonth() ||
|
||||
adate.getDate() !== bdate.getDate()
|
||||
) {
|
||||
render.push(<DateDivider date={adate} />);
|
||||
}
|
||||
|
||||
head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000;
|
||||
}
|
||||
|
||||
for (const message of state.messages) {
|
||||
if (previous) {
|
||||
compare(
|
||||
message._id,
|
||||
message.author,
|
||||
previous._id,
|
||||
previous.author
|
||||
);
|
||||
}
|
||||
|
||||
if (message.author === "00000000000000000000000000") {
|
||||
render.push(<SystemMessage key={message._id} message={message} attachContext />);
|
||||
} else {
|
||||
render.push(
|
||||
<Message message={message}
|
||||
key={message._id}
|
||||
head={head}
|
||||
attachContext />
|
||||
);
|
||||
/*render.push(
|
||||
<Message
|
||||
editing={editing === message._id ? stopEditing : undefined}
|
||||
user={users.find(x => x?._id === message.author)}
|
||||
message={message}
|
||||
key={message._id}
|
||||
head={head}
|
||||
/>
|
||||
);*/
|
||||
}
|
||||
|
||||
previous = message;
|
||||
}
|
||||
|
||||
const nonces = state.messages.map(x => x.nonce);
|
||||
if (state.atBottom) {
|
||||
for (const msg of queue) {
|
||||
if (msg.channel !== id) continue;
|
||||
if (nonces.includes(msg.id)) continue;
|
||||
|
||||
if (previous) {
|
||||
compare(
|
||||
msg.id,
|
||||
userId as string,
|
||||
previous._id,
|
||||
previous.author
|
||||
);
|
||||
|
||||
previous = {
|
||||
_id: msg.id,
|
||||
data: { author: userId as string }
|
||||
} as any;
|
||||
}
|
||||
|
||||
/*render.push(
|
||||
<Message
|
||||
user={users.find(x => x?._id === userId)}
|
||||
message={msg.data}
|
||||
queued={msg}
|
||||
key={msg.id}
|
||||
head={head}
|
||||
/>
|
||||
);*/
|
||||
render.push(
|
||||
<Message message={msg.data}
|
||||
key={msg.id}
|
||||
head={head}
|
||||
attachContext />
|
||||
);
|
||||
}
|
||||
|
||||
render.push(<div>end</div>);
|
||||
} else {
|
||||
render.push(
|
||||
<RequiresOnline>
|
||||
<Preloader />
|
||||
</RequiresOnline>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{ render }</>;
|
||||
}
|
||||
|
||||
export default connectState<Omit<Props, 'queue'>>(MessageRenderer, state => {
|
||||
return {
|
||||
queue: state.queue
|
||||
};
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useContext } from "preact/hooks";
|
||||
import { TextReact } from "../../lib/i18n";
|
||||
import Header from "../../components/ui/Header";
|
||||
import PaintCounter from "../../lib/PaintCounter";
|
||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||
@@ -19,6 +20,9 @@ export default function Developer() {
|
||||
<b>User ID:</b> {client.user!._id} <br/>
|
||||
<b>Permission against self:</b> {userPermission} <br/>
|
||||
</div>
|
||||
<div style={{ padding: "16px" }}>
|
||||
<TextReact id="login.open_mail_provider" fields={{ provider: <b>GAMING!</b> }} />
|
||||
</div>
|
||||
<div style={{ padding: "16px" }}>
|
||||
{/*<span>
|
||||
<b>Voice Status:</b> {VoiceStatus[voice.status]}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function ChannelSettings() {
|
||||
<GenericSettings
|
||||
pages={[
|
||||
{
|
||||
category: <Category variant="uniform" text={getChannelName(ctx.client, channel, [], true)} />,
|
||||
category: <Category variant="uniform" text={getChannelName(ctx.client, channel, true)} />,
|
||||
id: 'overview',
|
||||
icon: <List size={20} strokeWidth={2} />,
|
||||
title: <Text id="app.settings.channel_pages.overview.title" />
|
||||
|
||||
@@ -81,7 +81,7 @@ export function Overview({ channel }: Props) {
|
||||
if (!changed) setChanged(true)
|
||||
}}
|
||||
/>
|
||||
<Button onClick={save} style="contrast" disabled={!changed}>
|
||||
<Button onClick={save} contrast disabled={!changed}>
|
||||
<Text id="app.special.modals.actions.save" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function Overview({ server }: Props) {
|
||||
if (!changed) setChanged(true)
|
||||
}}
|
||||
/>
|
||||
<Button onClick={save} style="contrast" disabled={!changed}>
|
||||
<Button onClick={save} contrast disabled={!changed}>
|
||||
<Text id="app.special.modals.actions.save" />
|
||||
</Button>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user