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
pull/27073/head
Keno Dressel 2024-02-16 09:17:09 +01:00 committed by GitHub
parent 069e3c85e6
commit fc6280ad83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 387 additions and 47 deletions

View File

@ -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"

View File

@ -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"
}

View File

@ -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<Record<string, string>>("VERIFIED_ACCOUNTS", {});
export const verifiedBotsAtom = atomWithStorage<Record<string, string>>("VERIFIED_BOTS", {});
export const botAccountsAtom = atomWithStorage<BotAccounts | null>("BOT_ACCOUNTS", null);
export const minimumTokenThresholdAtom = atomWithStorage<Record<string, TokenThreshold>>("TOKEN_THRESHOLD", {});
export const communityBotAtom = atomWithStorage<BareUser>("COMMUNITY_BOT", {
userId: "",
rawDisplayName: "",
});
export function getBotAccountData(): BotAccounts | null {
const defaultStore = getDefaultStore();
return defaultStore.get(botAccountsAtom) as BotAccounts | null;
}

View File

@ -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<IProps> = () => {
const cli = useMatrixClientContext();
const config: any = SdkConfig.get();
const pageUrl = getHomePageUrl(config, cli);
const [botAccounts] = useAtom(botAccountsAtom);
if (pageUrl) {
return <EmbeddedPage className="mx_HomePage" url={pageUrl} scrollbar={true} />;
@ -81,7 +84,11 @@ const HomePage: React.FC<IProps> = () => {
<div className="mx_HomePage_default_buttons">
<AccessibleButton
onClick={(): void => {
startDmOnFirstMessage(cli, [new DirectoryMember({ user_id: config.wallet_bot_user_id })]);
startDmOnFirstMessage(cli, [
new DirectoryMember({
user_id: botAccounts?.superheroBot || "",
}),
]);
}}
className="mx_HomePage_button_custom"
>

View File

@ -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<void> {
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 <MessageButton member={communityBot} text={text} />;
const botUser = {
userId: botAccounts?.communityBot || "",
rawDisplayName: "Community Bot",
} as Member;
return <MessageButton member={botUser} text={text} />;
};

View File

@ -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();
}

View File

@ -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 isVerifiedBot;
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 !!(
botId &&
(botId === botAccounts?.communityBot ||
botId === botAccounts?.superheroBot ||
botId === botAccounts?.blockchainBot)
);
}

286
src/linkify-matrix.ts Normal file
View File

@ -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<linkifyjs.MultiToken>; // 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<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
const INITIAL_STATE = parser.start.tt(token);
// Localpart
const LOCALPART_STATE = new linkifyjs.State<linkifyjs.MultiToken>();
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<ViewUserPayload>({
action: Action.ViewUser,
member: new User(userId),
});
}
}
function onAliasClick(event: MouseEvent, roomAlias: string): void {
event.preventDefault();
dis.dispatch<ViewRoomPayload>({
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;

15
src/utils.ts Normal file
View File

@ -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<void> {
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]);
}