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 390 additions and 316 deletions
import { useParams, useHistory } from "react-router-dom";
import { Channels } from "revolt.js/dist/api/objects";
import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom";
import { Channel as ChannelI } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
import { useState } from "preact/hooks";
......@@ -8,12 +9,12 @@ import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { dispatch, getState } from "../../redux";
import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks";
import { useClient } from "../../context/revoltjs/RevoltClient";
import AgeGate from "../../components/common/AgeGate";
import MessageBox from "../../components/common/messaging/MessageBox";
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator";
import AgeGate from "../../components/common/AgeGate";
import MemberSidebar from "../../components/navigation/right/MemberSidebar";
import ChannelHeader from "./ChannelHeader";
......@@ -36,32 +37,35 @@ 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;
if (channel.channel_type === "VoiceChannel") {
return <VoiceChannel channel={channel} />;
} else {
return <TextChannel channel={channel} />;
}
return <TextChannel channel={channel} />;
}
const MEMBERS_SIDEBAR_KEY = "sidebar_members";
function TextChannel({ channel }: { channel: Channels.Channel }) {
const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
const [showMembers, setMembers] = useState(
getState().sectionToggle[MEMBERS_SIDEBAR_KEY] ?? true,
);
let id = channel._id;
const id = channel._id;
return (
<AgeGate
type="channel"
channel={channel}
gated={(channel.channel_type === "TextChannel" ||
channel.channel_type === "Group") &&
channel.name.includes("nsfw")}>
gated={
!!(
(channel.channel_type === "TextChannel" ||
channel.channel_type === "Group") &&
channel.name?.includes("nsfw")
)
}>
<ChannelHeader
channel={channel}
toggleSidebar={() => {
......@@ -85,7 +89,7 @@ function TextChannel({ channel }: { channel: Channels.Channel }) {
<ChannelContent>
<VoiceHeader id={id} />
<MessageArea id={id} />
<TypingIndicator id={id} />
<TypingIndicator channel={channel} />
<JumpToBottom id={id} />
<MessageBox channel={channel} />
</ChannelContent>
......@@ -95,9 +99,9 @@ function TextChannel({ channel }: { channel: Channels.Channel }) {
</ChannelMain>
</AgeGate>
);
}
});
function VoiceChannel({ channel }: { channel: Channels.Channel }) {
function VoiceChannel({ channel }: { channel: ChannelI }) {
return (
<>
<ChannelHeader channel={channel} />
......@@ -106,7 +110,7 @@ function VoiceChannel({ channel }: { channel: Channels.Channel }) {
);
}
export default function () {
export default function ChannelComponent() {
const { channel } = useParams<{ channel: string }>();
return <Channel id={channel} key={channel} />;
}
import { At, Hash, Menu } from "@styled-icons/boxicons-regular";
import { Notepad, Group } from "@styled-icons/boxicons-solid";
import { Channel, User } from "revolt.js";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import { User } from "revolt.js/dist/maps/Users";
import styled from "styled-components";
import { useContext } from "preact/hooks";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../context/revoltjs/util";
import { useStatusColour } from "../../components/common/user/UserIcon";
......@@ -58,30 +57,25 @@ const Info = styled.div`
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
> * {
pointer-events: none;
}
}
`;
export default function ChannelHeader({
channel,
toggleSidebar,
}: ChannelHeaderProps) {
export default observer(({ channel, toggleSidebar }: ChannelHeaderProps) => {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const name = getChannelName(client, channel);
let icon, recipient;
const name = getChannelName(channel);
let icon, recipient: User | undefined;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Notepad size={24} />;
break;
case "DirectMessage":
icon = <At size={24} />;
const uid = client.channels.getRecipient(channel._id);
recipient = client.users.get(uid);
recipient = channel.recipient;
break;
case "Group":
icon = <Group size={24} />;
......@@ -93,11 +87,11 @@ export default function ChannelHeader({
return (
<Header placement="primary">
{isTouchscreenDevice &&
{isTouchscreenDevice && (
<div className="menu">
<Menu size={27} />
</div>
}
)}
{icon}
<Info>
<span className="name">{name}</span>
......@@ -109,12 +103,11 @@ export default function ChannelHeader({
<div
className="status"
style={{
backgroundColor: useStatusColour(
recipient as User,
),
backgroundColor:
useStatusColour(recipient),
}}
/>
<UserStatus user={recipient as User} />
<UserStatus user={recipient} />
</span>
</>
)}
......@@ -129,7 +122,7 @@ export default function ChannelHeader({
onClick={() =>
openScreen({
id: "channel_info",
channel_id: channel._id,
channel,
})
}>
<Markdown
......@@ -145,4 +138,4 @@ export default function ChannelHeader({
<HeaderActions channel={channel} toggleSidebar={toggleSidebar} />
</Header>
);
}
});
/* eslint-disable react-hooks/rules-of-hooks */
import {
UserPlus,
Cog,
PhoneCall,
PhoneOutgoing,
Group
PhoneOff,
Group,
} from "@styled-icons/boxicons-solid";
import { useHistory } from "react-router-dom";
import { useContext } from "preact/hooks";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import {
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import UpdateIndicator from "../../../components/common/UpdateIndicator";
import IconButton from "../../../components/ui/IconButton";
......@@ -29,25 +27,21 @@ export default function HeaderActions({
toggleSidebar,
}: ChannelHeaderProps) {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const history = useHistory();
return (
<>
<UpdateIndicator />
<UpdateIndicator style="channel" />
{channel.channel_type === "Group" && (
<>
<IconButton
onClick={() =>
openScreen({
id: "user_picker",
omit: channel.recipients,
omit: channel.recipient_ids!,
callback: async (users) => {
for (const user of users) {
await client.channels.addMember(
channel._id,
user,
);
await channel.addMember(user);
}
},
})
......@@ -64,11 +58,11 @@ export default function HeaderActions({
)}
<VoiceActions channel={channel} />
{(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
<IconButton onClick={toggleSidebar}>
<Group size={25} />
</IconButton>
}
channel.channel_type === "TextChannel") && (
<IconButton onClick={toggleSidebar}>
<Group size={25} />
</IconButton>
)}
</>
);
}
......@@ -87,25 +81,23 @@ function VoiceActions({ channel }: Pick<ChannelHeaderProps, "channel">) {
if (voice.roomId === channel._id) {
return (
<IconButton onClick={disconnect}>
<PhoneOutgoing size={22} />
</IconButton>
);
} else {
return (
<IconButton
onClick={() => {
disconnect();
connect(channel._id);
}}>
<PhoneCall size={24} />
<PhoneOff size={22} />
</IconButton>
);
}
} else {
return (
<IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
<IconButton
onClick={() => {
disconnect();
connect(channel);
}}>
<PhoneCall size={24} />
</IconButton>
);
}
return (
<IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
</IconButton>
);
}
import { observer } from "mobx-react-lite";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../../context/revoltjs/util";
const StartBase = styled.div`
......@@ -24,17 +25,17 @@ interface Props {
id: string;
}
export default function ConversationStart({ id }: Props) {
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
export default observer(({ id }: Props) => {
const client = useClient();
const channel = client.channels.get(id);
if (!channel) return null;
return (
<StartBase>
<h1>{getChannelName(ctx.client, channel, true)}</h1>
<h1>{getChannelName(channel, true)}</h1>
<h4>
<Text id="app.main.channel.start.group" />
</h4>
</StartBase>
);
}
});
import { useHistory, useParams } from "react-router-dom";
import { animateScroll } from "react-scroll";
import styled from "styled-components";
import useResizeObserver from "use-resize-observer";
import { createContext } from "preact";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
......@@ -28,7 +30,6 @@ import Preloader from "../../../components/ui/Preloader";
import ConversationStart from "./ConversationStart";
import MessageRenderer from "./MessageRenderer";
import { useHistory, useParams } from "react-router-dom";
const Area = styled.div`
height: 100%;
......@@ -74,7 +75,7 @@ export function MessageArea({ id }: Props) {
// ? useRef to avoid re-renders
const scrollState = useRef<ScrollState>({ type: "Free" });
const setScrollState = (v: ScrollState) => {
const setScrollState = useCallback((v: ScrollState) => {
if (v.type === "StayAtBottom") {
if (scrollState.current.type === "Bottom" || atBottom()) {
scrollState.current = {
......@@ -100,9 +101,10 @@ export function MessageArea({ id }: Props) {
duration: scrollState.current.smooth ? 150 : 0,
});
} else if (scrollState.current.type === "ScrollToView") {
document.getElementById(scrollState.current.id)
?.scrollIntoView({ block: 'center' });
document
.getElementById(scrollState.current.id)
?.scrollIntoView({ block: "center" });
setScrollState({ type: "Free" });
} else if (scrollState.current.type === "OffsetTop") {
animateScroll.scrollTo(
......@@ -130,7 +132,7 @@ export function MessageArea({ id }: Props) {
setScrollState({ type: "Free" });
}
});
};
}, []);
// ? Determine if we are at the bottom of the scroll container.
// -> https://stackoverflow.com/a/44893438
......@@ -147,9 +149,10 @@ export function MessageArea({ id }: Props) {
// ? Handle global jump to bottom, e.g. when editing last message in chat.
useEffect(() => {
return internalSubscribe('MessageArea', 'jump_to_bottom',
() => setScrollState({ type: 'ScrollToBottom' }));
}, []);
return internalSubscribe("MessageArea", "jump_to_bottom", () =>
setScrollState({ type: "ScrollToBottom" }),
);
}, [setScrollState]);
// ? Handle events from renderer.
useEffect(() => {
......@@ -161,12 +164,13 @@ export function MessageArea({ id }: Props) {
SingletonMessageRenderer.addListener("scroll", setScrollState);
return () =>
SingletonMessageRenderer.removeListener("scroll", setScrollState);
}, [scrollState]);
}, [scrollState, setScrollState]);
// ? Load channel initially.
useEffect(() => {
if (message) return;
SingletonMessageRenderer.init(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
// ? If message present or changes, load it as well.
......@@ -175,13 +179,14 @@ export function MessageArea({ id }: Props) {
setHighlight(message);
SingletonMessageRenderer.init(id, message);
let channel = client.channels.get(id);
if (channel?.channel_type === 'TextChannel') {
history.push(`/server/${channel.server}/channel/${id}`);
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.
......@@ -201,11 +206,14 @@ export function MessageArea({ id }: Props) {
SingletonMessageRenderer.markStale();
break;
}
}, [status, state]);
}, [id, status, state]);
// ? When the container is scrolled.
// ? Also handle StayAtBottom
useEffect(() => {
const current = ref.current;
if (!current) return;
async function onScroll() {
if (scrollState.current.type === "Free" && atBottom()) {
setScrollState({ type: "Bottom" });
......@@ -219,12 +227,15 @@ export function MessageArea({ id }: Props) {
}
}
ref.current?.addEventListener("scroll", onScroll);
return () => ref.current?.removeEventListener("scroll", onScroll);
}, [ref, scrollState]);
current.addEventListener("scroll", onScroll);
return () => current.removeEventListener("scroll", onScroll);
}, [ref, scrollState, setScrollState]);
// ? Top and bottom loaders.
useEffect(() => {
const current = ref.current;
if (!current) return;
async function onScroll() {
if (atTop(100)) {
SingletonMessageRenderer.loadTop(ref.current!);
......@@ -235,12 +246,12 @@ export function MessageArea({ id }: Props) {
}
}
ref.current?.addEventListener("scroll", onScroll);
return () => ref.current?.removeEventListener("scroll", onScroll);
current.addEventListener("scroll", onScroll);
return () => current.removeEventListener("scroll", onScroll);
}, [ref]);
// ? Scroll down whenever the message area resizes.
function stbOnResize() {
const stbOnResize = useCallback(() => {
if (!atBottom() && scrollState.current.type === "Bottom") {
animateScroll.scrollToBottom({
container: ref.current,
......@@ -249,18 +260,18 @@ export function MessageArea({ id }: Props) {
setScrollState({ type: "Bottom" });
}
}
}, [setScrollState]);
// ? Scroll down when container resized.
useLayoutEffect(() => {
stbOnResize();
}, [height]);
}, [stbOnResize, height]);
// ? Scroll down whenever the window resizes.
useLayoutEffect(() => {
document.addEventListener("resize", stbOnResize);
return () => document.removeEventListener("resize", stbOnResize);
}, [ref, scrollState]);
}, [ref, scrollState, stbOnResize]);
// ? Scroll to bottom when pressing 'Escape'.
useEffect(() => {
......@@ -273,7 +284,7 @@ export function MessageArea({ id }: Props) {
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [ref, focusTaken]);
}, [id, ref, focusTaken]);
return (
<MessageAreaWidthContext.Provider
......@@ -287,7 +298,11 @@ export function MessageArea({ id }: Props) {
</RequiresOnline>
)}
{state.type === "RENDER" && (
<MessageRenderer id={id} state={state} highlight={highlight} />
<MessageRenderer
id={id}
state={state}
highlight={highlight}
/>
)}
{state.type === "EMPTY" && <ConversationStart id={id} />}
</div>
......
import { Message } from "revolt.js/dist/maps/Messages";
import styled from "styled-components";
import { useContext, useEffect, useState } from "preact/hooks";
......@@ -9,8 +10,6 @@ import {
IntermediateContext,
useIntermediate,
} from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { MessageObject } from "../../../context/revoltjs/util";
import AutoComplete, {
useAutoComplete,
......@@ -23,9 +22,9 @@ const EditorBase = styled.div`
textarea {
resize: none;
padding: 12px;
border-radius: 3px;
white-space: pre-wrap;
font-size: var(--text-size);
border-radius: var(--border-radius);
background: var(--secondary-header);
}
......@@ -44,7 +43,7 @@ const EditorBase = styled.div`
`;
interface Props {
message: MessageObject;
message: Message;
finish: () => void;
}
......@@ -52,7 +51,6 @@ export default function MessageEditor({ message, finish }: Props) {
const [content, setContent] = useState((message.content as string) ?? "");
const { focusTaken } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
async function save() {
finish();
......@@ -60,13 +58,11 @@ export default function MessageEditor({ message, finish }: Props) {
if (content.length === 0) {
openScreen({
id: "special_prompt",
// @ts-expect-error
type: "delete_message",
// @ts-expect-error
target: message,
});
} else if (content !== message.content) {
await client.channels.editMessage(message.channel, message._id, {
await message.edit({
content,
});
}
......@@ -82,7 +78,7 @@ export default function MessageEditor({ message, finish }: Props) {
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [focusTaken]);
}, [focusTaken, finish]);
const {
onChange,
......
/* eslint-disable react-hooks/rules-of-hooks */
import { X } from "@styled-icons/boxicons-regular";
import { Users } from "revolt.js/dist/api/objects";
import { RelationshipStatus } from "revolt-api/types/Users";
import { SYSTEM_USER_ID } from "revolt.js";
import { Message as MessageI } from "revolt.js/dist/maps/Messages";
import styled from "styled-components";
import { decodeTime } from "ulid";
import { Text } from "preact-i18n";
import { memo } from "preact/compat";
import { useContext, useEffect, useState } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter";
import { RenderState } from "../../../lib/renderer/types";
......@@ -13,8 +17,7 @@ import { connectState } from "../../../redux/connector";
import { QueuedMessage } from "../../../redux/reducers/queue";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { MessageObject } from "../../../context/revoltjs/util";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import Message from "../../../components/common/messaging/Message";
import { SystemMessage } from "../../../components/common/messaging/SystemMessage";
......@@ -46,7 +49,7 @@ const BlockedMessage = styled.div`
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);
......@@ -59,9 +62,9 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
function editLast() {
if (state.type !== "RENDER") return;
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].author === userId) {
if (state.messages[i].author_id === userId) {
setEditing(state.messages[i]._id);
internalEmit('MessageArea', 'jump_to_bottom');
internalEmit("MessageArea", "jump_to_bottom");
return;
}
}
......@@ -73,10 +76,10 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
];
return () => subs.forEach((unsub) => unsub());
}, [state.messages]);
}, [state.messages, state.type, userId]);
let render: Children[] = [],
previous: MessageObject | undefined;
const render: Children[] = [];
let previous: MessageI | undefined;
if (state.atTop) {
render.push(<ConversationStart id={id} />);
......@@ -116,7 +119,11 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
function pushBlocked() {
render.push(
<BlockedMessage>
<X size={16} /> {blocked} blocked messages
<X size={16} />{" "}
<Text
id="app.main.channel.misc.blocked_messages"
fields={{ count: blocked }}
/>
</BlockedMessage>,
);
blocked = 0;
......@@ -124,10 +131,15 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
for (const message of state.messages) {
if (previous) {
compare(message._id, message.author, previous._id, previous.author);
compare(
message._id,
message.author_id,
previous._id,
previous.author_id,
);
}
if (message.author === "00000000000000000000000000") {
if (message.author_id === SYSTEM_USER_ID) {
render.push(
<SystemMessage
key={message._id}
......@@ -136,34 +148,30 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
highlight={highlight === message._id}
/>,
);
} else if (
message.author?.relationship === RelationshipStatus.Blocked
) {
blocked++;
} else {
// ! FIXME: temp solution
if (
client.users.get(message.author)?.relationship ===
Users.Relationship.Blocked
) {
blocked++;
} else {
if (blocked > 0) pushBlocked();
render.push(
<Message
message={message}
key={message._id}
head={head}
content={
editing === message._id ? (
<MessageEditor
message={message}
finish={stopEditing}
/>
) : undefined
}
attachContext
highlight={highlight === message._id}
/>,
);
}
if (blocked > 0) pushBlocked();
render.push(
<Message
message={message}
key={message._id}
head={head}
content={
editing === message._id ? (
<MessageEditor
message={message}
finish={stopEditing}
/>
) : undefined
}
attachContext
highlight={highlight === message._id}
/>,
);
}
previous = message;
......@@ -178,20 +186,22 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
if (nonces.includes(msg.id)) continue;
if (previous) {
compare(msg.id, userId!, previous._id, previous.author);
compare(msg.id, userId!, previous._id, previous.author_id);
previous = {
_id: msg.id,
data: { author: userId! },
} as any;
author_id: userId!,
} as MessageI;
}
render.push(
<Message
message={{
...msg.data,
replies: msg.data.replies.map((x) => x.id),
}}
message={
new MessageI(client, {
...msg.data,
replies: msg.data.replies.map((x) => x.id),
})
}
key={msg.id}
queued={msg}
head={head}
......
import { BarChart } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import styled from "styled-components";
import { Text } from "preact-i18n";
......@@ -9,11 +10,7 @@ import {
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import {
useForceUpdate,
useSelf,
useUsers,
} from "../../../context/revoltjs/hooks";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import UserIcon from "../../../components/common/user/UserIcon";
import Button from "../../../components/ui/Button";
......@@ -27,18 +24,20 @@ const VoiceBase = styled.div`
background: var(--secondary-background);
.status {
position: absolute;
color: var(--success);
background: var(--primary-background);
flex: 1 0;
display: flex;
position: absolute;
align-items: center;
padding: 10px;
font-size: 14px;
font-weight: 600;
border-radius: 7px;
flex: 1 0;
user-select: none;
color: var(--success);
border-radius: var(--border-radius);
background: var(--primary-background);
svg {
margin-inline-end: 4px;
cursor: help;
......@@ -68,17 +67,20 @@ const VoiceBase = styled.div`
}
`;
export default function VoiceHeader({ id }: Props) {
export default observer(({ id }: Props) => {
const { status, participants, roomId } = useContext(VoiceContext);
if (roomId !== id) return null;
const { isProducing, startProducing, stopProducing, disconnect } =
useContext(VoiceOperationsContext);
const ctx = useForceUpdate();
const self = useSelf(ctx);
const client = useClient();
const self = client.users.get(client.user!._id);
//const ctx = useForceUpdate();
//const self = useSelf(ctx);
const keys = participants ? Array.from(participants.keys()) : undefined;
const users = keys ? useUsers(keys, ctx) : undefined;
const users = keys?.map((key) => client.users.get(key));
return (
<VoiceBase>
......@@ -133,7 +135,7 @@ export default function VoiceHeader({ id }: Props) {
</div>
</VoiceBase>
);
}
});
/**{voice.roomId === id && (
<div className={styles.rtc}>
......
......@@ -6,14 +6,13 @@ 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>
......
......@@ -18,7 +18,7 @@
&[data-empty="true"] {
img {
height: 120px;
border-radius: 8px;
border-radius: var(--border-radius);
}
gap: 16px;
......@@ -35,12 +35,12 @@
}
.friend {
padding: 0 10px;
height: 60px;
display: flex;
border-radius: 5px;
align-items: center;
padding: 0 10px;
cursor: pointer;
align-items: center;
border-radius: var(--border-radius);
&:hover {
background: var(--secondary-background);
......@@ -110,9 +110,9 @@
display: flex;
cursor: pointer;
margin-top: 1em;
border-radius: 7px;
align-items: center;
flex-direction: row;
border-radius: var(--border-radius);
background: var(--secondary-background);
svg {
......
import { X, Plus } from "@styled-icons/boxicons-regular";
import { PhoneCall, Envelope, UserX } from "@styled-icons/boxicons-solid";
import { User, Users } from "revolt.js/dist/api/objects";
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 classNames from "classnames";
......@@ -12,10 +15,6 @@ import { stopPropagation } from "../../lib/stopPropagation";
import { VoiceOperationsContext } from "../../context/Voice";
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";
......@@ -27,48 +26,57 @@ 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 { openDM } = useContext(OperationsContext);
const { connect } = useContext(VoiceOperationsContext);
const actions: Children[] = [];
let subtext: Children = null;
if (user.relationship === Users.Relationship.Friend) {
if (user.relationship === RelationshipStatus.Friend) {
subtext = <UserStatus user={user} />;
actions.push(
<>
<IconButton
type="circle"
className={classNames(
styles.button,
styles.success,
)}
className={classNames(styles.button, styles.success)}
onClick={(ev) =>
stopPropagation(ev, openDM(user._id).then(connect))
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, openDM(user._id))}>
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"
className={styles.button}
onClick={(ev) =>
stopPropagation(ev, client.users.addFriend(user.username))
}>
onClick={(ev) => stopPropagation(ev, user.addFriend())}>
<Plus size={24} />
</IconButton>,
);
......@@ -76,29 +84,33 @@ export function Friend({ user }: Props) {
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"
className={classNames(styles.button, styles.remove, styles.error)}
className={classNames(
styles.button,
styles.remove,
styles.error,
)}
onClick={(ev) =>
stopPropagation(
ev,
user.relationship === Users.Relationship.Friend
user.relationship === RelationshipStatus.Friend
? openScreen({
id: "special_prompt",
type: "unfriend_user",
target: user,
})
: client.users.removeFriend(user._id),
: user.removeFriend(),
)
}>
<X size={24} />
......@@ -106,14 +118,12 @@ export function Friend({ user }: Props) {
);
}
if (user.relationship === Users.Relationship.Blocked) {
if (user.relationship === RelationshipStatus.Blocked) {
actions.push(
<IconButton
type="circle"
className={classNames(styles.button, styles.error)}
onClick={(ev) =>
stopPropagation(ev, client.users.unblockUser(user._id))
}>
onClick={(ev) => stopPropagation(ev, user.unblockUser())}>
<UserX size={24} />
</IconButton>,
);
......@@ -132,4 +142,4 @@ export function Friend({ user }: Props) {
<div className={styles.actions}>{actions}</div>
</div>
);
}
});
import {
ChevronDown,
ChevronRight,
ListPlus,
} from "@styled-icons/boxicons-regular";
import { ChevronRight } from "@styled-icons/boxicons-regular";
import { UserDetail, MessageAdd, UserPlus } from "@styled-icons/boxicons-solid";
import { User, Users } from "revolt.js/dist/api/objects";
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 { Text } from "preact-i18n";
......@@ -13,65 +11,62 @@ import { TextReact } from "../../lib/i18n";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { useUsers } from "../../context/revoltjs/hooks";
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 Details from "../../components/ui/Details";
import Header from "../../components/ui/Header";
import IconButton from "../../components/ui/IconButton";
import Overline from "../../components/ui/Overline";
import { Children } from "../../types/Preact";
import { Friend } from "./Friend";
export default function Friends() {
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 friends = users.filter(
(x) => x.relationship === Users.Relationship.Friend,
(x) => x.relationship === RelationshipStatus.Friend,
);
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),
users.filter((x) => x.relationship === RelationshipStatus.Outgoing),
"outgoing",
],
[
"app.status.online",
friends.filter(
(x) =>
x.online && x.status?.presence !== Users.Presence.Invisible,
(x) => x.online && x.status?.presence !== Presence.Invisible,
),
"online",
],
[
"app.status.offline",
friends.filter(
(x) =>
!x.online ||
x.status?.presence === Users.Presence.Invisible,
(x) => !x.online || x.status?.presence === Presence.Invisible,
),
"offline",
],
[
"app.special.friends.blocked",
users.filter((x) => x.relationship === Users.Relationship.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>{x.username}</b>);
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;
......@@ -138,7 +133,7 @@ export default function Friends() {
onClick={() =>
openScreen({
id: "pending_requests",
users: incoming.map((x) => x._id),
users: incoming,
})
}>
<div className={styles.avatars}>
......@@ -198,6 +193,7 @@ export default function Friends() {
return (
<CollapsibleSection
key={section_id}
id={`friends_${section_id}`}
defaultValue={true}
sticky
......@@ -216,4 +212,4 @@ export default function Friends() {
</div>
</>
);
}
});
import { Home as HomeIcon } from "@styled-icons/boxicons-solid";
import Button from "../../components/ui/Button";
import { Link } from "react-router-dom";
import styles from "./Home.module.scss";
import { Text } from "preact-i18n";
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() {
......@@ -16,7 +17,8 @@ export default function Home() {
<Text id="app.navigation.tabs.home" />
</Header>
<h3>
<Text id="app.special.modals.onboarding.welcome" />{" "}
<Text id="app.special.modals.onboarding.welcome" />
<br />
<img src={wideSVG} />
</h3>
<div className={styles.actions}>
......@@ -25,16 +27,22 @@ export default function Home() {
Join testers server
</Button>
</Link>
<Link to="/settings/feedback">
<Button contrast>
Give feedback
</Button>
</Link>
<a href="https://gitlab.insrt.uk/revolt" target="_blank">
<Button contrast>
Source code
<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>
);
......
......@@ -34,10 +34,10 @@
.details {
text-align: center;
border-radius: 3px;
align-self: center;
padding: 32px 16px 16px 16px;
background: rgba(0, 0, 0, 0.6);
border-radius: var(--border-radius);
h1 {
margin: 0;
......
import { ArrowBack } from "@styled-icons/boxicons-regular";
import { autorun } from "mobx";
import { useHistory, useParams } from "react-router-dom";
import { Invites } from "revolt.js/dist/api/objects";
import { RetrievedInvite } from "revolt-api/types/Invites";
import styles from "./Invite.module.scss";
import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks";
import { defer } from "../../lib/defer";
import { TextReact } from "../../lib/i18n";
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
import {
AppContext,
......@@ -26,7 +31,7 @@ export default function Invite() {
const { code } = useParams<{ code: string }>();
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [invite, setInvite] = useState<Invites.RetrievedInvite | undefined>(
const [invite, setInvite] = useState<RetrievedInvite | undefined>(
undefined,
);
......@@ -40,7 +45,7 @@ export default function Invite() {
.then((data) => setInvite(data))
.catch((err) => setError(takeError(err)));
}
}, [status]);
}, [client, code, invite, status]);
if (typeof invite === "undefined") {
return (
......@@ -87,12 +92,20 @@ export default function Invite() {
<h1>{invite.server_name}</h1>
<h2>#{invite.channel_name}</h2>
<h3>
Invited by{" "}
<UserIcon
size={24}
attachment={invite.user_avatar}
/>{" "}
{invite.user_name}
<TextReact
id="app.special.invite.invited_by"
fields={{
user: (
<>
<UserIcon
size={24}
attachment={invite.user_avatar}
/>{" "}
{invite.user_name}
</>
),
}}
/>
</h3>
<Overline type="error" error={error} />
<Button
......@@ -106,23 +119,42 @@ export default function Invite() {
setProcessing(true);
if (invite.type === "Server") {
if (client.servers.get(invite.server_id)) {
history.push(`/server/${invite.server_id}/channel/${invite.channel_id}`);
if (
client.servers.get(invite.server_id)
) {
history.push(
`/server/${invite.server_id}/channel/${invite.channel_id}`,
);
}
}
let result = await client.joinInvite(code);
if (result.type === "Server") {
history.push(`/server/${result.server._id}/channel/${result.channel._id}`);
const dispose = autorun(() => {
const server = client.servers.get(
invite.server_id,
);
defer(() => {
if (server) {
history.push(
`/server/${server._id}/channel/${invite.channel_id}`,
);
}
});
dispose();
});
}
await client.joinInvite(code);
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}}>
{status === ClientStatus.READY
? "Login to Revolt"
: "Accept Invite"}
{status === ClientStatus.READY ? (
<Text id="app.special.invite.login" />
) : (
<Text id="app.special.invite.accept" />
)}
</Button>
</>
)}
......
import { UseFormMethods } from "react-hook-form";
import { Text, Localizer } from "preact-i18n";
import InputBox from "../../components/ui/InputBox";
......@@ -6,7 +8,7 @@ import Overline from "../../components/ui/Overline";
interface Props {
type: "email" | "username" | "password" | "invite" | "current_password";
showOverline?: boolean;
register: Function;
register: UseFormMethods["register"];
error?: string;
name?: string;
}
......@@ -27,9 +29,11 @@ export default function FormField({
)}
<Localizer>
<InputBox
// Styled uses React typing while we use Preact
// this leads to inconsistances where things need to be typed oddly
placeholder={(<Text id={`login.enter.${type}`} />) as any}
placeholder={
(
<Text id={`login.enter.${type}`} />
) as unknown as string
}
name={
type === "current_password" ? "password" : name ?? type
}
......@@ -40,6 +44,8 @@ export default function FormField({
? "password"
: type
}
// See https://github.com/mozilla/contain-facebook/issues/783
className="fbc-has-badge"
ref={register(
type === "password" || type === "current_password"
? {
......
......@@ -11,6 +11,7 @@ import { AppContext } from "../../context/revoltjs/RevoltClient";
import LocaleSelector from "../../components/common/LocaleSelector";
import { Titlebar } from "../../components/native/Titlebar";
import { APP_VERSION } from "../../version";
import background from "./background.jpg";
import { FormCreate } from "./forms/FormCreate";
......@@ -23,52 +24,57 @@ export default function Login() {
const client = useContext(AppContext);
return (
<div className={styles.login}>
<Helmet>
<meta name="theme-color" content={theme.background} />
</Helmet>
<div className={styles.content}>
<div className={styles.attribution}>
<span>
API:{" "}
<code>{client.configuration?.revolt ?? "???"}</code>{" "}
&middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
&middot; App: <code>{APP_VERSION}</code>
</span>
<span>
<LocaleSelector />
</span>
</div>
<div className={styles.modal}>
<Switch>
<Route path="/login/create">
<FormCreate />
</Route>
<Route path="/login/resend">
<FormResend />
</Route>
<Route path="/login/reset/:token">
<FormReset />
</Route>
<Route path="/login/reset">
<FormSendReset />
</Route>
<Route path="/">
<FormLogin />
</Route>
</Switch>
</div>
<div className={styles.attribution}>
<span>
<Text id="general.image_by" /> &lrm;@lorenzoherrera
&rlm;· unsplash.com
</span>
<>
{window.isNative && !window.native.getConfig().frame && (
<Titlebar />
)}
<div className={styles.login}>
<Helmet>
<meta name="theme-color" content={theme.background} />
</Helmet>
<div className={styles.content}>
<div className={styles.attribution}>
<span>
API:{" "}
<code>{client.configuration?.revolt ?? "???"}</code>{" "}
&middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
&middot; App: <code>{APP_VERSION}</code>
</span>
<span>
<LocaleSelector />
</span>
</div>
<div className={styles.modal}>
<Switch>
<Route path="/login/create">
<FormCreate />
</Route>
<Route path="/login/resend">
<FormResend />
</Route>
<Route path="/login/reset/:token">
<FormReset />
</Route>
<Route path="/login/reset">
<FormSendReset />
</Route>
<Route path="/">
<FormLogin />
</Route>
</Switch>
</div>
<div className={styles.attribution}>
<span>
<Text id="general.image_by" /> &lrm;@lorenzoherrera
&rlm;· unsplash.com
</span>
</div>
</div>
<div
className={styles.bg}
style={{ background: `url('${background}')` }}
/>
</div>
<div
className={styles.bg}
style={{ background: `url('${background}')` }}
/>
</div>
</>
);
}
......@@ -20,7 +20,7 @@ export function CaptchaBlock(props: CaptchaProps) {
if (!client.configuration?.features.captcha.enabled) {
props.onSuccess();
}
}, []);
}, [client.configuration?.features.captcha.enabled, props]);
if (!client.configuration?.features.captcha.enabled)
return <Preloader type="spinner" />;
......
......@@ -63,7 +63,7 @@ export function Form({ page, callback }: Props) {
setGlobalError(undefined);
setLoading(true);
function onError(err: any) {
function onError(err: unknown) {
setLoading(false);
const error = takeError(err);
......
......@@ -19,7 +19,11 @@ export function FormLogin() {
let device_name;
if (browser) {
const { name, os } = browser;
device_name = `${name} on ${os}`;
if (window.isNative) {
device_name = `Revolt Desktop on ${os}`;
} else {
device_name = `${name} on ${os}`;
}
} else {
device_name = "Unknown Device";
}
......