From fc6280ad8340b2d0efd58b83727d42dcbbf5ee4c Mon Sep 17 00:00:00 2001 From: Keno Dressel <765921+kenodressel@users.noreply.github.com> Date: Fri, 16 Feb 2024 09:17:09 +0100 Subject: [PATCH] feat: user handle links to dm (#54) * feat: adds linkify matrix replacement with slight mods * feat: adds fetching the bot accounts from the backend * feat: atoms for verified bots * fix(wallet): updates link storage to correct orders * refactor(dm): extracts openDmForUser * chore: removes community bot atom * chore: removes last wallet bot usage * chore: removes trailing comma --- components.json | 1 + config.sample.json | 4 +- src/atoms.ts | 18 +- src/components/structures/HomePage.tsx | 9 +- .../views/elements/MessageButton.tsx | 23 +- src/context/SuperheroProvider.tsx | 50 +-- src/hooks/useVerifiedBot.ts | 26 +- src/linkify-matrix.ts | 286 ++++++++++++++++++ src/utils.ts | 15 + 9 files changed, 386 insertions(+), 46 deletions(-) create mode 100644 src/linkify-matrix.ts create mode 100644 src/utils.ts diff --git a/components.json b/components.json index 7be3a557996..f1c6646f347 100644 --- a/components.json +++ b/components.json @@ -18,6 +18,7 @@ "src/components/structures/HomePage.tsx": "src/components/structures/HomePage.tsx", "src/components/views/dialogs/spotlight/SpotlightDialog.tsx": "src/components/views/dialogs/spotlight/SpotlightDialog.tsx", "src/components/views/elements/Pill.tsx": "src/components/views/elements/Pill.tsx", + "src/linkify-matrix.ts": "src/linkify-matrix.ts", "src/components/structures/LeftPanel.tsx": "src/components/structures/LeftPanel.tsx", "src/components/views/rooms/RoomList.tsx": "src/components/views/rooms/RoomList.tsx", "src/components/views/rooms/RoomSublist.tsx": "src/components/views/rooms/RoomSublist.tsx" diff --git a/config.sample.json b/config.sample.json index cbb8ccb8fae..25f886b6a97 100644 --- a/config.sample.json +++ b/config.sample.json @@ -47,7 +47,5 @@ "participant_limit": 8, "brand": "Element Call" }, - "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx", - "community_bot_user_id": "@communitybot:superhero.com", - "wallet_bot_user_id": "@walletbot:superhero.com" + "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx" } diff --git a/src/atoms.ts b/src/atoms.ts index 84da6cb5912..2d87f295a2d 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -1,4 +1,5 @@ import { atomWithStorage } from "jotai/utils"; +import { getDefaultStore } from "jotai/index"; type TokenThreshold = { threshold: string; @@ -10,10 +11,17 @@ export type BareUser = { rawDisplayName: string; }; +type BotAccounts = { + communityBot: string; + superheroBot: string; + blockchainBot: string; +}; + export const verifiedAccountsAtom = atomWithStorage>("VERIFIED_ACCOUNTS", {}); -export const verifiedBotsAtom = atomWithStorage>("VERIFIED_BOTS", {}); +export const botAccountsAtom = atomWithStorage("BOT_ACCOUNTS", null); export const minimumTokenThresholdAtom = atomWithStorage>("TOKEN_THRESHOLD", {}); -export const communityBotAtom = atomWithStorage("COMMUNITY_BOT", { - userId: "", - rawDisplayName: "", -}); + +export function getBotAccountData(): BotAccounts | null { + const defaultStore = getDefaultStore(); + return defaultStore.get(botAccountsAtom) as BotAccounts | null; +} diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index 55ee4996b02..ac572521be4 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -22,11 +22,13 @@ import { useMatrixClientContext } from "matrix-react-sdk/src/contexts/MatrixClie import { DirectoryMember, startDmOnFirstMessage } from "matrix-react-sdk/src/utils/direct-messages"; import { getHomePageUrl } from "matrix-react-sdk/src/utils/pages"; import * as React from "react"; +import { useAtom } from "jotai"; import { Icon as ChatScreenShot } from "../../../res/themes/superhero/img/arts/chat-screenshot.svg"; import { Icon as ChromeIcon } from "../../../res/themes/superhero/img/icons/chrome.svg"; import { Icon as FirefoxIcon } from "../../../res/themes/superhero/img/icons/firefox.svg"; import { Icon as SuperheroLogo } from "../../../res/themes/superhero/img/logos/superhero-logo.svg"; +import { botAccountsAtom } from "../../atoms"; interface IProps { justRegistered?: boolean; @@ -36,6 +38,7 @@ const HomePage: React.FC = () => { const cli = useMatrixClientContext(); const config: any = SdkConfig.get(); const pageUrl = getHomePageUrl(config, cli); + const [botAccounts] = useAtom(botAccountsAtom); if (pageUrl) { return ; @@ -81,7 +84,11 @@ const HomePage: React.FC = () => {
{ - startDmOnFirstMessage(cli, [new DirectoryMember({ user_id: config.wallet_bot_user_id })]); + startDmOnFirstMessage(cli, [ + new DirectoryMember({ + user_id: botAccounts?.superheroBot || "", + }), + ]); }} className="mx_HomePage_button_custom" > diff --git a/src/components/views/elements/MessageButton.tsx b/src/components/views/elements/MessageButton.tsx index c6ca62d4dce..6d4b272cb90 100644 --- a/src/components/views/elements/MessageButton.tsx +++ b/src/components/views/elements/MessageButton.tsx @@ -1,26 +1,16 @@ import React, { useContext, useState } from "react"; import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext"; import AccessibleButton from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; -import { MatrixClient, RoomMember, User } from "matrix-js-sdk/src/matrix"; -import { DirectoryMember, startDmOnFirstMessage } from "matrix-react-sdk/src/utils/direct-messages"; import { useAtom } from "jotai"; import { Member } from "../right_panel/UserInfo"; import { Icon as SendMessage } from "../../../../res/themes/superhero/img/icons/send.svg"; -import { BareUser, communityBotAtom } from "../../../atoms"; +import { BareUser, botAccountsAtom } from "../../../atoms"; +import { openDmForUser } from "../../../utils"; /** * Converts the member to a DirectoryMember and starts a DM with them. */ -async function openDmForUser(matrixClient: MatrixClient, user: Member | BareUser): Promise { - const avatarUrl = user instanceof User ? user.avatarUrl : user instanceof RoomMember ? user.getMxcAvatarUrl() : ""; - const startDmUser = new DirectoryMember({ - user_id: user.userId, - display_name: user.rawDisplayName, - avatar_url: avatarUrl, - }); - await startDmOnFirstMessage(matrixClient, [startDmUser]); -} export const MessageButton = ({ member, @@ -51,7 +41,12 @@ export const MessageButton = ({ }; export const MessageCommunityBotButton = ({ text = "Send Message" }: { text?: string }): JSX.Element => { - const [communityBot] = useAtom(communityBotAtom); + const [botAccounts] = useAtom(botAccountsAtom); - return ; + const botUser = { + userId: botAccounts?.communityBot || "", + rawDisplayName: "Community Bot", + } as Member; + + return ; }; diff --git a/src/context/SuperheroProvider.tsx b/src/context/SuperheroProvider.tsx index 3a40f910f9a..fed939eecfa 100644 --- a/src/context/SuperheroProvider.tsx +++ b/src/context/SuperheroProvider.tsx @@ -1,7 +1,20 @@ import { useAtom } from "jotai"; import React, { useCallback, useEffect } from "react"; -import { communityBotAtom, minimumTokenThresholdAtom, verifiedAccountsAtom, verifiedBotsAtom } from "../atoms"; +import { minimumTokenThresholdAtom, verifiedAccountsAtom, botAccountsAtom } from "../atoms"; + +type BotAccounts = { + domain: string; + communityBot: { + userId: string; + }; + superheroBot: { + userId: string; + }; + blockchainBot: { + userId: string; + }; +}; const useMinimumTokenThreshold = (config: any): void => { const [, setMinimumTokenThreshold] = useAtom(minimumTokenThresholdAtom); @@ -38,15 +51,7 @@ const useMinimumTokenThreshold = (config: any): void => { */ export const SuperheroProvider = ({ children, config }: any): any => { const [verifiedAccounts, setVerifiedAccounts] = useAtom(verifiedAccountsAtom); - const [, setVerifiedBots] = useAtom(verifiedBotsAtom); - const [, setCommunityBot] = useAtom(communityBotAtom); - - useEffect(() => { - setCommunityBot({ - userId: config.community_bot_user_id, - rawDisplayName: "Community DAO Room Bot", - }); - }, [setCommunityBot, config.community_bot_user_id]); + const [, setBotAccounts] = useAtom(botAccountsAtom); function loadVerifiedAccounts(): void { if (config.bots_backend_url) { @@ -61,15 +66,26 @@ export const SuperheroProvider = ({ children, config }: any): any => { } } - function loadVerifiedBots(): void { - setVerifiedBots({ - [config.community_bot_user_id]: "true", - [config.wallet_bot_user_id]: "true", - }); - } + useEffect(() => { + if (config.bots_backend_url) { + fetch(`${config.bots_backend_url}/ui/bot-accounts`, { + method: "GET", + }) + .then((res) => res.json()) + .then((data: BotAccounts) => { + setBotAccounts({ + communityBot: "@" + data.communityBot.userId + ":" + data.domain, + superheroBot: "@" + data.superheroBot.userId + ":" + data.domain, + blockchainBot: "@" + data.blockchainBot.userId + ":" + data.domain, + }); + }) + .catch(() => { + // + }); + } + }, [config.bots_backend_url, setBotAccounts]); useEffect(() => { - loadVerifiedBots(); if (!verifiedAccounts?.length) { loadVerifiedAccounts(); } diff --git a/src/hooks/useVerifiedBot.ts b/src/hooks/useVerifiedBot.ts index ed49d35a025..7a14f1a732d 100644 --- a/src/hooks/useVerifiedBot.ts +++ b/src/hooks/useVerifiedBot.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useAtom } from "jotai"; -import { verifiedBotsAtom } from "../atoms"; +import { botAccountsAtom, getBotAccountData } from "../atoms"; /** * Custom hook to check if a bot is verified. @@ -9,11 +9,25 @@ import { verifiedBotsAtom } from "../atoms"; * @returns A boolean indicating whether the bot is verified or not. */ export function useVerifiedBot(botId?: string): boolean { - const [verifiedBots] = useAtom(verifiedBotsAtom); + const [botAccounts] = useAtom(botAccountsAtom); - const isVerifiedBot: boolean = useMemo(() => { - return !!(botId && !!verifiedBots[botId]); - }, [botId, verifiedBots]); + return useMemo(() => { + return !!( + botId && + (botId === botAccounts?.communityBot || + botId === botAccounts?.superheroBot || + botId === botAccounts?.blockchainBot) + ); + }, [botId, botAccounts]); +} + +export function isVerifiedBot(botId?: string): boolean { + const botAccounts = getBotAccountData(); - return isVerifiedBot; + return !!( + botId && + (botId === botAccounts?.communityBot || + botId === botAccounts?.superheroBot || + botId === botAccounts?.blockchainBot) + ); } diff --git a/src/linkify-matrix.ts b/src/linkify-matrix.ts new file mode 100644 index 00000000000..005dac1e09e --- /dev/null +++ b/src/linkify-matrix.ts @@ -0,0 +1,286 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as linkifyjs from "linkifyjs"; +import { EventListeners, Opts, registerCustomProtocol, registerPlugin } from "linkifyjs"; +import linkifyElement from "linkify-element"; +import linkifyString from "linkify-string"; +import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix"; +import { ViewUserPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewUserPayload"; +import { Action } from "matrix-react-sdk/src/dispatcher/actions"; +import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload"; +import { + parsePermalink, + tryTransformEntityToPermalink, + tryTransformPermalinkToLocalHref, +} from "matrix-react-sdk/src/utils/permalinks/Permalinks"; +import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg"; +import dis from "matrix-react-sdk/src/dispatcher/dispatcher"; +import { PERMITTED_URL_SCHEMES } from "matrix-react-sdk/src/utils/UrlUtils"; + +import { isVerifiedBot } from "./hooks/useVerifiedBot"; +import { openDmForUser } from "./utils"; + +export enum Type { + URL = "url", + UserId = "userid", + RoomAlias = "roomalias", +} + +function matrixOpaqueIdLinkifyParser({ + scanner, + parser, + token, + name, +}: { + scanner: linkifyjs.ScannerInit; + parser: linkifyjs.ParserInit; + token: "#" | "+" | "@"; + name: Type; +}): void { + const { + DOT, + // IPV4 necessity + NUM, + COLON, + SYM, + SLASH, + EQUALS, + HYPHEN, + UNDERSCORE, + } = scanner.tokens; + + // Contains NUM, WORD, UWORD, EMOJI, TLD, UTLD, SCHEME, SLASH_SCHEME and LOCALHOST plus custom protocols (e.g. "matrix") + const { domain } = scanner.tokens.groups; + + // Tokens we need that are not contained in the domain group + const additionalLocalpartTokens = [DOT, SYM, SLASH, EQUALS, UNDERSCORE, HYPHEN]; + const additionalDomainpartTokens = [HYPHEN]; + + const matrixToken = linkifyjs.createTokenClass(name, { isLink: true }); + const matrixTokenState = new linkifyjs.State(matrixToken) as any as linkifyjs.State; // linkify doesn't appear to type this correctly + + const matrixTokenWithPort = linkifyjs.createTokenClass(name, { isLink: true }); + const matrixTokenWithPortState = new linkifyjs.State( + matrixTokenWithPort, + ) as any as linkifyjs.State; // linkify doesn't appear to type this correctly + + const INITIAL_STATE = parser.start.tt(token); + + // Localpart + const LOCALPART_STATE = new linkifyjs.State(); + INITIAL_STATE.ta(domain, LOCALPART_STATE); + INITIAL_STATE.ta(additionalLocalpartTokens, LOCALPART_STATE); + LOCALPART_STATE.ta(domain, LOCALPART_STATE); + LOCALPART_STATE.ta(additionalLocalpartTokens, LOCALPART_STATE); + + // Domainpart + const DOMAINPART_STATE_DOT = LOCALPART_STATE.tt(COLON); + DOMAINPART_STATE_DOT.ta(domain, matrixTokenState); + DOMAINPART_STATE_DOT.ta(additionalDomainpartTokens, matrixTokenState); + matrixTokenState.ta(domain, matrixTokenState); + matrixTokenState.ta(additionalDomainpartTokens, matrixTokenState); + matrixTokenState.tt(DOT, DOMAINPART_STATE_DOT); + + // Port suffixes + matrixTokenState.tt(COLON).tt(NUM, matrixTokenWithPortState); +} + +function onUserClick(event: MouseEvent, userId: string): void { + event.preventDefault(); + + const client = MatrixClientPeg.get(); + if (client && isVerifiedBot(userId)) { + void openDmForUser(client, { userId, rawDisplayName: userId }); + } else { + dis.dispatch({ + action: Action.ViewUser, + member: new User(userId), + }); + } +} + +function onAliasClick(event: MouseEvent, roomAlias: string): void { + event.preventDefault(); + dis.dispatch({ + action: Action.ViewRoom, + room_alias: roomAlias, + metricsTrigger: "Timeline", + metricsViaKeyboard: false, + }); +} + +const escapeRegExp = function (s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}; + +// Recognise URLs from both our local and official Element deployments. +// Anyone else really should be using matrix.to. vector:// allowed to support Element Desktop relative links. +export const ELEMENT_URL_PATTERN = + "^(?:vector://|https?://)?(?:" + + escapeRegExp(window.location.host + window.location.pathname) + + "|" + + "(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" + + "(?:app|beta|staging|develop)\\.element\\.io/" + + ")(#.*)"; + +export const options: Opts = { + events: function (href: string, type: string): EventListeners { + switch (type as Type) { + case Type.URL: { + // intercept local permalinks to users and show them like userids (in userinfo of current room) + try { + const permalink = parsePermalink(href); + if (permalink?.userId) { + return { + click: function (e: MouseEvent): void { + onUserClick(e, permalink.userId!); + }, + }; + } else { + // for events, rooms etc. (anything other than users) + const localHref = tryTransformPermalinkToLocalHref(href); + if (localHref !== href) { + // it could be converted to a localHref -> therefore handle locally + return { + click: function (e: MouseEvent): void { + e.preventDefault(); + window.location.hash = localHref; + }, + }; + } + } + } catch (e) { + // OK fine, it's not actually a permalink + } + break; + } + case Type.UserId: + return { + click: function (e: MouseEvent): void { + const userId = parsePermalink(href)?.userId ?? href; + if (userId) onUserClick(e, userId); + }, + }; + case Type.RoomAlias: + return { + click: function (e: MouseEvent): void { + const alias = parsePermalink(href)?.roomIdOrAlias ?? href; + if (alias) onAliasClick(e, alias); + }, + }; + } + + return {}; + }, + + formatHref: function (href: string, type: Type | string): string { + switch (type) { + case "url": + if (href.startsWith("mxc://") && MatrixClientPeg.get()) { + return getHttpUriForMxc(MatrixClientPeg.get()!.baseUrl, href); + } + // fallthrough + case Type.RoomAlias: + case Type.UserId: + default: { + return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? ""; + } + } + }, + + attributes: { + rel: "noreferrer noopener", + }, + + ignoreTags: ["pre", "code"], + + className: "linkified", + + target: function (href: string, type: Type | string): string { + if (type === Type.URL) { + try { + const transformed = tryTransformPermalinkToLocalHref(href); + if ( + transformed !== href || // if it could be converted to handle locally for matrix symbols e.g. @user:server.tdl and matrix.to + decodeURIComponent(href).match(ELEMENT_URL_PATTERN) // for https links to Element domains + ) { + return ""; + } else { + return "_blank"; + } + } catch (e) { + // malformed URI + } + } + return ""; + }, +}; + +// Run the plugins +registerPlugin(Type.RoomAlias, ({ scanner, parser }) => { + const token = scanner.tokens.POUND as "#"; + matrixOpaqueIdLinkifyParser({ + scanner, + parser, + token, + name: Type.RoomAlias, + }); +}); + +registerPlugin(Type.UserId, ({ scanner, parser }) => { + const token = scanner.tokens.AT as "@"; + matrixOpaqueIdLinkifyParser({ + scanner, + parser, + token, + name: Type.UserId, + }); +}); + +// Linkify supports some common protocols but not others, register all permitted url schemes if unsupported +// https://github.com/Hypercontext/linkifyjs/blob/f4fad9df1870259622992bbfba38bfe3d0515609/packages/linkifyjs/src/scanner.js#L133-L141 +// This also handles registering the `matrix:` protocol scheme +const linkifySupportedProtocols = ["file", "mailto", "http", "https", "ftp", "ftps"]; +const optionalSlashProtocols = [ + "bitcoin", + "geo", + "im", + "magnet", + "mailto", + "matrix", + "news", + "openpgp4fpr", + "sip", + "sms", + "smsto", + "tel", + "urn", + "xmpp", +]; + +PERMITTED_URL_SCHEMES.forEach((scheme) => { + if (!linkifySupportedProtocols.includes(scheme)) { + registerCustomProtocol(scheme, optionalSlashProtocols.includes(scheme)); + } +}); + +registerCustomProtocol("mxc", false); + +export const linkify = linkifyjs; +export const _linkifyElement = linkifyElement; +export const _linkifyString = linkifyString; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000000..082f433f1c0 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,15 @@ +import { MatrixClient, RoomMember, User } from "matrix-js-sdk/src/matrix"; +import { DirectoryMember, startDmOnFirstMessage } from "matrix-react-sdk/src/utils/direct-messages"; + +import { Member } from "./components/views/right_panel/UserInfo"; +import { BareUser } from "./atoms"; + +export async function openDmForUser(matrixClient: MatrixClient, user: Member | BareUser): Promise { + const avatarUrl = user instanceof User ? user.avatarUrl : user instanceof RoomMember ? user.getMxcAvatarUrl() : ""; + const startDmUser = new DirectoryMember({ + user_id: user.userId, + display_name: user.rawDisplayName, + avatar_url: avatarUrl, + }); + await startDmOnFirstMessage(matrixClient, [startDmUser]); +}