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 1059 additions and 670 deletions
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { import {
RtpCapabilities, RtpCapabilities,
RtpParameters RtpParameters,
} from "mediasoup-client/lib/RtpParameters"; } from "mediasoup-client/lib/RtpParameters";
import { DtlsParameters } from "mediasoup-client/lib/Transport"; import { DtlsParameters } from "mediasoup-client/lib/Transport";
...@@ -12,13 +13,14 @@ import { ...@@ -12,13 +13,14 @@ import {
WSCommandType, WSCommandType,
WSErrorCode, WSErrorCode,
ProduceType, ProduceType,
ConsumerData ConsumerData,
} from "./Types"; } from "./Types";
interface SignalingEvents { interface SignalingEvents {
open: (event: Event) => void; open: (event: Event) => void;
close: (event: CloseEvent) => void; close: (event: CloseEvent) => void;
error: (event: Event) => void; error: (event: Event) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: (data: any) => void; data: (data: any) => void;
} }
...@@ -44,10 +46,10 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -44,10 +46,10 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
connect(address: string): Promise<void> { connect(address: string): Promise<void> {
this.disconnect(); this.disconnect();
this.ws = new WebSocket(address); this.ws = new WebSocket(address);
this.ws.onopen = e => this.emit("open", e); this.ws.onopen = (e) => this.emit("open", e);
this.ws.onclose = e => this.emit("close", e); this.ws.onclose = (e) => this.emit("close", e);
this.ws.onerror = e => this.emit("error", e); this.ws.onerror = (e) => this.emit("error", e);
this.ws.onmessage = e => this.parseData(e); this.ws.onmessage = (e) => this.parseData(e);
let finished = false; let finished = false;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
...@@ -86,6 +88,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -86,6 +88,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
entry(json); entry(json);
} }
/* eslint-disable @typescript-eslint/no-explicit-any */
sendRequest(type: string, data?: any): Promise<any> { sendRequest(type: string, data?: any): Promise<any> {
if (this.ws === undefined || this.ws.readyState !== WebSocket.OPEN) if (this.ws === undefined || this.ws.readyState !== WebSocket.OPEN)
return Promise.reject({ error: WSErrorCode.NotConnected }); return Promise.reject({ error: WSErrorCode.NotConnected });
...@@ -97,7 +100,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -97,7 +100,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
const onClose = (e: CloseEvent) => { const onClose = (e: CloseEvent) => {
reject({ reject({
error: e.code, error: e.code,
message: e.reason message: e.reason,
}); });
}; };
...@@ -107,7 +110,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -107,7 +110,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
reject({ reject({
error: data.error, error: data.error,
message: data.message, message: data.message,
data: data.data data: data.data,
}); });
resolve(data.data); resolve(data.data);
}; };
...@@ -116,13 +119,14 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -116,13 +119,14 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
this.once("close", onClose); this.once("close", onClose);
const json = { const json = {
id: this.index, id: this.index,
type: type, type,
data data,
}; };
ws.send(JSON.stringify(json) + "\n"); ws.send(`${JSON.stringify(json)}\n`);
this.index++; this.index++;
}); });
} }
/* eslint-enable @typescript-eslint/no-explicit-any */
authenticate(token: string, roomId: string): Promise<AuthenticationResult> { authenticate(token: string, roomId: string): Promise<AuthenticationResult> {
return this.sendRequest(WSCommandType.Authenticate, { token, roomId }); return this.sendRequest(WSCommandType.Authenticate, { token, roomId });
...@@ -133,36 +137,36 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -133,36 +137,36 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
return { return {
id: room.id, id: room.id,
videoAllowed: room.videoAllowed, videoAllowed: room.videoAllowed,
users: new Map(Object.entries(room.users)) users: new Map(Object.entries(room.users)),
}; };
} }
initializeTransports( initializeTransports(
rtpCapabilities: RtpCapabilities rtpCapabilities: RtpCapabilities,
): Promise<TransportInitDataTuple> { ): Promise<TransportInitDataTuple> {
return this.sendRequest(WSCommandType.InitializeTransports, { return this.sendRequest(WSCommandType.InitializeTransports, {
mode: "SplitWebRTC", mode: "SplitWebRTC",
rtpCapabilities rtpCapabilities,
}); });
} }
connectTransport( connectTransport(
id: string, id: string,
dtlsParameters: DtlsParameters dtlsParameters: DtlsParameters,
): Promise<void> { ): Promise<void> {
return this.sendRequest(WSCommandType.ConnectTransport, { return this.sendRequest(WSCommandType.ConnectTransport, {
id, id,
dtlsParameters dtlsParameters,
}); });
} }
async startProduce( async startProduce(
type: ProduceType, type: ProduceType,
rtpParameters: RtpParameters rtpParameters: RtpParameters,
): Promise<string> { ): Promise<string> {
let result = await this.sendRequest(WSCommandType.StartProduce, { const result = await this.sendRequest(WSCommandType.StartProduce, {
type, type,
rtpParameters rtpParameters,
}); });
return result.producerId; return result.producerId;
} }
...@@ -182,7 +186,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -182,7 +186,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
setConsumerPause(consumerId: string, paused: boolean): Promise<void> { setConsumerPause(consumerId: string, paused: boolean): Promise<void> {
return this.sendRequest(WSCommandType.SetConsumerPause, { return this.sendRequest(WSCommandType.SetConsumerPause, {
id: consumerId, id: consumerId,
paused paused,
}); });
} }
} }
...@@ -2,13 +2,13 @@ import { Consumer } from "mediasoup-client/lib/Consumer"; ...@@ -2,13 +2,13 @@ import { Consumer } from "mediasoup-client/lib/Consumer";
import { import {
MediaKind, MediaKind,
RtpCapabilities, RtpCapabilities,
RtpParameters RtpParameters,
} from "mediasoup-client/lib/RtpParameters"; } from "mediasoup-client/lib/RtpParameters";
import { SctpParameters } from "mediasoup-client/lib/SctpParameters"; import { SctpParameters } from "mediasoup-client/lib/SctpParameters";
import { import {
DtlsParameters, DtlsParameters,
IceCandidate, IceCandidate,
IceParameters IceParameters,
} from "mediasoup-client/lib/Transport"; } from "mediasoup-client/lib/Transport";
export enum WSEventType { export enum WSEventType {
...@@ -16,7 +16,7 @@ export enum WSEventType { ...@@ -16,7 +16,7 @@ export enum WSEventType {
UserLeft = "UserLeft", UserLeft = "UserLeft",
UserStartProduce = "UserStartProduce", UserStartProduce = "UserStartProduce",
UserStopProduce = "UserStopProduce" UserStopProduce = "UserStopProduce",
} }
export enum WSCommandType { export enum WSCommandType {
...@@ -31,7 +31,7 @@ export enum WSCommandType { ...@@ -31,7 +31,7 @@ export enum WSCommandType {
StartConsume = "StartConsume", StartConsume = "StartConsume",
StopConsume = "StopConsume", StopConsume = "StopConsume",
SetConsumerPause = "SetConsumerPause" SetConsumerPause = "SetConsumerPause",
} }
export enum WSErrorCode { export enum WSErrorCode {
...@@ -44,7 +44,7 @@ export enum WSErrorCode { ...@@ -44,7 +44,7 @@ export enum WSErrorCode {
ProducerNotFound = 614, ProducerNotFound = 614,
ConsumerFailure = 621, ConsumerFailure = 621,
ConsumerNotFound = 624 ConsumerNotFound = 624,
} }
export enum WSCloseCode { export enum WSCloseCode {
...@@ -54,7 +54,7 @@ export enum WSCloseCode { ...@@ -54,7 +54,7 @@ export enum WSCloseCode {
RoomClosed = 4004, RoomClosed = 4004,
// Sent when a client tries to send an opcode in the wrong state // Sent when a client tries to send an opcode in the wrong state
InvalidState = 1002, InvalidState = 1002,
ServerError = 1011 ServerError = 1011,
} }
export interface VoiceError { export interface VoiceError {
......
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import * as mediasoupClient from "mediasoup-client"; import * as mediasoupClient from "mediasoup-client";
import { import { types } from "mediasoup-client";
Device,
Producer, import { Device, Producer, Transport } from "mediasoup-client/lib/types";
Transport,
UnsupportedError
} from "mediasoup-client/lib/types";
import Signaling from "./Signaling";
import { import {
ProduceType, ProduceType,
WSEventType, WSEventType,
VoiceError, VoiceError,
VoiceUser, VoiceUser,
ConsumerList, ConsumerList,
WSErrorCode WSErrorCode,
} from "./Types"; } from "./Types";
import Signaling from "./Signaling";
const UnsupportedError = types.UnsupportedError;
interface VoiceEvents { interface VoiceEvents {
ready: () => void; ready: () => void;
...@@ -58,7 +56,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -58,7 +56,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
this.signaling.on( this.signaling.on(
"data", "data",
json => { (json) => {
const data = json.data; const data = json.data;
switch (json.type) { switch (json.type) {
case WSEventType.UserJoined: { case WSEventType.UserJoined: {
...@@ -82,7 +80,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -82,7 +80,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
break; break;
default: default:
throw new Error( throw new Error(
`Invalid produce type ${data.type}` `Invalid produce type ${data.type}`,
); );
} }
...@@ -100,7 +98,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -100,7 +98,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
break; break;
default: default:
throw new Error( throw new Error(
`Invalid produce type ${data.type}` `Invalid produce type ${data.type}`,
); );
} }
...@@ -111,29 +109,29 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -111,29 +109,29 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
} }
} }
}, },
this this,
); );
this.signaling.on( this.signaling.on(
"error", "error",
error => { () => {
this.emit("error", new Error("Signaling error")); this.emit("error", new Error("Signaling error"));
}, },
this this,
); );
this.signaling.on( this.signaling.on(
"close", "close",
error => { (error) => {
this.disconnect( this.disconnect(
{ {
error: error.code, error: error.code,
message: error.reason message: error.reason,
}, },
true true,
); );
}, },
this this,
); );
} }
...@@ -174,9 +172,9 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -174,9 +172,9 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
if (this.device === undefined || this.roomId === undefined) if (this.device === undefined || this.roomId === undefined)
throw new ReferenceError("Voice Client is in an invalid state"); throw new ReferenceError("Voice Client is in an invalid state");
const result = await this.signaling.authenticate(token, this.roomId); const result = await this.signaling.authenticate(token, this.roomId);
let [room] = await Promise.all([ const [room] = await Promise.all([
this.signaling.roomInfo(), this.signaling.roomInfo(),
this.device.load({ routerRtpCapabilities: result.rtpCapabilities }) this.device.load({ routerRtpCapabilities: result.rtpCapabilities }),
]); ]);
this.userId = result.userId; this.userId = result.userId;
...@@ -188,14 +186,14 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -188,14 +186,14 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
if (this.device === undefined) if (this.device === undefined)
throw new ReferenceError("Voice Client is in an invalid state"); throw new ReferenceError("Voice Client is in an invalid state");
const initData = await this.signaling.initializeTransports( const initData = await this.signaling.initializeTransports(
this.device.rtpCapabilities this.device.rtpCapabilities,
); );
this.sendTransport = this.device.createSendTransport( this.sendTransport = this.device.createSendTransport(
initData.sendTransport initData.sendTransport,
); );
this.recvTransport = this.device.createRecvTransport( this.recvTransport = this.device.createRecvTransport(
initData.recvTransport initData.recvTransport,
); );
const connectTransport = (transport: Transport) => { const connectTransport = (transport: Transport) => {
...@@ -226,12 +224,12 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -226,12 +224,12 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
return errback(); return errback();
this.signaling this.signaling
.startProduce(type, parameters.rtpParameters) .startProduce(type, parameters.rtpParameters)
.then(id => callback({ id })) .then((id) => callback({ id }))
.catch(errback); .catch(errback);
}); });
this.emit("ready"); this.emit("ready");
for (let user of this.participants) { for (const user of this.participants) {
if (user[1].audio && user[0] !== this.userId) if (user[1].audio && user[0] !== this.userId)
this.startConsume(user[0], "audio"); this.startConsume(user[0], "audio");
} }
...@@ -283,7 +281,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -283,7 +281,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
throw new Error("Send transport undefined"); throw new Error("Send transport undefined");
const producer = await this.sendTransport.produce({ const producer = await this.sendTransport.produce({
track, track,
appData: { type } appData: { type },
}); });
switch (type) { switch (type) {
...@@ -325,7 +323,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -325,7 +323,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
await this.signaling.stopProduce(type); await this.signaling.stopProduce(type);
} catch (error) { } catch (error) {
if (error.error === WSErrorCode.ProducerNotFound) return; if (error.error === WSErrorCode.ProducerNotFound) return;
else throw error; throw error;
} }
} }
} }
...@@ -3,14 +3,14 @@ import { useEffect, useState } from "preact/hooks"; ...@@ -3,14 +3,14 @@ import { useEffect, useState } from "preact/hooks";
export function useWindowSize() { export function useWindowSize() {
const [windowSize, setWindowSize] = useState({ const [windowSize, setWindowSize] = useState({
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight height: window.innerHeight,
}); });
useEffect(() => { useEffect(() => {
function handleResize() { function handleResize() {
setWindowSize({ setWindowSize({
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight height: window.innerHeight,
}); });
} }
......
import { registerSW } from 'virtual:pwa-register'; import { registerSW } from "virtual:pwa-register";
import { internalEmit } from './lib/eventEmitter';
import "./styles/index.scss";
import { render } from "preact";
import { internalEmit } from "./lib/eventEmitter";
import { App } from "./pages/app";
export const updateSW = registerSW({ export const updateSW = registerSW({
onNeedRefresh() { onNeedRefresh() {
internalEmit('PWA', 'update'); internalEmit("PWA", "update");
}, },
onOfflineReady() { onOfflineReady() {
console.info('Ready to work offline.'); console.info("Ready to work offline.");
// show a ready to work offline to user // show a ready to work offline to user
}, },
}) });
import "./styles/index.scss";
import { render } from "preact";
import { App } from "./pages/app";
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
render(<App />, document.getElementById("app")!); render(<App />, document.getElementById("app")!);
/* eslint-disable react-hooks/rules-of-hooks */
import { useHistory, useParams } from "react-router-dom";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import Header from "../components/ui/Header";
import { useContext, useEffect } from "preact/hooks"; import { useContext, useEffect } from "preact/hooks";
import { useHistory, useParams } from "react-router-dom";
import { useIntermediate } from "../context/intermediate/Intermediate"; import { useIntermediate } from "../context/intermediate/Intermediate";
import { useChannels, useForceUpdate, useUser } from "../context/revoltjs/hooks"; import {
import { AppContext, ClientStatus, StatusContext } from "../context/revoltjs/RevoltClient"; AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import Header from "../components/ui/Header";
export default function Open() { export default function Open() {
const history = useHistory(); const history = useHistory();
...@@ -21,48 +28,48 @@ export default function Open() { ...@@ -21,48 +28,48 @@ export default function Open() {
); );
} }
const ctx = useForceUpdate();
const channels = useChannels(undefined, ctx);
const user = useUser(id, ctx);
useEffect(() => { useEffect(() => {
if (id === "saved") { if (id === "saved") {
for (const channel of channels) { for (const channel of [...client.channels.values()]) {
if (channel?.channel_type === "SavedMessages") { if (channel?.channel_type === "SavedMessages") {
history.push(`/channel/${channel._id}`); history.push(`/channel/${channel._id}`);
return; return;
} }
} }
client.users client
.openDM(client.user?._id as string) .user!.openDM()
.then(channel => history.push(`/channel/${channel?._id}`)) .then((channel) => history.push(`/channel/${channel?._id}`))
.catch(error => openScreen({ id: "error", error })); .catch((error) => openScreen({ id: "error", error }));
return; return;
} }
const user = client.users.get(id);
if (user) { if (user) {
const channel: string | undefined = channels.find( const channel: string | undefined = [
channel => ...client.channels.values(),
].find(
(channel) =>
channel?.channel_type === "DirectMessage" && channel?.channel_type === "DirectMessage" &&
channel.recipients.includes(id) channel.recipient_ids!.includes(id),
)?._id; )?._id;
if (channel) { if (channel) {
history.push(`/channel/${channel}`); history.push(`/channel/${channel}`);
} else { } else {
client.users client.users
.openDM(id) .get(id)
.then(channel => history.push(`/channel/${channel?._id}`)) ?.openDM()
.catch(error => openScreen({ id: "error", error })); .then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
} }
return; return;
} }
history.push("/"); history.push("/");
}, []); });
return ( return (
<Header placement="primary"> <Header placement="primary">
......
import { Docked, OverlappingPanels, ShowIf } from "react-overlapping-panels"; import { Docked, OverlappingPanels, ShowIf } from "react-overlapping-panels";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import { Switch, Route, useLocation } from "react-router-dom"; import { Switch, Route, useLocation } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import ContextMenus from "../lib/ContextMenus"; import ContextMenus from "../lib/ContextMenus";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import Popovers from "../context/intermediate/Popovers"; import Popovers from "../context/intermediate/Popovers";
import SyncManager from "../context/revoltjs/SyncManager";
import StateMonitor from "../context/revoltjs/StateMonitor";
import Notifications from "../context/revoltjs/Notifications"; 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 LeftSidebar from "../components/navigation/LeftSidebar";
import RightSidebar from "../components/navigation/RightSidebar"; import RightSidebar from "../components/navigation/RightSidebar";
import BottomNavigation from "../components/navigation/BottomNavigation";
import Open from "./Open"; import Open from "./Open";
import Home from './home/Home';
import Invite from "./invite/Invite";
import Friends from "./friends/Friends";
import Channel from "./channels/Channel"; import Channel from "./channels/Channel";
import Settings from './settings/Settings';
import Developer from "./developer/Developer"; import Developer from "./developer/Developer";
import ServerSettings from "./settings/ServerSettings"; import Friends from "./friends/Friends";
import Home from "./home/Home";
import Invite from "./invite/Invite";
import ChannelSettings from "./settings/ChannelSettings"; import ChannelSettings from "./settings/ChannelSettings";
import ServerSettings from "./settings/ServerSettings";
import Settings from "./settings/Settings";
const Routes = styled.div` const Routes = styled.div`
min-width: 0; min-width: 0;
...@@ -33,52 +34,101 @@ const Routes = styled.div` ...@@ -33,52 +34,101 @@ const Routes = styled.div`
export default function App() { export default function App() {
const path = useLocation().pathname; const path = useLocation().pathname;
const fixedBottomNav = (path === '/' || path === '/settings' || path.startsWith("/friends")); const fixedBottomNav =
const inSettings = path.includes('/settings'); path === "/" || path === "/settings" || path.startsWith("/friends");
const inChannel = path.includes('/channel'); const inChannel = path.includes("/channel");
const inSpecial = (path.startsWith("/friends") && isTouchscreenDevice) || path.startsWith('/invite') || path.startsWith("/settings"); const inSpecial =
(path.startsWith("/friends") && isTouchscreenDevice) ||
path.startsWith("/invite") ||
path.includes("/settings");
return ( return (
<OverlappingPanels <>
width="100vw" {window.isNative && !window.native.getConfig().frame && (
height="var(--app-height)" <Titlebar />
leftPanel={inSpecial ? undefined : { width: 292, component: <LeftSidebar /> }} )}
rightPanel={(!inSettings && inChannel) ? { width: 240, component: <RightSidebar /> } : undefined} <OverlappingPanels
bottomNav={{ width="100vw"
component: <BottomNavigation />, height={
showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left, window.isNative && !window.native.getConfig().frame
height: 50 ? "calc(var(--app-height) - var(--titlebar-height))"
}} : "var(--app-height)"
docked={isTouchscreenDevice ? Docked.None : Docked.Left}> }
<Routes> leftPanel={
<Switch> inSpecial
<Route path="/server/:server/channel/:channel/settings/:page" component={ChannelSettings} /> ? undefined
<Route path="/server/:server/channel/:channel/settings" component={ChannelSettings} /> : { width: 292, component: <LeftSidebar /> }
<Route path="/server/:server/settings/:page" component={ServerSettings} /> }
<Route path="/server/:server/settings" component={ServerSettings} /> rightPanel={
<Route path="/channel/:channel/settings/:page" component={ChannelSettings} /> !inSpecial && inChannel
<Route path="/channel/:channel/settings" component={ChannelSettings} /> ? { 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"
component={Channel}
/>
<Route
path="/server/:server/channel/:channel/: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/:message" component={Channel} /> <Route path="/settings/:page" component={Settings} />
<Route path="/server/:server/channel/:channel" component={Channel} /> <Route path="/settings" component={Settings} />
<Route path="/server/:server" />
<Route path="/channel/:channel" component={Channel} />
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
<Route path="/dev" component={Developer} /> <Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} /> <Route path="/friends" component={Friends} />
<Route path="/open/:id" component={Open} /> <Route path="/open/:id" component={Open} />
<Route path="/invite/:code" component={Invite} /> <Route path="/invite/:code" component={Invite} />
<Route path="/" component={Home} /> <Route path="/" component={Home} />
</Switch> </Switch>
</Routes> </Routes>
<ContextMenus /> <ContextMenus />
<Popovers /> <Popovers />
<Notifications /> <Notifications />
<StateMonitor /> <StateMonitor />
<SyncManager /> <SyncManager />
</OverlappingPanels> </OverlappingPanels>
</>
); );
}; }
import { CheckAuth } from "../context/revoltjs/CheckAuth";
import Preloader from "../components/ui/Preloader";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import Masks from "../components/ui/Masks";
import Context from "../context";
import { lazy, Suspense } from "preact/compat"; import { lazy, Suspense } from "preact/compat";
const Login = lazy(() => import('./login/Login'));
const RevoltApp = lazy(() => import('./RevoltApp')); import Context from "../context";
import { CheckAuth } from "../context/revoltjs/CheckAuth";
import Masks from "../components/ui/Masks";
import Preloader from "../components/ui/Preloader";
const Login = lazy(() => import("./login/Login"));
const RevoltApp = lazy(() => import("./RevoltApp"));
export function App() { export function App() {
return ( return (
<Context> <Context>
<Masks /> <Masks />
{/* {/*
// @ts-expect-error */} // @ts-expect-error typings mis-match between preact... and preact? */}
<Suspense fallback={<Preloader type="spinner" />}> <Suspense fallback={<Preloader type="spinner" />}>
<Switch> <Switch>
<Route path="/login"> <Route path="/login">
......
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 styled from "styled-components";
import { useEffect, useState } from "preact/hooks";
import ChannelHeader from "./ChannelHeader"; import { useState } from "preact/hooks";
import { useParams, useHistory } from "react-router-dom";
import { MessageArea } from "./messaging/MessageArea";
import Checkbox from "../../components/ui/Checkbox";
import Button from "../../components/ui/Button";
// import { useRenderState } from "../../lib/renderer/Singleton";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
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 MessageBox from "../../components/common/messaging/MessageBox";
import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks";
import MemberSidebar from "../../components/navigation/right/MemberSidebar";
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom"; import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator"; import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator";
import { Channel } from "revolt.js";
import MemberSidebar from "../../components/navigation/right/MemberSidebar";
import ChannelHeader from "./ChannelHeader";
import { MessageArea } from "./messaging/MessageArea";
import VoiceHeader from "./voice/VoiceHeader"; import VoiceHeader from "./voice/VoiceHeader";
const ChannelMain = styled.div` const ChannelMain = styled.div`
...@@ -30,93 +36,81 @@ const ChannelContent = styled.div` ...@@ -30,93 +36,81 @@ const ChannelContent = styled.div`
flex-direction: column; 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 }) { export function Channel({ id }: { id: string }) {
const ctx = useForceUpdate(); const client = useClient();
const channel = useChannel(id, ctx); const channel = client.channels.get(id);
if (!channel) return null; if (!channel) return null;
if (channel.channel_type === 'VoiceChannel') { if (channel.channel_type === "VoiceChannel") {
return <VoiceChannel channel={channel} />; return <VoiceChannel channel={channel} />;
} else {
return <TextChannel channel={channel} />;
} }
return <TextChannel channel={channel} />;
} }
function TextChannel({ channel }: { channel: Channel }) { const MEMBERS_SIDEBAR_KEY = "sidebar_members";
const [ showMembers, setMembers ] = useState(true); const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
const [showMembers, setMembers] = useState(
if ((channel.channel_type === 'TextChannel' || channel.channel_type === 'Group') && channel.name.includes('nsfw')) { getState().sectionToggle[MEMBERS_SIDEBAR_KEY] ?? true,
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>
)
}
}
let id = channel._id; const id = channel._id;
return <> return (
<ChannelHeader channel={channel} toggleSidebar={() => setMembers(!showMembers)} /> <AgeGate
<ChannelMain> type="channel"
<ChannelContent> channel={channel}
<VoiceHeader id={id} /> gated={
<MessageArea id={id} /> !!(
<TypingIndicator id={id} /> (channel.channel_type === "TextChannel" ||
<JumpToBottom id={id} /> channel.channel_type === "Group") &&
<MessageBox channel={channel} /> channel.name?.includes("nsfw")
</ChannelContent> )
{ !isTouchscreenDevice && showMembers && <MemberSidebar channel={channel} /> } }>
</ChannelMain> <ChannelHeader
</>; channel={channel}
} 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 channel={channel} />
<JumpToBottom id={id} />
<MessageBox channel={channel} />
</ChannelContent>
{!isTouchscreenDevice && showMembers && (
<MemberSidebar channel={channel} />
)}
</ChannelMain>
</AgeGate>
);
});
function VoiceChannel({ channel }: { channel: Channel }) { function VoiceChannel({ channel }: { channel: ChannelI }) {
return <> return (
<ChannelHeader channel={channel} /> <>
<VoiceHeader id={channel._id} /> <ChannelHeader channel={channel} />
</>; <VoiceHeader id={channel._id} />
</>
);
} }
export default function() { export default function ChannelComponent() {
const { channel } = useParams<{ channel: string }>(); const { channel } = useParams<{ channel: string }>();
return <Channel id={channel} key={channel} />; return <Channel id={channel} key={channel} />;
} }
import { At, Hash, Menu } from "@styled-icons/boxicons-regular";
import { Notepad, Group } from "@styled-icons/boxicons-solid";
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 styled from "styled-components";
import { Channel, User } from "revolt.js";
import { useContext } from "preact/hooks"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import Header from "../../components/ui/Header";
import HeaderActions from "./actions/HeaderActions"; import { useIntermediate } from "../../context/intermediate/Intermediate";
import Markdown from "../../components/markdown/Markdown";
import { getChannelName } from "../../context/revoltjs/util"; import { getChannelName } from "../../context/revoltjs/util";
import UserStatus from "../../components/common/user/UserStatus";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { At, Hash } from "@styled-icons/boxicons-regular";
import { Notepad, Group } from "@styled-icons/boxicons-solid";
import { useStatusColour } from "../../components/common/user/UserIcon"; import { useStatusColour } from "../../components/common/user/UserIcon";
import { useIntermediate } from "../../context/intermediate/Intermediate"; import UserStatus from "../../components/common/user/UserStatus";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; import Header from "../../components/ui/Header";
import Markdown from "../../components/markdown/Markdown";
import HeaderActions from "./actions/HeaderActions";
export interface ChannelHeaderProps { export interface ChannelHeaderProps {
channel: Channel, channel: Channel;
toggleSidebar?: () => void toggleSidebar?: () => void;
} }
const Info = styled.div` const Info = styled.div`
...@@ -53,23 +57,25 @@ const Info = styled.div` ...@@ -53,23 +57,25 @@ const Info = styled.div`
font-size: 0.8em; font-size: 0.8em;
font-weight: 400; font-weight: 400;
color: var(--secondary-foreground); color: var(--secondary-foreground);
> * {
pointer-events: none;
}
} }
`; `;
export default function ChannelHeader({ channel, toggleSidebar }: ChannelHeaderProps) { export default observer(({ channel, toggleSidebar }: ChannelHeaderProps) => {
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const name = getChannelName(client, channel); const name = getChannelName(channel);
let icon, recipient; let icon, recipient: User | undefined;
switch (channel.channel_type) { switch (channel.channel_type) {
case "SavedMessages": case "SavedMessages":
icon = <Notepad size={24} />; icon = <Notepad size={24} />;
break; break;
case "DirectMessage": case "DirectMessage":
icon = <At size={24} />; icon = <At size={24} />;
const uid = client.channels.getRecipient(channel._id); recipient = channel.recipient;
recipient = client.users.get(uid);
break; break;
case "Group": case "Group":
icon = <Group size={24} />; icon = <Group size={24} />;
...@@ -81,36 +87,55 @@ export default function ChannelHeader({ channel, toggleSidebar }: ChannelHeaderP ...@@ -81,36 +87,55 @@ export default function ChannelHeader({ channel, toggleSidebar }: ChannelHeaderP
return ( return (
<Header placement="primary"> <Header placement="primary">
{ icon } {isTouchscreenDevice && (
<div className="menu">
<Menu size={27} />
</div>
)}
{icon}
<Info> <Info>
<span className="name">{ name }</span> <span className="name">{name}</span>
{isTouchscreenDevice && channel.channel_type === "DirectMessage" && ( {isTouchscreenDevice &&
<> channel.channel_type === "DirectMessage" && (
<div className="divider" /> <>
<span className="desc"> <div className="divider" />
<div className="status" style={{ backgroundColor: useStatusColour(recipient as User) }} /> <span className="desc">
<UserStatus user={recipient as User} /> <div
</span> className="status"
</> style={{
)} backgroundColor:
{!isTouchscreenDevice && (channel.channel_type === "Group" || channel.channel_type === "TextChannel") && channel.description && ( useStatusColour(recipient),
<> }}
<div className="divider" /> />
<span <UserStatus user={recipient} />
className="desc" </span>
onClick={() => </>
openScreen({ )}
id: "channel_info", {!isTouchscreenDevice &&
channel_id: channel._id (channel.channel_type === "Group" ||
}) channel.channel_type === "TextChannel") &&
}> channel.description && (
<>
<Markdown content={channel.description.split("\n")[0] ?? ""} disallowBigEmoji /> <div className="divider" />
</span> <span
</> className="desc"
)} onClick={() =>
openScreen({
id: "channel_info",
channel,
})
}>
<Markdown
content={
channel.description.split("\n")[0] ?? ""
}
disallowBigEmoji
/>
</span>
</>
)}
</Info> </Info>
<HeaderActions channel={channel} toggleSidebar={toggleSidebar} /> <HeaderActions channel={channel} toggleSidebar={toggleSidebar} />
</Header> </Header>
) );
} });
import { useContext } from "preact/hooks"; /* eslint-disable react-hooks/rules-of-hooks */
import {
UserPlus,
Cog,
PhoneCall,
PhoneOff,
Group,
} from "@styled-icons/boxicons-solid";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { ChannelHeaderProps } from "../ChannelHeader";
import IconButton from "../../../components/ui/IconButton"; import { useContext } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import {
import UpdateIndicator from "../../../components/common/UpdateIndicator"; VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { VoiceContext, VoiceOperationsContext, VoiceStatus } from "../../../context/Voice";
import { UserPlus, Cog, PhoneCall, PhoneOutgoing } from "@styled-icons/boxicons-solid";
import { Sidebar as SidebarIcon } from "@styled-icons/boxicons-regular";
export default function HeaderActions({ channel, toggleSidebar }: ChannelHeaderProps) { import UpdateIndicator from "../../../components/common/UpdateIndicator";
import IconButton from "../../../components/ui/IconButton";
import { ChannelHeaderProps } from "../ChannelHeader";
export default function HeaderActions({
channel,
toggleSidebar,
}: ChannelHeaderProps) {
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const history = useHistory(); const history = useHistory();
return ( return (
<> <>
<UpdateIndicator /> <UpdateIndicator style="channel" />
{ channel.channel_type === "Group" && ( {channel.channel_type === "Group" && (
<> <>
<IconButton onClick={() => <IconButton
openScreen({ onClick={() =>
id: "user_picker", openScreen({
omit: channel.recipients, id: "user_picker",
callback: async users => { omit: channel.recipient_ids!,
for (const user of users) { callback: async (users) => {
await client.channels.addMember(channel._id, user); for (const user of users) {
} await channel.addMember(user);
} }
})}> },
})
}>
<UserPlus size={27} /> <UserPlus size={27} />
</IconButton> </IconButton>
<IconButton onClick={() => history.push(`/channel/${channel._id}/settings`)}> <IconButton
onClick={() =>
history.push(`/channel/${channel._id}/settings`)
}>
<Cog size={24} /> <Cog size={24} />
</IconButton> </IconButton>
</> </>
) } )}
<VoiceActions channel={channel} /> <VoiceActions channel={channel} />
{ (channel.channel_type === "Group" || channel.channel_type === "TextChannel") && !isTouchscreenDevice && ( {(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") && (
<IconButton onClick={toggleSidebar}> <IconButton onClick={toggleSidebar}>
<SidebarIcon size={22} /> <Group size={25} />
</IconButton> </IconButton>
) } )}
</> </>
) );
} }
function VoiceActions({ channel }: Pick<ChannelHeaderProps, 'channel'>) { function VoiceActions({ channel }: Pick<ChannelHeaderProps, "channel">) {
if (channel.channel_type === 'SavedMessages' || if (
channel.channel_type === 'TextChannel') return null; channel.channel_type === "SavedMessages" ||
channel.channel_type === "TextChannel"
)
return null;
const voice = useContext(VoiceContext); const voice = useContext(VoiceContext);
const { connect, disconnect } = useContext(VoiceOperationsContext); const { connect, disconnect } = useContext(VoiceOperationsContext);
...@@ -58,24 +81,23 @@ function VoiceActions({ channel }: Pick<ChannelHeaderProps, 'channel'>) { ...@@ -58,24 +81,23 @@ function VoiceActions({ channel }: Pick<ChannelHeaderProps, 'channel'>) {
if (voice.roomId === channel._id) { if (voice.roomId === channel._id) {
return ( return (
<IconButton onClick={disconnect}> <IconButton onClick={disconnect}>
<PhoneOutgoing size={22} /> <PhoneOff size={22} />
</IconButton>
)
} else {
return (
<IconButton onClick={() => {
disconnect();
connect(channel._id);
}}>
<PhoneCall size={24} />
</IconButton> </IconButton>
) );
} }
} else {
return ( return (
<IconButton> <IconButton
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" /> onClick={() => {
disconnect();
connect(channel);
}}>
<PhoneCall size={24} />
</IconButton> </IconButton>
) );
} }
return (
<IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
</IconButton>
);
} }
import { Text } from "preact-i18n"; import { observer } from "mobx-react-lite";
import styled from "styled-components"; import styled from "styled-components";
import { Text } from "preact-i18n";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../../context/revoltjs/util"; import { getChannelName } from "../../../context/revoltjs/util";
import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks";
const StartBase = styled.div` const StartBase = styled.div`
margin: 18px 16px 10px 16px; margin: 18px 16px 10px 16px;
...@@ -22,17 +25,17 @@ interface Props { ...@@ -22,17 +25,17 @@ interface Props {
id: string; id: string;
} }
export default function ConversationStart({ id }: Props) { export default observer(({ id }: Props) => {
const ctx = useForceUpdate(); const client = useClient();
const channel = useChannel(id, ctx); const channel = client.channels.get(id);
if (!channel) return null; if (!channel) return null;
return ( return (
<StartBase> <StartBase>
<h1>{ getChannelName(ctx.client, channel, true) }</h1> <h1>{getChannelName(channel, true)}</h1>
<h4> <h4>
<Text id="app.main.channel.start.group" /> <Text id="app.main.channel.start.group" />
</h4> </h4>
</StartBase> </StartBase>
); );
} });
import styled from "styled-components"; import { useHistory, useParams } from "react-router-dom";
import { createContext } from "preact";
import { animateScroll } from "react-scroll"; import { animateScroll } from "react-scroll";
import MessageRenderer from "./MessageRenderer"; import styled from "styled-components";
import ConversationStart from './ConversationStart';
import useResizeObserver from "use-resize-observer"; import useResizeObserver from "use-resize-observer";
import Preloader from "../../../components/ui/Preloader";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; import { createContext } from "preact";
import { RenderState, ScrollState } from "../../../lib/renderer/types"; import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "preact/hooks";
import { defer } from "../../../lib/defer";
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton"; import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton";
import { RenderState, ScrollState } from "../../../lib/renderer/types";
import { IntermediateContext } from "../../../context/intermediate/Intermediate"; import { IntermediateContext } from "../../../context/intermediate/Intermediate";
import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import {
import { defer } from "../../../lib/defer"; AppContext,
import { internalEmit } from "../../../lib/eventEmitter"; ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import Preloader from "../../../components/ui/Preloader";
import ConversationStart from "./ConversationStart";
import MessageRenderer from "./MessageRenderer";
const Area = styled.div` const Area = styled.div`
height: 100%; height: 100%;
...@@ -25,7 +42,7 @@ const Area = styled.div` ...@@ -25,7 +42,7 @@ const Area = styled.div`
> div { > div {
display: flex; display: flex;
min-height: 100%; min-height: 100%;
padding-bottom: 20px; padding-bottom: 24px;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
} }
...@@ -39,9 +56,15 @@ export const MessageAreaWidthContext = createContext(0); ...@@ -39,9 +56,15 @@ export const MessageAreaWidthContext = createContext(0);
export const MESSAGE_AREA_PADDING = 82; export const MESSAGE_AREA_PADDING = 82;
export function MessageArea({ id }: Props) { export function MessageArea({ id }: Props) {
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext); const status = useContext(StatusContext);
const { focusTaken } = useContext(IntermediateContext); 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. // ? This is the scroll container.
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { width, height } = useResizeObserver<HTMLDivElement>({ ref }); const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
...@@ -52,12 +75,15 @@ export function MessageArea({ id }: Props) { ...@@ -52,12 +75,15 @@ export function MessageArea({ id }: Props) {
// ? useRef to avoid re-renders // ? useRef to avoid re-renders
const scrollState = useRef<ScrollState>({ type: "Free" }); const scrollState = useRef<ScrollState>({ type: "Free" });
const setScrollState = (v: ScrollState) => { const setScrollState = useCallback((v: ScrollState) => {
if (v.type === 'StayAtBottom') { if (v.type === "StayAtBottom") {
if (scrollState.current.type === 'Bottom' || atBottom()) { if (scrollState.current.type === "Bottom" || atBottom()) {
scrollState.current = { type: 'ScrollToBottom', smooth: v.smooth }; scrollState.current = {
type: "ScrollToBottom",
smooth: v.smooth,
};
} else { } else {
scrollState.current = { type: 'Free' }; scrollState.current = { type: "Free" };
} }
} else { } else {
scrollState.current = v; scrollState.current = v;
...@@ -65,70 +91,109 @@ export function MessageArea({ id }: Props) { ...@@ -65,70 +91,109 @@ export function MessageArea({ id }: Props) {
defer(() => { defer(() => {
if (scrollState.current.type === "ScrollToBottom") { if (scrollState.current.type === "ScrollToBottom") {
setScrollState({ type: "Bottom", scrollingUntil: + new Date() + 150 }); setScrollState({
type: "Bottom",
scrollingUntil: +new Date() + 150,
});
animateScroll.scrollToBottom({ animateScroll.scrollToBottom({
container: ref.current, container: ref.current,
duration: scrollState.current.smooth ? 150 : 0 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") { } else if (scrollState.current.type === "OffsetTop") {
animateScroll.scrollTo( animateScroll.scrollTo(
Math.max( Math.max(
101, 101,
ref.current.scrollTop + ref.current
(ref.current.scrollHeight - scrollState.current.previousHeight) ? ref.current.scrollTop +
(ref.current.scrollHeight -
scrollState.current.previousHeight)
: 101,
), ),
{ {
container: ref.current, container: ref.current,
duration: 0 duration: 0,
} },
); );
setScrollState({ type: "Free" }); setScrollState({ type: "Free" });
} else if (scrollState.current.type === "ScrollTop") { } else if (scrollState.current.type === "ScrollTop") {
animateScroll.scrollTo(scrollState.current.y, { animateScroll.scrollTo(scrollState.current.y, {
container: ref.current, container: ref.current,
duration: 0 duration: 0,
}); });
setScrollState({ type: "Free" }); setScrollState({ type: "Free" });
} }
}); });
} }, []);
// ? Determine if we are at the bottom of the scroll container. // ? Determine if we are at the bottom of the scroll container.
// -> https://stackoverflow.com/a/44893438 // -> https://stackoverflow.com/a/44893438
// By default, we assume we are at the bottom, i.e. when we first load. // By default, we assume we are at the bottom, i.e. when we first load.
const atBottom = (offset = 0) => const atBottom = (offset = 0) =>
ref.current ref.current
? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) - ? Math.floor(ref.current?.scrollHeight - ref.current?.scrollTop) -
offset <= offset <=
ref.current.clientHeight ref.current?.clientHeight
: true; : 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. // ? Handle events from renderer.
useEffect(() => { useEffect(() => {
SingletonMessageRenderer.addListener('state', setState); SingletonMessageRenderer.addListener("state", setState);
return () => SingletonMessageRenderer.removeListener('state', setState); return () => SingletonMessageRenderer.removeListener("state", setState);
}, [ ]); }, []);
useEffect(() => { useEffect(() => {
SingletonMessageRenderer.addListener('scroll', setScrollState); SingletonMessageRenderer.addListener("scroll", setScrollState);
return () => SingletonMessageRenderer.removeListener('scroll', setScrollState); return () =>
}, [ scrollState ]); SingletonMessageRenderer.removeListener("scroll", setScrollState);
}, [scrollState, setScrollState]);
// ? Load channel initially. // ? Load channel initially.
useEffect(() => { useEffect(() => {
if (message) return;
SingletonMessageRenderer.init(id); SingletonMessageRenderer.init(id);
}, [ 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. // ? If we are waiting for network, try again.
useEffect(() => { useEffect(() => {
switch (status) { switch (status) {
case ClientStatus.ONLINE: case ClientStatus.ONLINE:
if (state.type === 'WAITING_FOR_NETWORK') { if (state.type === "WAITING_FOR_NETWORK") {
SingletonMessageRenderer.init(id); SingletonMessageRenderer.init(id);
} else { } else {
SingletonMessageRenderer.reloadStale(id); SingletonMessageRenderer.reloadStale(id);
...@@ -141,62 +206,72 @@ export function MessageArea({ id }: Props) { ...@@ -141,62 +206,72 @@ export function MessageArea({ id }: Props) {
SingletonMessageRenderer.markStale(); SingletonMessageRenderer.markStale();
break; break;
} }
}, [ status, state ]); }, [id, status, state]);
// ? When the container is scrolled. // ? When the container is scrolled.
// ? Also handle StayAtBottom // ? Also handle StayAtBottom
useEffect(() => { useEffect(() => {
const current = ref.current;
if (!current) return;
async function onScroll() { async function onScroll() {
if (scrollState.current.type === "Free" && atBottom()) { if (scrollState.current.type === "Free" && atBottom()) {
setScrollState({ type: "Bottom" }); setScrollState({ type: "Bottom" });
} else if (scrollState.current.type === "Bottom" && !atBottom()) { } else if (scrollState.current.type === "Bottom" && !atBottom()) {
if (scrollState.current.scrollingUntil && scrollState.current.scrollingUntil > + new Date()) return; if (
scrollState.current.scrollingUntil &&
scrollState.current.scrollingUntil > +new Date()
)
return;
setScrollState({ type: "Free" }); setScrollState({ type: "Free" });
} }
} }
ref.current.addEventListener("scroll", onScroll); current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll); return () => current.removeEventListener("scroll", onScroll);
}, [ref, scrollState]); }, [ref, scrollState, setScrollState]);
// ? Top and bottom loaders. // ? Top and bottom loaders.
useEffect(() => { useEffect(() => {
const current = ref.current;
if (!current) return;
async function onScroll() { async function onScroll() {
if (atTop(100)) { if (atTop(100)) {
SingletonMessageRenderer.loadTop(ref.current); SingletonMessageRenderer.loadTop(ref.current!);
} }
if (atBottom(100)) { if (atBottom(100)) {
SingletonMessageRenderer.loadBottom(ref.current); SingletonMessageRenderer.loadBottom(ref.current!);
} }
} }
ref.current.addEventListener("scroll", onScroll); current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll); return () => current.removeEventListener("scroll", onScroll);
}, [ref]); }, [ref]);
// ? Scroll down whenever the message area resizes. // ? Scroll down whenever the message area resizes.
function stbOnResize() { const stbOnResize = useCallback(() => {
if (!atBottom() && scrollState.current.type === "Bottom") { if (!atBottom() && scrollState.current.type === "Bottom") {
animateScroll.scrollToBottom({ animateScroll.scrollToBottom({
container: ref.current, container: ref.current,
duration: 0 duration: 0,
}); });
setScrollState({ type: "Bottom" }); setScrollState({ type: "Bottom" });
} }
} }, [setScrollState]);
// ? Scroll down when container resized. // ? Scroll down when container resized.
useLayoutEffect(() => { useLayoutEffect(() => {
stbOnResize(); stbOnResize();
}, [height]); }, [stbOnResize, height]);
// ? Scroll down whenever the window resizes. // ? Scroll down whenever the window resizes.
useLayoutEffect(() => { useLayoutEffect(() => {
document.addEventListener("resize", stbOnResize); document.addEventListener("resize", stbOnResize);
return () => document.removeEventListener("resize", stbOnResize); return () => document.removeEventListener("resize", stbOnResize);
}, [ref, scrollState]); }, [ref, scrollState, stbOnResize]);
// ? Scroll to bottom when pressing 'Escape'. // ? Scroll to bottom when pressing 'Escape'.
useEffect(() => { useEffect(() => {
...@@ -209,10 +284,11 @@ export function MessageArea({ id }: Props) { ...@@ -209,10 +284,11 @@ export function MessageArea({ id }: Props) {
document.body.addEventListener("keyup", keyUp); document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp); return () => document.body.removeEventListener("keyup", keyUp);
}, [ref, focusTaken]); }, [id, ref, focusTaken]);
return ( return (
<MessageAreaWidthContext.Provider value={(width ?? 0) - MESSAGE_AREA_PADDING}> <MessageAreaWidthContext.Provider
value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<Area ref={ref}> <Area ref={ref}>
<div> <div>
{state.type === "LOADING" && <Preloader type="ring" />} {state.type === "LOADING" && <Preloader type="ring" />}
...@@ -222,7 +298,11 @@ export function MessageArea({ id }: Props) { ...@@ -222,7 +298,11 @@ export function MessageArea({ id }: Props) {
</RequiresOnline> </RequiresOnline>
)} )}
{state.type === "RENDER" && ( {state.type === "RENDER" && (
<MessageRenderer id={id} state={state} /> <MessageRenderer
id={id}
state={state}
highlight={highlight}
/>
)} )}
{state.type === "EMPTY" && <ConversationStart id={id} />} {state.type === "EMPTY" && <ConversationStart id={id} />}
</div> </div>
......
import { Message } from "revolt.js/dist/maps/Messages";
import styled from "styled-components"; import styled from "styled-components";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { MessageObject } from "../../../context/revoltjs/util";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { IntermediateContext, useIntermediate } from "../../../context/intermediate/Intermediate";
import AutoComplete, { useAutoComplete } from "../../../components/common/AutoComplete"; import {
IntermediateContext,
useIntermediate,
} from "../../../context/intermediate/Intermediate";
import AutoComplete, {
useAutoComplete,
} from "../../../components/common/AutoComplete";
const EditorBase = styled.div` const EditorBase = styled.div`
display: flex; display: flex;
...@@ -14,9 +22,9 @@ const EditorBase = styled.div` ...@@ -14,9 +22,9 @@ const EditorBase = styled.div`
textarea { textarea {
resize: none; resize: none;
padding: 12px; padding: 12px;
font-size: .875rem;
border-radius: 3px;
white-space: pre-wrap; white-space: pre-wrap;
font-size: var(--text-size);
border-radius: var(--border-radius);
background: var(--secondary-header); background: var(--secondary-header);
} }
...@@ -35,28 +43,28 @@ const EditorBase = styled.div` ...@@ -35,28 +43,28 @@ const EditorBase = styled.div`
`; `;
interface Props { interface Props {
message: MessageObject message: Message;
finish: () => void finish: () => void;
} }
export default function MessageEditor({ message, finish }: Props) { export default function MessageEditor({ message, finish }: Props) {
const [ content, setContent ] = useState(message.content as string ?? ''); const [content, setContent] = useState((message.content as string) ?? "");
const { focusTaken } = useContext(IntermediateContext); const { focusTaken } = useContext(IntermediateContext);
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const client = useContext(AppContext);
async function save() { async function save() {
finish(); finish();
if (content.length === 0) { if (content.length === 0) {
// @ts-expect-error openScreen({
openScreen({ id: 'special_prompt', type: 'delete_message', target: message }); id: "special_prompt",
type: "delete_message",
target: message,
});
} else if (content !== message.content) { } else if (content !== message.content) {
await client.channels.editMessage( await message.edit({
message.channel, content,
message._id, });
{ content }
);
} }
} }
...@@ -70,12 +78,18 @@ export default function MessageEditor({ message, finish }: Props) { ...@@ -70,12 +78,18 @@ export default function MessageEditor({ message, finish }: Props) {
document.body.addEventListener("keyup", keyUp); document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp); return () => document.body.removeEventListener("keyup", keyUp);
}, [focusTaken]); }, [focusTaken, finish]);
const { onChange, onKeyUp, onKeyDown, onFocus, onBlur, ...autoCompleteProps } = const {
useAutoComplete(v => setContent(v ?? ''), { onChange,
users: { type: 'all' } onKeyUp,
}); onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete((v) => setContent(v ?? ""), {
users: { type: "all" },
});
return ( return (
<EditorBase> <EditorBase>
...@@ -83,14 +97,14 @@ export default function MessageEditor({ message, finish }: Props) { ...@@ -83,14 +97,14 @@ export default function MessageEditor({ message, finish }: Props) {
<TextAreaAutoSize <TextAreaAutoSize
forceFocus forceFocus
maxRows={3} maxRows={3}
padding={12}
value={content} value={content}
maxLength={2000} maxLength={2000}
onChange={ev => { padding="var(--message-box-padding)"
onChange={(ev) => {
onChange(ev); onChange(ev);
setContent(ev.currentTarget.value) setContent(ev.currentTarget.value);
}} }}
onKeyDown={e => { onKeyDown={(e) => {
if (onKeyDown(e)) return; if (onKeyDown(e)) return;
if ( if (
...@@ -107,9 +121,9 @@ export default function MessageEditor({ message, finish }: Props) { ...@@ -107,9 +121,9 @@ export default function MessageEditor({ message, finish }: Props) {
onBlur={onBlur} onBlur={onBlur}
/> />
<span className="caption"> <span className="caption">
escape to <a onClick={finish}>cancel</a> &middot; escape to <a onClick={finish}>cancel</a> &middot; enter to{" "}
enter to <a onClick={save}>save</a> <a onClick={save}>save</a>
</span> </span>
</EditorBase> </EditorBase>
) );
} }
/* eslint-disable react-hooks/rules-of-hooks */
import { X } from "@styled-icons/boxicons-regular";
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 { decodeTime } from "ulid";
import { Text } from "preact-i18n";
import { memo } from "preact/compat"; import { memo } from "preact/compat";
import styled from "styled-components"; import { useEffect, useState } from "preact/hooks";
import MessageEditor from "./MessageEditor";
import { Children } from "../../../types/Preact"; import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter";
import { Users } from "revolt.js/dist/api/objects";
import { X } from "@styled-icons/boxicons-regular";
import ConversationStart from "./ConversationStart";
import { connectState } from "../../../redux/connector";
import Preloader from "../../../components/ui/Preloader";
import { RenderState } from "../../../lib/renderer/types"; import { RenderState } from "../../../lib/renderer/types";
import DateDivider from "../../../components/ui/DateDivider";
import { connectState } from "../../../redux/connector";
import { QueuedMessage } from "../../../redux/reducers/queue"; import { QueuedMessage } from "../../../redux/reducers/queue";
import { useContext, useEffect, useState } from "preact/hooks";
import { MessageObject } from "../../../context/revoltjs/util";
import Message from "../../../components/common/messaging/Message";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter"; import { useClient } from "../../../context/revoltjs/RevoltClient";
import Message from "../../../components/common/messaging/Message";
import { SystemMessage } from "../../../components/common/messaging/SystemMessage"; import { SystemMessage } from "../../../components/common/messaging/SystemMessage";
import DateDivider from "../../../components/ui/DateDivider";
import Preloader from "../../../components/ui/Preloader";
import { Children } from "../../../types/Preact";
import ConversationStart from "./ConversationStart";
import MessageEditor from "./MessageEditor";
interface Props { interface Props {
id: string; id: string;
state: RenderState; state: RenderState;
highlight?: string;
queue: QueuedMessage[]; queue: QueuedMessage[];
} }
...@@ -36,10 +46,10 @@ const BlockedMessage = styled.div` ...@@ -36,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; if (state.type !== "RENDER") return null;
const client = useContext(AppContext); const client = useClient();
const userId = client.user!._id; const userId = client.user!._id;
const [editing, setEditing] = useState<string | undefined>(undefined); const [editing, setEditing] = useState<string | undefined>(undefined);
...@@ -50,10 +60,11 @@ function MessageRenderer({ id, state, queue }: Props) { ...@@ -50,10 +60,11 @@ function MessageRenderer({ id, state, queue }: Props) {
useEffect(() => { useEffect(() => {
function editLast() { function editLast() {
if (state.type !== 'RENDER') return; if (state.type !== "RENDER") return;
for (let i = state.messages.length - 1; i >= 0; i--) { 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); setEditing(state.messages[i]._id);
internalEmit("MessageArea", "jump_to_bottom");
return; return;
} }
} }
...@@ -61,14 +72,14 @@ function MessageRenderer({ id, state, queue }: Props) { ...@@ -61,14 +72,14 @@ function MessageRenderer({ id, state, queue }: Props) {
const subs = [ const subs = [
internalSubscribe("MessageRenderer", "edit_last", editLast), internalSubscribe("MessageRenderer", "edit_last", editLast),
internalSubscribe("MessageRenderer", "edit_message", setEditing) internalSubscribe("MessageRenderer", "edit_message", setEditing),
] ];
return () => subs.forEach(unsub => unsub()); return () => subs.forEach((unsub) => unsub());
}, [state.messages]); }, [state.messages, state.type, userId]);
let render: Children[] = [], const render: Children[] = [];
previous: MessageObject | undefined; let previous: MessageI | undefined;
if (state.atTop) { if (state.atTop) {
render.push(<ConversationStart id={id} />); render.push(<ConversationStart id={id} />);
...@@ -76,7 +87,7 @@ function MessageRenderer({ id, state, queue }: Props) { ...@@ -76,7 +87,7 @@ function MessageRenderer({ id, state, queue }: Props) {
render.push( render.push(
<RequiresOnline> <RequiresOnline>
<Preloader type="ring" /> <Preloader type="ring" />
</RequiresOnline> </RequiresOnline>,
); );
} }
...@@ -85,7 +96,7 @@ function MessageRenderer({ id, state, queue }: Props) { ...@@ -85,7 +96,7 @@ function MessageRenderer({ id, state, queue }: Props) {
current: string, current: string,
curAuthor: string, curAuthor: string,
previous: string, previous: string,
prevAuthor: string prevAuthor: string,
) { ) {
const atime = decodeTime(current), const atime = decodeTime(current),
adate = new Date(atime), adate = new Date(atime),
...@@ -106,7 +117,15 @@ function MessageRenderer({ id, state, queue }: Props) { ...@@ -106,7 +117,15 @@ function MessageRenderer({ id, state, queue }: Props) {
let blocked = 0; let blocked = 0;
function pushBlocked() { function pushBlocked() {
render.push(<BlockedMessage><X size={16} /> { blocked } blocked messages</BlockedMessage>); render.push(
<BlockedMessage>
<X size={16} />{" "}
<Text
id="app.main.channel.misc.blocked_messages"
fields={{ count: blocked }}
/>
</BlockedMessage>,
);
blocked = 0; blocked = 0;
} }
...@@ -114,85 +133,97 @@ function MessageRenderer({ id, state, queue }: Props) { ...@@ -114,85 +133,97 @@ function MessageRenderer({ id, state, queue }: Props) {
if (previous) { if (previous) {
compare( compare(
message._id, message._id,
message.author, message.author_id,
previous._id, previous._id,
previous.author previous.author_id,
); );
} }
if (message.author === "00000000000000000000000000") { if (message.author_id === SYSTEM_USER_ID) {
render.push(<SystemMessage key={message._id} message={message} attachContext />); render.push(
<SystemMessage
key={message._id}
message={message}
attachContext
highlight={highlight === message._id}
/>,
);
} else if (
message.author?.relationship === RelationshipStatus.Blocked
) {
blocked++;
} else { } else {
// ! FIXME: temp solution if (blocked > 0) pushBlocked();
if (client.users.get(message.author)?.relationship === Users.Relationship.Blocked) {
blocked++; render.push(
} else { <Message
if (blocked > 0) pushBlocked(); message={message}
key={message._id}
render.push( head={head}
<Message message={message} content={
key={message._id} editing === message._id ? (
head={head} <MessageEditor
content={ message={message}
editing === message._id ? finish={stopEditing}
<MessageEditor message={message} finish={stopEditing} /> />
: undefined ) : undefined
} }
attachContext /> attachContext
); highlight={highlight === message._id}
} />,
);
} }
previous = message; previous = message;
} }
if (blocked > 0) pushBlocked(); if (blocked > 0) pushBlocked();
const nonces = state.messages.map(x => x.nonce); const nonces = state.messages.map((x) => x.nonce);
if (state.atBottom) { if (state.atBottom) {
for (const msg of queue) { for (const msg of queue) {
if (msg.channel !== id) continue; if (msg.channel !== id) continue;
if (nonces.includes(msg.id)) continue; if (nonces.includes(msg.id)) continue;
if (previous) { if (previous) {
compare( compare(msg.id, userId!, previous._id, previous.author_id);
msg.id,
userId!,
previous._id,
previous.author
);
previous = { previous = {
_id: msg.id, _id: msg.id,
data: { author: userId! } author_id: userId!,
} as any; } as MessageI;
} }
render.push( render.push(
<Message <Message
message={{ message={
...msg.data, new MessageI(client, {
replies: msg.data.replies.map(x => x.id) ...msg.data,
}} replies: msg.data.replies.map((x) => x.id),
})
}
key={msg.id} key={msg.id}
queued={msg} queued={msg}
head={head} head={head}
attachContext /> attachContext
/>,
); );
} }
} else { } else {
render.push( render.push(
<RequiresOnline> <RequiresOnline>
<Preloader type="ring" /> <Preloader type="ring" />
</RequiresOnline> </RequiresOnline>,
); );
} }
return <>{ render }</>; return <>{render}</>;
} }
export default memo(connectState<Omit<Props, 'queue'>>(MessageRenderer, state => { export default memo(
return { connectState<Omit<Props, "queue">>(MessageRenderer, (state) => {
queue: state.queue return {
}; queue: state.queue,
})); };
}),
);
import { Text } from "preact-i18n"; import { BarChart } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import styled from "styled-components"; import styled from "styled-components";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { BarChart } from "@styled-icons/boxicons-regular";
import Button from "../../../components/ui/Button"; import {
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import UserIcon from "../../../components/common/user/UserIcon"; import UserIcon from "../../../components/common/user/UserIcon";
import { useForceUpdate, useSelf, useUsers } from "../../../context/revoltjs/hooks"; import Button from "../../../components/ui/Button";
import { VoiceContext, VoiceOperationsContext, VoiceStatus } from "../../../context/Voice";
interface Props { interface Props {
id: string id: string;
} }
const VoiceBase = styled.div` const VoiceBase = styled.div`
...@@ -16,18 +24,20 @@ const VoiceBase = styled.div` ...@@ -16,18 +24,20 @@ const VoiceBase = styled.div`
background: var(--secondary-background); background: var(--secondary-background);
.status { .status {
position: absolute; flex: 1 0;
color: var(--success);
background: var(--primary-background);
display: flex; display: flex;
position: absolute;
align-items: center; align-items: center;
padding: 10px; padding: 10px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
border-radius: 7px;
flex: 1 0;
user-select: none; user-select: none;
color: var(--success);
border-radius: var(--border-radius);
background: var(--primary-background);
svg { svg {
margin-inline-end: 4px; margin-inline-end: 4px;
cursor: help; cursor: help;
...@@ -57,50 +67,63 @@ const VoiceBase = styled.div` ...@@ -57,50 +67,63 @@ const VoiceBase = styled.div`
} }
`; `;
export default function VoiceHeader({ id }: Props) { export default observer(({ id }: Props) => {
const { status, participants, roomId } = useContext(VoiceContext); const { status, participants, roomId } = useContext(VoiceContext);
if (roomId !== id) return null; if (roomId !== id) return null;
const { isProducing, startProducing, stopProducing, disconnect } = useContext(VoiceOperationsContext); const { isProducing, startProducing, stopProducing, disconnect } =
useContext(VoiceOperationsContext);
const ctx = useForceUpdate(); const client = useClient();
const self = useSelf(ctx); const self = client.users.get(client.user!._id);
//const ctx = useForceUpdate();
//const self = useSelf(ctx);
const keys = participants ? Array.from(participants.keys()) : undefined; 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 ( return (
<VoiceBase> <VoiceBase>
<div className="participants"> <div className="participants">
{ users && users.length !== 0 ? users.map((user, index) => { {users && users.length !== 0
const id = keys![index]; ? users.map((user, index) => {
return ( const id = keys![index];
<div key={id}> return (
<UserIcon <div key={id}>
size={80} <UserIcon
target={user} size={80}
status={false} target={user}
voice={ participants!.get(id)?.audio ? undefined : "muted" } status={false}
/> voice={
</div> participants!.get(id)?.audio
); ? undefined
}) : self !== undefined && ( : "muted"
<div key={self._id} className="disconnected"> }
<UserIcon />
size={80} </div>
target={self} );
status={false} /> })
</div> : self !== undefined && (
)} <div key={self._id} className="disconnected">
<UserIcon
size={80}
target={self}
status={false}
/>
</div>
)}
</div> </div>
<div className="status"> <div className="status">
<BarChart size={20} /> <BarChart size={20} />
{ status === VoiceStatus.CONNECTED && <Text id="app.main.channel.voice.connected" /> } {status === VoiceStatus.CONNECTED && (
<Text id="app.main.channel.voice.connected" />
)}
</div> </div>
<div className="actions"> <div className="actions">
<Button error onClick={disconnect}> <Button error onClick={disconnect}>
<Text id="app.main.channel.voice.leave" /> <Text id="app.main.channel.voice.leave" />
</Button> </Button>
{ isProducing("audio") ? ( {isProducing("audio") ? (
<Button onClick={() => stopProducing("audio")}> <Button onClick={() => stopProducing("audio")}>
<Text id="app.main.channel.voice.mute" /> <Text id="app.main.channel.voice.mute" />
</Button> </Button>
...@@ -111,8 +134,8 @@ export default function VoiceHeader({ id }: Props) { ...@@ -111,8 +134,8 @@ export default function VoiceHeader({ id }: Props) {
)} )}
</div> </div>
</VoiceBase> </VoiceBase>
) );
} });
/**{voice.roomId === id && ( /**{voice.roomId === id && (
<div className={styles.rtc}> <div className={styles.rtc}>
......
import { Wrench } from "@styled-icons/boxicons-solid";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { TextReact } from "../../lib/i18n";
import Header from "../../components/ui/Header";
import PaintCounter from "../../lib/PaintCounter"; import PaintCounter from "../../lib/PaintCounter";
import { TextReact } from "../../lib/i18n";
import { AppContext } from "../../context/revoltjs/RevoltClient"; import { AppContext } from "../../context/revoltjs/RevoltClient";
import { useUserPermission } from "../../context/revoltjs/hooks";
import { Wrench } from "@styled-icons/boxicons-solid"; import Header from "../../components/ui/Header";
export default function Developer() { export default function Developer() {
// const voice = useContext(VoiceContext); // const voice = useContext(VoiceContext);
const client = useContext(AppContext); const client = useContext(AppContext);
const userPermission = useUserPermission(client.user!._id); const userPermission = client.user!.permission;
return ( return (
<div> <div>
...@@ -21,11 +24,14 @@ export default function Developer() { ...@@ -21,11 +24,14 @@ export default function Developer() {
<PaintCounter always /> <PaintCounter always />
</div> </div>
<div style={{ padding: "16px" }}> <div style={{ padding: "16px" }}>
<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" }}> <div style={{ padding: "16px" }}>
<TextReact id="login.open_mail_provider" fields={{ provider: <b>GAMING!</b> }} /> <TextReact
id="login.open_mail_provider"
fields={{ provider: <b>GAMING!</b> }}
/>
</div> </div>
<div style={{ padding: "16px" }}> <div style={{ padding: "16px" }}>
{/*<span> {/*<span>
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
&[data-empty="true"] { &[data-empty="true"] {
img { img {
height: 120px; height: 120px;
border-radius: 8px; border-radius: var(--border-radius);
} }
gap: 16px; gap: 16px;
...@@ -28,15 +28,19 @@ ...@@ -28,15 +28,19 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
} }
&[data-mobile="true"] {
padding-bottom: var(--bottom-navigation-height);
}
} }
.friend { .friend {
padding: 0 10px;
height: 60px; height: 60px;
display: flex; display: flex;
border-radius: 5px; padding: 0 10px;
align-items: center;
cursor: pointer; cursor: pointer;
align-items: center;
border-radius: var(--border-radius);
&:hover { &:hover {
background: var(--secondary-background); background: var(--secondary-background);
...@@ -106,9 +110,9 @@ ...@@ -106,9 +110,9 @@
display: flex; display: flex;
cursor: pointer; cursor: pointer;
margin-top: 1em; margin-top: 1em;
border-radius: 7px;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
border-radius: var(--border-radius);
background: var(--secondary-background); background: var(--secondary-background);
svg { svg {
...@@ -187,7 +191,7 @@ ...@@ -187,7 +191,7 @@
padding: 0 8px 8px 8px; padding: 0 8px 8px 8px;
} }
.call { .remove {
display: none; display: none;
} }
} }
import { Text } from "preact-i18n"; import { X, Plus } from "@styled-icons/boxicons-regular";
import classNames from "classnames"; import { PhoneCall, Envelope, UserX } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { RelationshipStatus } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./Friend.module.scss"; import styles from "./Friend.module.scss";
import { useContext } from "preact/hooks"; import classNames from "classnames";
import { Children } from "../../types/Preact";
import IconButton from "../../components/ui/IconButton";
import { attachContextMenu } from "preact-context-menu"; import { attachContextMenu } from "preact-context-menu";
import { X, Plus } from "@styled-icons/boxicons-regular"; import { Text } from "preact-i18n";
import { User, Users } from "revolt.js/dist/api/objects"; import { useContext } from "preact/hooks";
import { stopPropagation } from "../../lib/stopPropagation"; import { stopPropagation } from "../../lib/stopPropagation";
import { VoiceOperationsContext } from "../../context/Voice"; import { VoiceOperationsContext } from "../../context/Voice";
import UserIcon from "../../components/common/user/UserIcon";
import UserStatus from '../../components/common/user/UserStatus';
import { PhoneCall, Envelope } from "@styled-icons/boxicons-solid";
import { useIntermediate } from "../../context/intermediate/Intermediate"; import { useIntermediate } from "../../context/intermediate/Intermediate";
import { AppContext, OperationsContext } from "../../context/revoltjs/RevoltClient";
import UserIcon from "../../components/common/user/UserIcon";
import UserStatus from "../../components/common/user/UserStatus";
import IconButton from "../../components/ui/IconButton";
import { Children } from "../../types/Preact";
interface Props { interface Props {
user: User; user: User;
} }
export function Friend({ user }: Props) { export const Friend = observer(({ user }: Props) => {
const client = useContext(AppContext); const history = useHistory();
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const { openDM } = useContext(OperationsContext);
const { connect } = useContext(VoiceOperationsContext); const { connect } = useContext(VoiceOperationsContext);
const actions: Children[] = []; const actions: Children[] = [];
let subtext: Children = null; let subtext: Children = null;
if (user.relationship === Users.Relationship.Friend) { if (user.relationship === RelationshipStatus.Friend) {
subtext = <UserStatus user={user} /> subtext = <UserStatus user={user} />;
actions.push( actions.push(
<> <>
<IconButton type="circle" <IconButton
className={classNames(styles.button, styles.call, styles.success)} type="circle"
onClick={ev => stopPropagation(ev, openDM(user._id).then(connect))}> className={classNames(styles.button, styles.success)}
onClick={(ev) =>
stopPropagation(
ev,
user
.openDM()
.then(connect)
.then((x) => history.push(`/channel/${x._id}`)),
)
}>
<PhoneCall size={20} /> <PhoneCall size={20} />
</IconButton> </IconButton>
<IconButton type="circle" <IconButton
type="circle"
className={styles.button} className={styles.button}
onClick={ev => stopPropagation(ev, openDM(user._id))}> onClick={(ev) =>
stopPropagation(
ev,
user
.openDM()
.then((channel) =>
history.push(`/channel/${channel._id}`),
),
)
}>
<Envelope size={20} /> <Envelope size={20} />
</IconButton> </IconButton>
</> </>,
); );
} }
if (user.relationship === Users.Relationship.Incoming) { if (user.relationship === RelationshipStatus.Incoming) {
actions.push( actions.push(
<IconButton type="circle" <IconButton
type="circle"
className={styles.button} className={styles.button}
onClick={ev => stopPropagation(ev, client.users.addFriend(user.username))}> onClick={(ev) => stopPropagation(ev, user.addFriend())}>
<Plus size={24} /> <Plus size={24} />
</IconButton> </IconButton>,
); );
subtext = <Text id="app.special.friends.incoming" />; subtext = <Text id="app.special.friends.incoming" />;
} }
if (user.relationship === Users.Relationship.Outgoing) { if (user.relationship === RelationshipStatus.Outgoing) {
subtext = <Text id="app.special.friends.outgoing" />; subtext = <Text id="app.special.friends.outgoing" />;
} }
if ( if (
user.relationship === Users.Relationship.Friend || user.relationship === RelationshipStatus.Friend ||
user.relationship === Users.Relationship.Outgoing || user.relationship === RelationshipStatus.Outgoing ||
user.relationship === Users.Relationship.Incoming user.relationship === RelationshipStatus.Incoming
) { ) {
actions.push( actions.push(
<IconButton type="circle" <IconButton
className={classNames(styles.button, styles.error)} type="circle"
onClick={ev => stopPropagation(ev, className={classNames(
user.relationship === Users.Relationship.Friend ? styles.button,
openScreen({ id: 'special_prompt', type: 'unfriend_user', target: user }) styles.remove,
: client.users.removeFriend(user._id) styles.error,
)}> )}
onClick={(ev) =>
stopPropagation(
ev,
user.relationship === RelationshipStatus.Friend
? openScreen({
id: "special_prompt",
type: "unfriend_user",
target: user,
})
: user.removeFriend(),
)
}>
<X size={24} /> <X size={24} />
</IconButton> </IconButton>,
); );
} }
if (user.relationship === Users.Relationship.Blocked) { if (user.relationship === RelationshipStatus.Blocked) {
actions.push( actions.push(
<IconButton type="circle" <IconButton
type="circle"
className={classNames(styles.button, styles.error)} className={classNames(styles.button, styles.error)}
onClick={ev => stopPropagation(ev, client.users.unblockUser(user._id))}> onClick={(ev) => stopPropagation(ev, user.unblockUser())}>
<X size={24} /> <UserX size={24} />
</IconButton> </IconButton>,
); );
} }
return ( return (
<div className={styles.friend} <div
onClick={() => openScreen({ id: 'profile', user_id: user._id })} className={styles.friend}
onContextMenu={attachContextMenu('Menu', { user: user._id })}> onClick={() => openScreen({ id: "profile", user_id: user._id })}
onContextMenu={attachContextMenu("Menu", { user: user._id })}>
<UserIcon target={user} size={36} status /> <UserIcon target={user} size={36} status />
<div className={styles.name}> <div className={styles.name}>
<span>@{user.username}</span> <span>{user.username}</span>
{subtext && ( {subtext && <span className={styles.subtext}>{subtext}</span>}
<span className={styles.subtext}>{subtext}</span>
)}
</div> </div>
<div className={styles.actions}>{actions}</div> <div className={styles.actions}>{actions}</div>
</div> </div>
); );
} });
import { Friend } from "./Friend"; import { ChevronRight } from "@styled-icons/boxicons-regular";
import { Text } from "preact-i18n"; import { UserDetail, MessageAdd, UserPlus } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { RelationshipStatus, Presence } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./Friend.module.scss"; import styles from "./Friend.module.scss";
import Header from "../../components/ui/Header"; import { Text } from "preact-i18n";
import Overline from "../../components/ui/Overline";
import Tooltip from "../../components/common/Tooltip"; import { TextReact } from "../../lib/i18n";
import IconButton from "../../components/ui/IconButton";
import { useUsers } from "../../context/revoltjs/hooks";
import { User, Users } from "revolt.js/dist/api/objects";
import UserIcon from "../../components/common/user/UserIcon";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../context/intermediate/Intermediate"; import { useIntermediate } from "../../context/intermediate/Intermediate";
import { ChevronDown, ChevronRight, ListPlus } from "@styled-icons/boxicons-regular"; import { useClient } from "../../context/revoltjs/RevoltClient";
import { UserDetail, MessageAdd, UserPlus } from "@styled-icons/boxicons-solid";
import { TextReact } from "../../lib/i18n";
import { Children } from "../../types/Preact";
import Details from "../../components/ui/Details";
import CollapsibleSection from "../../components/common/CollapsibleSection"; import CollapsibleSection from "../../components/common/CollapsibleSection";
import Tooltip from "../../components/common/Tooltip";
import UserIcon from "../../components/common/user/UserIcon";
import Header from "../../components/ui/Header";
import IconButton from "../../components/ui/IconButton";
import { Children } from "../../types/Preact";
import { Friend } from "./Friend";
export default function Friends() { export default observer(() => {
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const users = useUsers() as User[]; const client = useClient();
const users = [...client.users.values()];
users.sort((a, b) => a.username.localeCompare(b.username)); users.sort((a, b) => a.username.localeCompare(b.username));
const friends = users.filter(x => x.relationship === Users.Relationship.Friend); const friends = users.filter(
(x) => x.relationship === RelationshipStatus.Friend,
);
const lists = [ const lists = [
[ '', users.filter(x => [
x.relationship === Users.Relationship.Incoming "",
) ], users.filter((x) => x.relationship === RelationshipStatus.Incoming),
[ 'app.special.friends.sent', users.filter(x => ],
x.relationship === Users.Relationship.Outgoing [
), 'outgoing' ], "app.special.friends.sent",
[ 'app.status.online', friends.filter(x => users.filter((x) => x.relationship === RelationshipStatus.Outgoing),
x.online && x.status?.presence !== Users.Presence.Invisible "outgoing",
), 'online' ], ],
[ 'app.status.offline', friends.filter(x => [
!x.online || x.status?.presence === Users.Presence.Invisible "app.status.online",
), 'offline' ], friends.filter(
[ 'app.special.friends.blocked', users.filter(x => (x) => x.online && x.status?.presence !== Presence.Invisible,
x.relationship === Users.Relationship.Blocked ),
), 'blocked' ] "online",
] as [ string, User[], string ][]; ],
[
"app.status.offline",
friends.filter(
(x) => !x.online || x.status?.presence === Presence.Invisible,
),
"offline",
],
[
"app.special.friends.blocked",
users.filter((x) => x.relationship === RelationshipStatus.Blocked),
"blocked",
],
] as [string, User[], string][];
const incoming = lists[0][1]; const incoming = lists[0][1];
const userlist: Children[] = incoming.map(x => <b>{ x.username }</b>); const userlist: Children[] = incoming.map((x) => (
for (let i=incoming.length-1;i>0;i--) userlist.splice(i, 0, ', '); <b key={x._id}>{x.username}</b>
));
for (let i = incoming.length - 1; i > 0; i--) userlist.splice(i, 0, ", ");
const isEmpty = lists.reduce((p: number, n) => p + n.length, 0) === 0; const isEmpty = lists.reduce((p: number, n) => p + n.length, 0) === 0;
return ( return (
<> <>
<Header placement="primary"> <Header placement="primary">
{ !isTouchscreenDevice && <UserDetail size={24} /> } {!isTouchscreenDevice && <UserDetail size={24} />}
<div className={styles.title}> <div className={styles.title}>
<Text id="app.navigation.tabs.friends" /> <Text id="app.navigation.tabs.friends" />
</div> </div>
...@@ -63,12 +85,24 @@ export default function Friends() { ...@@ -63,12 +85,24 @@ export default function Friends() {
</Tooltip> </Tooltip>
<div className={styles.divider} />*/} <div className={styles.divider} />*/}
<Tooltip content={"Create Group"} placement="bottom"> <Tooltip content={"Create Group"} placement="bottom">
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_group' })}> <IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "create_group",
})
}>
<MessageAdd size={24} /> <MessageAdd size={24} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip content={"Add Friend"} placement="bottom"> <Tooltip content={"Add Friend"} placement="bottom">
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'add_friend' })}> <IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "add_friend",
})
}>
<UserPlus size={27} /> <UserPlus size={27} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
...@@ -82,7 +116,10 @@ export default function Friends() { ...@@ -82,7 +116,10 @@ export default function Friends() {
*/} */}
</div> </div>
</Header> </Header>
<div className={styles.list} data-empty={isEmpty}> <div
className={styles.list}
data-empty={isEmpty}
data-mobile={isTouchscreenDevice}>
{isEmpty && ( {isEmpty && (
<> <>
<img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" /> <img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" />
...@@ -90,41 +127,89 @@ export default function Friends() { ...@@ -90,41 +127,89 @@ export default function Friends() {
</> </>
)} )}
{ incoming.length > 0 && <div className={styles.pending} {incoming.length > 0 && (
onClick={() => openScreen({ id: 'pending_requests', users: incoming.map(x => x._id) })}> <div
<div className={styles.avatars}> className={styles.pending}
{ incoming.map((x, i) => i < 3 && <UserIcon target={x} size={64} mask={ i < Math.min(incoming.length - 1, 2) ? "url(#overlap)" : undefined } />) } onClick={() =>
</div> openScreen({
<div className={styles.details}> id: "pending_requests",
<div><Text id="app.special.friends.pending" /> <span>{ incoming.length }</span></div> users: incoming,
<span> })
{ }>
incoming.length > 3 ? <TextReact id="app.special.friends.from.several" fields={{ userlist: userlist.slice(0, 6), count: incoming.length - 3 }} /> <div className={styles.avatars}>
: incoming.length > 1 ? <TextReact id="app.special.friends.from.multiple" fields={{ user: userlist.shift()!, userlist: userlist.slice(1) }} /> {incoming.map(
: <TextReact id="app.special.friends.from.single" fields={{ user: userlist[0] }} /> (x, i) =>
} i < 3 && (
</span> <UserIcon
target={x}
size={64}
mask={
i <
Math.min(incoming.length - 1, 2)
? "url(#overlap)"
: undefined
}
/>
),
)}
</div>
<div className={styles.details}>
<div>
<Text id="app.special.friends.pending" />{" "}
<span>{incoming.length}</span>
</div>
<span>
{incoming.length > 3 ? (
<TextReact
id="app.special.friends.from.several"
fields={{
userlist: userlist.slice(0, 6),
count: incoming.length - 3,
}}
/>
) : incoming.length > 1 ? (
<TextReact
id="app.special.friends.from.multiple"
fields={{
user: userlist.shift()!,
userlist: userlist.slice(1),
}}
/>
) : (
<TextReact
id="app.special.friends.from.single"
fields={{ user: userlist[0] }}
/>
)}
</span>
</div>
<ChevronRight size={28} />
</div> </div>
<ChevronRight size={28} /> )}
</div> }
{ {lists.map(([i18n, list, section_id], index) => {
lists.map(([i18n, list, section_id], index) => { if (index === 0) return;
if (index === 0) return; if (list.length === 0) return;
if (list.length === 0) return;
return ( return (
<CollapsibleSection <CollapsibleSection
id={`friends_${section_id}`} key={section_id}
defaultValue={true} id={`friends_${section_id}`}
sticky large defaultValue={true}
summary={<div class="title"><Text id={i18n} />{ list.length }</div>}> sticky
{ list.map(x => <Friend key={x._id} user={x} />) } large
</CollapsibleSection> summary={
) <div class="title">
}) <Text id={i18n} />{list.length}
} </div>
}>
{list.map((x) => (
<Friend key={x._id} user={x} />
))}
</CollapsibleSection>
);
})}
</div> </div>
</> </>
); );
} });