Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
No results found
Show changes
Showing
with 493 additions and 440 deletions
import { IntlContext, translate } from "preact-i18n";
import { useContext } from "preact/hooks";
import { Dictionary } from "../context/Locale";
import { Children } from "../types/Preact";
interface Fields {
......@@ -14,9 +16,7 @@ interface Props {
export interface IntlType {
intl: {
dictionary: {
[key: string]: Object | string;
};
dictionary: Dictionary;
};
}
......@@ -36,10 +36,9 @@ function recursiveReplaceFields(input: string, fields: Fields) {
}
return values.flat();
} else {
// base case
return [input];
}
// base case
return [input];
}
export function TextReact({ id, fields }: Props) {
......@@ -47,8 +46,8 @@ export function TextReact({ id, fields }: Props) {
const path = id.split(".");
let entry = intl.dictionary[path.shift()!];
for (let key of path) {
// @ts-expect-error
for (const key of path) {
// @ts-expect-error TODO: lazy
entry = entry[key];
}
......@@ -57,6 +56,15 @@ export function TextReact({ id, fields }: Props) {
export function useTranslation() {
const { intl } = useContext(IntlContext) as unknown as IntlType;
return (id: string, fields?: Object, plural?: number, fallback?: string) =>
translate(id, "", intl.dictionary, fields, plural, fallback);
return (
id: string,
fields?: Record<string, string | undefined>,
plural?: number,
fallback?: string,
) => translate(id, "", intl.dictionary, fields, plural, fallback);
}
export function useDictionary() {
const { intl } = useContext(IntlContext) as unknown as IntlType;
return intl.dictionary;
}
import { isDesktop, isMobile, isTablet } from "react-device-detect";
export const isTouchscreenDevice =
isDesktop && !isTablet
isDesktop || isTablet
? false
: (typeof window !== "undefined"
? navigator.maxTouchPoints > 0
......
/* eslint-disable @typescript-eslint/no-empty-function */
export const noop = () => {};
export const noopAsync = async () => {};
/* eslint-enable @typescript-eslint/no-empty-function */
/* eslint-disable react-hooks/rules-of-hooks */
import EventEmitter3 from "eventemitter3";
import { Client, Message } from "revolt.js";
import { Client } from "revolt.js";
import { Message } from "revolt.js/dist/maps/Messages";
import { useEffect, useState } from "preact/hooks";
......@@ -72,11 +74,26 @@ export class SingletonRenderer extends EventEmitter3 {
this.stale = true;
}
async init(id: string) {
async init(id: string, message_id?: string) {
if (message_id) {
if (this.state.type === "RENDER") {
const message = this.state.messages.find(
(x) => x._id === message_id,
);
if (message) {
this.emit("scroll", {
type: "ScrollToView",
id: message_id,
});
return;
}
}
}
this.channel = id;
this.stale = false;
this.setStateUnguarded({ type: "LOADING" });
await this.currentRenderer.init(this, id);
await this.currentRenderer.init(this, id, message_id);
}
async reloadStale(id: string) {
......@@ -93,9 +110,9 @@ export class SingletonRenderer extends EventEmitter3 {
function generateScroll(end: string): ScrollState {
if (ref) {
let heightRemoved = 0;
let messageContainer = ref.children[0];
const messageContainer = ref.children[0];
if (messageContainer) {
for (let child of Array.from(messageContainer.children)) {
for (const child of Array.from(messageContainer.children)) {
// If this child has a ulid.
if (child.id?.length === 26) {
// Check whether it was removed.
......@@ -107,6 +124,7 @@ export class SingletonRenderer extends EventEmitter3 {
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
10,
);
}
}
......@@ -117,12 +135,11 @@ export class SingletonRenderer extends EventEmitter3 {
type: "OffsetTop",
previousHeight: ref.scrollHeight - heightRemoved,
};
} else {
return {
type: "OffsetTop",
previousHeight: 0,
};
}
return {
type: "OffsetTop",
previousHeight: 0,
};
}
await this.currentRenderer.loadTop(this, generateScroll);
......@@ -138,9 +155,9 @@ export class SingletonRenderer extends EventEmitter3 {
function generateScroll(start: string): ScrollState {
if (ref) {
let heightRemoved = 0;
let messageContainer = ref.children[0];
const messageContainer = ref.children[0];
if (messageContainer) {
for (let child of Array.from(messageContainer.children)) {
for (const child of Array.from(messageContainer.children)) {
// If this child has a ulid.
if (child.id?.length === 26) {
// Check whether it was removed.
......@@ -152,6 +169,7 @@ export class SingletonRenderer extends EventEmitter3 {
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
10,
);
}
}
......@@ -162,11 +180,10 @@ export class SingletonRenderer extends EventEmitter3 {
type: "ScrollTop",
y: ref.scrollTop - heightRemoved,
};
} else {
return {
type: "ScrollToBottom",
};
}
return {
type: "ScrollToBottom",
};
}
await this.currentRenderer.loadBottom(this, generateScroll);
......@@ -180,7 +197,7 @@ export class SingletonRenderer extends EventEmitter3 {
if (this.state.type === "RENDER" && this.state.atBottom) {
this.emit("scroll", { type: "ScrollToBottom", smooth });
} else {
await this.currentRenderer.init(this, id, true);
await this.currentRenderer.init(this, id, undefined, true);
}
}
}
......
import { mapMessage } from "../../../context/revoltjs/util";
import { noopAsync } from "../../js";
import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton";
import { RendererRoutines } from "../types";
export const SimpleRenderer: RendererRoutines = {
init: async (renderer, id, smooth) => {
init: async (renderer, id, nearby, 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 },
);
});
if (nearby)
renderer
.client!.channels.get(id)!
.fetchMessagesWithUsers({ nearby, limit: 100 })
.then(({ messages }) => {
messages.sort((a, b) => a._id.localeCompare(b._id));
renderer.setState(
id,
{
type: "RENDER",
messages,
atTop: false,
atBottom: false,
},
{ type: "ScrollToView", id: nearby },
);
});
else
renderer
.client!.channels.get(id)!
.fetchMessagesWithUsers({})
.then(({ messages }) => {
messages.reverse();
renderer.setState(
id,
{
type: "RENDER",
messages,
atTop: messages.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 (message.channel_id !== 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 messages = [...renderer.state.messages, message];
let atTop = renderer.state.atTop;
if (messages.length > 150) {
messages = messages.slice(messages.length - 150);
......@@ -40,7 +57,7 @@ export const SimpleRenderer: RendererRoutines = {
}
renderer.setState(
message.channel,
message.channel_id,
{
...renderer.state,
messages,
......@@ -49,35 +66,14 @@ export const SimpleRenderer: RendererRoutines = {
{ 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" },
);
}
},
edit: noopAsync,
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);
const messages = [...renderer.state.messages];
const index = messages.findIndex((x) => x._id === id);
if (index > -1) {
messages.splice(index, 1);
......@@ -100,14 +96,11 @@ export const SimpleRenderer: RendererRoutines = {
if (state.type !== "RENDER") return;
if (state.atTop) return;
const { messages: data } =
await renderer.client!.channels.fetchMessagesWithUsers(
channel,
{
before: state.messages[0]._id,
},
true,
);
const { messages: data } = await renderer
.client!.channels.get(channel)!
.fetchMessagesWithUsers({
before: state.messages[0]._id,
});
if (data.length === 0) {
return renderer.setState(channel, {
......@@ -117,7 +110,7 @@ export const SimpleRenderer: RendererRoutines = {
}
data.reverse();
let messages = [...data.map((x) => mapMessage(x)), ...state.messages];
let messages = [...data, ...state.messages];
let atTop = false;
if (data.length < 50) {
......@@ -144,15 +137,12 @@ export const SimpleRenderer: RendererRoutines = {
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,
);
const { messages: data } = await renderer
.client!.channels.get(channel)!
.fetchMessagesWithUsers({
after: state.messages[state.messages.length - 1]._id,
sort: "Oldest",
});
if (data.length === 0) {
return renderer.setState(channel, {
......@@ -161,7 +151,7 @@ export const SimpleRenderer: RendererRoutines = {
});
}
let messages = [...state.messages, ...data.map((x) => mapMessage(x))];
let messages = [...state.messages, ...data];
let atBottom = false;
if (data.length < 50) {
......
import { Message } from "revolt.js";
import { MessageObject } from "../../context/revoltjs/util";
import { Message } from "revolt.js/dist/maps/Messages";
import { SingletonRenderer } from "./Singleton";
......@@ -8,6 +6,7 @@ export type ScrollState =
| { type: "Free" }
| { type: "Bottom"; scrollingUntil?: number }
| { type: "ScrollToBottom" | "StayAtBottom"; smooth?: boolean }
| { type: "ScrollToView"; id: string }
| { type: "OffsetTop"; previousHeight: number }
| { type: "ScrollTop"; y: number };
......@@ -19,13 +18,14 @@ export type RenderState =
type: "RENDER";
atTop: boolean;
atBottom: boolean;
messages: MessageObject[];
messages: Message[];
};
export interface RendererRoutines {
init: (
renderer: SingletonRenderer,
id: string,
message?: string,
smooth?: boolean,
) => Promise<void>;
......
export const stopPropagation = (
ev: JSX.TargetedMouseEvent<HTMLDivElement>,
_consume?: any,
ev: JSX.TargetedMouseEvent<HTMLElement>,
// eslint-disable-next-line
_consume?: unknown,
) => {
ev.preventDefault();
ev.stopPropagation();
......
......@@ -20,6 +20,7 @@ interface SignalingEvents {
open: (event: Event) => void;
close: (event: CloseEvent) => void;
error: (event: Event) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: (data: any) => void;
}
......@@ -87,6 +88,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
entry(json);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
sendRequest(type: string, data?: any): Promise<any> {
if (this.ws === undefined || this.ws.readyState !== WebSocket.OPEN)
return Promise.reject({ error: WSErrorCode.NotConnected });
......@@ -117,13 +119,14 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
this.once("close", onClose);
const json = {
id: this.index,
type: type,
type,
data,
};
ws.send(JSON.stringify(json) + "\n");
ws.send(`${JSON.stringify(json)}\n`);
this.index++;
});
}
/* eslint-enable @typescript-eslint/no-explicit-any */
authenticate(token: string, roomId: string): Promise<AuthenticationResult> {
return this.sendRequest(WSCommandType.Authenticate, { token, roomId });
......@@ -161,7 +164,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
type: ProduceType,
rtpParameters: RtpParameters,
): Promise<string> {
let result = await this.sendRequest(WSCommandType.StartProduce, {
const result = await this.sendRequest(WSCommandType.StartProduce, {
type,
rtpParameters,
});
......
import EventEmitter from "eventemitter3";
import * as mediasoupClient from "mediasoup-client";
import { types } from "mediasoup-client";
import {
Device,
Producer,
Transport,
UnsupportedError,
} from "mediasoup-client/lib/types";
import { Device, Producer, Transport } from "mediasoup-client/lib/types";
import Signaling from "./Signaling";
import {
......@@ -18,6 +14,8 @@ import {
WSErrorCode,
} from "./Types";
const UnsupportedError = types.UnsupportedError;
interface VoiceEvents {
ready: () => void;
error: (error: Error) => void;
......@@ -116,7 +114,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
this.signaling.on(
"error",
(error) => {
() => {
this.emit("error", new Error("Signaling error"));
},
this,
......@@ -174,7 +172,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
if (this.device === undefined || this.roomId === undefined)
throw new ReferenceError("Voice Client is in an invalid state");
const result = await this.signaling.authenticate(token, this.roomId);
let [room] = await Promise.all([
const [room] = await Promise.all([
this.signaling.roomInfo(),
this.device.load({ routerRtpCapabilities: result.rtpCapabilities }),
]);
......@@ -231,7 +229,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
});
this.emit("ready");
for (let user of this.participants) {
for (const user of this.participants) {
if (user[1].audio && user[0] !== this.userId)
this.startConsume(user[0], "audio");
}
......@@ -325,7 +323,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
await this.signaling.stopProduce(type);
} catch (error) {
if (error.error === WSErrorCode.ProducerNotFound) return;
else throw error;
throw error;
}
}
}
/* eslint-disable react-hooks/rules-of-hooks */
import { useHistory, useParams } from "react-router-dom";
import { Text } from "preact-i18n";
......@@ -9,11 +10,6 @@ import {
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import {
useChannels,
useForceUpdate,
useUser,
} from "../context/revoltjs/hooks";
import Header from "../components/ui/Header";
......@@ -32,39 +28,39 @@ export default function Open() {
);
}
const ctx = useForceUpdate();
const channels = useChannels(undefined, ctx);
const user = useUser(id, ctx);
useEffect(() => {
if (id === "saved") {
for (const channel of channels) {
for (const channel of [...client.channels.values()]) {
if (channel?.channel_type === "SavedMessages") {
history.push(`/channel/${channel._id}`);
return;
}
}
client.users
.openDM(client.user?._id as string)
client
.user!.openDM()
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
return;
}
const user = client.users.get(id);
if (user) {
const channel: string | undefined = channels.find(
const channel: string | undefined = [
...client.channels.values(),
].find(
(channel) =>
channel?.channel_type === "DirectMessage" &&
channel.recipients.includes(id),
channel.recipient_ids!.includes(id),
)?._id;
if (channel) {
history.push(`/channel/${channel}`);
} else {
client.users
.openDM(id)
.get(id)
?.openDM()
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
}
......@@ -73,7 +69,7 @@ export default function Open() {
}
history.push("/");
}, []);
});
return (
<Header placement="primary">
......
......@@ -10,6 +10,7 @@ import Notifications from "../context/revoltjs/Notifications";
import StateMonitor from "../context/revoltjs/StateMonitor";
import SyncManager from "../context/revoltjs/SyncManager";
import { Titlebar } from "../components/native/Titlebar";
import BottomNavigation from "../components/navigation/BottomNavigation";
import LeftSidebar from "../components/navigation/LeftSidebar";
import RightSidebar from "../components/navigation/RightSidebar";
......@@ -35,86 +36,99 @@ export default function App() {
const path = useLocation().pathname;
const fixedBottomNav =
path === "/" || path === "/settings" || path.startsWith("/friends");
const inSettings = path.includes("/settings");
const inChannel = path.includes("/channel");
const inSpecial =
(path.startsWith("/friends") && isTouchscreenDevice) ||
path.startsWith("/invite") ||
path.startsWith("/settings");
path.includes("/settings");
return (
<OverlappingPanels
width="100vw"
height="var(--app-height)"
leftPanel={
inSpecial
? undefined
: { width: 292, component: <LeftSidebar /> }
}
rightPanel={
!inSettings && inChannel
? { width: 240, component: <RightSidebar /> }
: undefined
}
bottomNav={{
component: <BottomNavigation />,
showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left,
height: 50,
}}
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
<Routes>
<Switch>
<Route
path="/server/:server/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/server/:server/channel/:channel/settings"
component={ChannelSettings}
/>
<Route
path="/server/:server/settings/:page"
component={ServerSettings}
/>
<Route
path="/server/:server/settings"
component={ServerSettings}
/>
<Route
path="/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/channel/:channel/settings"
component={ChannelSettings}
/>
<>
{window.isNative && !window.native.getConfig().frame && (
<Titlebar />
)}
<OverlappingPanels
width="100vw"
height={
window.isNative && !window.native.getConfig().frame
? "calc(var(--app-height) - var(--titlebar-height))"
: "var(--app-height)"
}
leftPanel={
inSpecial
? undefined
: { width: 292, component: <LeftSidebar /> }
}
rightPanel={
!inSpecial && inChannel
? { width: 240, component: <RightSidebar /> }
: undefined
}
bottomNav={{
component: <BottomNavigation />,
showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left,
height: 50,
}}
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
<Routes>
<Switch>
<Route
path="/server/:server/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/server/:server/channel/:channel/settings"
component={ChannelSettings}
/>
<Route
path="/server/:server/settings/:page"
component={ServerSettings}
/>
<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="/channel/:channel/:message"
component={Channel}
/>
<Route
path="/server/:server/channel/:channel/:message"
component={Channel}
/>
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
<Route
path="/server/:server/channel/:channel"
component={Channel}
/>
<Route path="/server/:server" />
<Route path="/channel/:channel" component={Channel} />
<Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} />
<Route path="/open/:id" component={Open} />
<Route path="/invite/:code" component={Invite} />
<Route path="/" component={Home} />
</Switch>
</Routes>
<ContextMenus />
<Popovers />
<Notifications />
<StateMonitor />
<SyncManager />
</OverlappingPanels>
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
<Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} />
<Route path="/open/:id" component={Open} />
<Route path="/invite/:code" component={Invite} />
<Route path="/" component={Home} />
</Switch>
</Routes>
<ContextMenus />
<Popovers />
<Notifications />
<StateMonitor />
<SyncManager />
</OverlappingPanels>
</>
);
}
......@@ -16,7 +16,7 @@ export function App() {
<Context>
<Masks />
{/*
// @ts-expect-error */}
// @ts-expect-error typings mis-match between preact... and preact? */}
<Suspense fallback={<Preloader type="spinner" />}>
<Switch>
<Route path="/login">
......
import { useParams, useHistory } from "react-router-dom";
import { Channels } from "revolt.js/dist/api/objects";
import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom";
import { Channel as ChannelI } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
import { useState } from "preact/hooks";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks";
import { dispatch, getState } from "../../redux";
import { useClient } from "../../context/revoltjs/RevoltClient";
import AgeGate from "../../components/common/AgeGate";
import MessageBox from "../../components/common/messaging/MessageBox";
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator";
import Button from "../../components/ui/Button";
import Checkbox from "../../components/ui/Checkbox";
import MemberSidebar from "../../components/navigation/right/MemberSidebar";
import ChannelHeader from "./ChannelHeader";
......@@ -34,99 +36,60 @@ const ChannelContent = styled.div`
flex-direction: column;
`;
const AgeGate = styled.div`
display: flex;
flex-grow: 1;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
padding: 12px;
img {
height: 150px;
}
.subtext {
color: var(--secondary-foreground);
margin-bottom: 12px;
font-size: 14px;
}
.actions {
margin-top: 20px;
display: flex;
gap: 12px;
}
`;
export function Channel({ id }: { id: string }) {
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
const client = useClient();
const channel = client.channels.get(id);
if (!channel) return null;
if (channel.channel_type === "VoiceChannel") {
return <VoiceChannel channel={channel} />;
} else {
return <TextChannel channel={channel} />;
}
return <TextChannel channel={channel} />;
}
function TextChannel({ channel }: { channel: Channels.Channel }) {
const [showMembers, setMembers] = useState(true);
if (
(channel.channel_type === "TextChannel" ||
channel.channel_type === "Group") &&
channel.name.includes("nsfw")
) {
const goBack = useHistory();
const [consent, setConsent] = useState(false);
const [ageGate, setAgeGate] = useState(false);
if (!ageGate) {
return (
<AgeGate>
<img
src={"https://static.revolt.chat/emoji/mutant/26a0.svg"}
draggable={false}
/>
<h2>{channel.name}</h2>
<span className="subtext">
This channel is marked as NSFW.{" "}
<a href="#">Learn more</a>
</span>
<Checkbox checked={consent} onChange={(v) => setConsent(v)}>
I confirm that I am at least 18 years old.
</Checkbox>
<div className="actions">
<Button contrast onClick={() => goBack}>
Go back
</Button>
<Button
contrast
onClick={() => consent && setAgeGate(true)}>
Enter Channel
</Button>
</div>
</AgeGate>
);
}
}
const MEMBERS_SIDEBAR_KEY = "sidebar_members";
const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
const [showMembers, setMembers] = useState(
getState().sectionToggle[MEMBERS_SIDEBAR_KEY] ?? true,
);
let id = channel._id;
const id = channel._id;
return (
<>
<AgeGate
type="channel"
channel={channel}
gated={
!!(
(channel.channel_type === "TextChannel" ||
channel.channel_type === "Group") &&
channel.name?.includes("nsfw")
)
}>
<ChannelHeader
channel={channel}
toggleSidebar={() => setMembers(!showMembers)}
toggleSidebar={() => {
setMembers(!showMembers);
if (showMembers) {
dispatch({
type: "SECTION_TOGGLE_SET",
id: MEMBERS_SIDEBAR_KEY,
state: false,
});
} else {
dispatch({
type: "SECTION_TOGGLE_UNSET",
id: MEMBERS_SIDEBAR_KEY,
});
}
}}
/>
<ChannelMain>
<ChannelContent>
<VoiceHeader id={id} />
<MessageArea id={id} />
<TypingIndicator id={id} />
<TypingIndicator channel={channel} />
<JumpToBottom id={id} />
<MessageBox channel={channel} />
</ChannelContent>
......@@ -134,11 +97,11 @@ function TextChannel({ channel }: { channel: Channels.Channel }) {
<MemberSidebar channel={channel} />
)}
</ChannelMain>
</>
</AgeGate>
);
}
});
function VoiceChannel({ channel }: { channel: Channels.Channel }) {
function VoiceChannel({ channel }: { channel: ChannelI }) {
return (
<>
<ChannelHeader channel={channel} />
......@@ -147,7 +110,7 @@ function VoiceChannel({ channel }: { channel: Channels.Channel }) {
);
}
export default function () {
export default function ChannelComponent() {
const { channel } = useParams<{ channel: string }>();
return <Channel id={channel} key={channel} />;
}
import { At, Hash } from "@styled-icons/boxicons-regular";
import { At, Hash, Menu } from "@styled-icons/boxicons-regular";
import { Notepad, Group } from "@styled-icons/boxicons-solid";
import { Channel, User } from "revolt.js";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import { User } from "revolt.js/dist/maps/Users";
import styled from "styled-components";
import { useContext } from "preact/hooks";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../context/revoltjs/util";
import { useStatusColour } from "../../components/common/user/UserIcon";
......@@ -58,26 +57,25 @@ const Info = styled.div`
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
> * {
pointer-events: none;
}
}
`;
export default function ChannelHeader({
channel,
toggleSidebar,
}: ChannelHeaderProps) {
export default observer(({ channel, toggleSidebar }: ChannelHeaderProps) => {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const name = getChannelName(client, channel);
let icon, recipient;
const name = getChannelName(channel);
let icon, recipient: User | undefined;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Notepad size={24} />;
break;
case "DirectMessage":
icon = <At size={24} />;
const uid = client.channels.getRecipient(channel._id);
recipient = client.users.get(uid);
recipient = channel.recipient;
break;
case "Group":
icon = <Group size={24} />;
......@@ -89,6 +87,11 @@ export default function ChannelHeader({
return (
<Header placement="primary">
{isTouchscreenDevice && (
<div className="menu">
<Menu size={27} />
</div>
)}
{icon}
<Info>
<span className="name">{name}</span>
......@@ -100,12 +103,11 @@ export default function ChannelHeader({
<div
className="status"
style={{
backgroundColor: useStatusColour(
recipient as User,
),
backgroundColor:
useStatusColour(recipient),
}}
/>
<UserStatus user={recipient as User} />
<UserStatus user={recipient} />
</span>
</>
)}
......@@ -120,7 +122,7 @@ export default function ChannelHeader({
onClick={() =>
openScreen({
id: "channel_info",
channel_id: channel._id,
channel,
})
}>
<Markdown
......@@ -136,4 +138,4 @@ export default function ChannelHeader({
<HeaderActions channel={channel} toggleSidebar={toggleSidebar} />
</Header>
);
}
});
import { Sidebar as SidebarIcon } from "@styled-icons/boxicons-regular";
/* eslint-disable react-hooks/rules-of-hooks */
import {
UserPlus,
Cog,
PhoneCall,
PhoneOutgoing,
PhoneOff,
Group,
} from "@styled-icons/boxicons-solid";
import { useHistory } from "react-router-dom";
import { useContext } from "preact/hooks";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import {
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import UpdateIndicator from "../../../components/common/UpdateIndicator";
import IconButton from "../../../components/ui/IconButton";
......@@ -29,25 +27,21 @@ export default function HeaderActions({
toggleSidebar,
}: ChannelHeaderProps) {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const history = useHistory();
return (
<>
<UpdateIndicator />
<UpdateIndicator style="channel" />
{channel.channel_type === "Group" && (
<>
<IconButton
onClick={() =>
openScreen({
id: "user_picker",
omit: channel.recipients,
omit: channel.recipient_ids!,
callback: async (users) => {
for (const user of users) {
await client.channels.addMember(
channel._id,
user,
);
await channel.addMember(user);
}
},
})
......@@ -64,12 +58,11 @@ export default function HeaderActions({
)}
<VoiceActions channel={channel} />
{(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
!isTouchscreenDevice && (
<IconButton onClick={toggleSidebar}>
<SidebarIcon size={22} />
</IconButton>
)}
channel.channel_type === "TextChannel") && (
<IconButton onClick={toggleSidebar}>
<Group size={25} />
</IconButton>
)}
</>
);
}
......@@ -88,25 +81,23 @@ function VoiceActions({ channel }: Pick<ChannelHeaderProps, "channel">) {
if (voice.roomId === channel._id) {
return (
<IconButton onClick={disconnect}>
<PhoneOutgoing size={22} />
</IconButton>
);
} else {
return (
<IconButton
onClick={() => {
disconnect();
connect(channel._id);
}}>
<PhoneCall size={24} />
<PhoneOff size={22} />
</IconButton>
);
}
} else {
return (
<IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
<IconButton
onClick={() => {
disconnect();
connect(channel);
}}>
<PhoneCall size={24} />
</IconButton>
);
}
return (
<IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
</IconButton>
);
}
import { observer } from "mobx-react-lite";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../../context/revoltjs/util";
const StartBase = styled.div`
......@@ -24,17 +25,17 @@ interface Props {
id: string;
}
export default function ConversationStart({ id }: Props) {
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
export default observer(({ id }: Props) => {
const client = useClient();
const channel = client.channels.get(id);
if (!channel) return null;
return (
<StartBase>
<h1>{getChannelName(ctx.client, channel, true)}</h1>
<h1>{getChannelName(channel, true)}</h1>
<h4>
<Text id="app.main.channel.start.group" />
</h4>
</StartBase>
);
}
});
import { useHistory, useParams } from "react-router-dom";
import { animateScroll } from "react-scroll";
import styled from "styled-components";
import useResizeObserver from "use-resize-observer";
import { createContext } from "preact";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
......@@ -12,13 +14,14 @@ import {
} from "preact/hooks";
import { defer } from "../../../lib/defer";
import { internalEmit } from "../../../lib/eventEmitter";
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton";
import { RenderState, ScrollState } from "../../../lib/renderer/types";
import { IntermediateContext } from "../../../context/intermediate/Intermediate";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
......@@ -53,9 +56,15 @@ export const MessageAreaWidthContext = createContext(0);
export const MESSAGE_AREA_PADDING = 82;
export function MessageArea({ id }: Props) {
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const { focusTaken } = useContext(IntermediateContext);
// ? Required data for message links.
const { message } = useParams<{ message: string }>();
const [highlight, setHighlight] = useState<string | undefined>(undefined);
// ? This is the scroll container.
const ref = useRef<HTMLDivElement>(null);
const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
......@@ -66,7 +75,7 @@ export function MessageArea({ id }: Props) {
// ? useRef to avoid re-renders
const scrollState = useRef<ScrollState>({ type: "Free" });
const setScrollState = (v: ScrollState) => {
const setScrollState = useCallback((v: ScrollState) => {
if (v.type === "StayAtBottom") {
if (scrollState.current.type === "Bottom" || atBottom()) {
scrollState.current = {
......@@ -91,13 +100,21 @@ export function MessageArea({ id }: Props) {
container: ref.current,
duration: scrollState.current.smooth ? 150 : 0,
});
} else if (scrollState.current.type === "ScrollToView") {
document
.getElementById(scrollState.current.id)
?.scrollIntoView({ block: "center" });
setScrollState({ type: "Free" });
} else if (scrollState.current.type === "OffsetTop") {
animateScroll.scrollTo(
Math.max(
101,
ref.current.scrollTop +
(ref.current.scrollHeight -
scrollState.current.previousHeight),
ref.current
? ref.current.scrollTop +
(ref.current.scrollHeight -
scrollState.current.previousHeight)
: 101,
),
{
container: ref.current,
......@@ -115,19 +132,27 @@ export function MessageArea({ id }: Props) {
setScrollState({ type: "Free" });
}
});
};
}, []);
// ? 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) -
? Math.floor(ref.current?.scrollHeight - ref.current?.scrollTop) -
offset <=
ref.current.clientHeight
ref.current?.clientHeight
: true;
const atTop = (offset = 0) => ref.current.scrollTop <= offset;
const atTop = (offset = 0) =>
ref.current ? ref.current.scrollTop <= offset : false;
// ? Handle global jump to bottom, e.g. when editing last message in chat.
useEffect(() => {
return internalSubscribe("MessageArea", "jump_to_bottom", () =>
setScrollState({ type: "ScrollToBottom" }),
);
}, [setScrollState]);
// ? Handle events from renderer.
useEffect(() => {
......@@ -139,13 +164,31 @@ export function MessageArea({ id }: Props) {
SingletonMessageRenderer.addListener("scroll", setScrollState);
return () =>
SingletonMessageRenderer.removeListener("scroll", setScrollState);
}, [scrollState]);
}, [scrollState, setScrollState]);
// ? Load channel initially.
useEffect(() => {
if (message) return;
SingletonMessageRenderer.init(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
// ? If message present or changes, load it as well.
useEffect(() => {
if (message) {
setHighlight(message);
SingletonMessageRenderer.init(id, message);
const channel = client.channels.get(id);
if (channel?.channel_type === "TextChannel") {
history.push(`/server/${channel.server_id}/channel/${id}`);
} else {
history.push(`/channel/${id}`);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message]);
// ? If we are waiting for network, try again.
useEffect(() => {
switch (status) {
......@@ -163,11 +206,14 @@ export function MessageArea({ id }: Props) {
SingletonMessageRenderer.markStale();
break;
}
}, [status, state]);
}, [id, status, state]);
// ? When the container is scrolled.
// ? Also handle StayAtBottom
useEffect(() => {
const current = ref.current;
if (!current) return;
async function onScroll() {
if (scrollState.current.type === "Free" && atBottom()) {
setScrollState({ type: "Bottom" });
......@@ -181,28 +227,31 @@ export function MessageArea({ id }: Props) {
}
}
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
}, [ref, scrollState]);
current.addEventListener("scroll", onScroll);
return () => current.removeEventListener("scroll", onScroll);
}, [ref, scrollState, setScrollState]);
// ? Top and bottom loaders.
useEffect(() => {
const current = ref.current;
if (!current) return;
async function onScroll() {
if (atTop(100)) {
SingletonMessageRenderer.loadTop(ref.current);
SingletonMessageRenderer.loadTop(ref.current!);
}
if (atBottom(100)) {
SingletonMessageRenderer.loadBottom(ref.current);
SingletonMessageRenderer.loadBottom(ref.current!);
}
}
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
current.addEventListener("scroll", onScroll);
return () => current.removeEventListener("scroll", onScroll);
}, [ref]);
// ? Scroll down whenever the message area resizes.
function stbOnResize() {
const stbOnResize = useCallback(() => {
if (!atBottom() && scrollState.current.type === "Bottom") {
animateScroll.scrollToBottom({
container: ref.current,
......@@ -211,18 +260,18 @@ export function MessageArea({ id }: Props) {
setScrollState({ type: "Bottom" });
}
}
}, [setScrollState]);
// ? Scroll down when container resized.
useLayoutEffect(() => {
stbOnResize();
}, [height]);
}, [stbOnResize, height]);
// ? Scroll down whenever the window resizes.
useLayoutEffect(() => {
document.addEventListener("resize", stbOnResize);
return () => document.removeEventListener("resize", stbOnResize);
}, [ref, scrollState]);
}, [ref, scrollState, stbOnResize]);
// ? Scroll to bottom when pressing 'Escape'.
useEffect(() => {
......@@ -235,7 +284,7 @@ export function MessageArea({ id }: Props) {
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [ref, focusTaken]);
}, [id, ref, focusTaken]);
return (
<MessageAreaWidthContext.Provider
......@@ -249,7 +298,11 @@ export function MessageArea({ id }: Props) {
</RequiresOnline>
)}
{state.type === "RENDER" && (
<MessageRenderer id={id} state={state} />
<MessageRenderer
id={id}
state={state}
highlight={highlight}
/>
)}
{state.type === "EMPTY" && <ConversationStart id={id} />}
</div>
......
import { Message } from "revolt.js/dist/maps/Messages";
import styled from "styled-components";
import { useContext, useEffect, useState } from "preact/hooks";
......@@ -9,8 +10,6 @@ import {
IntermediateContext,
useIntermediate,
} from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { MessageObject } from "../../../context/revoltjs/util";
import AutoComplete, {
useAutoComplete,
......@@ -23,9 +22,9 @@ const EditorBase = styled.div`
textarea {
resize: none;
padding: 12px;
font-size: 0.875rem;
border-radius: 3px;
white-space: pre-wrap;
font-size: var(--text-size);
border-radius: var(--border-radius);
background: var(--secondary-header);
}
......@@ -44,7 +43,7 @@ const EditorBase = styled.div`
`;
interface Props {
message: MessageObject;
message: Message;
finish: () => void;
}
......@@ -52,7 +51,6 @@ export default function MessageEditor({ message, finish }: Props) {
const [content, setContent] = useState((message.content as string) ?? "");
const { focusTaken } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
async function save() {
finish();
......@@ -60,13 +58,11 @@ export default function MessageEditor({ message, finish }: Props) {
if (content.length === 0) {
openScreen({
id: "special_prompt",
// @ts-expect-error
type: "delete_message",
// @ts-expect-error
target: message,
});
} else if (content !== message.content) {
await client.channels.editMessage(message.channel, message._id, {
await message.edit({
content,
});
}
......@@ -82,7 +78,7 @@ export default function MessageEditor({ message, finish }: Props) {
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [focusTaken]);
}, [focusTaken, finish]);
const {
onChange,
......@@ -101,9 +97,9 @@ export default function MessageEditor({ message, finish }: Props) {
<TextAreaAutoSize
forceFocus
maxRows={3}
padding={12}
value={content}
maxLength={2000}
padding="var(--message-box-padding)"
onChange={(ev) => {
onChange(ev);
setContent(ev.currentTarget.value);
......
/* eslint-disable react-hooks/rules-of-hooks */
import { X } from "@styled-icons/boxicons-regular";
import { Users } from "revolt.js/dist/api/objects";
import { RelationshipStatus } from "revolt-api/types/Users";
import { SYSTEM_USER_ID } from "revolt.js";
import { Message as MessageI } from "revolt.js/dist/maps/Messages";
import styled from "styled-components";
import { decodeTime } from "ulid";
import { Text } from "preact-i18n";
import { memo } from "preact/compat";
import { useContext, useEffect, useState } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter";
import { RenderState } from "../../../lib/renderer/types";
......@@ -13,8 +17,7 @@ import { connectState } from "../../../redux/connector";
import { QueuedMessage } from "../../../redux/reducers/queue";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { MessageObject } from "../../../context/revoltjs/util";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import Message from "../../../components/common/messaging/Message";
import { SystemMessage } from "../../../components/common/messaging/SystemMessage";
......@@ -28,6 +31,7 @@ import MessageEditor from "./MessageEditor";
interface Props {
id: string;
state: RenderState;
highlight?: string;
queue: QueuedMessage[];
}
......@@ -42,10 +46,10 @@ const BlockedMessage = styled.div`
}
`;
function MessageRenderer({ id, state, queue }: Props) {
function MessageRenderer({ id, state, queue, highlight }: Props) {
if (state.type !== "RENDER") return null;
const client = useContext(AppContext);
const client = useClient();
const userId = client.user!._id;
const [editing, setEditing] = useState<string | undefined>(undefined);
......@@ -58,8 +62,9 @@ function MessageRenderer({ id, state, queue }: Props) {
function editLast() {
if (state.type !== "RENDER") return;
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].author === userId) {
if (state.messages[i].author_id === userId) {
setEditing(state.messages[i]._id);
internalEmit("MessageArea", "jump_to_bottom");
return;
}
}
......@@ -71,10 +76,10 @@ function MessageRenderer({ id, state, queue }: Props) {
];
return () => subs.forEach((unsub) => unsub());
}, [state.messages]);
}, [state.messages, state.type, userId]);
let render: Children[] = [],
previous: MessageObject | undefined;
const render: Children[] = [];
let previous: MessageI | undefined;
if (state.atTop) {
render.push(<ConversationStart id={id} />);
......@@ -114,7 +119,11 @@ function MessageRenderer({ id, state, queue }: Props) {
function pushBlocked() {
render.push(
<BlockedMessage>
<X size={16} /> {blocked} blocked messages
<X size={16} />{" "}
<Text
id="app.main.channel.misc.blocked_messages"
fields={{ count: blocked }}
/>
</BlockedMessage>,
);
blocked = 0;
......@@ -122,44 +131,47 @@ function MessageRenderer({ id, state, queue }: Props) {
for (const message of state.messages) {
if (previous) {
compare(message._id, message.author, previous._id, previous.author);
compare(
message._id,
message.author_id,
previous._id,
previous.author_id,
);
}
if (message.author === "00000000000000000000000000") {
if (message.author_id === SYSTEM_USER_ID) {
render.push(
<SystemMessage
key={message._id}
message={message}
attachContext
highlight={highlight === message._id}
/>,
);
} else if (
message.author?.relationship === RelationshipStatus.Blocked
) {
blocked++;
} else {
// ! FIXME: temp solution
if (
client.users.get(message.author)?.relationship ===
Users.Relationship.Blocked
) {
blocked++;
} else {
if (blocked > 0) pushBlocked();
render.push(
<Message
message={message}
key={message._id}
head={head}
content={
editing === message._id ? (
<MessageEditor
message={message}
finish={stopEditing}
/>
) : undefined
}
attachContext
/>,
);
}
if (blocked > 0) pushBlocked();
render.push(
<Message
message={message}
key={message._id}
head={head}
content={
editing === message._id ? (
<MessageEditor
message={message}
finish={stopEditing}
/>
) : undefined
}
attachContext
highlight={highlight === message._id}
/>,
);
}
previous = message;
......@@ -174,20 +186,22 @@ function MessageRenderer({ id, state, queue }: Props) {
if (nonces.includes(msg.id)) continue;
if (previous) {
compare(msg.id, userId!, previous._id, previous.author);
compare(msg.id, userId!, previous._id, previous.author_id);
previous = {
_id: msg.id,
data: { author: userId! },
} as any;
author_id: userId!,
} as MessageI;
}
render.push(
<Message
message={{
...msg.data,
replies: msg.data.replies.map((x) => x.id),
}}
message={
new MessageI(client, {
...msg.data,
replies: msg.data.replies.map((x) => x.id),
})
}
key={msg.id}
queued={msg}
head={head}
......
import { BarChart } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import styled from "styled-components";
import { Text } from "preact-i18n";
......@@ -9,11 +10,7 @@ import {
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import {
useForceUpdate,
useSelf,
useUsers,
} from "../../../context/revoltjs/hooks";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import UserIcon from "../../../components/common/user/UserIcon";
import Button from "../../../components/ui/Button";
......@@ -27,18 +24,20 @@ const VoiceBase = styled.div`
background: var(--secondary-background);
.status {
position: absolute;
color: var(--success);
background: var(--primary-background);
flex: 1 0;
display: flex;
position: absolute;
align-items: center;
padding: 10px;
font-size: 14px;
font-weight: 600;
border-radius: 7px;
flex: 1 0;
user-select: none;
color: var(--success);
border-radius: var(--border-radius);
background: var(--primary-background);
svg {
margin-inline-end: 4px;
cursor: help;
......@@ -68,17 +67,20 @@ const VoiceBase = styled.div`
}
`;
export default function VoiceHeader({ id }: Props) {
export default observer(({ id }: Props) => {
const { status, participants, roomId } = useContext(VoiceContext);
if (roomId !== id) return null;
const { isProducing, startProducing, stopProducing, disconnect } =
useContext(VoiceOperationsContext);
const ctx = useForceUpdate();
const self = useSelf(ctx);
const client = useClient();
const self = client.users.get(client.user!._id);
//const ctx = useForceUpdate();
//const self = useSelf(ctx);
const keys = participants ? Array.from(participants.keys()) : undefined;
const users = keys ? useUsers(keys, ctx) : undefined;
const users = keys?.map((key) => client.users.get(key));
return (
<VoiceBase>
......@@ -133,7 +135,7 @@ export default function VoiceHeader({ id }: Props) {
</div>
</VoiceBase>
);
}
});
/**{voice.roomId === id && (
<div className={styles.rtc}>
......