From 5058136737e177a5cabecc8b53b489eaabc23429 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 30 Jul 2024 17:48:59 +0530 Subject: [PATCH] support matrix.to links (#1849) * support room via server params and eventId * change copy link to matrix.to links * display matrix.to links in messages as pill and stop generating url previews for them * improve editor mention to include viaServers and eventId * fix mention custom attributes * always try to open room in current space * jump to latest remove target eventId from url * add create direct search options to open/create dm with url --- src/app/components/RenderMessageContent.tsx | 55 ++-- .../autocomplete/RoomMentionAutocomplete.tsx | 7 +- src/app/components/editor/input.ts | 37 ++- src/app/components/editor/output.ts | 17 +- src/app/components/editor/slate.d.ts | 2 + src/app/components/editor/utils.ts | 6 +- src/app/components/message/RenderBody.tsx | 11 +- src/app/components/room-card/RoomCard.tsx | 4 +- .../JoinBeforeNavigate.tsx | 11 +- .../message-search/SearchResultGroup.tsx | 55 ++-- src/app/features/room-nav/RoomNavItem.tsx | 19 +- src/app/features/room/RoomTimeline.tsx | 67 ++--- src/app/features/room/RoomTombstone.tsx | 4 +- src/app/features/room/RoomViewHeader.tsx | 240 +++++++++--------- src/app/features/room/message/Message.tsx | 32 +-- .../hooks/router/useSearchParamsViaServers.ts | 14 + src/app/hooks/useMentionClickHandler.ts | 43 ++++ src/app/hooks/useRoomNavigate.ts | 21 +- src/app/hooks/useSpoilerClickHandler.ts | 14 + .../space-add-existing/SpaceAddExisting.jsx | 5 +- src/app/pages/Router.tsx | 4 +- src/app/pages/client/direct/DirectCreate.tsx | 33 +++ src/app/pages/client/direct/RoomProvider.tsx | 4 +- src/app/pages/client/direct/index.ts | 1 + src/app/pages/client/home/RoomProvider.tsx | 12 +- src/app/pages/client/inbox/Notifications.tsx | 58 ++--- src/app/pages/client/sidebar/SpaceTabs.tsx | 19 +- src/app/pages/client/space/RoomProvider.tsx | 12 +- src/app/pages/client/space/Space.tsx | 20 +- src/app/pages/client/space/SpaceProvider.tsx | 5 +- src/app/pages/pathSearchParam.ts | 13 + src/app/pages/paths.ts | 8 + src/app/plugins/matrix-to.ts | 84 ++++++ src/app/plugins/react-custom-html-parser.tsx | 176 +++++++++---- src/app/plugins/via-servers.ts | 65 +++++ src/app/utils/keyboard.ts | 14 +- src/app/utils/matrix.ts | 9 - src/util/matrixUtil.js | 56 ---- 38 files changed, 781 insertions(+), 476 deletions(-) create mode 100644 src/app/hooks/router/useSearchParamsViaServers.ts create mode 100644 src/app/hooks/useMentionClickHandler.ts create mode 100644 src/app/hooks/useSpoilerClickHandler.ts create mode 100644 src/app/pages/client/direct/DirectCreate.tsx create mode 100644 src/app/pages/pathSearchParam.ts create mode 100644 src/app/plugins/matrix-to.ts create mode 100644 src/app/plugins/via-servers.ts diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 60e03313..1ce37e5c 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { MsgType } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; +import { Opts } from 'linkifyjs'; import { AudioContent, DownloadFile, @@ -27,6 +28,7 @@ import { Image, MediaControl, Video } from './media'; import { ImageViewer } from './image-viewer'; import { PdfViewer } from './Pdf-viewer'; import { TextViewer } from './text-viewer'; +import { testMatrixTo } from '../plugins/matrix-to'; type RenderMessageContentProps = { displayName: string; @@ -38,6 +40,7 @@ type RenderMessageContentProps = { urlPreview?: boolean; highlightRegex?: RegExp; htmlReactParserOptions: HTMLReactParserOptions; + linkifyOpts: Opts; outlineAttachment?: boolean; }; export function RenderMessageContent({ @@ -50,8 +53,21 @@ export function RenderMessageContent({ urlPreview, highlightRegex, htmlReactParserOptions, + linkifyOpts, outlineAttachment, }: RenderMessageContentProps) { + const renderUrlsPreview = (urls: string[]) => { + const filteredUrls = urls.filter((url) => !testMatrixTo(url)); + if (filteredUrls.length === 0) return undefined; + return ( + + {filteredUrls.map((url) => ( + + ))} + + ); + }; + const renderFile = () => ( )} - renderUrlsPreview={ - urlPreview - ? (urls) => ( - - {urls.map((url) => ( - - ))} - - ) - : undefined - } + renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} /> ); } @@ -123,19 +130,10 @@ export function RenderMessageContent({ {...props} highlightRegex={highlightRegex} htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> )} - renderUrlsPreview={ - urlPreview - ? (urls) => ( - - {urls.map((url) => ( - - ))} - - ) - : undefined - } + renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} /> ); } @@ -150,19 +148,10 @@ export function RenderMessageContent({ {...props} highlightRegex={highlightRegex} htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> )} - renderUrlsPreview={ - urlPreview - ? (urls) => ( - - {urls.map((url) => ( - - ))} - - ) - : undefined - } + renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} /> ); } diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index 439d98ca..049be94a 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -17,6 +17,7 @@ import { mDirectAtom } from '../../../state/mDirectList'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { factoryRoomIdByActivity } from '../../../utils/sort'; import { RoomAvatar, RoomIcon } from '../../room-avatar'; +import { getViaServers } from '../../../plugins/via-servers'; type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; @@ -104,10 +105,14 @@ export function RoomMentionAutocomplete({ }, [query.text, search, resetSearch]); const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => { + const mentionRoom = mx.getRoom(roomAliasOrId); + const viaServers = mentionRoom ? getViaServers(mentionRoom) : undefined; const mentionEl = createMentionElement( roomAliasOrId, name.startsWith('#') ? name : `#${name}`, - roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId + roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId, + undefined, + viaServers ); replaceWithElement(editor, query.range, mentionEl); moveCursor(editor, true); diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index 272b9707..29e5bd6a 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -18,8 +18,13 @@ import { ParagraphElement, UnorderedListElement, } from './slate'; -import { parseMatrixToUrl } from '../../utils/matrix'; import { createEmoticonElement, createMentionElement } from './utils'; +import { + parseMatrixToRoom, + parseMatrixToRoomEvent, + parseMatrixToUser, + testMatrixTo, +} from '../../plugins/matrix-to'; const markNodeToType: Record = { b: MarkType.Bold, @@ -68,11 +73,33 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | return createEmoticonElement(src, alt || 'Unknown Emoji'); } if (node.name === 'a') { - const { href } = node.attribs; + const href = decodeURIComponent(node.attribs.href); if (typeof href !== 'string') return undefined; - const [mxId] = parseMatrixToUrl(href); - if (mxId) { - return createMentionElement(mxId, parseNodeText(node) || mxId, false); + if (testMatrixTo(href)) { + const userMention = parseMatrixToUser(href); + if (userMention) { + return createMentionElement(userMention, parseNodeText(node) || userMention, false); + } + const roomMention = parseMatrixToRoom(href); + if (roomMention) { + return createMentionElement( + roomMention.roomIdOrAlias, + parseNodeText(node) || roomMention.roomIdOrAlias, + false, + undefined, + roomMention.viaServers + ); + } + const eventMention = parseMatrixToRoomEvent(href); + if (eventMention) { + return createMentionElement( + eventMention.roomIdOrAlias, + parseNodeText(node) || eventMention.roomIdOrAlias, + false, + eventMention.eventId, + eventMention.viaServers + ); + } } } return undefined; diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 53ee6ddf..864aee3d 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -51,10 +51,19 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { case BlockType.UnorderedList: return ``; - case BlockType.Mention: - return `${sanitizeText( - node.name - )}`; + case BlockType.Mention: { + let fragment = node.id; + + if (node.eventId) { + fragment += `/${node.eventId}`; + } + if (node.viaServers && node.viaServers.length > 0) { + fragment += `?${node.viaServers.map((server) => `via=${server}`).join('&')}`; + } + + const matrixTo = `https://matrix.to/#/${fragment}`; + return `${sanitizeText(node.name)}`; + } case BlockType.Emoticon: return node.key.startsWith('mxc://') ? `${sanitizeText(
diff --git a/src/app/components/editor/slate.d.ts b/src/app/components/editor/slate.d.ts
index 1b08ae88..da1460e5 100644
--- a/src/app/components/editor/slate.d.ts
+++ b/src/app/components/editor/slate.d.ts
@@ -29,6 +29,8 @@ export type LinkElement = {
 export type MentionElement = {
   type: BlockType.Mention;
   id: string;
+  eventId?: string;
+  viaServers?: string[];
   highlight: boolean;
   name: string;
   children: Text[];
diff --git a/src/app/components/editor/utils.ts b/src/app/components/editor/utils.ts
index 3f4f9547..90c549c8 100644
--- a/src/app/components/editor/utils.ts
+++ b/src/app/components/editor/utils.ts
@@ -158,10 +158,14 @@ export const resetEditorHistory = (editor: Editor) => {
 export const createMentionElement = (
   id: string,
   name: string,
-  highlight: boolean
+  highlight: boolean,
+  eventId?: string,
+  viaServers?: string[]
 ): MentionElement => ({
   type: BlockType.Mention,
   id,
+  eventId,
+  viaServers,
   highlight,
   name,
   children: [{ text: '' }],
diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx
index b5b517e1..6db9ee48 100644
--- a/src/app/components/message/RenderBody.tsx
+++ b/src/app/components/message/RenderBody.tsx
@@ -1,13 +1,10 @@
 import React from 'react';
 import parse, { HTMLReactParserOptions } from 'html-react-parser';
 import Linkify from 'linkify-react';
+import { Opts } from 'linkifyjs';
 import { MessageEmptyContent } from './content';
 import { sanitizeCustomHtml } from '../../utils/sanitize';
-import {
-  LINKIFY_OPTS,
-  highlightText,
-  scaleSystemEmoji,
-} from '../../plugins/react-custom-html-parser';
+import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
 
 type RenderBodyProps = {
   body: string;
@@ -15,12 +12,14 @@ type RenderBodyProps = {
 
   highlightRegex?: RegExp;
   htmlReactParserOptions: HTMLReactParserOptions;
+  linkifyOpts: Opts;
 };
 export function RenderBody({
   body,
   customBody,
   highlightRegex,
   htmlReactParserOptions,
+  linkifyOpts,
 }: RenderBodyProps) {
   if (body === '') <MessageEmptyContent />;
   if (customBody) {
@@ -28,7 +27,7 @@ export function RenderBody({
     return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
   }
   return (
-    <Linkify options={LINKIFY_OPTS}>
+    <Linkify options={linkifyOpts}>
       {highlightRegex
         ? highlightText(highlightRegex, scaleSystemEmoji(body))
         : scaleSystemEmoji(body)}
diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx
index 79dd87db..2bb10e3c 100644
--- a/src/app/components/room-card/RoomCard.tsx
+++ b/src/app/components/room-card/RoomCard.tsx
@@ -138,6 +138,7 @@ type RoomCardProps = {
   topic?: string;
   memberCount?: number;
   roomType?: string;
+  viaServers?: string[];
   onView?: (roomId: string) => void;
   renderTopicViewer: (name: string, topic: string, requestClose: () => void) => ReactNode;
 };
@@ -152,6 +153,7 @@ export const RoomCard = as<'div', RoomCardProps>(
       topic,
       memberCount,
       roomType,
+      viaServers,
       onView,
       renderTopicViewer,
       ...props
@@ -194,7 +196,7 @@ export const RoomCard = as<'div', RoomCardProps>(
     );
 
     const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
-      useCallback(() => mx.joinRoom(roomIdOrAlias), [mx, roomIdOrAlias])
+      useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
     );
     const joining =
       joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
index 2b9c3e50..1cec6599 100644
--- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
+++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
@@ -9,8 +9,12 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { allRoomsAtom } from '../../state/room-list/roomList';
 
-type JoinBeforeNavigateProps = { roomIdOrAlias: string };
-export function JoinBeforeNavigate({ roomIdOrAlias }: JoinBeforeNavigateProps) {
+type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
+export function JoinBeforeNavigate({
+  roomIdOrAlias,
+  eventId,
+  viaServers,
+}: JoinBeforeNavigateProps) {
   const mx = useMatrixClient();
   const allRooms = useAtomValue(allRoomsAtom);
   const { navigateRoom, navigateSpace } = useRoomNavigate();
@@ -20,7 +24,7 @@ export function JoinBeforeNavigate({ roomIdOrAlias }: JoinBeforeNavigateProps) {
       navigateSpace(roomId);
       return;
     }
-    navigateRoom(roomId);
+    navigateRoom(roomId, eventId);
   };
 
   return (
@@ -46,6 +50,7 @@ export function JoinBeforeNavigate({ roomIdOrAlias }: JoinBeforeNavigateProps) {
                   topic={summary?.topic}
                   memberCount={summary?.num_joined_members}
                   roomType={summary?.room_type}
+                  viaServers={viaServers}
                   renderTopicViewer={(name, topic, requestClose) => (
                     <RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
                   )}
diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx
index 6f84f621..2b2a816a 100644
--- a/src/app/features/message-search/SearchResultGroup.tsx
+++ b/src/app/features/message-search/SearchResultGroup.tsx
@@ -3,13 +3,17 @@ import React, { MouseEventHandler, useMemo } from 'react';
 import { IEventWithRoomId, JoinRule, RelationType, Room } from 'matrix-js-sdk';
 import { HTMLReactParserOptions } from 'html-react-parser';
 import { Avatar, Box, Chip, Header, Icon, Icons, Text, config } from 'folds';
+import { Opts as LinkifyOpts } from 'linkifyjs';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import {
+  factoryRenderLinkifyWithMention,
   getReactCustomHtmlParser,
+  LINKIFY_OPTS,
   makeHighlightRegex,
+  makeMentionCustomProps,
+  renderMatrixMention,
 } from '../../plugins/react-custom-html-parser';
-import { getMxIdLocalPart, isRoomId, isUserId } from '../../utils/matrix';
-import { openJoinAlias, openProfileViewer } from '../../../client/action/navigation';
+import { getMxIdLocalPart } from '../../utils/matrix';
 import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
 import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
 import {
@@ -31,8 +35,9 @@ import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../.
 import colorMXID from '../../../util/colorMXID';
 import { ResultItem } from './useMessageSearch';
 import { SequenceCard } from '../../components/sequence-card';
-import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 import { UserAvatar } from '../../components/user-avatar';
+import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
+import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
 
 type SearchResultGroupProps = {
   room: Room;
@@ -51,38 +56,29 @@ export function SearchResultGroup({
   onOpen,
 }: SearchResultGroupProps) {
   const mx = useMatrixClient();
-  const { navigateRoom, navigateSpace } = useRoomNavigate();
   const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
 
+  const mentionClickHandler = useMentionClickHandler(room.roomId);
+  const spoilerClickHandler = useSpoilerClickHandler();
+
+  const linkifyOpts = useMemo<LinkifyOpts>(
+    () => ({
+      ...LINKIFY_OPTS,
+      render: factoryRenderLinkifyWithMention((href) =>
+        renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
+      ),
+    }),
+    [mx, room, mentionClickHandler]
+  );
   const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
     () =>
-      getReactCustomHtmlParser(mx, room, {
+      getReactCustomHtmlParser(mx, room.roomId, {
+        linkifyOpts,
         highlightRegex,
-        handleSpoilerClick: (evt) => {
-          const target = evt.currentTarget;
-          if (target.getAttribute('aria-pressed') === 'true') {
-            evt.stopPropagation();
-            target.setAttribute('aria-pressed', 'false');
-            target.style.cursor = 'initial';
-          }
-        },
-        handleMentionClick: (evt) => {
-          const target = evt.currentTarget;
-          const mentionId = target.getAttribute('data-mention-id');
-          if (typeof mentionId !== 'string') return;
-          if (isUserId(mentionId)) {
-            openProfileViewer(mentionId, room.roomId);
-            return;
-          }
-          if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
-            if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
-            else navigateRoom(mentionId);
-            return;
-          }
-          openJoinAlias(mentionId);
-        },
+        handleSpoilerClick: spoilerClickHandler,
+        handleMentionClick: mentionClickHandler,
       }),
-    [mx, room, highlightRegex, navigateRoom, navigateSpace]
+    [mx, room, linkifyOpts, highlightRegex, mentionClickHandler, spoilerClickHandler]
   );
 
   const renderMatrixEvent = useMatrixEventRenderer<[IEventWithRoomId, string, GetContentCallback]>(
@@ -101,6 +97,7 @@ export function SearchResultGroup({
             mediaAutoLoad={mediaAutoLoad}
             urlPreview={urlPreview}
             htmlReactParserOptions={htmlReactParserOptions}
+            linkifyOpts={linkifyOpts}
             highlightRegex={highlightRegex}
             outlineAttachment
           />
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index 281c5b77..aa7b468e 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -28,25 +28,24 @@ import { useRoomUnread } from '../../state/hooks/unread';
 import { roomToUnreadAtom } from '../../state/room/roomToUnread';
 import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
 import { copyToClipboard } from '../../utils/dom';
-import { getOriginBaseUrl, withOriginBaseUrl } from '../../pages/pathUtils';
 import { markAsRead } from '../../../client/action/notifications';
 import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
-import { useClientConfig } from '../../hooks/useClientConfig';
 import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
 import { TypingIndicator } from '../../components/typing-indicator';
 import { stopPropagation } from '../../utils/keyboard';
+import { getMatrixToRoom } from '../../plugins/matrix-to';
+import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
+import { getViaServers } from '../../plugins/via-servers';
 
 type RoomNavItemMenuProps = {
   room: Room;
-  linkPath: string;
   requestClose: () => void;
 };
 const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
-  ({ room, linkPath, requestClose }, ref) => {
+  ({ room, requestClose }, ref) => {
     const mx = useMatrixClient();
-    const { hashRouter } = useClientConfig();
     const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
     const powerLevels = usePowerLevels(room);
     const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
@@ -63,7 +62,9 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
     };
 
     const handleCopyLink = () => {
-      copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), linkPath));
+      const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
+      const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
+      copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
       requestClose();
     };
 
@@ -273,11 +274,7 @@ export function RoomNavItem({
                   escapeDeactivates: stopPropagation,
                 }}
               >
-                <RoomNavItemMenu
-                  room={room}
-                  linkPath={linkPath}
-                  requestClose={() => setMenuAnchor(undefined)}
-                />
+                <RoomNavItemMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
               </FocusTrap>
             }
           >
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index b9bfb843..6e503703 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -45,13 +45,12 @@ import {
   toRem,
 } from 'folds';
 import { isKeyHotkey } from 'is-hotkey';
+import { Opts as LinkifyOpts } from 'linkifyjs';
 import {
   decryptFile,
   eventWithShortcode,
   factoryEventSentBy,
   getMxIdLocalPart,
-  isRoomId,
-  isUserId,
 } from '../../utils/matrix';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
@@ -70,7 +69,13 @@ import {
   ImageContent,
   EventContent,
 } from '../../components/message';
-import { getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
+import {
+  factoryRenderLinkifyWithMention,
+  getReactCustomHtmlParser,
+  LINKIFY_OPTS,
+  makeMentionCustomProps,
+  renderMatrixMention,
+} from '../../plugins/react-custom-html-parser';
 import {
   canEditEvent,
   decryptAllTimelineEvent,
@@ -85,7 +90,7 @@ import {
 } from '../../utils/room';
 import { useSetting } from '../../state/hooks/settings';
 import { settingsAtom } from '../../state/settings';
-import { openJoinAlias, openProfileViewer } from '../../../client/action/navigation';
+import { openProfileViewer } from '../../../client/action/navigation';
 import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
 import { Reactions, Message, Event, EncryptedContent } from './message';
 import { useMemberEventParser } from '../../hooks/useMemberEventParser';
@@ -109,10 +114,12 @@ import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
 import { RenderMessageContent } from '../../components/RenderMessageContent';
 import { Image } from '../../components/media';
 import { ImageViewer } from '../../components/image-viewer';
-import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 import { roomToParentsAtom } from '../../state/room/roomToParents';
 import { useRoomUnread } from '../../state/hooks/unread';
 import { roomToUnreadAtom } from '../../state/room/roomToUnread';
+import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
+import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
+import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 
 const TimelineFloat = as<'div', css.TimelineFloatVariants>(
   ({ position, className, ...props }, ref) => (
@@ -445,9 +452,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
   const canRedact = canDoAction('redact', myPowerLevel);
   const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
   const [editId, setEditId] = useState<string>();
-  const { navigateRoom, navigateSpace } = useRoomNavigate();
   const roomToParents = useAtomValue(roomToParentsAtom);
   const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
+  const { navigateRoom } = useRoomNavigate();
+  const mentionClickHandler = useMentionClickHandler(room.roomId);
+  const spoilerClickHandler = useSpoilerClickHandler();
 
   const imagePackRooms: Room[] = useMemo(() => {
     const allParentSpaces = [room.roomId].concat(
@@ -487,34 +496,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
   >();
   const alive = useAlive();
 
+  const linkifyOpts = useMemo<LinkifyOpts>(
+    () => ({
+      ...LINKIFY_OPTS,
+      render: factoryRenderLinkifyWithMention((href) =>
+        renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
+      ),
+    }),
+    [mx, room, mentionClickHandler]
+  );
   const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
     () =>
-      getReactCustomHtmlParser(mx, room, {
-        handleSpoilerClick: (evt) => {
-          const target = evt.currentTarget;
-          if (target.getAttribute('aria-pressed') === 'true') {
-            evt.stopPropagation();
-            target.setAttribute('aria-pressed', 'false');
-            target.style.cursor = 'initial';
-          }
-        },
-        handleMentionClick: (evt) => {
-          const target = evt.currentTarget;
-          const mentionId = target.getAttribute('data-mention-id');
-          if (typeof mentionId !== 'string') return;
-          if (isUserId(mentionId)) {
-            openProfileViewer(mentionId, room.roomId);
-            return;
-          }
-          if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
-            if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
-            else navigateRoom(mentionId);
-            return;
-          }
-          openJoinAlias(mentionId);
-        },
+      getReactCustomHtmlParser(mx, room.roomId, {
+        linkifyOpts,
+        handleSpoilerClick: spoilerClickHandler,
+        handleMentionClick: mentionClickHandler,
       }),
-    [mx, room, navigateRoom, navigateSpace]
+    [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler]
   );
   const parseMemberEvent = useMemberEventParser();
 
@@ -597,7 +595,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         // so timeline can be updated with evt like: edits, reactions etc
         if (atBottomRef.current) {
           if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
-            requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()));
+            requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!));
           }
 
           if (document.hasFocus()) {
@@ -819,6 +817,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
   }, [scrollToElement, editId]);
 
   const handleJumpToLatest = () => {
+    if (eventId) {
+      navigateRoom(room.roomId, undefined, { replace: true });
+    }
     setTimeline(getInitialTimeline(room));
     scrollToBottomRef.current.count += 1;
     scrollToBottomRef.current.smooth = false;
@@ -1036,6 +1037,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
                 mediaAutoLoad={mediaAutoLoad}
                 urlPreview={showUrlPreview}
                 htmlReactParserOptions={htmlReactParserOptions}
+                linkifyOpts={linkifyOpts}
                 outlineAttachment={messageLayout === 2}
               />
             )}
@@ -1132,6 +1134,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
                       mediaAutoLoad={mediaAutoLoad}
                       urlPreview={showUrlPreview}
                       htmlReactParserOptions={htmlReactParserOptions}
+                      linkifyOpts={linkifyOpts}
                       outlineAttachment={messageLayout === 2}
                     />
                   );
diff --git a/src/app/features/room/RoomTombstone.tsx b/src/app/features/room/RoomTombstone.tsx
index e3f8251f..24f0b80e 100644
--- a/src/app/features/room/RoomTombstone.tsx
+++ b/src/app/features/room/RoomTombstone.tsx
@@ -3,11 +3,11 @@ import { Box, Button, Spinner, Text, color } from 'folds';
 
 import * as css from './RoomTombstone.css';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { genRoomVia } from '../../../util/matrixUtil';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 import { Membership } from '../../../types/matrix/room';
 import { RoomInputPlaceholder } from './RoomInputPlaceholder';
 import { useRoomNavigate } from '../../hooks/useRoomNavigate';
+import { getViaServers } from '../../plugins/via-servers';
 
 type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
 export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
@@ -17,7 +17,7 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
   const [joinState, handleJoin] = useAsyncCallback(
     useCallback(() => {
       const currentRoom = mx.getRoom(roomId);
-      const via = currentRoom ? genRoomVia(currentRoom) : [];
+      const via = currentRoom ? getViaServers(currentRoom) : [];
       return mx.joinRoom(replacementRoomId, {
         viaServers: via,
       });
diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx
index 6750f923..709f66c8 100644
--- a/src/app/features/room/RoomViewHeader.tsx
+++ b/src/app/features/room/RoomViewHeader.tsx
@@ -20,7 +20,7 @@ import {
   PopOut,
   RectCords,
 } from 'folds';
-import { useLocation, useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
 import { JoinRule, Room } from 'matrix-js-sdk';
 import { useAtomValue } from 'jotai';
 
@@ -35,15 +35,8 @@ import { useRoom } from '../../hooks/useRoom';
 import { useSetSetting } from '../../state/hooks/settings';
 import { settingsAtom } from '../../state/settings';
 import { useSpaceOptionally } from '../../hooks/useSpace';
-import {
-  getHomeSearchPath,
-  getOriginBaseUrl,
-  getSpaceSearchPath,
-  joinPathComponent,
-  withOriginBaseUrl,
-  withSearchParam,
-} from '../../pages/pathUtils';
-import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
+import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
+import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
 import { _SearchPathSearchParams } from '../../pages/paths';
 import * as css from './RoomViewHeader.css';
 import { useRoomUnread } from '../../state/hooks/unread';
@@ -55,128 +48,127 @@ import { copyToClipboard } from '../../utils/dom';
 import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
 import { mDirectAtom } from '../../state/mDirectList';
-import { useClientConfig } from '../../hooks/useClientConfig';
 import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
 import { stopPropagation } from '../../utils/keyboard';
+import { getMatrixToRoom } from '../../plugins/matrix-to';
+import { getViaServers } from '../../plugins/via-servers';
 
 type RoomMenuProps = {
   room: Room;
-  linkPath: string;
   requestClose: () => void;
 };
-const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
-  ({ room, linkPath, requestClose }, ref) => {
-    const mx = useMatrixClient();
-    const { hashRouter } = useClientConfig();
-    const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
-    const powerLevels = usePowerLevelsContext();
-    const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
-    const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose }, ref) => {
+  const mx = useMatrixClient();
+  const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
+  const powerLevels = usePowerLevelsContext();
+  const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
+  const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
 
-    const handleMarkAsRead = () => {
-      markAsRead(mx, room.roomId);
-      requestClose();
-    };
+  const handleMarkAsRead = () => {
+    markAsRead(mx, room.roomId);
+    requestClose();
+  };
 
-    const handleInvite = () => {
-      openInviteUser(room.roomId);
-      requestClose();
-    };
+  const handleInvite = () => {
+    openInviteUser(room.roomId);
+    requestClose();
+  };
 
-    const handleCopyLink = () => {
-      copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), linkPath));
-      requestClose();
-    };
+  const handleCopyLink = () => {
+    const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
+    const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
+    copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
+    requestClose();
+  };
 
-    const handleRoomSettings = () => {
-      toggleRoomSettings(room.roomId);
-      requestClose();
-    };
+  const handleRoomSettings = () => {
+    toggleRoomSettings(room.roomId);
+    requestClose();
+  };
 
-    return (
-      <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
-        <Box direction= - } - radii="300" - disabled={!unread} - > - - Mark as Read - - - - - - } - radii="300" - disabled={!canInvite} - > - - Invite - - - } - radii="300" - > - - Copy Link - - - } - radii="300" - > - - Room Settings - - - - - - - {(promptLeave, setPromptLeave) => ( - <> - setPromptLeave(true)} - variant="Critical" - fill="None" - size="300" - after={} - radii="300" - aria-pressed={promptLeave} - > - - Leave Room - - - {promptLeave && ( - setPromptLeave(false)} - /> - )} - - )} - - - - ); - } -); + return ( + + + } + radii="300" + disabled={!unread} + > + + Mark as Read + + + + + + } + radii="300" + disabled={!canInvite} + > + + Invite + + + } + radii="300" + > + + Copy Link + + + } + radii="300" + > + + Room Settings + + + + + + + {(promptLeave, setPromptLeave) => ( + <> + setPromptLeave(true)} + variant="Critical" + fill="None" + size="300" + after={} + radii="300" + aria-pressed={promptLeave} + > + + Leave Room + + + {promptLeave && ( + setPromptLeave(false)} + /> + )} + + )} + + + + ); +}); export function RoomViewHeader() { const navigate = useNavigate(); @@ -195,8 +187,6 @@ export function RoomViewHeader() { const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined; const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); - const location = useLocation(); - const currentPath = joinPathComponent(location); const handleSearchClick = () => { const searchParams: _SearchPathSearchParams = { @@ -336,11 +326,7 @@ export function RoomViewHeader() { escapeDeactivates: stopPropagation, }} > - setMenuAnchor(undefined)} - /> + setMenuAnchor(undefined)} /> } /> diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 6db366ac..d8b2b3e5 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -51,7 +51,7 @@ import { getMemberAvatarMxc, getMemberDisplayName, } from '../../../utils/room'; -import { getCanonicalAliasOrRoomId, getMxIdLocalPart } from '../../../utils/matrix'; +import { getCanonicalAliasOrRoomId, getMxIdLocalPart, isRoomAlias } from '../../../utils/matrix'; import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; @@ -63,18 +63,10 @@ import { EmojiBoard } from '../../../components/emoji-board'; import { ReactionViewer } from '../reaction-viewer'; import { MessageEditor } from './MessageEditor'; import { UserAvatar } from '../../../components/user-avatar'; -import { useSpaceOptionally } from '../../../hooks/useSpace'; -import { useDirectSelected } from '../../../hooks/router/useDirectSelected'; -import { - getDirectRoomPath, - getHomeRoomPath, - getOriginBaseUrl, - getSpaceRoomPath, - withOriginBaseUrl, -} from '../../../pages/pathUtils'; import { copyToClipboard } from '../../../utils/dom'; -import { useClientConfig } from '../../../hooks/useClientConfig'; import { stopPropagation } from '../../../utils/keyboard'; +import { getMatrixToRoomEvent } from '../../../plugins/matrix-to'; +import { getViaServers } from '../../../plugins/via-servers'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -321,23 +313,13 @@ export const MessageCopyLinkItem = as< } >(({ room, mEvent, onClose, ...props }, ref) => { const mx = useMatrixClient(); - const { hashRouter } = useClientConfig(); - const space = useSpaceOptionally(); - const directSelected = useDirectSelected(); const handleCopy = () => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - let eventPath = getHomeRoomPath(roomIdOrAlias, mEvent.getId()); - if (space) { - eventPath = getSpaceRoomPath( - getCanonicalAliasOrRoomId(mx, space.roomId), - roomIdOrAlias, - mEvent.getId() - ); - } else if (directSelected) { - eventPath = getDirectRoomPath(roomIdOrAlias, mEvent.getId()); - } - copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), eventPath)); + const eventId = mEvent.getId(); + const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + if (!eventId) return; + copyToClipboard(getMatrixToRoomEvent(roomIdOrAlias, eventId, viaServers)); onClose?.(); }; diff --git a/src/app/hooks/router/useSearchParamsViaServers.ts b/src/app/hooks/router/useSearchParamsViaServers.ts new file mode 100644 index 00000000..0b1b2db7 --- /dev/null +++ b/src/app/hooks/router/useSearchParamsViaServers.ts @@ -0,0 +1,14 @@ +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { getRoomSearchParams } from '../../pages/pathSearchParam'; +import { decodeSearchParamValueArray } from '../../pages/pathUtils'; + +export const useSearchParamsViaServers = (): string[] | undefined => { + const [searchParams] = useSearchParams(); + const roomSearchParams = useMemo(() => getRoomSearchParams(searchParams), [searchParams]); + const viaServers = roomSearchParams.viaServers + ? decodeSearchParamValueArray(roomSearchParams.viaServers) + : undefined; + + return viaServers; +}; diff --git a/src/app/hooks/useMentionClickHandler.ts b/src/app/hooks/useMentionClickHandler.ts new file mode 100644 index 00000000..f8f4bf54 --- /dev/null +++ b/src/app/hooks/useMentionClickHandler.ts @@ -0,0 +1,43 @@ +import { ReactEventHandler, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useRoomNavigate } from './useRoomNavigate'; +import { useMatrixClient } from './useMatrixClient'; +import { isRoomId, isUserId } from '../utils/matrix'; +import { openProfileViewer } from '../../client/action/navigation'; +import { getHomeRoomPath, withSearchParam } from '../pages/pathUtils'; +import { _RoomSearchParams } from '../pages/paths'; + +export const useMentionClickHandler = (roomId: string): ReactEventHandler => { + const mx = useMatrixClient(); + const { navigateRoom, navigateSpace } = useRoomNavigate(); + const navigate = useNavigate(); + + const handleClick: ReactEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + const target = evt.currentTarget; + const mentionId = target.getAttribute('data-mention-id'); + if (typeof mentionId !== 'string') return; + + if (isUserId(mentionId)) { + openProfileViewer(mentionId, roomId); + return; + } + + const eventId = target.getAttribute('data-mention-event-id') || undefined; + if (isRoomId(mentionId) && mx.getRoom(mentionId)) { + if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId); + else navigateRoom(mentionId, eventId); + return; + } + + const viaServers = target.getAttribute('data-mention-via') || undefined; + const path = getHomeRoomPath(mentionId, eventId); + + navigate(viaServers ? withSearchParam<_RoomSearchParams>(path, { viaServers }) : path); + }, + [mx, navigate, navigateRoom, navigateSpace, roomId] + ); + + return handleClick; +}; diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 55528e7e..0f9f365c 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { NavigateOptions, useNavigate } from 'react-router-dom'; import { useAtomValue } from 'jotai'; import { getCanonicalAliasOrRoomId } from '../utils/matrix'; import { @@ -12,12 +12,14 @@ import { useMatrixClient } from './useMatrixClient'; import { getOrphanParents } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { mDirectAtom } from '../state/mDirectList'; +import { useSelectedSpace } from './router/useSelectedSpace'; export const useRoomNavigate = () => { const navigate = useNavigate(); const mx = useMatrixClient(); const roomToParents = useAtomValue(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); + const spaceSelectedId = useSelectedSpace(); const navigateSpace = useCallback( (roomId: string) => { @@ -28,24 +30,29 @@ export const useRoomNavigate = () => { ); const navigateRoom = useCallback( - (roomId: string, eventId?: string) => { + (roomId: string, eventId?: string, opts?: NavigateOptions) => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const orphanParents = getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { - const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, orphanParents[0]); - navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId)); + const pSpaceIdOrAlias = getCanonicalAliasOrRoomId( + mx, + spaceSelectedId && orphanParents.includes(spaceSelectedId) + ? spaceSelectedId + : orphanParents[0] + ); + navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts); return; } if (mDirects.has(roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, eventId)); + navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); return; } - navigate(getHomeRoomPath(roomIdOrAlias, eventId)); + navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); }, - [mx, navigate, roomToParents, mDirects] + [mx, navigate, spaceSelectedId, roomToParents, mDirects] ); return { diff --git a/src/app/hooks/useSpoilerClickHandler.ts b/src/app/hooks/useSpoilerClickHandler.ts new file mode 100644 index 00000000..b2101188 --- /dev/null +++ b/src/app/hooks/useSpoilerClickHandler.ts @@ -0,0 +1,14 @@ +import { ReactEventHandler, useCallback } from 'react'; + +export const useSpoilerClickHandler = (): ReactEventHandler => { + const handleClick: ReactEventHandler = useCallback((evt) => { + const target = evt.currentTarget; + if (target.getAttribute('aria-pressed') === 'true') { + evt.stopPropagation(); + target.setAttribute('aria-pressed', 'false'); + target.style.cursor = 'initial'; + } + }, []); + + return handleClick; +}; diff --git a/src/app/molecules/space-add-existing/SpaceAddExisting.jsx b/src/app/molecules/space-add-existing/SpaceAddExisting.jsx index ff338f3f..83b967bc 100644 --- a/src/app/molecules/space-add-existing/SpaceAddExisting.jsx +++ b/src/app/molecules/space-add-existing/SpaceAddExisting.jsx @@ -5,7 +5,7 @@ import './SpaceAddExisting.scss'; import cons from '../../../client/state/cons'; import navigation from '../../../client/state/navigation'; -import { joinRuleToIconSrc, getIdServer, genRoomVia } from '../../../util/matrixUtil'; +import { joinRuleToIconSrc, getIdServer } from '../../../util/matrixUtil'; import { Debounce } from '../../../util/common'; import Text from '../../atoms/text/Text'; @@ -27,6 +27,7 @@ import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList'; import { mDirectAtom } from '../../state/mDirectList'; import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getViaServers } from '../../plugins/via-servers'; function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) { const mountStore = useStore(roomId); @@ -69,7 +70,7 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) { const promises = selected.map((rId) => { const room = mx.getRoom(rId); - const via = genRoomVia(room); + const via = getViaServers(room); if (via.length === 0) { via.push(getIdServer(rId)); } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 7d0f4fde..88fa9932 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -41,7 +41,7 @@ import { } from './pathUtils'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; -import { Direct, DirectRouteRoomProvider } from './client/direct'; +import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites } from './client/inbox'; @@ -160,7 +160,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } > {mobile ? null : } />} - create

} /> + } /> { + if (userId) { + const room = getDMRoomFor(mx, userId); + const { roomId } = room ?? {}; + if (roomId && directs.includes(roomId)) { + navigate(getDirectRoomPath(roomId), { replace: true }); + } else { + openInviteUser(undefined, userId); + } + } else { + navigate(getDirectPath(), { replace: true }); + } + }, [mx, navigate, directs, userId]); + + return ; +} diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index c78a8f44..ca45aa19 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -10,12 +10,12 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { const mx = useMatrixClient(); const rooms = useDirectRooms(); - const { roomIdOrAlias } = useParams(); + const { roomIdOrAlias, eventId } = useParams(); const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); if (!room || !rooms.includes(room.roomId)) { - return ; + return ; } return ( diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts index 36f44d63..d247bbc0 100644 --- a/src/app/pages/client/direct/index.ts +++ b/src/app/pages/client/direct/index.ts @@ -1,2 +1,3 @@ export * from './Direct'; export * from './RoomProvider'; +export * from './DirectCreate'; diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index 282cee7d..aa14d153 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -5,17 +5,25 @@ import { RoomProvider } from '../../../hooks/useRoom'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useHomeRooms } from './useHomeRooms'; +import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { const mx = useMatrixClient(); const rooms = useHomeRooms(); - const { roomIdOrAlias } = useParams(); + const { roomIdOrAlias, eventId } = useParams(); + const viaServers = useSearchParamsViaServers(); const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); if (!room || !rooms.includes(room.roomId)) { - return ; + return ( + + ); } return ( diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index a01ecb8e..3425b519 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -24,9 +24,10 @@ import { } from 'matrix-js-sdk'; import { useVirtualizer } from '@tanstack/react-virtual'; import { HTMLReactParserOptions } from 'html-react-parser'; +import { Opts as LinkifyOpts } from 'linkifyjs'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getMxIdLocalPart, isRoomId, isUserId } from '../../../utils/matrix'; +import { getMxIdLocalPart } from '../../../utils/matrix'; import { InboxNotificationsPathSearchParams } from '../../paths'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { SequenceCard } from '../../../components/sequence-card'; @@ -52,8 +53,13 @@ import { Username, } from '../../../components/message'; import colorMXID from '../../../../util/colorMXID'; -import { getReactCustomHtmlParser } from '../../../plugins/react-custom-html-parser'; -import { openJoinAlias, openProfileViewer } from '../../../../client/action/navigation'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '../../../plugins/react-custom-html-parser'; import { RenderMessageContent } from '../../../components/RenderMessageContent'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; @@ -70,6 +76,8 @@ import { ContainerColor } from '../../../styles/ContainerColor.css'; import { VirtualTile } from '../../../components/virtualizer'; import { UserAvatar } from '../../../components/user-avatar'; import { EncryptedContent } from '../../../features/room/message'; +import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; type RoomNotificationsGroup = { roomId: string; @@ -181,36 +189,26 @@ function RoomNotificationsGroupComp({ }: RoomNotificationsGroupProps) { const mx = useMatrixClient(); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); - const { navigateRoom, navigateSpace } = useRoomNavigate(); + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href) => + renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) + ), + }), + [mx, room, mentionClickHandler] + ); const htmlReactParserOptions = useMemo( () => - getReactCustomHtmlParser(mx, room, { - handleSpoilerClick: (evt) => { - const target = evt.currentTarget; - if (target.getAttribute('aria-pressed') === 'true') { - evt.stopPropagation(); - target.setAttribute('aria-pressed', 'false'); - target.style.cursor = 'initial'; - } - }, - handleMentionClick: (evt) => { - const target = evt.currentTarget; - const mentionId = target.getAttribute('data-mention-id'); - if (typeof mentionId !== 'string') return; - if (isUserId(mentionId)) { - openProfileViewer(mentionId, room.roomId); - return; - } - if (isRoomId(mentionId) && mx.getRoom(mentionId)) { - if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId); - else navigateRoom(mentionId); - return; - } - openJoinAlias(mentionId); - }, + getReactCustomHtmlParser(mx, room.roomId, { + linkifyOpts, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, }), - [mx, room, navigateRoom, navigateSpace] + [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler] ); const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>( @@ -229,6 +227,7 @@ function RoomNotificationsGroupComp({ mediaAutoLoad={mediaAutoLoad} urlPreview={urlPreview} htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} outlineAttachment /> ); @@ -287,6 +286,7 @@ function RoomNotificationsGroupComp({ mediaAutoLoad={mediaAutoLoad} urlPreview={urlPreview} htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> ); } diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 7b3e61e7..f14976fb 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -47,13 +47,7 @@ import { import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { - getOriginBaseUrl, - getSpaceLobbyPath, - getSpacePath, - joinPathComponent, - withOriginBaseUrl, -} from '../../pathUtils'; +import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils'; import { SidebarAvatar, SidebarItem, @@ -67,7 +61,7 @@ import { import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider'; import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace'; import { UnreadBadge } from '../../../components/unread-badge'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; +import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; import { RoomAvatar } from '../../../components/room-avatar'; import { nameInitials, randomStr } from '../../../utils/common'; import { @@ -83,7 +77,6 @@ import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder'; -import { useClientConfig } from '../../../hooks/useClientConfig'; import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; import { useRoomsUnread } from '../../../state/hooks/unread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; @@ -91,6 +84,8 @@ import { markAsRead } from '../../../../client/action/notifications'; import { copyToClipboard } from '../../../utils/dom'; import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation'; import { stopPropagation } from '../../../utils/keyboard'; +import { getMatrixToRoom } from '../../../plugins/matrix-to'; +import { getViaServers } from '../../../plugins/via-servers'; type SpaceMenuProps = { room: Room; @@ -100,7 +95,6 @@ type SpaceMenuProps = { const SpaceMenu = forwardRef( ({ room, requestClose, onUnpin }, ref) => { const mx = useMatrixClient(); - const { hashRouter } = useClientConfig(); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); @@ -124,8 +118,9 @@ const SpaceMenu = forwardRef( }; const handleCopyLink = () => { - const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, room.roomId)); - copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), spacePath)); + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); requestClose(); }; diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx index 1105e220..0f13f933 100644 --- a/src/app/pages/client/space/RoomProvider.tsx +++ b/src/app/pages/client/space/RoomProvider.tsx @@ -9,6 +9,7 @@ import { useSpace } from '../../../hooks/useSpace'; import { getAllParents } from '../../../utils/room'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { const mx = useMatrixClient(); @@ -16,7 +17,8 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { const roomToParents = useAtomValue(roomToParentsAtom); const allRooms = useAtomValue(allRoomsAtom); - const { roomIdOrAlias } = useParams(); + const { roomIdOrAlias, eventId } = useParams(); + const viaServers = useSearchParamsViaServers(); const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); @@ -26,7 +28,13 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { !allRooms.includes(room.roomId) || !getAllParents(roomToParents, room.roomId).has(space.roomId) ) { - return ; + return ( + + ); } return ( diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index e280c603..d3dc0be7 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -34,15 +34,8 @@ import { NavItemContent, NavLink, } from '../../../components/nav'; -import { - getOriginBaseUrl, - getSpaceLobbyPath, - getSpacePath, - getSpaceRoomPath, - getSpaceSearchPath, - withOriginBaseUrl, -} from '../../pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; +import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils'; +import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSpaceLobbySelected, @@ -69,11 +62,12 @@ import { useRoomsUnread } from '../../../state/hooks/unread'; import { UseStateProvider } from '../../../components/UseStateProvider'; import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; import { copyToClipboard } from '../../../utils/dom'; -import { useClientConfig } from '../../../hooks/useClientConfig'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { useStateEvent } from '../../../hooks/useStateEvent'; import { StateEvent } from '../../../../types/matrix/room'; import { stopPropagation } from '../../../utils/keyboard'; +import { getMatrixToRoom } from '../../../plugins/matrix-to'; +import { getViaServers } from '../../../plugins/via-servers'; type SpaceMenuProps = { room: Room; @@ -81,7 +75,6 @@ type SpaceMenuProps = { }; const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { const mx = useMatrixClient(); - const { hashRouter } = useClientConfig(); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); @@ -100,8 +93,9 @@ const SpaceMenu = forwardRef(({ room, requestClo }; const handleCopyLink = () => { - const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, room.roomId)); - copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), spacePath)); + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); requestClose(); }; diff --git a/src/app/pages/client/space/SpaceProvider.tsx b/src/app/pages/client/space/SpaceProvider.tsx index 530fc3cc..2e0f79a2 100644 --- a/src/app/pages/client/space/SpaceProvider.tsx +++ b/src/app/pages/client/space/SpaceProvider.tsx @@ -6,6 +6,7 @@ import { allRoomsAtom } from '../../../state/room-list/roomList'; import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace'; import { SpaceProvider } from '../../../hooks/useSpace'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; +import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; type RouteSpaceProviderProps = { children: ReactNode; @@ -13,13 +14,15 @@ type RouteSpaceProviderProps = { export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) { const mx = useMatrixClient(); const joinedSpaces = useSpaces(mx, allRoomsAtom); + const { spaceIdOrAlias } = useParams(); + const viaServers = useSearchParamsViaServers(); const selectedSpaceId = useSelectedSpace(); const space = mx.getRoom(selectedSpaceId); if (!space || !joinedSpaces.includes(space.roomId)) { - return ; + return ; } return ( diff --git a/src/app/pages/pathSearchParam.ts b/src/app/pages/pathSearchParam.ts new file mode 100644 index 00000000..8e4c93e5 --- /dev/null +++ b/src/app/pages/pathSearchParam.ts @@ -0,0 +1,13 @@ +import { _RoomSearchParams, DirectCreateSearchParams } from './paths'; + +type SearchParamsGetter = (searchParams: URLSearchParams) => T; + +export const getRoomSearchParams: SearchParamsGetter<_RoomSearchParams> = (searchParams) => ({ + viaServers: searchParams.get('viaServers') ?? undefined, +}); + +export const getDirectCreateSearchParams: SearchParamsGetter = ( + searchParams +) => ({ + userId: searchParams.get('userId') ?? undefined, +}); diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index fd3266e9..57750383 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -35,6 +35,11 @@ export type _SearchPathSearchParams = { senders?: string; }; export const _SEARCH_PATH = 'search/'; + +export type _RoomSearchParams = { + /* comma separated string of servers */ + viaServers?: string; +}; export const _ROOM_PATH = ':roomIdOrAlias/:eventId?/'; export const HOME_PATH = '/home/'; @@ -44,6 +49,9 @@ export const HOME_SEARCH_PATH = `/home/${_SEARCH_PATH}`; export const HOME_ROOM_PATH = `/home/${_ROOM_PATH}`; export const DIRECT_PATH = '/direct/'; +export type DirectCreateSearchParams = { + userId?: string; +}; export const DIRECT_CREATE_PATH = `/direct/${_CREATE_PATH}`; export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`; diff --git a/src/app/plugins/matrix-to.ts b/src/app/plugins/matrix-to.ts new file mode 100644 index 00000000..c9df0a87 --- /dev/null +++ b/src/app/plugins/matrix-to.ts @@ -0,0 +1,84 @@ +const MATRIX_TO_BASE = 'https://matrix.to'; + +export const getMatrixToUser = (userId: string): string => `${MATRIX_TO_BASE}/#/${userId}`; + +const withViaServers = (fragment: string, viaServers: string[]): string => + `${fragment}?${viaServers.map((server) => `via=${server}`).join('&')}`; + +export const getMatrixToRoom = (roomIdOrAlias: string, viaServers?: string[]): string => { + let fragment = roomIdOrAlias; + + if (Array.isArray(viaServers) && viaServers.length > 0) { + fragment = withViaServers(fragment, viaServers); + } + + return `${MATRIX_TO_BASE}/#/${fragment}`; +}; + +export const getMatrixToRoomEvent = ( + roomIdOrAlias: string, + eventId: string, + viaServers?: string[] +): string => { + let fragment = `${roomIdOrAlias}/${eventId}`; + + if (Array.isArray(viaServers) && viaServers.length > 0) { + fragment = withViaServers(fragment, viaServers); + } + + return `${MATRIX_TO_BASE}/#/${fragment}`; +}; + +export type MatrixToRoom = { + roomIdOrAlias: string; + viaServers?: string[]; +}; + +export type MatrixToRoomEvent = MatrixToRoom & { + eventId: string; +}; + +const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/; +export const testMatrixTo = (href: string): boolean => MATRIX_TO.test(href); + +const MATRIX_TO_USER = /^https?:\/\/matrix\.to\/#\/(@[^:\s]+:[^?/\s]+)\/?$/; +const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^:\s]+:[^?/\s]+)\/?(\?[\S]*)?$/; +const MATRIX_TO_ROOM_EVENT = + /^https?:\/\/matrix\.to\/#\/([#!][^:\s]+:[^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/; + +export const parseMatrixToUser = (href: string): string | undefined => { + const match = href.match(MATRIX_TO_USER); + if (!match) return undefined; + const userId = match[1]; + return userId; +}; + +export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => { + const match = href.match(MATRIX_TO_ROOM); + if (!match) return undefined; + + const roomIdOrAlias = match[1]; + const viaSearchStr = match[2]; + const viaServers = new URLSearchParams(viaSearchStr).getAll('via'); + + return { + roomIdOrAlias, + viaServers: viaServers.length === 0 ? undefined : viaServers, + }; +}; + +export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefined => { + const match = href.match(MATRIX_TO_ROOM_EVENT); + if (!match) return undefined; + + const roomIdOrAlias = match[1]; + const eventId = match[2]; + const viaSearchStr = match[3]; + const viaServers = new URLSearchParams(viaSearchStr).getAll('via'); + + return { + roomIdOrAlias, + eventId, + viaServers: viaServers.length === 0 ? undefined : viaServers, + }; +}; diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index a8086687..16704374 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -1,5 +1,5 @@ /* eslint-disable jsx-a11y/alt-text */ -import React, { ReactEventHandler, Suspense, lazy } from 'react'; +import React, { ComponentPropsWithoutRef, ReactEventHandler, Suspense, lazy } from 'react'; import { Element, Text as DOMText, @@ -7,18 +7,25 @@ import { attributesToProps, domToReact, } from 'html-react-parser'; -import { MatrixClient, Room } from 'matrix-js-sdk'; +import { MatrixClient } from 'matrix-js-sdk'; import classNames from 'classnames'; import { Scroll, Text } from 'folds'; -import { Opts as LinkifyOpts } from 'linkifyjs'; +import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs'; import Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; import * as css from '../styles/CustomHtml.css'; -import { getMxIdLocalPart, getCanonicalAliasRoomId } from '../utils/matrix'; +import { getMxIdLocalPart, getCanonicalAliasRoomId, isRoomAlias } from '../utils/matrix'; import { getMemberDisplayName } from '../utils/room'; import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex'; import { getHexcodeForEmoji, getShortcodeFor } from './emoji'; import { findAndReplace } from '../utils/findAndReplace'; +import { + parseMatrixToRoom, + parseMatrixToRoomEvent, + parseMatrixToUser, + testMatrixTo, +} from './matrix-to'; +import { onEnterOrSpace } from '../utils/keyboard'; const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); @@ -35,6 +42,108 @@ export const LINKIFY_OPTS: LinkifyOpts = { ignoreTags: ['span'], }; +export const makeMentionCustomProps = ( + handleMentionClick?: ReactEventHandler +): ComponentPropsWithoutRef<'a'> => ({ + style: { cursor: 'pointer' }, + target: '_blank', + rel: 'noreferrer noopener', + role: 'link', + tabIndex: handleMentionClick ? 0 : -1, + onKeyDown: handleMentionClick ? onEnterOrSpace(handleMentionClick) : undefined, + onClick: handleMentionClick, +}); + +export const renderMatrixMention = ( + mx: MatrixClient, + currentRoomId: string | undefined, + href: string, + customProps: ComponentPropsWithoutRef<'a'> +) => { + const userId = parseMatrixToUser(href); + if (userId) { + const currentRoom = mx.getRoom(currentRoomId); + + return ( + + {`@${ + (currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId) + }`} + + ); + } + + const matrixToRoom = parseMatrixToRoom(href); + if (matrixToRoom) { + const { roomIdOrAlias, viaServers } = matrixToRoom; + const mentionRoom = mx.getRoom( + isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias + ); + + return ( + + {mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias} + + ); + } + + const matrixToRoomEvent = parseMatrixToRoomEvent(href); + if (matrixToRoomEvent) { + const { roomIdOrAlias, eventId, viaServers } = matrixToRoomEvent; + const mentionRoom = mx.getRoom( + isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias + ); + + return ( + + Message: {mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias} + + ); + } + + return undefined; +}; + +export const factoryRenderLinkifyWithMention = ( + mentionRender: (href: string) => JSX.Element | undefined +): OptFn<(ir: IntermediateRepresentation) => any> => { + const render: OptFn<(ir: IntermediateRepresentation) => any> = ({ + tagName, + attributes, + content, + }) => { + if (tagName === 'a' && testMatrixTo(decodeURIComponent(attributes.href))) { + const mention = mentionRender(decodeURIComponent(attributes.href)); + if (mention) return mention; + } + + return {content}; + }; + return render; +}; + export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] => findAndReplace( text, @@ -76,8 +185,9 @@ export const highlightText = ( export const getReactCustomHtmlParser = ( mx: MatrixClient, - room: Room, + roomId: string | undefined, params: { + linkifyOpts: LinkifyOpts; highlightRegex?: RegExp; handleSpoilerClick?: ReactEventHandler; handleMentionClick?: ReactEventHandler; @@ -215,54 +325,14 @@ export const getReactCustomHtmlParser = ( } } - if (name === 'a') { - const mention = decodeURIComponent(props.href).match( - /^https?:\/\/matrix.to\/#\/((@|#|!).+:[^?/]+)/ + if (name === 'a' && testMatrixTo(decodeURIComponent(props.href))) { + const mention = renderMatrixMention( + mx, + roomId, + decodeURIComponent(props.href), + makeMentionCustomProps(params.handleMentionClick) ); - if (mention) { - // convert mention link to pill - const mentionId = mention[1]; - const mentionPrefix = mention[2]; - if (mentionPrefix === '#' || mentionPrefix === '!') { - const mentionRoom = mx.getRoom( - mentionPrefix === '#' ? getCanonicalAliasRoomId(mx, mentionId) : mentionId - ); - - return ( - - {domToReact(children, opts)} - - ); - } - if (mentionPrefix === '@') - return ( - - {`@${getMemberDisplayName(room, mentionId) ?? getMxIdLocalPart(mentionId)}`} - - ); - } + if (mention) return mention; } if (name === 'span' && 'data-mx-spoiler' in props) { @@ -316,7 +386,7 @@ export const getReactCustomHtmlParser = ( } if (linkify) { - return {jsx}; + return {jsx}; } return jsx; } diff --git a/src/app/plugins/via-servers.ts b/src/app/plugins/via-servers.ts new file mode 100644 index 00000000..75470999 --- /dev/null +++ b/src/app/plugins/via-servers.ts @@ -0,0 +1,65 @@ +import { Room } from 'matrix-js-sdk'; +import { IPowerLevels } from '../hooks/usePowerLevels'; +import { getMxIdServer } from '../utils/matrix'; +import { StateEvent } from '../../types/matrix/room'; +import { getStateEvent } from '../utils/room'; + +export const getViaServers = (room: Room): string[] => { + const getHighestPowerUserId = (): string | undefined => { + const powerLevels = getStateEvent(room, StateEvent.RoomPowerLevels)?.getContent(); + + if (!powerLevels) return undefined; + const userIdToPower = powerLevels.users; + if (!userIdToPower) return undefined; + let powerUserId: string | undefined; + + Object.keys(userIdToPower).forEach((userId) => { + if (userIdToPower[userId] <= (powerLevels.users_default ?? 0)) return; + + if (!powerUserId) { + powerUserId = userId; + return; + } + if (userIdToPower[userId] > userIdToPower[powerUserId]) { + powerUserId = userId; + } + }); + return powerUserId; + }; + + const getServerToPopulation = (): Record => { + const members = room.getMembers(); + const serverToPop: Record = {}; + + members?.forEach((member) => { + const { userId } = member; + const server = getMxIdServer(userId); + if (!server) return; + const serverPop = serverToPop[server]; + if (serverPop === undefined) { + serverToPop[server] = 1; + return; + } + serverToPop[server] = serverPop + 1; + }); + + return serverToPop; + }; + + const via: string[] = []; + const userId = getHighestPowerUserId(); + if (userId) { + const server = getMxIdServer(userId); + if (server) via.push(server); + } + const serverToPop = getServerToPopulation(); + const sortedServers = Object.keys(serverToPop).sort( + (svrA, svrB) => serverToPop[svrB] - serverToPop[svrA] + ); + const mostPop3 = sortedServers.slice(0, 3); + if (via.length === 0) return mostPop3; + if (mostPop3.includes(via[0])) { + mostPop3.splice(mostPop3.indexOf(via[0]), 1); + } + return via.concat(mostPop3.slice(0, 2)); +}; diff --git a/src/app/utils/keyboard.ts b/src/app/utils/keyboard.ts index da3fe8cb..46a951ff 100644 --- a/src/app/utils/keyboard.ts +++ b/src/app/utils/keyboard.ts @@ -24,12 +24,14 @@ export const preventScrollWithArrowKey: KeyboardEventHandler = (evt) => { } }; -export const onEnterOrSpace = (callback: () => void) => (evt: KeyboardEventLike) => { - if (isKeyHotkey('enter', evt) || isKeyHotkey('space', evt)) { - evt.preventDefault(); - callback(); - } -}; +export const onEnterOrSpace = + (callback: (evt: T) => void) => + (evt: KeyboardEventLike) => { + if (isKeyHotkey('enter', evt) || isKeyHotkey('space', evt)) { + evt.preventDefault(); + callback(evt as T); + } + }; export const stopPropagation = (evt: KeyboardEvent): boolean => { evt.stopPropagation(); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 278bb46b..f837ed45 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -32,15 +32,6 @@ export const isRoomId = (id: string): boolean => validMxId(id) && id.startsWith( export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#'); -export const parseMatrixToUrl = (url: string): [string | undefined, string | undefined] => { - const href = decodeURIComponent(url); - - const match = href.match(/^https?:\/\/matrix.to\/#\/([@!$+#]\S+:[^\\?|^\s|^\\/]+)(\?(via=\S+))?/); - if (!match) return [undefined, undefined]; - const [, g1AsMxId, , g3AsVia] = match; - return [g1AsMxId, g3AsVia]; -}; - export const getCanonicalAliasRoomId = (mx: MatrixClient, alias: string): string | undefined => mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias)?.roomId; diff --git a/src/util/matrixUtil.js b/src/util/matrixUtil.js index e4fd40c3..9f1d9421 100644 --- a/src/util/matrixUtil.js +++ b/src/util/matrixUtil.js @@ -95,67 +95,11 @@ export function joinRuleToIconSrc(joinRule, isSpace) { }[joinRule]?.() || null); } -// NOTE: it gives userId with minimum power level 50; -function getHighestPowerUserId(room) { - const userIdToPower = room.currentState.getStateEvents('m.room.power_levels', '')?.getContent().users; - let powerUserId = null; - if (!userIdToPower) return powerUserId; - - Object.keys(userIdToPower).forEach((userId) => { - if (userIdToPower[userId] < 50) return; - if (powerUserId === null) { - powerUserId = userId; - return; - } - if (userIdToPower[userId] > userIdToPower[powerUserId]) { - powerUserId = userId; - } - }); - return powerUserId; -} - export function getIdServer(userId) { const idParts = userId.split(':'); return idParts[1]; } -export function getServerToPopulation(room) { - const members = room.getMembers(); - const serverToPop = {}; - - members?.forEach((member) => { - const { userId } = member; - const server = getIdServer(userId); - const serverPop = serverToPop[server]; - if (serverPop === undefined) { - serverToPop[server] = 1; - return; - } - serverToPop[server] = serverPop + 1; - }); - - return serverToPop; -} - -export function genRoomVia(room) { - const via = []; - const userId = getHighestPowerUserId(room); - if (userId) { - const server = getIdServer(userId); - if (server) via.push(server); - } - const serverToPop = getServerToPopulation(room); - const sortedServers = Object.keys(serverToPop).sort( - (svrA, svrB) => serverToPop[svrB] - serverToPop[svrA], - ); - const mostPop3 = sortedServers.slice(0, 3); - if (via.length === 0) return mostPop3; - if (mostPop3.includes(via[0])) { - mostPop3.splice(mostPop3.indexOf(via[0]), 1); - } - return via.concat(mostPop3.slice(0, 2)); -} - export function isCrossVerified(mx, deviceId) { try { const crossSignInfo = mx.getStoredCrossSigningForUser(mx.getUserId());