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 1609 additions and 423 deletions
/* 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 styled from "styled-components";
import { Text } from "preact-i18n";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../../context/revoltjs/util"; import { getChannelName } from "../../../context/revoltjs/util";
import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks";
const StartBase = styled.div` const StartBase = styled.div`
margin: 18px 16px 10px 16px; margin: 18px 16px 10px 16px;
...@@ -22,17 +25,17 @@ interface Props { ...@@ -22,17 +25,17 @@ interface Props {
id: string; id: string;
} }
export default function ConversationStart({ id }: Props) { export default observer(({ id }: Props) => {
const ctx = useForceUpdate(); const client = useClient();
const channel = useChannel(id, ctx); const channel = client.channels.get(id);
if (!channel) return null; if (!channel) return null;
return ( return (
<StartBase> <StartBase>
<h1>{ getChannelName(ctx.client, channel, true) }</h1> <h1>{getChannelName(channel, true)}</h1>
<h4> <h4>
<Text id="app.main.channel.start.group" /> <Text id="app.main.channel.start.group" />
</h4> </h4>
</StartBase> </StartBase>
); );
} });
import styled from "styled-components"; import { useHistory, useParams } from "react-router-dom";
import { createContext } from "preact";
import { animateScroll } from "react-scroll"; import { animateScroll } from "react-scroll";
import MessageRenderer from "./MessageRenderer"; import styled from "styled-components";
import ConversationStart from './ConversationStart';
import useResizeObserver from "use-resize-observer"; import useResizeObserver from "use-resize-observer";
import Preloader from "../../../components/ui/Preloader";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; import { createContext } from "preact";
import { RenderState, ScrollState } from "../../../lib/renderer/types"; import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "preact/hooks";
import { defer } from "../../../lib/defer";
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton"; import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton";
import { RenderState, ScrollState } from "../../../lib/renderer/types";
import { IntermediateContext } from "../../../context/intermediate/Intermediate"; import { IntermediateContext } from "../../../context/intermediate/Intermediate";
import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import {
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` const Area = styled.div`
height: 100%; height: 100%;
...@@ -23,6 +42,7 @@ const Area = styled.div` ...@@ -23,6 +42,7 @@ const Area = styled.div`
> div { > div {
display: flex; display: flex;
min-height: 100%; min-height: 100%;
padding-bottom: 24px;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
} }
...@@ -36,9 +56,15 @@ export const MessageAreaWidthContext = createContext(0); ...@@ -36,9 +56,15 @@ export const MessageAreaWidthContext = createContext(0);
export const MESSAGE_AREA_PADDING = 82; export const MESSAGE_AREA_PADDING = 82;
export function MessageArea({ id }: Props) { export function MessageArea({ id }: Props) {
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext); const status = useContext(StatusContext);
const { focusTaken } = useContext(IntermediateContext); const { focusTaken } = useContext(IntermediateContext);
// ? Required data for message links.
const { message } = useParams<{ message: string }>();
const [highlight, setHighlight] = useState<string | undefined>(undefined);
// ? This is the scroll container. // ? This is the scroll container.
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { width, height } = useResizeObserver<HTMLDivElement>({ ref }); const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
...@@ -46,56 +72,128 @@ export function MessageArea({ id }: Props) { ...@@ -46,56 +72,128 @@ export function MessageArea({ id }: Props) {
// ? Current channel state. // ? Current channel state.
const [state, setState] = useState<RenderState>({ type: "LOADING" }); const [state, setState] = useState<RenderState>({ type: "LOADING" });
// ? Hook-based scrolling mechanism. // ? useRef to avoid re-renders
const [scrollState, setSS] = useState<ScrollState>({ const scrollState = useRef<ScrollState>({ type: "Free" });
type: "Free"
});
const setScrollState = (v: ScrollState) => { const setScrollState = useCallback((v: ScrollState) => {
if (v.type === 'StayAtBottom') { if (v.type === "StayAtBottom") {
if (scrollState.type === 'Bottom' || atBottom()) { if (scrollState.current.type === "Bottom" || atBottom()) {
setSS({ type: 'ScrollToBottom', smooth: v.smooth }); scrollState.current = {
type: "ScrollToBottom",
smooth: v.smooth,
};
} else { } else {
setSS({ type: 'Free' }); scrollState.current = { type: "Free" };
} }
} else { } 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. // ? Determine if we are at the bottom of the scroll container.
// -> https://stackoverflow.com/a/44893438 // -> https://stackoverflow.com/a/44893438
// By default, we assume we are at the bottom, i.e. when we first load. // By default, we assume we are at the bottom, i.e. when we first load.
const atBottom = (offset = 0) => const atBottom = (offset = 0) =>
ref.current ref.current
? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) - ? Math.floor(ref.current?.scrollHeight - ref.current?.scrollTop) -
offset <= offset <=
ref.current.clientHeight ref.current?.clientHeight
: true; : true;
const atTop = (offset = 0) => ref.current.scrollTop <= offset; const atTop = (offset = 0) =>
ref.current ? ref.current.scrollTop <= offset : false;
// ? Handle global jump to bottom, e.g. when editing last message in chat.
useEffect(() => {
return internalSubscribe("MessageArea", "jump_to_bottom", () =>
setScrollState({ type: "ScrollToBottom" }),
);
}, [setScrollState]);
// ? Handle events from renderer. // ? Handle events from renderer.
useEffect(() => { useEffect(() => {
SingletonMessageRenderer.addListener('state', setState); SingletonMessageRenderer.addListener("state", setState);
return () => SingletonMessageRenderer.removeListener('state', setState); return () => SingletonMessageRenderer.removeListener("state", setState);
}, [ ]); }, []);
useEffect(() => { useEffect(() => {
SingletonMessageRenderer.addListener('scroll', setScrollState); SingletonMessageRenderer.addListener("scroll", setScrollState);
return () => SingletonMessageRenderer.removeListener('scroll', setScrollState); return () =>
}, [ scrollState ]); SingletonMessageRenderer.removeListener("scroll", setScrollState);
}, [scrollState, setScrollState]);
// ? Load channel initially. // ? Load channel initially.
useEffect(() => { useEffect(() => {
if (message) return;
SingletonMessageRenderer.init(id); SingletonMessageRenderer.init(id);
}, [ id ]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
// ? If message present or changes, load it as well.
useEffect(() => {
if (message) {
setHighlight(message);
SingletonMessageRenderer.init(id, message);
const channel = client.channels.get(id);
if (channel?.channel_type === "TextChannel") {
history.push(`/server/${channel.server_id}/channel/${id}`);
} else {
history.push(`/channel/${id}`);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message]);
// ? If we are waiting for network, try again. // ? If we are waiting for network, try again.
useEffect(() => { useEffect(() => {
switch (status) { switch (status) {
case ClientStatus.ONLINE: case ClientStatus.ONLINE:
if (state.type === 'WAITING_FOR_NETWORK') { if (state.type === "WAITING_FOR_NETWORK") {
SingletonMessageRenderer.init(id); SingletonMessageRenderer.init(id);
} else { } else {
SingletonMessageRenderer.reloadStale(id); SingletonMessageRenderer.reloadStale(id);
...@@ -108,120 +206,103 @@ export function MessageArea({ id }: Props) { ...@@ -108,120 +206,103 @@ export function MessageArea({ id }: Props) {
SingletonMessageRenderer.markStale(); SingletonMessageRenderer.markStale();
break; break;
} }
}, [ status, state ]); }, [id, status, state]);
// ? Scroll to the bottom before the browser paints.
useLayoutEffect(() => {
if (scrollState.type === "ScrollToBottom") {
setScrollState({ type: "Bottom", scrollingUntil: + new Date() + 150 });
animateScroll.scrollToBottom({
container: ref.current,
duration: scrollState.smooth ? 150 : 0
});
} else if (scrollState.type === "OffsetTop") {
animateScroll.scrollTo(
Math.max(
101,
ref.current.scrollTop +
(ref.current.scrollHeight - scrollState.previousHeight)
),
{
container: ref.current,
duration: 0
}
);
setScrollState({ type: "Free" });
} else if (scrollState.type === "ScrollTop") {
animateScroll.scrollTo(scrollState.y, {
container: ref.current,
duration: 0
});
setScrollState({ type: "Free" });
}
}, [scrollState]);
// ? When the container is scrolled. // ? When the container is scrolled.
// ? Also handle StayAtBottom // ? Also handle StayAtBottom
useEffect(() => { useEffect(() => {
const current = ref.current;
if (!current) return;
async function onScroll() { async function onScroll() {
if (scrollState.type === "Free" && atBottom()) { if (scrollState.current.type === "Free" && atBottom()) {
setScrollState({ type: "Bottom" }); setScrollState({ type: "Bottom" });
} else if (scrollState.type === "Bottom" && !atBottom()) { } else if (scrollState.current.type === "Bottom" && !atBottom()) {
if (scrollState.scrollingUntil && scrollState.scrollingUntil > + new Date()) return; if (
scrollState.current.scrollingUntil &&
scrollState.current.scrollingUntil > +new Date()
)
return;
setScrollState({ type: "Free" }); setScrollState({ type: "Free" });
} }
} }
ref.current.addEventListener("scroll", onScroll); current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll); return () => current.removeEventListener("scroll", onScroll);
}, [ref, scrollState]); }, [ref, scrollState, setScrollState]);
// ? Top and bottom loaders. // ? Top and bottom loaders.
useEffect(() => { useEffect(() => {
const current = ref.current;
if (!current) return;
async function onScroll() { async function onScroll() {
if (atTop(100)) { if (atTop(100)) {
SingletonMessageRenderer.loadTop(ref.current); SingletonMessageRenderer.loadTop(ref.current!);
} }
if (atBottom(100)) { if (atBottom(100)) {
SingletonMessageRenderer.loadBottom(ref.current); SingletonMessageRenderer.loadBottom(ref.current!);
} }
} }
ref.current.addEventListener("scroll", onScroll); current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll); return () => current.removeEventListener("scroll", onScroll);
}, [ref]); }, [ref]);
// ? Scroll down whenever the message area resizes. // ? Scroll down whenever the message area resizes.
function stbOnResize() { const stbOnResize = useCallback(() => {
if (!atBottom() && scrollState.type === "Bottom") { if (!atBottom() && scrollState.current.type === "Bottom") {
animateScroll.scrollToBottom({ animateScroll.scrollToBottom({
container: ref.current, container: ref.current,
duration: 0 duration: 0,
}); });
setScrollState({ type: "Bottom" }); setScrollState({ type: "Bottom" });
} }
} }, [setScrollState]);
// ? Scroll down when container resized. // ? Scroll down when container resized.
useLayoutEffect(() => { useLayoutEffect(() => {
stbOnResize(); stbOnResize();
}, [height]); }, [stbOnResize, height]);
// ? Scroll down whenever the window resizes. // ? Scroll down whenever the window resizes.
useLayoutEffect(() => { useLayoutEffect(() => {
document.addEventListener("resize", stbOnResize); document.addEventListener("resize", stbOnResize);
return () => document.removeEventListener("resize", stbOnResize); return () => document.removeEventListener("resize", stbOnResize);
}, [ref, scrollState]); }, [ref, scrollState, stbOnResize]);
// ? Scroll to bottom when pressing 'Escape'. // ? Scroll to bottom when pressing 'Escape'.
useEffect(() => { useEffect(() => {
function keyUp(e: KeyboardEvent) { function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) { if (e.key === "Escape" && !focusTaken) {
SingletonMessageRenderer.jumpToBottom(id, true); SingletonMessageRenderer.jumpToBottom(id, true);
internalEmit("TextArea", "focus", "message");
} }
} }
document.body.addEventListener("keyup", keyUp); document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp); return () => document.body.removeEventListener("keyup", keyUp);
}, [ref, focusTaken]); }, [id, ref, focusTaken]);
return ( return (
<MessageAreaWidthContext.Provider value={(width ?? 0) - MESSAGE_AREA_PADDING}> <MessageAreaWidthContext.Provider
value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<Area ref={ref}> <Area ref={ref}>
<div> <div>
{state.type === "LOADING" && <Preloader />} {state.type === "LOADING" && <Preloader type="ring" />}
{state.type === "WAITING_FOR_NETWORK" && ( {state.type === "WAITING_FOR_NETWORK" && (
<RequiresOnline> <RequiresOnline>
<Preloader /> <Preloader type="ring" />
</RequiresOnline> </RequiresOnline>
)} )}
{state.type === "RENDER" && ( {state.type === "RENDER" && (
<MessageRenderer id={id} state={state} /> <MessageRenderer
id={id}
state={state}
highlight={highlight}
/>
)} )}
{state.type === "EMPTY" && <ConversationStart id={id} />} {state.type === "EMPTY" && <ConversationStart id={id} />}
</div> </div>
......
import { Message } from "revolt.js/dist/maps/Messages";
import styled from "styled-components";
import { useContext, useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
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;
textarea {
resize: none;
padding: 12px;
white-space: pre-wrap;
font-size: var(--text-size);
border-radius: var(--border-radius);
background: var(--secondary-header);
}
.caption {
padding: 2px;
font-size: 11px;
color: var(--tertiary-foreground);
a {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
`;
interface Props {
message: Message;
finish: () => void;
}
export default function MessageEditor({ message, finish }: Props) {
const [content, setContent] = useState((message.content as string) ?? "");
const { focusTaken } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
async function save() {
finish();
if (content.length === 0) {
openScreen({
id: "special_prompt",
type: "delete_message",
target: message,
});
} else if (content !== message.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}
value={content}
maxLength={2000}
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" &&
!isTouchscreenDevice
) {
e.preventDefault();
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>
</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 { decodeTime } from "ulid";
import { Text } from "preact-i18n";
import { memo } from "preact/compat";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import ConversationStart from "./ConversationStart";
import { connectState } from "../../../redux/connector"; import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter";
import Preloader from "../../../components/ui/Preloader";
import { RenderState } from "../../../lib/renderer/types"; import { RenderState } from "../../../lib/renderer/types";
import DateDivider from "../../../components/ui/DateDivider";
import { connectState } from "../../../redux/connector";
import { QueuedMessage } from "../../../redux/reducers/queue"; import { QueuedMessage } from "../../../redux/reducers/queue";
import { MessageObject } from "../../../context/revoltjs/util";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import { useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; import { useClient } from "../../../context/revoltjs/RevoltClient";
import { Children } from "../../../types/Preact";
import { SystemMessage } from "../../../components/common/messaging/SystemMessage";
import Message from "../../../components/common/messaging/Message"; 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 { interface Props {
id: string; id: string;
state: RenderState; state: RenderState;
highlight?: string;
queue: QueuedMessage[]; queue: QueuedMessage[];
} }
function MessageRenderer({ id, state, queue }: Props) { const BlockedMessage = styled.div`
if (state.type !== 'RENDER') return null; font-size: 0.8em;
margin-top: 6px;
padding: 4px 64px;
color: var(--tertiary-foreground);
const ctx = useForceUpdate(); &:hover {
const users = useUsers(); background: var(--hover);
const userId = ctx.client.user!._id; }
`;
function MessageRenderer({ id, state, queue, highlight }: Props) {
if (state.type !== "RENDER") return null;
/* const client = useClient();
const view = useView(id);*/ const userId = client.user!._id;
const [editing, setEditing] = useState<string | undefined>(undefined); const [editing, setEditing] = useState<string | undefined>(undefined);
const stopEditing = () => { const stopEditing = () => {
setEditing(undefined); setEditing(undefined);
// InternalEventEmitter.emit("focus_textarea", "message"); internalEmit("TextArea", "focus", "message");
}; };
useEffect(() => { useEffect(() => {
function editLast() { function editLast() {
if (state.type !== 'RENDER') return; if (state.type !== "RENDER") return;
for (let i = state.messages.length - 1; i >= 0; i--) { for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].author === userId) { if (state.messages[i].author_id === userId) {
setEditing(state.messages[i]._id); setEditing(state.messages[i]._id);
internalEmit("MessageArea", "jump_to_bottom");
return; return;
} }
} }
} }
// InternalEventEmitter.addListener("edit_last", editLast); const subs = [
// InternalEventEmitter.addListener("edit_message", setEditing); internalSubscribe("MessageRenderer", "edit_last", editLast),
internalSubscribe("MessageRenderer", "edit_message", setEditing),
];
return () => { return () => subs.forEach((unsub) => unsub());
// InternalEventEmitter.removeListener("edit_last", editLast); }, [state.messages, state.type, userId]);
// InternalEventEmitter.removeListener("edit_message", setEditing);
};
}, [state.messages]);
let render: Children[] = [], const render: Children[] = [];
previous: MessageObject | undefined; let previous: MessageI | undefined;
if (state.atTop) { if (state.atTop) {
render.push(<ConversationStart id={id} />); render.push(<ConversationStart id={id} />);
} else { } else {
render.push( render.push(
<RequiresOnline> <RequiresOnline>
<Preloader /> <Preloader type="ring" />
</RequiresOnline> </RequiresOnline>,
); );
} }
...@@ -72,7 +96,7 @@ function MessageRenderer({ id, state, queue }: Props) { ...@@ -72,7 +96,7 @@ function MessageRenderer({ id, state, queue }: Props) {
current: string, current: string,
curAuthor: string, curAuthor: string,
previous: string, previous: string,
prevAuthor: string prevAuthor: string,
) { ) {
const atime = decodeTime(current), const atime = decodeTime(current),
adate = new Date(atime), adate = new Date(atime),
...@@ -85,95 +109,121 @@ function MessageRenderer({ id, state, queue }: Props) { ...@@ -85,95 +109,121 @@ function MessageRenderer({ id, state, queue }: Props) {
adate.getDate() !== bdate.getDate() adate.getDate() !== bdate.getDate()
) { ) {
render.push(<DateDivider date={adate} />); render.push(<DateDivider date={adate} />);
head = true;
} }
head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000; 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) { for (const message of state.messages) {
if (previous) { if (previous) {
compare( compare(
message._id, message._id,
message.author, message.author_id,
previous._id, previous._id,
previous.author previous.author_id,
); );
} }
if (message.author === "00000000000000000000000000") { if (message.author_id === SYSTEM_USER_ID) {
render.push(<SystemMessage key={message._id} message={message} attachContext />);
} else {
render.push( render.push(
<Message message={message} <SystemMessage
key={message._id} key={message._id}
head={head} message={message}
attachContext /> attachContext
highlight={highlight === message._id}
/>,
); );
/*render.push( } else if (
message.author?.relationship === RelationshipStatus.Blocked
) {
blocked++;
} else {
if (blocked > 0) pushBlocked();
render.push(
<Message <Message
editing={editing === message._id ? stopEditing : undefined}
user={users.find(x => x?._id === message.author)}
message={message} message={message}
key={message._id} key={message._id}
head={head} head={head}
/> content={
);*/ editing === message._id ? (
<MessageEditor
message={message}
finish={stopEditing}
/>
) : undefined
}
attachContext
highlight={highlight === message._id}
/>,
);
} }
previous = message; 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) { if (state.atBottom) {
for (const msg of queue) { for (const msg of queue) {
if (msg.channel !== id) continue; if (msg.channel !== id) continue;
if (nonces.includes(msg.id)) continue; if (nonces.includes(msg.id)) continue;
if (previous) { if (previous) {
compare( compare(msg.id, userId!, previous._id, previous.author_id);
msg.id,
userId as string,
previous._id,
previous.author
);
previous = { previous = {
_id: msg.id, _id: msg.id,
data: { author: userId as string } author_id: userId!,
} as any; } as MessageI;
} }
/*render.push(
<Message
user={users.find(x => x?._id === userId)}
message={msg.data}
queued={msg}
key={msg.id}
head={head}
/>
);*/
render.push( render.push(
<Message message={msg.data} <Message
message={
new MessageI(client, {
...msg.data,
replies: msg.data.replies.map((x) => x.id),
})
}
key={msg.id} key={msg.id}
queued={msg}
head={head} head={head}
attachContext /> attachContext
/>,
); );
} }
render.push(<div>end</div>);
} else { } else {
render.push( render.push(
<RequiresOnline> <RequiresOnline>
<Preloader /> <Preloader type="ring" />
</RequiresOnline> </RequiresOnline>,
); );
} }
return <>{ render }</>; return <>{render}</>;
} }
export default connectState<Omit<Props, 'queue'>>(MessageRenderer, state => { export default memo(
return { connectState<Omit<Props, "queue">>(MessageRenderer, (state) => {
queue: state.queue 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 { useContext } from "preact/hooks";
import { TextReact } from "../../lib/i18n";
import Header from "../../components/ui/Header";
import PaintCounter from "../../lib/PaintCounter"; import PaintCounter from "../../lib/PaintCounter";
import { TextReact } from "../../lib/i18n";
import { AppContext } from "../../context/revoltjs/RevoltClient"; import { AppContext } from "../../context/revoltjs/RevoltClient";
import { useUserPermission } from "../../context/revoltjs/hooks";
import Header from "../../components/ui/Header";
export default function Developer() { export default function Developer() {
// const voice = useContext(VoiceContext); // const voice = useContext(VoiceContext);
const client = useContext(AppContext); const client = useContext(AppContext);
const userPermission = useUserPermission(client.user!._id); const userPermission = client.user!.permission;
return ( return (
<div> <div>
<Header placement="primary">Developer Tab</Header> <Header placement="primary">
<Wrench size="24" />
Developer Tab
</Header>
<div style={{ padding: "16px" }}> <div style={{ padding: "16px" }}>
<PaintCounter always /> <PaintCounter always />
</div> </div>
<div style={{ padding: "16px" }}> <div style={{ padding: "16px" }}>
<b>User ID:</b> {client.user!._id} <br/> <b>User ID:</b> {client.user!._id} <br />
<b>Permission against self:</b> {userPermission} <br/> <b>Permission against self:</b> {userPermission} <br />
</div> </div>
<div style={{ padding: "16px" }}> <div style={{ padding: "16px" }}>
<TextReact id="login.open_mail_provider" fields={{ provider: <b>GAMING!</b> }} /> <TextReact
id="login.open_mail_provider"
fields={{ provider: <b>GAMING!</b> }}
/>
</div> </div>
<div style={{ padding: "16px" }}> <div style={{ padding: "16px" }}>
{/*<span> {/*<span>
......
.title {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.actions {
display: flex;
align-items: center;
gap: 20px;
}
.list { .list {
padding: 16px; padding: 0 10px 10px 10px;
user-select: none; user-select: none;
overflow-y: scroll; overflow-y: scroll;
&[data-empty="true"] { &[data-empty="true"] {
img { img {
height: 120px; height: 120px;
border-radius: 8px; border-radius: var(--border-radius);
} }
gap: 16px; gap: 16px;
...@@ -16,15 +28,19 @@ ...@@ -16,15 +28,19 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
} }
&[data-mobile="true"] {
padding-bottom: var(--bottom-navigation-height);
}
} }
.friend { .friend {
padding: 10px; height: 60px;
display: flex; display: flex;
border-radius: 5px; padding: 0 10px;
align-items: center;
flex-direction: row;
cursor: pointer; cursor: pointer;
align-items: center;
border-radius: var(--border-radius);
&:hover { &:hover {
background: var(--secondary-background); background: var(--secondary-background);
...@@ -38,17 +54,25 @@ ...@@ -38,17 +54,25 @@
flex-grow: 1; flex-grow: 1;
margin: 0 12px; margin: 0 12px;
font-size: 16px; font-size: 16px;
font-weight: 600;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden;
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtext { .subtext {
font-size: 12px; font-size: 12px;
font-weight: 400;
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
} }
...@@ -56,16 +80,118 @@ ...@@ -56,16 +80,118 @@
display: flex; display: flex;
gap: 12px; gap: 12px;
> div { .button {
height: 32px; width: 36px;
width: 32px; height: 36px;
&:hover.error {
background: var(--error);
}
&:hover.success {
background: var(--success);
}
} }
} }
} }
//! FIXME: Move this to the Header component, do this: .divider {
// 1. Check if header has topic, if yes, flex-grow: 0 on the title. width: 1px;
// 2. If header has no topic (example: friends page), flex-grow 1 on the header title. height: 24px;
background: var(--primary-background);
}
.title { .title {
flex-grow: 1; 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 { X, Plus } from "@styled-icons/boxicons-regular";
import { Link } from "react-router-dom"; import { PhoneCall, Envelope, UserX } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { RelationshipStatus } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./Friend.module.scss"; import styles from "./Friend.module.scss";
import { useContext } from "preact/hooks"; import classNames from "classnames";
import { Children } from "../../types/Preact";
import { X, Plus, Mail } from "@styled-icons/feather";
import UserIcon from "../../components/common/UserIcon";
import IconButton from "../../components/ui/IconButton";
import { attachContextMenu } from "preact-context-menu"; import { attachContextMenu } from "preact-context-menu";
import { User, Users } from "revolt.js/dist/api/objects"; import { Text } from "preact-i18n";
import UserStatus from '../../components/common/UserStatus'; import { useContext } from "preact/hooks";
import { stopPropagation } from "../../lib/stopPropagation"; import { stopPropagation } from "../../lib/stopPropagation";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { VoiceOperationsContext } from "../../context/Voice";
import { useIntermediate } from "../../context/intermediate/Intermediate"; 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 { interface Props {
user: User; user: User;
} }
export function Friend({ user }: Props) { export const Friend = observer(({ user }: Props) => {
const client = useContext(AppContext); const history = useHistory();
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const { connect } = useContext(VoiceOperationsContext);
const actions: Children[] = []; const actions: Children[] = [];
let subtext: Children = null; let subtext: Children = null;
if (user.relationship === Users.Relationship.Friend) { if (user.relationship === RelationshipStatus.Friend) {
subtext = <UserStatus user={user} /> subtext = <UserStatus user={user} />;
actions.push( actions.push(
<IconButton type="circle" <>
onClick={stopPropagation}> <IconButton
<Link to={'/open/' + user._id}> type="circle"
<Mail size={20} /> className={classNames(styles.button, styles.success)}
</Link> onClick={(ev) =>
</IconButton> 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( actions.push(
<IconButton type="circle" <IconButton
onClick={ev => stopPropagation(ev, client.users.addFriend(user.username))}> type="circle"
className={styles.button}
onClick={(ev) => stopPropagation(ev, user.addFriend())}>
<Plus size={24} /> <Plus size={24} />
</IconButton> </IconButton>,
); );
subtext = <Text id="app.special.friends.incoming" />; subtext = <Text id="app.special.friends.incoming" />;
} }
if (user.relationship === Users.Relationship.Outgoing) { if (user.relationship === RelationshipStatus.Outgoing) {
subtext = <Text id="app.special.friends.outgoing" />; subtext = <Text id="app.special.friends.outgoing" />;
} }
if ( if (
user.relationship === Users.Relationship.Friend || user.relationship === RelationshipStatus.Friend ||
user.relationship === Users.Relationship.Outgoing || user.relationship === RelationshipStatus.Outgoing ||
user.relationship === Users.Relationship.Incoming user.relationship === RelationshipStatus.Incoming
) { ) {
actions.push( actions.push(
<IconButton type="circle" <IconButton
onClick={ev => stopPropagation(ev, client.users.removeFriend(user._id))}> 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} /> <X size={24} />
</IconButton> </IconButton>,
); );
} }
if (user.relationship === Users.Relationship.Blocked) { if (user.relationship === RelationshipStatus.Blocked) {
actions.push( actions.push(
<IconButton type="circle" <IconButton
onClick={ev => stopPropagation(ev, client.users.unblockUser(user._id))}> type="circle"
<X size={24} /> className={classNames(styles.button, styles.error)}
</IconButton> onClick={(ev) => stopPropagation(ev, user.unblockUser())}>
<UserX size={24} />
</IconButton>,
); );
} }
return ( return (
<div className={styles.friend} <div
onClick={() => openScreen({ id: 'profile', user_id: user._id })} className={styles.friend}
onContextMenu={attachContextMenu('Menu', { user: user._id })}> onClick={() => openScreen({ id: "profile", user_id: user._id })}
<UserIcon target={user} size={32} status /> onContextMenu={attachContextMenu("Menu", { user: user._id })}>
<UserIcon target={user} size={36} status />
<div className={styles.name}> <div className={styles.name}>
<span>@{user.username}</span> <span>{user.username}</span>
{subtext && ( {subtext && <span className={styles.subtext}>{subtext}</span>}
<span className={styles.subtext}>{subtext}</span>
)}
</div> </div>
<div className={styles.actions}>{actions}</div> <div className={styles.actions}>{actions}</div>
</div> </div>
); );
} });
import styles from "./Friend.module.scss"; import { ChevronRight } from "@styled-icons/boxicons-regular";
import { UserPlus } from "@styled-icons/feather"; 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 { 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 Header from "../../components/ui/Header";
import Overline from "../../components/ui/Overline";
import IconButton from "../../components/ui/IconButton"; 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 { openScreen } = useIntermediate();
const users = useUsers() as User[]; const client = useClient();
const users = [...client.users.values()];
users.sort((a, b) => a.username.localeCompare(b.username)); users.sort((a, b) => a.username.localeCompare(b.username));
const pending = users.filter(
x =>
x.relationship === Users.Relationship.Incoming ||
x.relationship === Users.Relationship.Outgoing
);
const friends = users.filter( const friends = users.filter(
x => x.relationship === Users.Relationship.Friend (x) => x.relationship === RelationshipStatus.Friend,
);
const blocked = users.filter(
x => x.relationship === Users.Relationship.Blocked
); );
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 ( return (
<> <>
<Header placement="primary"> <Header placement="primary">
{!isTouchscreenDevice && <UserDetail size={24} />}
<div className={styles.title}> <div className={styles.title}>
<Text id="app.navigation.tabs.friends" /> <Text id="app.navigation.tabs.friends" />
</div> </div>
<div className="actions"> <div className={styles.actions}>
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'add_friend' })}> {/*<Tooltip content={"Create Category"} placement="bottom">
<UserPlus size={24} /> <IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_group' })}>
</IconButton> <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> </div>
</Header> </Header>
<div <div
className={styles.list} className={styles.list}
data-empty={ data-empty={isEmpty}
pending.length + friends.length + blocked.length === 0 data-mobile={isTouchscreenDevice}>
} {isEmpty && (
>
{pending.length + friends.length + blocked.length === 0 && (
<> <>
<img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" /> <img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" />
<Text id="app.special.friends.nobody" /> <Text id="app.special.friends.nobody" />
</> </>
)} )}
{pending.length > 0 && (
<Overline type="subtle"> {incoming.length > 0 && (
<Text id="app.special.friends.pending" />{" "} <div
{pending.length} className={styles.pending}
</Overline> onClick={() =>
)} openScreen({
{pending.map(y => ( id: "pending_requests",
<Friend key={y._id} user={y} /> users: incoming,
))} })
{friends.length > 0 && ( }>
<Overline type="subtle"> <div className={styles.avatars}>
<Text id="app.navigation.tabs.friends" />{" "} {incoming.map(
{friends.length} (x, i) =>
</Overline> i < 3 && (
)} <UserIcon
{friends.map(y => ( target={x}
<Friend key={y._id} user={y} /> size={64}
))} mask={
{blocked.length > 0 && ( i <
<Overline type="subtle"> Math.min(incoming.length - 1, 2)
<Text id="app.special.friends.blocked" />{" "} ? "url(#overlap)"
{blocked.length} : undefined
</Overline> }
/>
),
)}
</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> </div>
</> </>
); );
} });
...@@ -11,15 +11,13 @@ ...@@ -11,15 +11,13 @@
} }
} }
ul { .actions {
gap: 8px;
margin: auto; margin: auto;
display: block; display: flex;
font-size: 18px; width: fit-content;
text-align: center; align-items: center;
flex-direction: column;
li {
list-style: lower-greek;
}
} }
} }
......
import styles from "./Home.module.scss"; import { Home as HomeIcon } from "@styled-icons/boxicons-solid";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import styles from "./Home.module.scss";
import { Text } from "preact-i18n"; 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() { export default function Home() {
return ( return (
<div className={styles.home}> <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> <h3>
<Text id="app.special.modals.onboarding.welcome" /> <img src={wideSVG} /> <Text id="app.special.modals.onboarding.welcome" />
<br />
<img src={wideSVG} />
</h3> </h3>
<ul> <div className={styles.actions}>
<li> <Link to="/invite/Testers">
Go to your <Link to="/friends">friends list</Link>. <Button contrast error>
</li> Join testers server
<li> </Button>
Give <Link to="/settings/feedback">feedback</Link>. </Link>
</li> <a
<li> href="https://insrt.uk/donate"
Join <Link to="/invite/Testers">testers server</Link>. target="_blank"
</li> rel="noreferrer">
<li> <Button contrast gold>
View{" "} Donate to Revolt
<a href="https://gitlab.insrt.uk/revolt" target="_blank"> </Button>
source code </a>
</a> <Link to="/settings/feedback">
. <Button contrast>Give feedback</Button>
</li> </Link>
</ul> <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> </div>
); );
} }
.invite {
height: 100%;
display: flex;
color: white;
user-select: none;
align-items: center;
flex-direction: column;
background-size: cover;
justify-content: center;
background-position: center;
* {
overflow: visible;
}
.icon {
width: 64px;
z-index: 100;
text-align: left;
position: relative;
> * {
top: -32px;
position: absolute;
}
}
.leave {
top: 8px;
left: 8px;
cursor: pointer;
position: fixed;
}
.details {
text-align: center;
align-self: center;
padding: 32px 16px 16px 16px;
background: rgba(0, 0, 0, 0.6);
border-radius: var(--border-radius);
h1 {
margin: 0;
font-weight: 500;
}
h2 {
margin: 4px;
opacity: 0.7;
font-size: 0.8em;
font-weight: 400;
}
h3 {
gap: 8px;
display: flex;
font-size: 1em;
font-weight: 400;
flex-direction: row;
justify-content: center;
}
button {
margin: auto;
display: block;
background: rgba(0, 0, 0, 0.8);
}
}
}
.preloader {
height: 100%;
display: grid;
place-items: center;
}
import { ArrowBack } from "@styled-icons/boxicons-regular";
import { autorun } from "mobx";
import { useHistory, useParams } from "react-router-dom";
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,
ClientStatus,
StatusContext,
} from "../../context/revoltjs/RevoltClient";
import { takeError } from "../../context/revoltjs/util";
import ServerIcon from "../../components/common/ServerIcon";
import UserIcon from "../../components/common/user/UserIcon";
import Button from "../../components/ui/Button";
import Overline from "../../components/ui/Overline";
import Preloader from "../../components/ui/Preloader";
export default function Invite() {
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const { code } = useParams<{ code: string }>();
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [invite, setInvite] = useState<RetrievedInvite | undefined>(
undefined,
);
useEffect(() => {
if (
typeof invite === "undefined" &&
(status === ClientStatus.ONLINE || status === ClientStatus.READY)
) {
client
.fetchInvite(code)
.then((data) => setInvite(data))
.catch((err) => setError(takeError(err)));
}
}, [client, code, invite, status]);
if (typeof invite === "undefined") {
return (
<div className={styles.preloader}>
<RequiresOnline>
{error ? (
<Overline type="error" error={error} />
) : (
<Preloader type="spinner" />
)}
</RequiresOnline>
</div>
);
}
// ! FIXME: add i18n translations
return (
<div
className={styles.invite}
style={{
backgroundImage: invite.server_banner
? `url('${client.generateFileURL(invite.server_banner)}')`
: undefined,
}}>
<div className={styles.leave}>
<ArrowBack size={32} onClick={() => history.push("/")} />
</div>
{!processing && (
<div className={styles.icon}>
<ServerIcon
attachment={invite.server_icon}
server_name={invite.server_name}
size={64}
/>
</div>
)}
<div className={styles.details}>
{processing ? (
<Preloader type="ring" />
) : (
<>
<h1>{invite.server_name}</h1>
<h2>#{invite.channel_name}</h2>
<h3>
<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
contrast
onClick={async () => {
if (status === ClientStatus.READY) {
return history.push("/");
}
try {
setProcessing(true);
if (invite.type === "Server") {
if (
client.servers.get(invite.server_id)
) {
history.push(
`/server/${invite.server_id}/channel/${invite.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 ? (
<Text id="app.special.invite.login" />
) : (
<Text id="app.special.invite.accept" />
)}
</Button>
</>
)}
</div>
</div>
);
}
import Overline from '../../components/ui/Overline'; import { UseFormMethods } from "react-hook-form";
import InputBox from '../../components/ui/InputBox';
import { Text, Localizer } from 'preact-i18n'; import { Text, Localizer } from "preact-i18n";
import InputBox from "../../components/ui/InputBox";
import Overline from "../../components/ui/Overline";
interface Props { interface Props {
type: "email" | "username" | "password" | "invite" | "current_password"; type: "email" | "username" | "password" | "invite" | "current_password";
showOverline?: boolean; showOverline?: boolean;
register: Function; register: UseFormMethods["register"];
error?: string; error?: string;
name?: string; name?: string;
} }
...@@ -15,7 +18,7 @@ export default function FormField({ ...@@ -15,7 +18,7 @@ export default function FormField({
register, register,
showOverline, showOverline,
error, error,
name name,
}: Props) { }: Props) {
return ( return (
<> <>
...@@ -26,7 +29,11 @@ export default function FormField({ ...@@ -26,7 +29,11 @@ export default function FormField({
)} )}
<Localizer> <Localizer>
<InputBox <InputBox
placeholder={(<Text id={`login.enter.${type}`} />) as any} placeholder={
(
<Text id={`login.enter.${type}`} />
) as unknown as string
}
name={ name={
type === "current_password" ? "password" : name ?? type type === "current_password" ? "password" : name ?? type
} }
...@@ -37,6 +44,8 @@ export default function FormField({ ...@@ -37,6 +44,8 @@ export default function FormField({
? "password" ? "password"
: type : type
} }
// See https://github.com/mozilla/contain-facebook/issues/783
className="fbc-has-badge"
ref={register( ref={register(
type === "password" || type === "current_password" type === "password" || type === "current_password"
? { ? {
...@@ -47,19 +56,19 @@ export default function FormField({ ...@@ -47,19 +56,19 @@ export default function FormField({
? "TooShort" ? "TooShort"
: value.length > 1024 : value.length > 1024
? "TooLong" ? "TooLong"
: undefined : undefined,
} }
: type === "email" : type === "email"
? { ? {
required: "RequiredField", required: "RequiredField",
pattern: { pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "InvalidEmail" message: "InvalidEmail",
} },
} }
: type === "username" : type === "username"
? { required: "RequiredField" } ? { required: "RequiredField" }
: { required: "RequiredField" } : { required: "RequiredField" },
)} )}
/> />
</Localizer> </Localizer>
......
import { Text } from "preact-i18n";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Route, Switch } from "react-router-dom";
import { LIBRARY_VERSION } from "revolt.js";
import styles from "./Login.module.scss"; import styles from "./Login.module.scss";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { APP_VERSION } from "../../version";
import { LIBRARY_VERSION } from "revolt.js";
import { Route, Switch } from "react-router-dom";
import { ThemeContext } from "../../context/Theme"; import { ThemeContext } from "../../context/Theme";
import { AppContext } from "../../context/revoltjs/RevoltClient"; import { AppContext } from "../../context/revoltjs/RevoltClient";
import background from "./background.jpg"; import LocaleSelector from "../../components/common/LocaleSelector";
import { FormLogin } from "./forms/FormLogin"; import { Titlebar } from "../../components/native/Titlebar";
import { APP_VERSION } from "../../version";
import background from "./background.jpg";
import { FormCreate } from "./forms/FormCreate"; import { FormCreate } from "./forms/FormCreate";
import { FormLogin } from "./forms/FormLogin";
import { FormResend } from "./forms/FormResend"; import { FormResend } from "./forms/FormResend";
import { FormReset, FormSendReset } from "./forms/FormReset"; import { FormReset, FormSendReset } from "./forms/FormReset";
...@@ -20,52 +24,57 @@ export default function Login() { ...@@ -20,52 +24,57 @@ export default function Login() {
const client = useContext(AppContext); const client = useContext(AppContext);
return ( return (
<div className={styles.login}> <>
<Helmet> {window.isNative && !window.native.getConfig().frame && (
<meta name="theme-color" content={theme.background} /> <Titlebar />
</Helmet> )}
<div className={styles.content}> <div className={styles.login}>
<div className={styles.attribution}> <Helmet>
<span> <meta name="theme-color" content={theme.background} />
API:{" "} </Helmet>
<code>{client.configuration?.revolt ?? "???"}</code>{" "} <div className={styles.content}>
&middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "} <div className={styles.attribution}>
&middot; App: <code>{APP_VERSION}</code> <span>
</span> API:{" "}
<span> <code>{client.configuration?.revolt ?? "???"}</code>{" "}
{/*<LocaleSelector />*/} &middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
</span> &middot; App: <code>{APP_VERSION}</code>
</div> </span>
<div className={styles.modal}> <span>
<Switch> <LocaleSelector />
<Route path="/login/create"> </span>
<FormCreate /> </div>
</Route> <div className={styles.modal}>
<Route path="/login/resend"> <Switch>
<FormResend /> <Route path="/login/create">
</Route> <FormCreate />
<Route path="/login/reset/:token"> </Route>
<FormReset /> <Route path="/login/resend">
</Route> <FormResend />
<Route path="/login/reset"> </Route>
<FormSendReset /> <Route path="/login/reset/:token">
</Route> <FormReset />
<Route path="/"> </Route>
<FormLogin /> <Route path="/login/reset">
</Route> <FormSendReset />
</Switch> </Route>
</div> <Route path="/">
<div className={styles.attribution}> <FormLogin />
<span> </Route>
<Text id="general.image_by" /> &lrm;@lorenzoherrera </Switch>
&rlm;· unsplash.com </div>
</span> <div className={styles.attribution}>
<span>
<Text id="general.image_by" /> &lrm;@lorenzoherrera
&rlm;· unsplash.com
</span>
</div>
</div> </div>
<div
className={styles.bg}
style={{ background: `url('${background}')` }}
/>
</div> </div>
<div </>
className={styles.bg}
style={{ background: `url('${background}')` }}
/>
</div>
); );
}; }
import { Text } from "preact-i18n";
import styles from "../Login.module.scss";
import HCaptcha from "@hcaptcha/react-hcaptcha"; import HCaptcha from "@hcaptcha/react-hcaptcha";
import styles from "../Login.module.scss";
import { Text } from "preact-i18n";
import { useContext, useEffect } from "preact/hooks"; import { useContext, useEffect } from "preact/hooks";
import Preloader from "../../../components/ui/Preloader";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Preloader from "../../../components/ui/Preloader";
export interface CaptchaProps { export interface CaptchaProps {
onSuccess: (token?: string) => void; onSuccess: (token?: string) => void;
onCancel: () => void; onCancel: () => void;
...@@ -17,16 +20,16 @@ export function CaptchaBlock(props: CaptchaProps) { ...@@ -17,16 +20,16 @@ export function CaptchaBlock(props: CaptchaProps) {
if (!client.configuration?.features.captcha.enabled) { if (!client.configuration?.features.captcha.enabled) {
props.onSuccess(); props.onSuccess();
} }
}, []); }, [client.configuration?.features.captcha.enabled, props]);
if (!client.configuration?.features.captcha.enabled) if (!client.configuration?.features.captcha.enabled)
return <Preloader />; return <Preloader type="spinner" />;
return ( return (
<div> <div>
<HCaptcha <HCaptcha
sitekey={client.configuration.features.captcha.key} sitekey={client.configuration.features.captcha.key}
onVerify={token => props.onSuccess(token)} onVerify={(token) => props.onSuccess(token)}
/> />
<div className={styles.footer}> <div className={styles.footer}>
<a onClick={props.onCancel}> <a onClick={props.onCancel}>
......
import { Legal } from "./Legal"; import { CheckCircle, Envelope } from "@styled-icons/boxicons-regular";
import { Text } from "preact-i18n"; import { useForm } from "react-hook-form";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import styles from "../Login.module.scss"; import styles from "../Login.module.scss";
import { useForm } from "react-hook-form"; import { Text } from "preact-i18n";
import { MailProvider } from "./MailProvider";
import { useContext, useState } from "preact/hooks"; import { useContext, useState } from "preact/hooks";
import { CheckCircle, Mail } from "@styled-icons/feather";
import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock";
import { takeError } from "../../../context/revoltjs/util";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../context/revoltjs/util";
import FormField from "../FormField"; import wideSVG from "../../../assets/wide.svg";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import Overline from "../../../components/ui/Overline"; import Overline from "../../../components/ui/Overline";
import Preloader from "../../../components/ui/Preloader"; import Preloader from "../../../components/ui/Preloader";
import FormField from "../FormField";
import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock";
import { Legal } from "./Legal";
import { MailProvider } from "./MailProvider";
interface Props { interface Props {
page: "create" | "login" | "send_reset" | "reset" | "resend"; page: "create" | "login" | "send_reset" | "reset" | "resend";
callback: (fields: { callback: (fields: {
...@@ -26,11 +30,17 @@ interface Props { ...@@ -26,11 +30,17 @@ interface Props {
} }
function getInviteCode() { function getInviteCode() {
if (typeof window === 'undefined') return ''; if (typeof window === "undefined") return "";
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code'); const code = urlParams.get("code");
return code ?? ''; return code ?? "";
}
interface FormInputs {
email: string;
password: string;
invite: string;
} }
export function Form({ page, callback }: Props) { export function Form({ page, callback }: Props) {
...@@ -41,23 +51,19 @@ export function Form({ page, callback }: Props) { ...@@ -41,23 +51,19 @@ export function Form({ page, callback }: Props) {
const [error, setGlobalError] = useState<string | undefined>(undefined); const [error, setGlobalError] = useState<string | undefined>(undefined);
const [captcha, setCaptcha] = useState<CaptchaProps | undefined>(undefined); const [captcha, setCaptcha] = useState<CaptchaProps | undefined>(undefined);
const { handleSubmit, register, errors, setError } = useForm({ const { handleSubmit, register, errors, setError } = useForm<FormInputs>({
defaultValues: { defaultValues: {
email: '', email: "",
password: '', password: "",
invite: getInviteCode() invite: getInviteCode(),
} },
}); });
async function onSubmit(data: { async function onSubmit(data: FormInputs) {
email: string;
password: string;
invite: string;
}) {
setGlobalError(undefined); setGlobalError(undefined);
setLoading(true); setLoading(true);
function onError(err: any) { function onError(err: unknown) {
setLoading(false); setLoading(false);
const error = takeError(err); const error = takeError(err);
...@@ -79,7 +85,7 @@ export function Form({ page, callback }: Props) { ...@@ -79,7 +85,7 @@ export function Form({ page, callback }: Props) {
page !== "reset" page !== "reset"
) { ) {
setCaptcha({ setCaptcha({
onSuccess: async captcha => { onSuccess: async (captcha) => {
setCaptcha(undefined); setCaptcha(undefined);
try { try {
await callback({ ...data, captcha }); await callback({ ...data, captcha });
...@@ -91,7 +97,7 @@ export function Form({ page, callback }: Props) { ...@@ -91,7 +97,7 @@ export function Form({ page, callback }: Props) {
onCancel: () => { onCancel: () => {
setCaptcha(undefined); setCaptcha(undefined);
setLoading(false); setLoading(false);
} },
}); });
} else { } else {
await callback(data); await callback(data);
...@@ -107,7 +113,7 @@ export function Form({ page, callback }: Props) { ...@@ -107,7 +113,7 @@ export function Form({ page, callback }: Props) {
<div className={styles.success}> <div className={styles.success}>
{client.configuration?.features.email ? ( {client.configuration?.features.email ? (
<> <>
<Mail size={72} /> <Envelope size={72} />
<h2> <h2>
<Text id="login.check_mail" /> <Text id="login.check_mail" />
</h2> </h2>
...@@ -136,11 +142,18 @@ export function Form({ page, callback }: Props) { ...@@ -136,11 +142,18 @@ export function Form({ page, callback }: Props) {
} }
if (captcha) return <CaptchaBlock {...captcha} />; if (captcha) return <CaptchaBlock {...captcha} />;
if (loading) return <Preloader />; if (loading) return <Preloader type="spinner" />;
return ( return (
<div className={styles.form}> <div className={styles.form}>
<form onSubmit={handleSubmit(onSubmit) as any}> <img src={wideSVG} />
{/* Preact / React typing incompatabilities */}
<form
onSubmit={
handleSubmit(
onSubmit,
) as JSX.GenericEventHandler<HTMLFormElement>
}>
{page !== "reset" && ( {page !== "reset" && (
<FormField <FormField
type="email" type="email"
......
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form"; import { Form } from "./Form";
export function FormCreate() { export function FormCreate() {
...@@ -8,7 +10,7 @@ export function FormCreate() { ...@@ -8,7 +10,7 @@ export function FormCreate() {
return ( return (
<Form <Form
page="create" page="create"
callback={async data => { callback={async (data) => {
await client.register(import.meta.env.VITE_API_URL, data); await client.register(import.meta.env.VITE_API_URL, data);
}} }}
/> />
......
import { Form } from "./Form";
import { detect } from "detect-browser"; import { detect } from "detect-browser";
import { useContext } from "preact/hooks";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { useContext } from "preact/hooks";
import { OperationsContext } from "../../../context/revoltjs/RevoltClient"; import { OperationsContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormLogin() { export function FormLogin() {
const { login } = useContext(OperationsContext); const { login } = useContext(OperationsContext);
const history = useHistory(); const history = useHistory();
...@@ -11,12 +14,16 @@ export function FormLogin() { ...@@ -11,12 +14,16 @@ export function FormLogin() {
return ( return (
<Form <Form
page="login" page="login"
callback={async data => { callback={async (data) => {
const browser = detect(); const browser = detect();
let device_name; let device_name;
if (browser) { if (browser) {
const { name, os } = 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 { } else {
device_name = "Unknown Device"; device_name = "Unknown Device";
} }
......