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 1805 additions and 509 deletions
import EventEmitter from "eventemitter3";
import * as mediasoupClient from "mediasoup-client";
import { types } from "mediasoup-client";
import { Device, Producer, Transport } from "mediasoup-client/lib/types";
import Signaling from "./Signaling";
import {
ProduceType,
WSEventType,
VoiceError,
VoiceUser,
ConsumerList,
WSErrorCode,
} from "./Types";
const UnsupportedError = types.UnsupportedError;
interface VoiceEvents {
ready: () => void;
error: (error: Error) => void;
close: (error?: VoiceError) => void;
startProduce: (type: ProduceType) => void;
stopProduce: (type: ProduceType) => void;
userJoined: (userId: string) => void;
userLeft: (userId: string) => void;
userStartProduce: (userId: string, type: ProduceType) => void;
userStopProduce: (userId: string, type: ProduceType) => void;
}
export default class VoiceClient extends EventEmitter<VoiceEvents> {
private _supported: boolean;
device?: Device;
signaling: Signaling;
sendTransport?: Transport;
recvTransport?: Transport;
userId?: string;
roomId?: string;
participants: Map<string, VoiceUser>;
consumers: Map<string, ConsumerList>;
audioProducer?: Producer;
constructor() {
super();
this._supported = mediasoupClient.detectDevice() !== undefined;
this.signaling = new Signaling();
this.participants = new Map();
this.consumers = new Map();
this.signaling.on(
"data",
(json) => {
const data = json.data;
switch (json.type) {
case WSEventType.UserJoined: {
this.participants.set(data.id, {});
this.emit("userJoined", data.id);
break;
}
case WSEventType.UserLeft: {
this.participants.delete(data.id);
this.emit("userLeft", data.id);
if (this.recvTransport) this.stopConsume(data.id);
break;
}
case WSEventType.UserStartProduce: {
const user = this.participants.get(data.id);
if (user === undefined) return;
switch (data.type) {
case "audio":
user.audio = true;
break;
default:
throw new Error(
`Invalid produce type ${data.type}`,
);
}
if (this.recvTransport)
this.startConsume(data.id, data.type);
this.emit("userStartProduce", data.id, data.type);
break;
}
case WSEventType.UserStopProduce: {
const user = this.participants.get(data.id);
if (user === undefined) return;
switch (data.type) {
case "audio":
user.audio = false;
break;
default:
throw new Error(
`Invalid produce type ${data.type}`,
);
}
if (this.recvTransport)
this.stopConsume(data.id, data.type);
this.emit("userStopProduce", data.id, data.type);
break;
}
}
},
this,
);
this.signaling.on(
"error",
() => {
this.emit("error", new Error("Signaling error"));
},
this,
);
this.signaling.on(
"close",
(error) => {
this.disconnect(
{
error: error.code,
message: error.reason,
},
true,
);
},
this,
);
}
supported() {
return this._supported;
}
throwIfUnsupported() {
if (!this._supported) throw new UnsupportedError("RTC not supported");
}
connect(address: string, roomId: string) {
this.throwIfUnsupported();
this.device = new Device();
this.roomId = roomId;
return this.signaling.connect(address);
}
disconnect(error?: VoiceError, ignoreDisconnected?: boolean) {
if (!this.signaling.connected() && !ignoreDisconnected) return;
this.signaling.disconnect();
this.participants = new Map();
this.consumers = new Map();
this.userId = undefined;
this.roomId = undefined;
this.audioProducer = undefined;
if (this.sendTransport) this.sendTransport.close();
if (this.recvTransport) this.recvTransport.close();
this.sendTransport = undefined;
this.recvTransport = undefined;
this.emit("close", error);
}
async authenticate(token: string) {
this.throwIfUnsupported();
if (this.device === undefined || this.roomId === undefined)
throw new ReferenceError("Voice Client is in an invalid state");
const result = await this.signaling.authenticate(token, this.roomId);
const [room] = await Promise.all([
this.signaling.roomInfo(),
this.device.load({ routerRtpCapabilities: result.rtpCapabilities }),
]);
this.userId = result.userId;
this.participants = room.users;
}
async initializeTransports() {
this.throwIfUnsupported();
if (this.device === undefined)
throw new ReferenceError("Voice Client is in an invalid state");
const initData = await this.signaling.initializeTransports(
this.device.rtpCapabilities,
);
this.sendTransport = this.device.createSendTransport(
initData.sendTransport,
);
this.recvTransport = this.device.createRecvTransport(
initData.recvTransport,
);
const connectTransport = (transport: Transport) => {
transport.on("connect", ({ dtlsParameters }, callback, errback) => {
this.signaling
.connectTransport(transport.id, dtlsParameters)
.then(callback)
.catch(errback);
});
};
connectTransport(this.sendTransport);
connectTransport(this.recvTransport);
this.sendTransport.on("produce", (parameters, callback, errback) => {
const type = parameters.appData.type;
if (
parameters.kind === "audio" &&
type !== "audio" &&
type !== "saudio"
)
return errback();
if (
parameters.kind === "video" &&
type !== "video" &&
type !== "svideo"
)
return errback();
this.signaling
.startProduce(type, parameters.rtpParameters)
.then((id) => callback({ id }))
.catch(errback);
});
this.emit("ready");
for (const user of this.participants) {
if (user[1].audio && user[0] !== this.userId)
this.startConsume(user[0], "audio");
}
}
private async startConsume(userId: string, type: ProduceType) {
if (this.recvTransport === undefined)
throw new Error("Receive transport undefined");
const consumers = this.consumers.get(userId) || {};
const consumerParams = await this.signaling.startConsume(userId, type);
const consumer = await this.recvTransport.consume(consumerParams);
switch (type) {
case "audio":
consumers.audio = consumer;
}
const mediaStream = new MediaStream([consumer.track]);
const audio = new Audio();
audio.srcObject = mediaStream;
await this.signaling.setConsumerPause(consumer.id, false);
audio.play();
this.consumers.set(userId, consumers);
}
private async stopConsume(userId: string, type?: ProduceType) {
const consumers = this.consumers.get(userId);
if (consumers === undefined) return;
if (type === undefined) {
if (consumers.audio !== undefined) consumers.audio.close();
this.consumers.delete(userId);
} else {
switch (type) {
case "audio": {
if (consumers.audio !== undefined) {
consumers.audio.close();
this.signaling.stopConsume(consumers.audio.id);
}
consumers.audio = undefined;
break;
}
}
this.consumers.set(userId, consumers);
}
}
async startProduce(track: MediaStreamTrack, type: ProduceType) {
if (this.sendTransport === undefined)
throw new Error("Send transport undefined");
const producer = await this.sendTransport.produce({
track,
appData: { type },
});
switch (type) {
case "audio":
this.audioProducer = producer;
break;
}
const participant = this.participants.get(this.userId || "");
if (participant !== undefined) {
participant[type] = true;
this.participants.set(this.userId || "", participant);
}
this.emit("startProduce", type);
}
async stopProduce(type: ProduceType) {
let producer;
switch (type) {
case "audio":
producer = this.audioProducer;
this.audioProducer = undefined;
break;
}
if (producer !== undefined) {
producer.close();
this.emit("stopProduce", type);
}
const participant = this.participants.get(this.userId || "");
if (participant !== undefined) {
participant[type] = false;
this.participants.set(this.userId || "", participant);
}
try {
await this.signaling.stopProduce(type);
} catch (error) {
if (error.error === WSErrorCode.ProducerNotFound) return;
throw error;
}
}
}
......@@ -3,14 +3,14 @@ import { useEffect, useState } from "preact/hooks";
export function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
height: window.innerHeight,
});
}
......
import { render } from "preact";
import { registerSW } from "virtual:pwa-register";
import "./styles/index.scss";
import { App } from "./pages/app";
import { render } from "preact";
import { internalEmit } from "./lib/eventEmitter";
import { registerSW } from 'virtual:pwa-register'
import { App } from "./pages/app";
const updateSW = registerSW({
export const updateSW = registerSW({
onNeedRefresh() {
// ! FIXME: temp
updateSW(true);
// show a prompt to user
internalEmit("PWA", "update");
},
onOfflineReady() {
console.info('Ready to work offline.');
console.info("Ready to work offline.");
// show a ready to work offline to user
},
})
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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 Header from "../components/ui/Header";
import { useContext, useEffect } from "preact/hooks";
import { useHistory, useParams } from "react-router-dom";
import { useIntermediate } from "../context/intermediate/Intermediate";
import { useChannels, useForceUpdate, useUser } from "../context/revoltjs/hooks";
import { AppContext, ClientStatus, StatusContext } from "../context/revoltjs/RevoltClient";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import Header from "../components/ui/Header";
export default function Open() {
const history = useHistory();
......@@ -21,48 +28,48 @@ export default function Open() {
);
}
const ctx = useForceUpdate();
const channels = useChannels(undefined, ctx);
const user = useUser(id, ctx);
useEffect(() => {
if (id === "saved") {
for (const channel of channels) {
for (const channel of [...client.channels.values()]) {
if (channel?.channel_type === "SavedMessages") {
history.push(`/channel/${channel._id}`);
return;
}
}
client.users
.openDM(client.user?._id as string)
.then(channel => history.push(`/channel/${channel?._id}`))
.catch(error => openScreen({ id: "error", error }));
client
.user!.openDM()
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
return;
}
const user = client.users.get(id);
if (user) {
const channel: string | undefined = channels.find(
channel =>
const channel: string | undefined = [
...client.channels.values(),
].find(
(channel) =>
channel?.channel_type === "DirectMessage" &&
channel.recipients.includes(id)
channel.recipient_ids!.includes(id),
)?._id;
if (channel) {
history.push(`/channel/${channel}`);
} else {
client.users
.openDM(id)
.then(channel => history.push(`/channel/${channel?._id}`))
.catch(error => openScreen({ id: "error", error }));
.get(id)
?.openDM()
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
}
return;
}
history.push("/");
}, []);
});
return (
<Header placement="primary">
......
import { Docked, OverlappingPanels, ShowIf } from "react-overlapping-panels";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import { Switch, Route, useLocation } from "react-router-dom";
import styled from "styled-components";
import ContextMenus from "../lib/ContextMenus";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
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 StateMonitor from "../context/revoltjs/StateMonitor";
import SyncManager from "../context/revoltjs/SyncManager";
import { Titlebar } from "../components/native/Titlebar";
import BottomNavigation from "../components/navigation/BottomNavigation";
import LeftSidebar from "../components/navigation/LeftSidebar";
import RightSidebar from "../components/navigation/RightSidebar";
import BottomNavigation from "../components/navigation/BottomNavigation";
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 Settings from './settings/Settings';
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 ServerSettings from "./settings/ServerSettings";
import Settings from "./settings/Settings";
const Routes = styled.div`
min-width: 0;
......@@ -33,49 +34,101 @@ const Routes = styled.div`
export default function App() {
const path = useLocation().pathname;
const fixedBottomNav = (path === '/' || path === '/settings' || path.startsWith("/friends"));
const fixedBottomNav =
path === "/" || path === "/settings" || path.startsWith("/friends");
const inChannel = path.includes("/channel");
const inSpecial =
(path.startsWith("/friends") && isTouchscreenDevice) ||
path.startsWith("/invite") ||
path.includes("/settings");
return (
<OverlappingPanels
width="100vw"
height="100vh"
leftPanel={{ width: 292, component: <LeftSidebar /> }}
rightPanel={{ width: 240, component: <RightSidebar /> }}
bottomNav={{
component: <BottomNavigation />,
showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left,
height: 50
}}
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
<Routes>
<Switch>
<Route path="/server/:server/channel/:channel/settings/:page" component={ChannelSettings} />
<Route path="/server/:server/channel/:channel/settings" component={ChannelSettings} />
<Route path="/server/:server/settings/:page" component={ServerSettings} />
<Route path="/server/:server/settings" component={ServerSettings} />
<Route path="/channel/:channel/settings/:page" component={ChannelSettings} />
<Route path="/channel/:channel/settings" component={ChannelSettings} />
<>
{window.isNative && !window.native.getConfig().frame && (
<Titlebar />
)}
<OverlappingPanels
width="100vw"
height={
window.isNative && !window.native.getConfig().frame
? "calc(var(--app-height) - var(--titlebar-height))"
: "var(--app-height)"
}
leftPanel={
inSpecial
? undefined
: { width: 292, component: <LeftSidebar /> }
}
rightPanel={
!inSpecial && inChannel
? { width: 240, component: <RightSidebar /> }
: undefined
}
bottomNav={{
component: <BottomNavigation />,
showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left,
height: 50,
}}
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
<Routes>
<Switch>
<Route
path="/server/:server/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/server/:server/channel/:channel/settings"
component={ChannelSettings}
/>
<Route
path="/server/:server/settings/:page"
component={ServerSettings}
/>
<Route
path="/server/:server/settings"
component={ServerSettings}
/>
<Route
path="/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/channel/:channel/settings"
component={ChannelSettings}
/>
<Route
path="/channel/:channel/:message"
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="/server/:server/channel/:channel" component={Channel} />
<Route path="/server/:server" />
<Route path="/channel/:channel" component={Channel} />
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
<Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} />
<Route path="/open/:id" component={Open} />
<Route path="/invite/:code" component={Invite} />
<Route path="/" component={Home} />
</Switch>
</Routes>
<ContextMenus />
<Popovers />
<Notifications />
<StateMonitor />
<SyncManager />
</OverlappingPanels>
<Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} />
<Route path="/open/:id" component={Open} />
<Route path="/invite/:code" component={Invite} />
<Route path="/" component={Home} />
</Switch>
</Routes>
<ContextMenus />
<Popovers />
<Notifications />
<StateMonitor />
<SyncManager />
</OverlappingPanels>
</>
);
};
}
import { CheckAuth } from "../context/revoltjs/CheckAuth";
import Preloader from "../components/ui/Preloader";
import { Route, Switch } from "react-router-dom";
import Context from "../context";
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() {
return (
<Context>
<Masks />
{/*
// @ts-expect-error */}
<Suspense fallback={<Preloader />}>
// @ts-expect-error typings mis-match between preact... and preact? */}
<Suspense fallback={<Preloader type="spinner" />}>
<Switch>
<Route path="/login">
<CheckAuth>
......
import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom";
import { Channel as ChannelI } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
import { useState } from "preact/hooks";
import ChannelHeader from "./ChannelHeader";
import { useParams } from "react-router-dom";
import { MessageArea } from "./messaging/MessageArea";
// import { useRenderState } from "../../lib/renderer/Singleton";
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 { useChannel, useForceUpdate } from "../../context/revoltjs/hooks";
import MemberSidebar from "../../components/navigation/right/MemberSidebar";
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator";
import MemberSidebar from "../../components/navigation/right/MemberSidebar";
import ChannelHeader from "./ChannelHeader";
import { MessageArea } from "./messaging/MessageArea";
import VoiceHeader from "./voice/VoiceHeader";
const ChannelMain = styled.div`
flex-grow: 1;
display: flex;
......@@ -27,29 +37,80 @@ const ChannelContent = styled.div`
`;
export function Channel({ id }: { id: string }) {
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
const client = useClient();
const channel = client.channels.get(id);
if (!channel) return null;
const [ showMembers, setMembers ] = useState(true);
if (channel.channel_type === "VoiceChannel") {
return <VoiceChannel channel={channel} />;
}
return <TextChannel channel={channel} />;
}
const MEMBERS_SIDEBAR_KEY = "sidebar_members";
const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
const [showMembers, setMembers] = useState(
getState().sectionToggle[MEMBERS_SIDEBAR_KEY] ?? true,
);
const id = channel._id;
return (
<>
<ChannelHeader channel={channel} toggleSidebar={() => setMembers(!showMembers)} />
<AgeGate
type="channel"
channel={channel}
gated={
!!(
(channel.channel_type === "TextChannel" ||
channel.channel_type === "Group") &&
channel.name?.includes("nsfw")
)
}>
<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 id={channel._id} />
<TypingIndicator channel={channel} />
<JumpToBottom id={id} />
<MessageBox channel={channel} />
</ChannelContent>
{ !isTouchscreenDevice && showMembers && <MemberSidebar channel={channel} /> }
{!isTouchscreenDevice && showMembers && (
<MemberSidebar channel={channel} />
)}
</ChannelMain>
</AgeGate>
);
});
function VoiceChannel({ channel }: { channel: ChannelI }) {
return (
<>
<ChannelHeader channel={channel} />
<VoiceHeader id={channel._id} />
</>
)
);
}
export default function() {
export default function ChannelComponent() {
const { channel } = useParams<{ channel: string }>();
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 { Channel, User } from "revolt.js";
import { useContext } from "preact/hooks";
import { useHistory } from "react-router-dom";
import Header from "../../components/ui/Header";
import IconButton from "../../components/ui/IconButton";
import Markdown from "../../components/markdown/Markdown";
import { getChannelName } from "../../context/revoltjs/util";
import UserStatus from "../../components/common/user/UserStatus";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useStatusColour } from "../../components/common/user/UserIcon";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { Save, AtSign, Users, Hash, UserPlus, Settings, Sidebar as SidebarIcon } from "@styled-icons/feather";
import { getChannelName } from "../../context/revoltjs/util";
interface Props {
channel: Channel,
toggleSidebar?: () => void
import { useStatusColour } from "../../components/common/user/UserIcon";
import UserStatus from "../../components/common/user/UserStatus";
import Header from "../../components/ui/Header";
import Markdown from "../../components/markdown/Markdown";
import HeaderActions from "./actions/HeaderActions";
export interface ChannelHeaderProps {
channel: Channel;
toggleSidebar?: () => void;
}
const Info = styled.div`
......@@ -24,12 +28,16 @@ const Info = styled.div`
overflow: hidden;
white-space: nowrap;
display: flex;
gap: 8px;
align-items: center;
* {
display: inline-block;
}
.divider {
height: 14px;
height: 20px;
margin: 0 5px;
padding-left: 1px;
background-color: var(--tertiary-background);
......@@ -45,92 +53,89 @@ const Info = styled.div`
.desc {
cursor: pointer;
margin-top: 2px;
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
> * {
pointer-events: none;
}
}
`;
export default function ChannelHeader({ channel, toggleSidebar }: Props) {
export default observer(({ channel, toggleSidebar }: ChannelHeaderProps) => {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const history = useHistory();
const name = getChannelName(client, channel);
let icon, recipient;
const name = getChannelName(channel);
let icon, recipient: User | undefined;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Save size={20} strokeWidth={1.5} />;
icon = <Notepad size={24} />;
break;
case "DirectMessage":
icon = <AtSign size={20} strokeWidth={1.5} />;
const uid = client.channels.getRecipient(channel._id);
recipient = client.users.get(uid);
icon = <At size={24} />;
recipient = channel.recipient;
break;
case "Group":
icon = <Users size={20} strokeWidth={1.5} />;
icon = <Group size={24} />;
break;
case "TextChannel":
icon = <Hash size={20} strokeWidth={1.5} />;
icon = <Hash size={24} />;
break;
}
return (
<Header placement="primary">
{ icon }
{isTouchscreenDevice && (
<div className="menu">
<Menu size={27} />
</div>
)}
{icon}
<Info>
<span className="name">{ name }</span>
{channel.channel_type === "DirectMessage" && (
<>
<div className="divider" />
<span className="desc">
<div className="status" style={{ backgroundColor: useStatusColour(recipient as User) }} />
<UserStatus user={recipient as User} />
</span>
</>
)}
{(channel.channel_type === "Group" || channel.channel_type === "TextChannel") && channel.description && (
<>
<div className="divider" />
<span
className="desc"
onClick={() =>
openScreen({
id: "channel_info",
channel_id: channel._id
})
}>
<Markdown content={channel.description.split("\n")[0] ?? ""} disallowBigEmoji />
</span>
</>
)}
</Info>
<>
{ channel.channel_type === "Group" && (
<>
<IconButton onClick={() =>
openScreen({
id: "user_picker",
omit: channel.recipients,
callback: async users => {
for (const user of users) {
await client.channels.addMember(channel._id, user);
<span className="name">{name}</span>
{isTouchscreenDevice &&
channel.channel_type === "DirectMessage" && (
<>
<div className="divider" />
<span className="desc">
<div
className="status"
style={{
backgroundColor:
useStatusColour(recipient),
}}
/>
<UserStatus user={recipient} />
</span>
</>
)}
{!isTouchscreenDevice &&
(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
channel.description && (
<>
<div className="divider" />
<span
className="desc"
onClick={() =>
openScreen({
id: "channel_info",
channel,
})
}>
<Markdown
content={
channel.description.split("\n")[0] ?? ""
}
}
})}>
<UserPlus size={22} />
</IconButton>
<IconButton onClick={() => history.push(`/channel/${channel._id}/settings`)}>
<Settings size={22} />
</IconButton>
</>
) }
{ channel.channel_type === "Group" && !isTouchscreenDevice && (
<IconButton onClick={toggleSidebar}>
<SidebarIcon size={22} />
</IconButton>
) }
</>
disallowBigEmoji
/>
</span>
</>
)}
</Info>
<HeaderActions channel={channel} toggleSidebar={toggleSidebar} />
</Header>
)
}
);
});
/* 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 { useContext } from "preact/hooks";
import {
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
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 history = useHistory();
return (
<>
<UpdateIndicator style="channel" />
{channel.channel_type === "Group" && (
<>
<IconButton
onClick={() =>
openScreen({
id: "user_picker",
omit: channel.recipient_ids!,
callback: async (users) => {
for (const user of users) {
await channel.addMember(user);
}
},
})
}>
<UserPlus size={27} />
</IconButton>
<IconButton
onClick={() =>
history.push(`/channel/${channel._id}/settings`)
}>
<Cog size={24} />
</IconButton>
</>
)}
<VoiceActions channel={channel} />
{(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") && (
<IconButton onClick={toggleSidebar}>
<Group size={25} />
</IconButton>
)}
</>
);
}
function VoiceActions({ channel }: Pick<ChannelHeaderProps, "channel">) {
if (
channel.channel_type === "SavedMessages" ||
channel.channel_type === "TextChannel"
)
return null;
const voice = useContext(VoiceContext);
const { connect, disconnect } = useContext(VoiceOperationsContext);
if (voice.status >= VoiceStatus.READY) {
if (voice.roomId === channel._id) {
return (
<IconButton onClick={disconnect}>
<PhoneOff size={22} />
</IconButton>
);
}
return (
<IconButton
onClick={() => {
disconnect();
connect(channel);
}}>
<PhoneCall size={24} />
</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 { Text } from "preact-i18n";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../../context/revoltjs/util";
import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks";
const StartBase = styled.div`
margin: 18px 16px 10px 16px;
......@@ -22,17 +25,17 @@ interface Props {
id: string;
}
export default function ConversationStart({ id }: Props) {
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
export default observer(({ id }: Props) => {
const client = useClient();
const channel = client.channels.get(id);
if (!channel) return null;
return (
<StartBase>
<h1>{ getChannelName(ctx.client, channel, true) }</h1>
<h1>{getChannelName(channel, true)}</h1>
<h4>
<Text id="app.main.channel.start.group" />
</h4>
</StartBase>
);
}
});
import styled from "styled-components";
import { createContext } from "preact";
import { useHistory, useParams } from "react-router-dom";
import { animateScroll } from "react-scroll";
import MessageRenderer from "./MessageRenderer";
import ConversationStart from './ConversationStart';
import styled from "styled-components";
import useResizeObserver from "use-resize-observer";
import Preloader from "../../../components/ui/Preloader";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import { RenderState, ScrollState } from "../../../lib/renderer/types";
import { createContext } from "preact";
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 { RenderState, ScrollState } from "../../../lib/renderer/types";
import { IntermediateContext } from "../../../context/intermediate/Intermediate";
import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import Preloader from "../../../components/ui/Preloader";
import ConversationStart from "./ConversationStart";
import MessageRenderer from "./MessageRenderer";
const Area = styled.div`
height: 100%;
......@@ -23,7 +42,7 @@ const Area = styled.div`
> div {
display: flex;
min-height: 100%;
padding-bottom: 20px;
padding-bottom: 24px;
flex-direction: column;
justify-content: flex-end;
}
......@@ -37,9 +56,15 @@ export const MessageAreaWidthContext = createContext(0);
export const MESSAGE_AREA_PADDING = 82;
export function MessageArea({ id }: Props) {
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const { focusTaken } = useContext(IntermediateContext);
// ? Required data for message links.
const { message } = useParams<{ message: string }>();
const [highlight, setHighlight] = useState<string | undefined>(undefined);
// ? This is the scroll container.
const ref = useRef<HTMLDivElement>(null);
const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
......@@ -47,56 +72,128 @@ export function MessageArea({ id }: Props) {
// ? Current channel state.
const [state, setState] = useState<RenderState>({ type: "LOADING" });
// ? Hook-based scrolling mechanism.
const [scrollState, setSS] = useState<ScrollState>({
type: "Free"
});
// ? useRef to avoid re-renders
const scrollState = useRef<ScrollState>({ type: "Free" });
const setScrollState = (v: ScrollState) => {
if (v.type === 'StayAtBottom') {
if (scrollState.type === 'Bottom' || atBottom()) {
setSS({ type: 'ScrollToBottom', smooth: v.smooth });
const setScrollState = useCallback((v: ScrollState) => {
if (v.type === "StayAtBottom") {
if (scrollState.current.type === "Bottom" || atBottom()) {
scrollState.current = {
type: "ScrollToBottom",
smooth: v.smooth,
};
} else {
setSS({ type: 'Free' });
scrollState.current = { type: "Free" };
}
} else {
setSS(v);
scrollState.current = v;
}
}
defer(() => {
if (scrollState.current.type === "ScrollToBottom") {
setScrollState({
type: "Bottom",
scrollingUntil: +new Date() + 150,
});
animateScroll.scrollToBottom({
container: ref.current,
duration: scrollState.current.smooth ? 150 : 0,
});
} else if (scrollState.current.type === "ScrollToView") {
document
.getElementById(scrollState.current.id)
?.scrollIntoView({ block: "center" });
setScrollState({ type: "Free" });
} else if (scrollState.current.type === "OffsetTop") {
animateScroll.scrollTo(
Math.max(
101,
ref.current
? ref.current.scrollTop +
(ref.current.scrollHeight -
scrollState.current.previousHeight)
: 101,
),
{
container: ref.current,
duration: 0,
},
);
setScrollState({ type: "Free" });
} else if (scrollState.current.type === "ScrollTop") {
animateScroll.scrollTo(scrollState.current.y, {
container: ref.current,
duration: 0,
});
setScrollState({ type: "Free" });
}
});
}, []);
// ? Determine if we are at the bottom of the scroll container.
// -> https://stackoverflow.com/a/44893438
// By default, we assume we are at the bottom, i.e. when we first load.
const atBottom = (offset = 0) =>
ref.current
? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) -
? Math.floor(ref.current?.scrollHeight - ref.current?.scrollTop) -
offset <=
ref.current.clientHeight
ref.current?.clientHeight
: true;
const atTop = (offset = 0) => ref.current.scrollTop <= offset;
const atTop = (offset = 0) =>
ref.current ? ref.current.scrollTop <= offset : false;
// ? Handle global jump to bottom, e.g. when editing last message in chat.
useEffect(() => {
return internalSubscribe("MessageArea", "jump_to_bottom", () =>
setScrollState({ type: "ScrollToBottom" }),
);
}, [setScrollState]);
// ? Handle events from renderer.
useEffect(() => {
SingletonMessageRenderer.addListener('state', setState);
return () => SingletonMessageRenderer.removeListener('state', setState);
}, [ ]);
SingletonMessageRenderer.addListener("state", setState);
return () => SingletonMessageRenderer.removeListener("state", setState);
}, []);
useEffect(() => {
SingletonMessageRenderer.addListener('scroll', setScrollState);
return () => SingletonMessageRenderer.removeListener('scroll', setScrollState);
}, [ scrollState ]);
SingletonMessageRenderer.addListener("scroll", setScrollState);
return () =>
SingletonMessageRenderer.removeListener("scroll", setScrollState);
}, [scrollState, setScrollState]);
// ? Load channel initially.
useEffect(() => {
if (message) return;
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.
useEffect(() => {
switch (status) {
case ClientStatus.ONLINE:
if (state.type === 'WAITING_FOR_NETWORK') {
if (state.type === "WAITING_FOR_NETWORK") {
SingletonMessageRenderer.init(id);
} else {
SingletonMessageRenderer.reloadStale(id);
......@@ -109,120 +206,103 @@ export function MessageArea({ id }: Props) {
SingletonMessageRenderer.markStale();
break;
}
}, [ status, state ]);
// ? Scroll to the bottom before the browser paints.
useLayoutEffect(() => {
if (scrollState.type === "ScrollToBottom") {
setScrollState({ type: "Bottom", scrollingUntil: + new Date() + 150 });
animateScroll.scrollToBottom({
container: ref.current,
duration: scrollState.smooth ? 150 : 0
});
} else if (scrollState.type === "OffsetTop") {
animateScroll.scrollTo(
Math.max(
101,
ref.current.scrollTop +
(ref.current.scrollHeight - scrollState.previousHeight)
),
{
container: ref.current,
duration: 0
}
);
setScrollState({ type: "Free" });
} else if (scrollState.type === "ScrollTop") {
animateScroll.scrollTo(scrollState.y, {
container: ref.current,
duration: 0
});
setScrollState({ type: "Free" });
}
}, [scrollState]);
}, [id, status, state]);
// ? When the container is scrolled.
// ? Also handle StayAtBottom
useEffect(() => {
const current = ref.current;
if (!current) return;
async function onScroll() {
if (scrollState.type === "Free" && atBottom()) {
if (scrollState.current.type === "Free" && atBottom()) {
setScrollState({ type: "Bottom" });
} else if (scrollState.type === "Bottom" && !atBottom()) {
if (scrollState.scrollingUntil && scrollState.scrollingUntil > + new Date()) return;
} else if (scrollState.current.type === "Bottom" && !atBottom()) {
if (
scrollState.current.scrollingUntil &&
scrollState.current.scrollingUntil > +new Date()
)
return;
setScrollState({ type: "Free" });
}
}
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
}, [ref, scrollState]);
current.addEventListener("scroll", onScroll);
return () => current.removeEventListener("scroll", onScroll);
}, [ref, scrollState, setScrollState]);
// ? Top and bottom loaders.
useEffect(() => {
const current = ref.current;
if (!current) return;
async function onScroll() {
if (atTop(100)) {
SingletonMessageRenderer.loadTop(ref.current);
SingletonMessageRenderer.loadTop(ref.current!);
}
if (atBottom(100)) {
SingletonMessageRenderer.loadBottom(ref.current);
SingletonMessageRenderer.loadBottom(ref.current!);
}
}
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
current.addEventListener("scroll", onScroll);
return () => current.removeEventListener("scroll", onScroll);
}, [ref]);
// ? Scroll down whenever the message area resizes.
function stbOnResize() {
if (!atBottom() && scrollState.type === "Bottom") {
const stbOnResize = useCallback(() => {
if (!atBottom() && scrollState.current.type === "Bottom") {
animateScroll.scrollToBottom({
container: ref.current,
duration: 0
duration: 0,
});
setScrollState({ type: "Bottom" });
}
}
}, [setScrollState]);
// ? Scroll down when container resized.
useLayoutEffect(() => {
stbOnResize();
}, [height]);
}, [stbOnResize, height]);
// ? Scroll down whenever the window resizes.
useLayoutEffect(() => {
document.addEventListener("resize", stbOnResize);
return () => document.removeEventListener("resize", stbOnResize);
}, [ref, scrollState]);
}, [ref, scrollState, stbOnResize]);
// ? Scroll to bottom when pressing 'Escape'.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
SingletonMessageRenderer.jumpToBottom(id, true);
internalEmit("TextArea", "focus", "message");
}
}
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [ref, focusTaken]);
}, [id, ref, focusTaken]);
return (
<MessageAreaWidthContext.Provider value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<MessageAreaWidthContext.Provider
value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<Area ref={ref}>
<div>
{state.type === "LOADING" && <Preloader />}
{state.type === "LOADING" && <Preloader type="ring" />}
{state.type === "WAITING_FOR_NETWORK" && (
<RequiresOnline>
<Preloader />
<Preloader type="ring" />
</RequiresOnline>
)}
{state.type === "RENDER" && (
<MessageRenderer id={id} state={state} />
<MessageRenderer
id={id}
state={state}
highlight={highlight}
/>
)}
{state.type === "EMPTY" && <ConversationStart id={id} />}
</div>
......
import { Message } from "revolt.js/dist/maps/Messages";
import styled from "styled-components";
import { useContext, useState } from "preact/hooks";
import { useContext, useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { MessageObject } from "../../../context/revoltjs/util";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import {
IntermediateContext,
useIntermediate,
} from "../../../context/intermediate/Intermediate";
import AutoComplete, {
useAutoComplete,
} from "../../../components/common/AutoComplete";
const EditorBase = styled.div`
display: flex;
flex-direction: column;
......@@ -12,8 +22,9 @@ const EditorBase = styled.div`
textarea {
resize: none;
padding: 12px;
border-radius: 3px;
white-space: pre-wrap;
font-size: var(--text-size);
border-radius: var(--border-radius);
background: var(--secondary-header);
}
......@@ -32,39 +43,70 @@ const EditorBase = styled.div`
`;
interface Props {
message: MessageObject
finish: () => void
message: Message;
finish: () => void;
}
export default function MessageEditor({ message, finish }: Props) {
const [ content, setContent ] = useState(message.content as string ?? '');
const client = useContext(AppContext);
const [content, setContent] = useState((message.content as string) ?? "");
const { focusTaken } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
async function save() {
finish();
if (content.length === 0) {
// @ts-expect-error
openScreen({ id: 'special_prompt', type: 'delete_message', target: message });
openScreen({
id: "special_prompt",
type: "delete_message",
target: message,
});
} else if (content !== message.content) {
await client.channels.editMessage(
message.channel,
message._id,
{ content }
);
await message.edit({
content,
});
}
}
// ? Stop editing when pressing ESC.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
finish();
}
}
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [focusTaken, finish]);
const {
onChange,
onKeyUp,
onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete((v) => setContent(v ?? ""), {
users: { type: "all" },
});
return (
<EditorBase>
<AutoComplete detached {...autoCompleteProps} />
<TextAreaAutoSize
forceFocus
maxRows={3}
padding={12}
value={content}
maxLength={2000}
onChange={ev => setContent(ev.currentTarget.value)}
onKeyDown={e => {
padding="var(--message-box-padding)"
onChange={(ev) => {
onChange(ev);
setContent(ev.currentTarget.value);
}}
onKeyDown={(e) => {
if (onKeyDown(e)) return;
if (
!e.shiftKey &&
e.key === "Enter" &&
......@@ -74,11 +116,14 @@ export default function MessageEditor({ message, finish }: Props) {
save();
}
}}
onKeyUp={onKeyUp}
onFocus={onFocus}
onBlur={onBlur}
/>
<span className="caption">
escape to <a onClick={finish}>cancel</a> &middot;
enter to <a onClick={save}>save</a>
escape to <a onClick={finish}>cancel</a> &middot; enter to{" "}
<a onClick={save}>save</a>
</span>
</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 MessageEditor from "./MessageEditor";
import { Children } from "../../../types/Preact";
import ConversationStart from "./ConversationStart";
import { connectState } from "../../../redux/connector";
import Preloader from "../../../components/ui/Preloader";
import { Text } from "preact-i18n";
import { memo } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter";
import { RenderState } from "../../../lib/renderer/types";
import DateDivider from "../../../components/ui/DateDivider";
import { connectState } from "../../../redux/connector";
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 { 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 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 {
id: string;
state: RenderState;
highlight?: string;
queue: QueuedMessage[];
}
function MessageRenderer({ id, state, queue }: Props) {
if (state.type !== 'RENDER') return null;
const BlockedMessage = styled.div`
font-size: 0.8em;
margin-top: 6px;
padding: 4px 64px;
color: var(--tertiary-foreground);
&:hover {
background: var(--hover);
}
`;
function MessageRenderer({ id, state, queue, highlight }: Props) {
if (state.type !== "RENDER") return null;
const client = useContext(AppContext);
const client = useClient();
const userId = client.user!._id;
const [editing, setEditing] = useState<string | undefined>(undefined);
......@@ -35,10 +60,11 @@ function MessageRenderer({ id, state, queue }: Props) {
useEffect(() => {
function editLast() {
if (state.type !== 'RENDER') return;
if (state.type !== "RENDER") return;
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].author === userId) {
if (state.messages[i].author_id === userId) {
setEditing(state.messages[i]._id);
internalEmit("MessageArea", "jump_to_bottom");
return;
}
}
......@@ -46,22 +72,22 @@ function MessageRenderer({ id, state, queue }: Props) {
const subs = [
internalSubscribe("MessageRenderer", "edit_last", editLast),
internalSubscribe("MessageRenderer", "edit_message", setEditing)
]
internalSubscribe("MessageRenderer", "edit_message", setEditing),
];
return () => subs.forEach(unsub => unsub());
}, [state.messages]);
return () => subs.forEach((unsub) => unsub());
}, [state.messages, state.type, userId]);
let render: Children[] = [],
previous: MessageObject | undefined;
const render: Children[] = [];
let previous: MessageI | undefined;
if (state.atTop) {
render.push(<ConversationStart id={id} />);
} else {
render.push(
<RequiresOnline>
<Preloader />
</RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>,
);
}
......@@ -70,7 +96,7 @@ function MessageRenderer({ id, state, queue }: Props) {
current: string,
curAuthor: string,
previous: string,
prevAuthor: string
prevAuthor: string,
) {
const atime = decodeTime(current),
adate = new Date(atime),
......@@ -83,81 +109,121 @@ function MessageRenderer({ id, state, queue }: Props) {
adate.getDate() !== bdate.getDate()
) {
render.push(<DateDivider date={adate} />);
head = true;
}
head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000;
}
let blocked = 0;
function pushBlocked() {
render.push(
<BlockedMessage>
<X size={16} />{" "}
<Text
id="app.main.channel.misc.blocked_messages"
fields={{ count: blocked }}
/>
</BlockedMessage>,
);
blocked = 0;
}
for (const message of state.messages) {
if (previous) {
compare(
message._id,
message.author,
message.author_id,
previous._id,
previous.author
previous.author_id,
);
}
if (message.author === "00000000000000000000000000") {
render.push(<SystemMessage key={message._id} message={message} attachContext />);
if (message.author_id === SYSTEM_USER_ID) {
render.push(
<SystemMessage
key={message._id}
message={message}
attachContext
highlight={highlight === message._id}
/>,
);
} else if (
message.author?.relationship === RelationshipStatus.Blocked
) {
blocked++;
} else {
if (blocked > 0) pushBlocked();
render.push(
<Message message={message}
<Message
message={message}
key={message._id}
head={head}
content={
editing === message._id ?
<MessageEditor message={message} finish={stopEditing} />
: undefined
editing === message._id ? (
<MessageEditor
message={message}
finish={stopEditing}
/>
) : undefined
}
attachContext />
attachContext
highlight={highlight === message._id}
/>,
);
}
previous = message;
}
const nonces = state.messages.map(x => x.nonce);
if (blocked > 0) pushBlocked();
const nonces = state.messages.map((x) => x.nonce);
if (state.atBottom) {
for (const msg of queue) {
if (msg.channel !== id) continue;
if (nonces.includes(msg.id)) continue;
if (previous) {
compare(
msg.id,
userId!,
previous._id,
previous.author
);
compare(msg.id, userId!, previous._id, previous.author_id);
previous = {
_id: msg.id,
data: { author: userId! }
} as any;
author_id: userId!,
} as MessageI;
}
render.push(
<Message message={msg.data}
<Message
message={
new MessageI(client, {
...msg.data,
replies: msg.data.replies.map((x) => x.id),
})
}
key={msg.id}
queued={msg}
head={head}
attachContext />
attachContext
/>,
);
}
} else {
render.push(
<RequiresOnline>
<Preloader />
</RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>,
);
}
return <>{ render }</>;
return <>{render}</>;
}
export default connectState<Omit<Props, 'queue'>>(MessageRenderer, state => {
return {
queue: state.queue
};
});
export default memo(
connectState<Omit<Props, "queue">>(MessageRenderer, (state) => {
return {
queue: state.queue,
};
}),
);
import { BarChart } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import {
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import UserIcon from "../../../components/common/user/UserIcon";
import Button from "../../../components/ui/Button";
interface Props {
id: string;
}
const VoiceBase = styled.div`
padding: 20px;
background: var(--secondary-background);
.status {
flex: 1 0;
display: flex;
position: absolute;
align-items: center;
padding: 10px;
font-size: 14px;
font-weight: 600;
user-select: none;
color: var(--success);
border-radius: var(--border-radius);
background: var(--primary-background);
svg {
margin-inline-end: 4px;
cursor: help;
}
}
display: flex;
flex-direction: column;
.participants {
margin: 20px 0;
justify-content: center;
pointer-events: none;
user-select: none;
display: flex;
gap: 16px;
.disconnected {
opacity: 0.5;
}
}
.actions {
display: flex;
justify-content: center;
gap: 10px;
}
`;
export default observer(({ id }: Props) => {
const { status, participants, roomId } = useContext(VoiceContext);
if (roomId !== id) return null;
const { isProducing, startProducing, stopProducing, disconnect } =
useContext(VoiceOperationsContext);
const client = useClient();
const self = client.users.get(client.user!._id);
//const ctx = useForceUpdate();
//const self = useSelf(ctx);
const keys = participants ? Array.from(participants.keys()) : undefined;
const users = keys?.map((key) => client.users.get(key));
return (
<VoiceBase>
<div className="participants">
{users && users.length !== 0
? users.map((user, index) => {
const id = keys![index];
return (
<div key={id}>
<UserIcon
size={80}
target={user}
status={false}
voice={
participants!.get(id)?.audio
? undefined
: "muted"
}
/>
</div>
);
})
: self !== undefined && (
<div key={self._id} className="disconnected">
<UserIcon
size={80}
target={self}
status={false}
/>
</div>
)}
</div>
<div className="status">
<BarChart size={20} />
{status === VoiceStatus.CONNECTED && (
<Text id="app.main.channel.voice.connected" />
)}
</div>
<div className="actions">
<Button error onClick={disconnect}>
<Text id="app.main.channel.voice.leave" />
</Button>
{isProducing("audio") ? (
<Button onClick={() => stopProducing("audio")}>
<Text id="app.main.channel.voice.mute" />
</Button>
) : (
<Button onClick={() => startProducing("audio")}>
<Text id="app.main.channel.voice.unmute" />
</Button>
)}
</div>
</VoiceBase>
);
});
/**{voice.roomId === id && (
<div className={styles.rtc}>
<div className={styles.participants}>
{participants.length !== 0 ? participants.map((user, index) => {
const id = participantIds[index];
return (
<div key={id}>
<UserIcon
size={80}
user={user}
status={false}
voice={
voice.participants.get(id)
?.audio
? undefined
: "muted"
}
/>
</div>
);
}) : self !== undefined && (
<div key={self._id} className={styles.disconnected}>
<UserIcon
size={80}
user={self}
status={false}
/>
</div>
)}
</div>
<div className={styles.status}>
<BarChart size={20} />
{ voice.status === VoiceStatus.CONNECTED && <Text id="app.main.channel.voice.connected" /> }
</div>
<div className={styles.actions}>
<Button
style="error"
onClick={() =>
voice.operations.disconnect()
}
>
<Text id="app.main.channel.voice.leave" />
</Button>
{voice.operations.isProducing("audio") ? (
<Button
onClick={() =>
voice.operations.stopProducing(
"audio"
)
}
>
<Text id="app.main.channel.voice.mute" />
</Button>
) : (
<Button
onClick={() =>
voice.operations.startProducing(
"audio"
)
}
>
<Text id="app.main.channel.voice.unmute" />
</Button>
)}
</div>
</div>
)} */
import { Wrench } from "@styled-icons/boxicons-solid";
import { useContext } from "preact/hooks";
import { TextReact } from "../../lib/i18n";
import Header from "../../components/ui/Header";
import PaintCounter from "../../lib/PaintCounter";
import { TextReact } from "../../lib/i18n";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { useUserPermission } from "../../context/revoltjs/hooks";
import Header from "../../components/ui/Header";
export default function Developer() {
// const voice = useContext(VoiceContext);
const client = useContext(AppContext);
const userPermission = useUserPermission(client.user!._id);
const userPermission = client.user!.permission;
return (
<div>
<Header placement="primary">Developer Tab</Header>
<Header placement="primary">
<Wrench size="24" />
Developer Tab
</Header>
<div style={{ padding: "16px" }}>
<PaintCounter always />
</div>
<div style={{ padding: "16px" }}>
<b>User ID:</b> {client.user!._id} <br/>
<b>Permission against self:</b> {userPermission} <br/>
<b>User ID:</b> {client.user!._id} <br />
<b>Permission against self:</b> {userPermission} <br />
</div>
<div style={{ padding: "16px" }}>
<TextReact id="login.open_mail_provider" fields={{ provider: <b>GAMING!</b> }} />
<TextReact
id="login.open_mail_provider"
fields={{ provider: <b>GAMING!</b> }}
/>
</div>
<div style={{ padding: "16px" }}>
{/*<span>
......
.title {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.actions {
display: flex;
align-items: center;
gap: 20px;
}
.list {
padding: 16px;
padding: 0 10px 10px 10px;
user-select: none;
overflow-y: scroll;
&[data-empty="true"] {
img {
height: 120px;
border-radius: 8px;
border-radius: var(--border-radius);
}
gap: 16px;
......@@ -16,15 +28,19 @@
flex-direction: column;
justify-content: center;
}
&[data-mobile="true"] {
padding-bottom: var(--bottom-navigation-height);
}
}
.friend {
padding: 10px;
height: 60px;
display: flex;
border-radius: 5px;
align-items: center;
flex-direction: row;
padding: 0 10px;
cursor: pointer;
align-items: center;
border-radius: var(--border-radius);
&:hover {
background: var(--secondary-background);
......@@ -38,17 +54,25 @@
flex-grow: 1;
margin: 0 12px;
font-size: 16px;
font-weight: 600;
display: flex;
flex-direction: column;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
overflow: hidden;
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtext {
font-size: 12px;
font-weight: 400;
color: var(--tertiary-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
......@@ -56,16 +80,118 @@
display: flex;
gap: 12px;
> div {
height: 32px;
width: 32px;
.button {
width: 36px;
height: 36px;
&:hover.error {
background: var(--error);
}
&:hover.success {
background: var(--success);
}
}
}
}
//! FIXME: Move this to the Header component, do this:
// 1. Check if header has topic, if yes, flex-grow: 0 on the title.
// 2. If header has no topic (example: friends page), flex-grow 1 on the header title.
.divider {
width: 1px;
height: 24px;
background: var(--primary-background);
}
.title {
flex-grow: 1;
}
.pending {
padding: 1em;
display: flex;
cursor: pointer;
margin-top: 1em;
align-items: center;
flex-direction: row;
border-radius: var(--border-radius);
background: var(--secondary-background);
svg {
flex-shrink: 0;
}
.avatars {
display: flex;
flex-shrink: 0;
margin-inline-end: 15px;
svg:not(:first-child) {
position: relative;
margin-left: -32px;
}
}
.details {
flex-grow: 1;
display: flex;
flex-direction: column;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
> div {
font-size: 16px;
font-weight: 800;
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
.title {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
span {
width: 1.5em;
height: 1.5em;
font-size: 12px;
color: white;
border-radius: 50%;
align-items: center;
display: inline-flex;
justify-content: center;
background: var(--error);
flex-shrink: 0;
}
}
.from {
.user {
font-weight: 600;
}
}
> span {
font-weight: 400;
font-size: 12px;
color: var(--tertiary-foreground);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
@media only screen and (max-width: 768px) {
.list {
padding: 0 8px 8px 8px;
}
.remove {
display: none;
}
}
import { Text } from "preact-i18n";
import { Link } from "react-router-dom";
import { X, Plus } from "@styled-icons/boxicons-regular";
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 { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { X, Plus, Mail } from "@styled-icons/feather";
import IconButton from "../../components/ui/IconButton";
import classNames from "classnames";
import { attachContextMenu } from "preact-context-menu";
import { User, Users } from "revolt.js/dist/api/objects";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { stopPropagation } from "../../lib/stopPropagation";
import UserIcon from "../../components/common/user/UserIcon";
import UserStatus from '../../components/common/user/UserStatus';
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { VoiceOperationsContext } from "../../context/Voice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
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 {
user: User;
}
export function Friend({ user }: Props) {
const client = useContext(AppContext);
export const Friend = observer(({ user }: Props) => {
const history = useHistory();
const { openScreen } = useIntermediate();
const { connect } = useContext(VoiceOperationsContext);
const actions: Children[] = [];
let subtext: Children = null;
if (user.relationship === Users.Relationship.Friend) {
subtext = <UserStatus user={user} />
if (user.relationship === RelationshipStatus.Friend) {
subtext = <UserStatus user={user} />;
actions.push(
<IconButton type="circle"
onClick={stopPropagation}>
<Link to={'/open/' + user._id}>
<Mail size={20} />
</Link>
</IconButton>
<>
<IconButton
type="circle"
className={classNames(styles.button, styles.success)}
onClick={(ev) =>
stopPropagation(
ev,
user
.openDM()
.then(connect)
.then((x) => history.push(`/channel/${x._id}`)),
)
}>
<PhoneCall size={20} />
</IconButton>
<IconButton
type="circle"
className={styles.button}
onClick={(ev) =>
stopPropagation(
ev,
user
.openDM()
.then((channel) =>
history.push(`/channel/${channel._id}`),
),
)
}>
<Envelope size={20} />
</IconButton>
</>,
);
}
if (user.relationship === Users.Relationship.Incoming) {
if (user.relationship === RelationshipStatus.Incoming) {
actions.push(
<IconButton type="circle"
onClick={ev => stopPropagation(ev, client.users.addFriend(user.username))}>
<IconButton
type="circle"
className={styles.button}
onClick={(ev) => stopPropagation(ev, user.addFriend())}>
<Plus size={24} />
</IconButton>
</IconButton>,
);
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" />;
}
if (
user.relationship === Users.Relationship.Friend ||
user.relationship === Users.Relationship.Outgoing ||
user.relationship === Users.Relationship.Incoming
user.relationship === RelationshipStatus.Friend ||
user.relationship === RelationshipStatus.Outgoing ||
user.relationship === RelationshipStatus.Incoming
) {
actions.push(
<IconButton type="circle"
onClick={ev => stopPropagation(ev, client.users.removeFriend(user._id))}>
<IconButton
type="circle"
className={classNames(
styles.button,
styles.remove,
styles.error,
)}
onClick={(ev) =>
stopPropagation(
ev,
user.relationship === RelationshipStatus.Friend
? openScreen({
id: "special_prompt",
type: "unfriend_user",
target: user,
})
: user.removeFriend(),
)
}>
<X size={24} />
</IconButton>
</IconButton>,
);
}
if (user.relationship === Users.Relationship.Blocked) {
if (user.relationship === RelationshipStatus.Blocked) {
actions.push(
<IconButton type="circle"
onClick={ev => stopPropagation(ev, client.users.unblockUser(user._id))}>
<X size={24} />
</IconButton>
<IconButton
type="circle"
className={classNames(styles.button, styles.error)}
onClick={(ev) => stopPropagation(ev, user.unblockUser())}>
<UserX size={24} />
</IconButton>,
);
}
return (
<div className={styles.friend}
onClick={() => openScreen({ id: 'profile', user_id: user._id })}
onContextMenu={attachContextMenu('Menu', { user: user._id })}>
<UserIcon target={user} size={32} status />
<div
className={styles.friend}
onClick={() => openScreen({ id: "profile", user_id: user._id })}
onContextMenu={attachContextMenu("Menu", { user: user._id })}>
<UserIcon target={user} size={36} status />
<div className={styles.name}>
<span>@{user.username}</span>
{subtext && (
<span className={styles.subtext}>{subtext}</span>
)}
<span>{user.username}</span>
{subtext && <span className={styles.subtext}>{subtext}</span>}
</div>
<div className={styles.actions}>{actions}</div>
</div>
);
}
});
import styles from "./Friend.module.scss";
import { UserPlus } from "@styled-icons/feather";
import { ChevronRight } from "@styled-icons/boxicons-regular";
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 { Friend } from "./Friend";
import styles from "./Friend.module.scss";
import { Text } from "preact-i18n";
import { TextReact } from "../../lib/i18n";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { useClient } from "../../context/revoltjs/RevoltClient";
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 Overline from "../../components/ui/Overline";
import IconButton from "../../components/ui/IconButton";
import { useUsers } from "../../context/revoltjs/hooks";
import { User, Users } from "revolt.js/dist/api/objects";
import { useIntermediate } from "../../context/intermediate/Intermediate";
export default function Friends() {
import { Children } from "../../types/Preact";
import { Friend } from "./Friend";
export default observer(() => {
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));
const pending = users.filter(
x =>
x.relationship === Users.Relationship.Incoming ||
x.relationship === Users.Relationship.Outgoing
);
const friends = users.filter(
x => x.relationship === Users.Relationship.Friend
);
const blocked = users.filter(
x => x.relationship === Users.Relationship.Blocked
(x) => x.relationship === RelationshipStatus.Friend,
);
const lists = [
[
"",
users.filter((x) => x.relationship === RelationshipStatus.Incoming),
],
[
"app.special.friends.sent",
users.filter((x) => x.relationship === RelationshipStatus.Outgoing),
"outgoing",
],
[
"app.status.online",
friends.filter(
(x) => x.online && x.status?.presence !== Presence.Invisible,
),
"online",
],
[
"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 userlist: Children[] = incoming.map((x) => (
<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;
return (
<>
<Header placement="primary">
{!isTouchscreenDevice && <UserDetail size={24} />}
<div className={styles.title}>
<Text id="app.navigation.tabs.friends" />
</div>
<div className="actions">
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'add_friend' })}>
<UserPlus size={24} />
</IconButton>
<div className={styles.actions}>
{/*<Tooltip content={"Create Category"} placement="bottom">
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_group' })}>
<ListPlus size={28} />
</IconButton>
</Tooltip>
<div className={styles.divider} />*/}
<Tooltip content={"Create Group"} placement="bottom">
<IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "create_group",
})
}>
<MessageAdd size={24} />
</IconButton>
</Tooltip>
<Tooltip content={"Add Friend"} placement="bottom">
<IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "add_friend",
})
}>
<UserPlus size={27} />
</IconButton>
</Tooltip>
{/*
<div className={styles.divider} />
<Tooltip content={"Friend Activity"} placement="bottom">
<IconButton>
<TennisBall size={24} />
</IconButton>
</Tooltip>
*/}
</div>
</Header>
<div
className={styles.list}
data-empty={
pending.length + friends.length + blocked.length === 0
}
>
{pending.length + friends.length + blocked.length === 0 && (
data-empty={isEmpty}
data-mobile={isTouchscreenDevice}>
{isEmpty && (
<>
<img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" />
<Text id="app.special.friends.nobody" />
</>
)}
{pending.length > 0 && (
<Overline type="subtle">
<Text id="app.special.friends.pending" />{" "}
{pending.length}
</Overline>
)}
{pending.map(y => (
<Friend key={y._id} user={y} />
))}
{friends.length > 0 && (
<Overline type="subtle">
<Text id="app.navigation.tabs.friends" />{" "}
{friends.length}
</Overline>
)}
{friends.map(y => (
<Friend key={y._id} user={y} />
))}
{blocked.length > 0 && (
<Overline type="subtle">
<Text id="app.special.friends.blocked" />{" "}
{blocked.length}
</Overline>
{incoming.length > 0 && (
<div
className={styles.pending}
onClick={() =>
openScreen({
id: "pending_requests",
users: incoming,
})
}>
<div className={styles.avatars}>
{incoming.map(
(x, i) =>
i < 3 && (
<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>
)}
{blocked.map(y => (
<Friend key={y._id} user={y} />
))}
{lists.map(([i18n, list, section_id], index) => {
if (index === 0) return;
if (list.length === 0) return;
return (
<CollapsibleSection
key={section_id}
id={`friends_${section_id}`}
defaultValue={true}
sticky
large
summary={
<div class="title">
<Text id={i18n} />{list.length}
</div>
}>
{list.map((x) => (
<Friend key={x._id} user={x} />
))}
</CollapsibleSection>
);
})}
</div>
</>
);
}
});
......@@ -11,15 +11,13 @@
}
}
ul {
.actions {
gap: 8px;
margin: auto;
display: block;
font-size: 18px;
text-align: center;
li {
list-style: lower-greek;
}
display: flex;
width: fit-content;
align-items: center;
flex-direction: column;
}
}
......
import styles from "./Home.module.scss";
import { Home as HomeIcon } from "@styled-icons/boxicons-solid";
import { Link } from "react-router-dom";
import styles from "./Home.module.scss";
import { Text } from "preact-i18n";
import Header from "../../components/ui/Header";
import wideSVG from '../../assets/wide.svg';
import wideSVG from "../../assets/wide.svg";
import Tooltip from "../../components/common/Tooltip";
import Button from "../../components/ui/Button";
import Header from "../../components/ui/Header";
export default function Home() {
return (
<div className={styles.home}>
<Header placement="primary"><Text id="app.navigation.tabs.home" /></Header>
<Header placement="primary">
<HomeIcon size={24} />
<Text id="app.navigation.tabs.home" />
</Header>
<h3>
<Text id="app.special.modals.onboarding.welcome" /> <img src={wideSVG} />
<Text id="app.special.modals.onboarding.welcome" />
<br />
<img src={wideSVG} />
</h3>
<ul>
<li>
Go to your <Link to="/friends">friends list</Link>.
</li>
<li>
Give <Link to="/settings/feedback">feedback</Link>.
</li>
<li>
Join <Link to="/invite/Testers">testers server</Link>.
</li>
<li>
View{" "}
<a href="https://gitlab.insrt.uk/revolt" target="_blank">
source code
</a>
.
</li>
</ul>
<div className={styles.actions}>
<Link to="/invite/Testers">
<Button contrast error>
Join testers server
</Button>
</Link>
<a
href="https://insrt.uk/donate"
target="_blank"
rel="noreferrer">
<Button contrast gold>
Donate to Revolt
</Button>
</a>
<Link to="/settings/feedback">
<Button contrast>Give feedback</Button>
</Link>
<Link to="/settings">
<Tooltip content="You can also right-click the user icon in the top left, or left click it if you're already home.">
<Button contrast>Open settings</Button>
</Tooltip>
</Link>
</div>
</div>
);
}