Change invite rendering logic.

Handle link warnings on embeds.
Remove "EDIT!!" 🙏🙏🙏
This commit is contained in:
Paul 2021-09-03 13:04:37 +01:00
parent 571b30243c
commit 2ccc0b7b5e
7 changed files with 219 additions and 179 deletions

View file

@ -24,15 +24,7 @@ import MessageBase, {
import Attachment from "./attachments/Attachment"; import Attachment from "./attachments/Attachment";
import { MessageReply } from "./attachments/MessageReply"; import { MessageReply } from "./attachments/MessageReply";
import Embed from "./embed/Embed"; import Embed from "./embed/Embed";
import EmbedInvite from "./embed/EmbedInvite"; import InviteList from "./embed/EmbedInvite";
const INVITE_PATHS = [
location.hostname + "/invite",
"app.revolt.chat/invite",
"nightly.revolt.chat/invite",
"local.revolt.chat/invite",
"rvlt.gg"
]
interface Props { interface Props {
attachContext?: boolean; attachContext?: boolean;
@ -151,28 +143,7 @@ const Message = observer(
</span> </span>
)} )}
{replacement ?? <Markdown content={content} />} {replacement ?? <Markdown content={content} />}
{(() => { {!queued && <InviteList message={message} />}
let isInvite = false;
INVITE_PATHS.forEach(path => {
if (content.includes(path)) {
isInvite = true;
}
})
if (isInvite) {
const inviteRegex = new RegExp("(?:" + INVITE_PATHS.map((path, index) => path.split(".").join("\\.") + (index !== INVITE_PATHS.length - 1 ? "|" : "")).join("") + ")/([A-Za-z0-9]*)", "g");
if (inviteRegex.test(content)) {
let results: string[] = [];
let match: RegExpExecArray | null;
inviteRegex.lastIndex = 0;
while ((match = inviteRegex.exec(content)) !== null) {
if (!results.includes(match[match.length - 1])) {
results.push(match[match.length - 1]);
}
}
return results.map(code => <EmbedInvite code={code} />);
}
}
})()}
{queued?.error && ( {queued?.error && (
<Overline type="error" error={queued.error} /> <Overline type="error" error={queued.error} />
)} )}

View file

@ -22,7 +22,7 @@ const MAX_PREVIEW_SIZE = 150;
export default function Embed({ embed }: Props) { export default function Embed({ embed }: Props) {
const client = useClient(); const client = useClient();
const { openScreen } = useIntermediate(); const { openScreen, openLink } = useIntermediate();
const maxWidth = Math.min( const maxWidth = Math.min(
useContext(MessageAreaWidthContext) - CONTAINER_PADDING, useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
MAX_EMBED_WIDTH, MAX_EMBED_WIDTH,
@ -111,6 +111,10 @@ export default function Embed({ embed }: Props) {
{embed.title && ( {embed.title && (
<span> <span>
<a <a
onClick={(e) =>
openLink(e.currentTarget.href) &&
e.preventDefault()
}
href={embed.url} href={embed.url}
target={"_blank"} target={"_blank"}
className={styles.title} className={styles.title}

View file

@ -1,8 +1,9 @@
import styled from "styled-components";
import { autorun } from "mobx"; import { autorun } from "mobx";
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { RetrievedInvite } from "revolt-api/types/Invites"; import { RetrievedInvite } from "revolt-api/types/Invites";
import { Message } from "revolt.js/dist/maps/Messages";
import styled from "styled-components";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
@ -10,8 +11,6 @@ import { defer } from "../../../../lib/defer";
import { dispatch } from "../../../../redux"; import { dispatch } from "../../../../redux";
import { useClient } from "../../../../context/revoltjs/RevoltClient";
import { import {
AppContext, AppContext,
ClientStatus, ClientStatus,
@ -21,10 +20,8 @@ import { takeError } from "../../../../context/revoltjs/util";
import ServerIcon from "../../../../components/common/ServerIcon"; import ServerIcon from "../../../../components/common/ServerIcon";
import Button from "../../../../components/ui/Button"; import Button from "../../../../components/ui/Button";
import Overline from "../../../ui/Overline";
import Preloader from "../../../ui/Preloader"; import Preloader from "../../../ui/Preloader";
const EmbedInviteBase = styled.div` const EmbedInviteBase = styled.div`
width: 400px; width: 400px;
height: 80px; height: 80px;
@ -35,22 +32,25 @@ const EmbedInviteBase = styled.div`
padding: 0 12px; padding: 0 12px;
margin-top: 2px; margin-top: 2px;
`; `;
const EmbedInviteDetails = styled.div` const EmbedInviteDetails = styled.div`
flex-grow: 1; flex-grow: 1;
padding-left: 12px; padding-left: 12px;
`; `;
const EmbedInviteName = styled.div` const EmbedInviteName = styled.div`
font-weight: bold; font-weight: bold;
`; `;
const EmbedInviteMemberCount = styled.div` const EmbedInviteMemberCount = styled.div`
font-size: 0.8em; font-size: 0.8em;
`; `;
type Props = { type Props = {
code: string code: string;
} };
export default function EmbedInvite(props: Props) { export function EmbedInvite(props: Props) {
const history = useHistory(); const history = useHistory();
const client = useContext(AppContext); const client = useContext(AppContext);
const status = useContext(StatusContext); const status = useContext(StatusContext);
@ -72,90 +72,120 @@ export default function EmbedInvite(props: Props) {
.catch((err) => setError(takeError(err))); .catch((err) => setError(takeError(err)));
} }
}, [client, code, invite, status]); }, [client, code, invite, status]);
if (typeof invite === "undefined") { if (typeof invite === "undefined") {
return error ? ( return error ? (
<EmbedInviteBase> <EmbedInviteBase>
<ServerIcon <ServerIcon size={55} />
size={55}
/>
<EmbedInviteDetails> <EmbedInviteDetails>
<EmbedInviteName> <EmbedInviteName>Invalid invite!</EmbedInviteName>
Invalid invite!
</EmbedInviteName>
</EmbedInviteDetails> </EmbedInviteDetails>
</EmbedInviteBase> </EmbedInviteBase>
) : ( ) : (
<EmbedInviteBase> <EmbedInviteBase>
<Preloader type="ring" /> <Preloader type="ring" />
</EmbedInviteBase> </EmbedInviteBase>
) );
} }
return <EmbedInviteBase>
<ServerIcon
attachment={invite.server_icon}
server_name={invite.server_name}
size={55}
/>
<EmbedInviteDetails>
<EmbedInviteName>
{invite.server_name}
</EmbedInviteName>
<EmbedInviteMemberCount>
{invite.member_count} members
</EmbedInviteMemberCount>
</EmbedInviteDetails>
{processing ? (
<div>
<Preloader type="ring" />
</div>
) : (
<Button onClick={async () => {
try {
setProcessing(true);
if (invite.type === "Server") { return (
if ( <EmbedInviteBase>
client.servers.get(invite.server_id) <ServerIcon
) { attachment={invite.server_icon}
history.push( server_name={invite.server_name}
`/server/${invite.server_id}/channel/${invite.channel_id}`, size={55}
); />
} <EmbedInviteDetails>
<EmbedInviteName>{invite.server_name}</EmbedInviteName>
const dispose = autorun(() => { <EmbedInviteMemberCount>
const server = client.servers.get( {invite.member_count} members
invite.server_id, </EmbedInviteMemberCount>
); </EmbedInviteDetails>
{processing ? (
defer(() => { <div>
if (server) { <Preloader type="ring" />
dispatch({ </div>
type: "UNREADS_MARK_MULTIPLE_READ", ) : (
channels: <Button
server.channel_ids, onClick={async () => {
}); try {
setProcessing(true);
if (invite.type === "Server") {
if (client.servers.get(invite.server_id)) {
history.push( history.push(
`/server/${server._id}/channel/${invite.channel_id}`, `/server/${invite.server_id}/channel/${invite.channel_id}`,
); );
} }
});
dispose(); const dispose = autorun(() => {
}); const server = client.servers.get(
} invite.server_id,
);
await client.joinInvite(code); defer(() => {
setProcessing(false); if (server) {
} catch (err) { dispatch({
setError(takeError(err)); type: "UNREADS_MARK_MULTIPLE_READ",
setProcessing(false); channels: server.channel_ids,
} });
}}>
{client.servers.get(invite.server_id) ? "Joined" : "Join"} history.push(
</Button> `/server/${server._id}/channel/${invite.channel_id}`,
)} );
</EmbedInviteBase> }
});
dispose();
});
}
await client.joinInvite(code);
setProcessing(false);
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}}>
{client.servers.get(invite.server_id) ? "Joined" : "Join"}
</Button>
)}
</EmbedInviteBase>
);
} }
const INVITE_PATHS = [
`${location.hostname}/invite`,
"app.revolt.chat/invite",
"nightly.revolt.chat/invite",
"local.revolt.chat/invite",
"rvlt.gg",
];
const RE_INVITE = new RegExp(
`(?:${INVITE_PATHS.map((x) => x.replaceAll(".", "\\.")).join(
"|",
)})/([A-Za-z0-9]*)`,
"g",
);
export default observer(({ message }: { message: Message }) => {
if (typeof message.content !== "string") return null;
const matches = [...message.content.matchAll(RE_INVITE)];
if (matches.length > 0) {
const entries = [
...new Set(matches.slice(0, 5).map((x) => x[1])),
].slice(0, 5);
return (
<>
{entries.map((entry) => (
<EmbedInvite key={entry} code={entry} />
))}
</>
);
}
return null;
});

View file

@ -17,6 +17,7 @@ import styles from "./Markdown.module.scss";
import { useCallback, useContext } from "preact/hooks"; import { useCallback, useContext } from "preact/hooks";
import { internalEmit } from "../../lib/eventEmitter"; import { internalEmit } from "../../lib/eventEmitter";
import { determineLink } from "../../lib/links";
import { getState } from "../../redux"; import { getState } from "../../redux";
@ -35,13 +36,6 @@ declare global {
} }
} }
const ALLOWED_ORIGINS = [
location.hostname,
"app.revolt.chat",
"nightly.revolt.chat",
"local.revolt.chat",
];
// Handler for code block copy. // Handler for code block copy.
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.copycode = function (element: HTMLDivElement) { window.copycode = function (element: HTMLDivElement) {
@ -100,7 +94,7 @@ const RE_CHANNELS = /<#([A-z0-9]{26})>/g;
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) { export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
const client = useContext(AppContext); const client = useContext(AppContext);
const { openScreen } = useIntermediate(); const { openLink } = useIntermediate();
if (typeof content === "undefined") return null; if (typeof content === "undefined") return null;
if (content.length === 0) return null; if (content.length === 0) return null;
@ -142,24 +136,15 @@ export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
} }
}, []); }, []);
const handleLink = useCallback((ev: MouseEvent) => { const handleLink = useCallback(
if (ev.currentTarget) { (ev: MouseEvent) => {
const element = ev.currentTarget as HTMLAnchorElement; if (ev.currentTarget) {
const url = new URL(element.href, location.href); const element = ev.currentTarget as HTMLAnchorElement;
const pathname = url.pathname; if (openLink(element.href)) ev.preventDefault();
if (pathname.startsWith("/@")) {
const id = pathname.substr(2);
if (/[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}/.test(id)) {
ev.preventDefault();
internalEmit("Intermediate", "openProfile", id);
}
} else {
ev.preventDefault();
internalEmit("Intermediate", "navigate", pathname);
} }
} },
}, []); [openLink],
);
return ( return (
<span <span
@ -175,52 +160,23 @@ export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
el.querySelectorAll<HTMLAnchorElement>("a").forEach( el.querySelectorAll<HTMLAnchorElement>("a").forEach(
(element) => { (element) => {
element.removeEventListener("click", handleLink); element.removeEventListener("click", handleLink);
element.addEventListener("click", handleLink);
element.removeAttribute("data-type"); element.removeAttribute("data-type");
element.removeAttribute("target"); element.removeAttribute("target");
let internal, const link = determineLink(element.href);
url: URL | null = null; switch (link.type) {
const href = element.href; case "profile": {
if (href) { element.setAttribute(
try { "data-type",
url = new URL(href, location.href); "mention",
);
if ( break;
ALLOWED_ORIGINS.includes(url.hostname) }
) { case "external": {
internal = true; element.setAttribute("target", "_blank");
element.addEventListener( break;
"click", }
handleLink,
);
if (url.pathname.startsWith("/@")) {
element.setAttribute(
"data-type",
"mention",
);
}
}
} catch (err) {}
}
if (!internal) {
element.setAttribute("target", "_blank");
element.onclick = (ev) => {
const { trustedLinks } = getState();
if (
!url ||
!trustedLinks.domains?.includes(
url.hostname,
)
) {
ev.preventDefault();
openScreen({
id: "external_link_prompt",
link: href,
});
}
};
} }
}, },
); );

View file

@ -1,6 +1,7 @@
import { Prompt } from "react-router"; import { Prompt } from "react-router";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import type { Attachment } from "revolt-api/types/Autumn"; import type { Attachment } from "revolt-api/types/Autumn";
import { Bot } from "revolt-api/types/Bots";
import type { EmbedImage } from "revolt-api/types/January"; import type { EmbedImage } from "revolt-api/types/January";
import { Channel } from "revolt.js/dist/maps/Channels"; import { Channel } from "revolt.js/dist/maps/Channels";
import { Message } from "revolt.js/dist/maps/Messages"; import { Message } from "revolt.js/dist/maps/Messages";
@ -11,12 +12,14 @@ import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks"; import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { internalSubscribe } from "../../lib/eventEmitter"; import { internalSubscribe } from "../../lib/eventEmitter";
import { determineLink } from "../../lib/links";
import { getState } from "../../redux";
import { Action } from "../../components/ui/Modal"; import { Action } from "../../components/ui/Modal";
import { Children } from "../../types/Preact"; import { Children } from "../../types/Preact";
import Modals from "./Modals"; import Modals from "./Modals";
import { Bot } from "revolt-api/types/Bots";
export type Screen = export type Screen =
| { id: "none" } | { id: "none" }
@ -103,9 +106,11 @@ export const IntermediateContext = createContext({
}); });
export const IntermediateActionsContext = createContext<{ export const IntermediateActionsContext = createContext<{
openLink: (href?: string) => boolean;
openScreen: (screen: Screen) => void; openScreen: (screen: Screen) => void;
writeClipboard: (text: string) => void; writeClipboard: (text: string) => void;
}>({ }>({
openLink: null!,
openScreen: null!, openScreen: null!,
writeClipboard: null!, writeClipboard: null!,
}); });
@ -125,6 +130,37 @@ export default function Intermediate(props: Props) {
const actions = useMemo(() => { const actions = useMemo(() => {
return { return {
openLink: (href?: string) => {
const link = determineLink(href);
switch (link.type) {
case "profile": {
openScreen({ id: "profile", user_id: link.id });
return true;
}
case "navigate": {
history.push(link.path);
return true;
}
case "external": {
const { trustedLinks } = getState();
if (
!trustedLinks.domains?.includes(link.url.hostname)
) {
openScreen({
id: "external_link_prompt",
link: link.href,
});
return true;
}
return false;
}
default: {
return true;
}
}
},
openScreen: (screen: Screen) => openScreen(screen), openScreen: (screen: Screen) => openScreen(screen),
writeClipboard: (text: string) => { writeClipboard: (text: string) => {
if (navigator.clipboard) { if (navigator.clipboard) {
@ -134,6 +170,7 @@ export default function Intermediate(props: Props) {
} }
}, },
}; };
// eslint-disable-next-line
}, []); }, []);
useEffect(() => { useEffect(() => {

43
src/lib/links.ts Normal file
View file

@ -0,0 +1,43 @@
type LinkType =
| { type: "profile"; id: string }
| { type: "navigate"; path: string }
| { type: "external"; href: string; url: URL }
| { type: "none" };
const ALLOWED_ORIGINS = [
location.hostname,
"app.revolt.chat",
"nightly.revolt.chat",
"local.revolt.chat",
];
export function determineLink(href?: string): LinkType {
let internal,
url: URL | null = null;
if (href) {
try {
url = new URL(href, location.href);
if (ALLOWED_ORIGINS.includes(url.hostname)) {
const path = url.pathname;
if (path.startsWith("/@")) {
const id = path.substr(2);
if (/[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}/.test(id)) {
return { type: "profile", id };
}
} else {
return { type: "navigate", path };
}
internal = true;
}
} catch (err) {}
if (!internal && url) {
return { type: "external", href, url };
}
}
return { type: "none" };
}

View file

@ -73,7 +73,6 @@ export const SimpleRenderer: RendererRoutines = {
}); });
}, },
edit: async (renderer) => { edit: async (renderer) => {
console.log("EDIT!!");
renderer.emitScroll({ renderer.emitScroll({
type: "StayAtBottom", type: "StayAtBottom",
smooth: false, smooth: false,