support matrix.to links (#1849)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Tue, 30 Jul 2024 12:18:59 +0000 (17:48 +0530)
committerGitHub <noreply@github.com>
Tue, 30 Jul 2024 12:18:59 +0000 (22:18 +1000)
* 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

38 files changed:
src/app/components/RenderMessageContent.tsx
src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx
src/app/components/editor/input.ts
src/app/components/editor/output.ts
src/app/components/editor/slate.d.ts
src/app/components/editor/utils.ts
src/app/components/message/RenderBody.tsx
src/app/components/room-card/RoomCard.tsx
src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
src/app/features/message-search/SearchResultGroup.tsx
src/app/features/room-nav/RoomNavItem.tsx
src/app/features/room/RoomTimeline.tsx
src/app/features/room/RoomTombstone.tsx
src/app/features/room/RoomViewHeader.tsx
src/app/features/room/message/Message.tsx
src/app/hooks/router/useSearchParamsViaServers.ts [new file with mode: 0644]
src/app/hooks/useMentionClickHandler.ts [new file with mode: 0644]
src/app/hooks/useRoomNavigate.ts
src/app/hooks/useSpoilerClickHandler.ts [new file with mode: 0644]
src/app/molecules/space-add-existing/SpaceAddExisting.jsx
src/app/pages/Router.tsx
src/app/pages/client/direct/DirectCreate.tsx [new file with mode: 0644]
src/app/pages/client/direct/RoomProvider.tsx
src/app/pages/client/direct/index.ts
src/app/pages/client/home/RoomProvider.tsx
src/app/pages/client/inbox/Notifications.tsx
src/app/pages/client/sidebar/SpaceTabs.tsx
src/app/pages/client/space/RoomProvider.tsx
src/app/pages/client/space/Space.tsx
src/app/pages/client/space/SpaceProvider.tsx
src/app/pages/pathSearchParam.ts [new file with mode: 0644]
src/app/pages/paths.ts
src/app/plugins/matrix-to.ts [new file with mode: 0644]
src/app/plugins/react-custom-html-parser.tsx
src/app/plugins/via-servers.ts [new file with mode: 0644]
src/app/utils/keyboard.ts
src/app/utils/matrix.ts
src/util/matrixUtil.js

index 60e03313571767382eeeb98b788a721a381919d4..1ce37e5ca3432e23327746d5e55b7ef1b3bfdcff 100644 (file)
@@ -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 (
+      <UrlPreviewHolder>
+        {filteredUrls.map((url) => (
+          <UrlPreviewCard key={url} url={url} ts={ts} />
+        ))}
+      </UrlPreviewHolder>
+    );
+  };
+
   const renderFile = () => (
     <MFile
       content={getContent()}
@@ -95,19 +111,10 @@ export function RenderMessageContent({
             {...props}
             highlightRegex={highlightRegex}
             htmlReactParserOptions={htmlReactParserOptions}
+            linkifyOpts={linkifyOpts}
           />
         )}
-        renderUrlsPreview={
-          urlPreview
-            ? (urls) => (
-                <UrlPreviewHolder>
-                  {urls.map((url) => (
-                    <UrlPreviewCard key={url} url={url} ts={ts} />
-                  ))}
-                </UrlPreviewHolder>
-              )
-            : undefined
-        }
+        renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
       />
     );
   }
@@ -123,19 +130,10 @@ export function RenderMessageContent({
             {...props}
             highlightRegex={highlightRegex}
             htmlReactParserOptions={htmlReactParserOptions}
+            linkifyOpts={linkifyOpts}
           />
         )}
-        renderUrlsPreview={
-          urlPreview
-            ? (urls) => (
-                <UrlPreviewHolder>
-                  {urls.map((url) => (
-                    <UrlPreviewCard key={url} url={url} ts={ts} />
-                  ))}
-                </UrlPreviewHolder>
-              )
-            : undefined
-        }
+        renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
       />
     );
   }
@@ -150,19 +148,10 @@ export function RenderMessageContent({
             {...props}
             highlightRegex={highlightRegex}
             htmlReactParserOptions={htmlReactParserOptions}
+            linkifyOpts={linkifyOpts}
           />
         )}
-        renderUrlsPreview={
-          urlPreview
-            ? (urls) => (
-                <UrlPreviewHolder>
-                  {urls.map((url) => (
-                    <UrlPreviewCard key={url} url={url} ts={ts} />
-                  ))}
-                </UrlPreviewHolder>
-              )
-            : undefined
-        }
+        renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
       />
     );
   }
index 439d98ca0956fc158058663f7065e7d746d739c8..049be94a0494eb283ad02f8755f5663ad5c8bf1d 100644 (file)
@@ -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);
index 272b9707abb7af90550bfdb3f7d6b86436cccd65..29e5bd6a2abc368b7c7d5ebe8a6732b88b38c174 100644 (file)
@@ -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<string, MarkType> = {
   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;
index 53ee6ddf5c5b51d60198a29293731da678ccfd5b..864aee3d2564dc92227fab3fdbe5bc61250a3772 100644 (file)
@@ -51,10 +51,19 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
     case BlockType.UnorderedList:
       return `<ul>${children}</ul>`;
 
-    case BlockType.Mention:
-      return `<a href="https://matrix.to/#/${encodeURIComponent(node.id)}">${sanitizeText(
-        node.name
-      )}</a>`;
+    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 `<a href="${encodeURIComponent(matrixTo)}">${sanitizeText(node.name)}</a>`;
+    }
     case BlockType.Emoticon:
       return node.key.startsWith('mxc://')
         ? `<img data-mx-emoticon src="${node.key}" alt="${sanitizeText(
index 1b08ae884c2cd74099c7c41111eb66b76917cc8c..da1460e5f54c20c16f9a3a8d5646b0c29de76cf0 100644 (file)
@@ -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[];
index 3f4f9547badddc3a9c9b6cff7b04e3422d92392f..90c549c846e77ceb14bbf5279f8027f313f97234 100644 (file)
@@ -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: '' }],
index b5b517e1359bc1b7a70bf542a1c0bc51949b6db5..6db9ee4861a3b71cfba0b97d7ecc698e8689fd47 100644 (file)
@@ -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)}
index 79dd87db2c93f8a65b6daff18e6aaee1b274af9e..2bb10e3c34f49a6bcb0b4c7d75c681858be14d7b 100644 (file)
@@ -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;
index 2b9c3e50b43374fd276e0271db75149ed01bba2e..1cec659929ca8cc8f29d25556a3ce440d355751e 100644 (file)
@@ -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} />
                   )}
index 6f84f621011862ca8274d72c8fd65b6f276f51cd..2b2a816a5777c9f8b1426edce28955fa53d3b075 100644 (file)
@@ -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
           />
index 281c5b777b6843a4da48629c62f21f8895c0f0dc..aa7b468efe7923b707dbc254cea02e6e8d1bce12 100644 (file)
@@ -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>
             }
           >
index b9bfb8434cd687228aae89398e9d6214b8adc9c5..6e503703817e2a2956e32c4d75014cae08d59542 100644 (file)
@@ -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}
                     />
                   );
index e3f8251f4b3bea0201c657657ccbcec41b0d28a9..24f0b80e20cdec9760476aae9e27f752b5dc1bfc 100644 (file)
@@ -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,
       });
index 6750f92370d075efe86bb51a45a50a350fcf3a56..709f66c8e112e751cc2d3d363d0d427b592e4220 100644 (file)
@@ -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="Column" gap="100" style={{ padding: config.space.S100 }}>
-          <MenuItem
-            onClick={handleMarkAsRead}
-            size="300"
-            after={<Icon size="100" src={Icons.CheckTwice} />}
-            radii="300"
-            disabled={!unread}
-          >
-            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
-              Mark as Read
-            </Text>
-          </MenuItem>
-        </Box>
-        <Line variant="Surface" size="300" />
-        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
-          <MenuItem
-            onClick={handleInvite}
-            variant="Primary"
-            fill="None"
-            size="300"
-            after={<Icon size="100" src={Icons.UserPlus} />}
-            radii="300"
-            disabled={!canInvite}
-          >
-            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
-              Invite
-            </Text>
-          </MenuItem>
-          <MenuItem
-            onClick={handleCopyLink}
-            size="300"
-            after={<Icon size="100" src={Icons.Link} />}
-            radii="300"
-          >
-            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
-              Copy Link
-            </Text>
-          </MenuItem>
-          <MenuItem
-            onClick={handleRoomSettings}
-            size="300"
-            after={<Icon size="100" src={Icons.Setting} />}
-            radii="300"
-          >
-            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
-              Room Settings
-            </Text>
-          </MenuItem>
-        </Box>
-        <Line variant="Surface" size="300" />
-        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
-          <UseStateProvider initial={false}>
-            {(promptLeave, setPromptLeave) => (
-              <>
-                <MenuItem
-                  onClick={() => setPromptLeave(true)}
-                  variant="Critical"
-                  fill="None"
-                  size="300"
-                  after={<Icon size="100" src={Icons.ArrowGoLeft} />}
-                  radii="300"
-                  aria-pressed={promptLeave}
-                >
-                  <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
-                    Leave Room
-                  </Text>
-                </MenuItem>
-                {promptLeave && (
-                  <LeaveRoomPrompt
-                    roomId={room.roomId}
-                    onDone={requestClose}
-                    onCancel={() => setPromptLeave(false)}
-                  />
-                )}
-              </>
-            )}
-          </UseStateProvider>
-        </Box>
-      </Menu>
-    );
-  }
-);
+  return (
+    <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        <MenuItem
+          onClick={handleMarkAsRead}
+          size="300"
+          after={<Icon size="100" src={Icons.CheckTwice} />}
+          radii="300"
+          disabled={!unread}
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Mark as Read
+          </Text>
+        </MenuItem>
+      </Box>
+      <Line variant="Surface" size="300" />
+      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        <MenuItem
+          onClick={handleInvite}
+          variant="Primary"
+          fill="None"
+          size="300"
+          after={<Icon size="100" src={Icons.UserPlus} />}
+          radii="300"
+          disabled={!canInvite}
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Invite
+          </Text>
+        </MenuItem>
+        <MenuItem
+          onClick={handleCopyLink}
+          size="300"
+          after={<Icon size="100" src={Icons.Link} />}
+          radii="300"
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Copy Link
+          </Text>
+        </MenuItem>
+        <MenuItem
+          onClick={handleRoomSettings}
+          size="300"
+          after={<Icon size="100" src={Icons.Setting} />}
+          radii="300"
+        >
+          <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+            Room Settings
+          </Text>
+        </MenuItem>
+      </Box>
+      <Line variant="Surface" size="300" />
+      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        <UseStateProvider initial={false}>
+          {(promptLeave, setPromptLeave) => (
+            <>
+              <MenuItem
+                onClick={() => setPromptLeave(true)}
+                variant="Critical"
+                fill="None"
+                size="300"
+                after={<Icon size="100" src={Icons.ArrowGoLeft} />}
+                radii="300"
+                aria-pressed={promptLeave}
+              >
+                <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
+                  Leave Room
+                </Text>
+              </MenuItem>
+              {promptLeave && (
+                <LeaveRoomPrompt
+                  roomId={room.roomId}
+                  onDone={requestClose}
+                  onCancel={() => setPromptLeave(false)}
+                />
+              )}
+            </>
+          )}
+        </UseStateProvider>
+      </Box>
+    </Menu>
+  );
+});
 
 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,
                 }}
               >
-                <RoomMenu
-                  room={room}
-                  linkPath={currentPath}
-                  requestClose={() => setMenuAnchor(undefined)}
-                />
+                <RoomMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
               </FocusTrap>
             }
           />
index 6db366ac44eea934cd8f4647aaa06406557f8461..d8b2b3e56fdbd3f5263359d8472258f84e30e925 100644 (file)
@@ -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 (file)
index 0000000..0b1b2db
--- /dev/null
@@ -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 (file)
index 0000000..f8f4bf5
--- /dev/null
@@ -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<HTMLElement> => {
+  const mx = useMatrixClient();
+  const { navigateRoom, navigateSpace } = useRoomNavigate();
+  const navigate = useNavigate();
+
+  const handleClick: ReactEventHandler<HTMLElement> = 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;
+};
index 55528e7ee892777b56a7b6b82d42e32ce17c640f..0f9f365cd4947b97d4f80e77b6e5938b795fa6d7 100644 (file)
@@ -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 (file)
index 0000000..b210118
--- /dev/null
@@ -0,0 +1,14 @@
+import { ReactEventHandler, useCallback } from 'react';
+
+export const useSpoilerClickHandler = (): ReactEventHandler<HTMLElement> => {
+  const handleClick: ReactEventHandler<HTMLElement> = 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;
+};
index ff338f3f8dc49b3bd358e042fb4ae68cea37fbac..83b967bcbdf5b5476a6184d702e202e3bf25d340 100644 (file)
@@ -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));
       }
index 7d0f4fdeb1cc302c50a13f875bc446f81e090431..88fa9932599bb86be08d789c067b90b2c963b976 100644 (file)
@@ -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 : <Route index element={<WelcomePage />} />}
-          <Route path={_CREATE_PATH} element={<p>create</p>} />
+          <Route path={_CREATE_PATH} element={<DirectCreate />} />
           <Route
             path={_ROOM_PATH}
             element={
diff --git a/src/app/pages/client/direct/DirectCreate.tsx b/src/app/pages/client/direct/DirectCreate.tsx
new file mode 100644 (file)
index 0000000..3affb9c
--- /dev/null
@@ -0,0 +1,33 @@
+import React, { useEffect } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { WelcomePage } from '../WelcomePage';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getDirectCreateSearchParams } from '../../pathSearchParam';
+import { getDirectPath, getDirectRoomPath } from '../../pathUtils';
+import { getDMRoomFor } from '../../../utils/matrix';
+import { openInviteUser } from '../../../../client/action/navigation';
+import { useDirectRooms } from './useDirectRooms';
+
+export function DirectCreate() {
+  const mx = useMatrixClient();
+  const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
+  const { userId } = getDirectCreateSearchParams(searchParams);
+  const directs = useDirectRooms();
+
+  useEffect(() => {
+    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 <WelcomePage />;
+}
index c78a8f44e975245ed00e51bbeb88ad75550f4ccf..ca45aa19c237cb79cb282800cf6f5e9f5c83bbaa 100644 (file)
@@ -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 <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
+    return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} eventId={eventId} />;
   }
 
   return (
index 36f44d63e0311ee5fd04fb4250e1d1511da29c1b..d247bbc03ae384101c16bb0bc04ead309cf48bbb 100644 (file)
@@ -1,2 +1,3 @@
 export * from './Direct';
 export * from './RoomProvider';
+export * from './DirectCreate';
index 282cee7dd4787120e0673bafef8776213b261902..aa14d1531ca952dbd3b29cd97d8c52ab508e806d 100644 (file)
@@ -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 <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
+    return (
+      <JoinBeforeNavigate
+        roomIdOrAlias={roomIdOrAlias!}
+        eventId={eventId}
+        viaServers={viaServers}
+      />
+    );
   }
 
   return (
index a01ecb8e0518e584361acce952e036d1b312ffbe..3425b51940e75204dcab74d90e1f1e42474f9706 100644 (file)
@@ -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<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, 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}
                   />
                 );
               }
index 7b3e61e7825e4f2e9aa400b4a6a5020ee5f925b8..f14976fbc9d4316df7679030a4d847ac3f42c82e 100644 (file)
@@ -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<HTMLDivElement, SpaceMenuProps>(
   ({ 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<HTMLDivElement, SpaceMenuProps>(
     };
 
     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();
     };
 
index 1105e220ace68655532c8ed0fd9d221f06752a01..0f13f933687afed6e9f9723f10b05242e0687e8a 100644 (file)
@@ -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 <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
+    return (
+      <JoinBeforeNavigate
+        roomIdOrAlias={roomIdOrAlias!}
+        eventId={eventId}
+        viaServers={viaServers}
+      />
+    );
   }
 
   return (
index e280c603e50640b79b5b2cb6bec6874a5efc241c..d3dc0be7f0eb2c510cd672850ae0fa37dfc4b24f 100644 (file)
@@ -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<HTMLDivElement, SpaceMenuProps>(({ 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<HTMLDivElement, SpaceMenuProps>(({ 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();
   };
 
index 530fc3cca04dee43a756678507d587158386fa6b..2e0f79a22811c87d1281ee314f190e01b2eb3855 100644 (file)
@@ -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 <JoinBeforeNavigate roomIdOrAlias={spaceIdOrAlias ?? ''} />;
+    return <JoinBeforeNavigate roomIdOrAlias={spaceIdOrAlias ?? ''} viaServers={viaServers} />;
   }
 
   return (
diff --git a/src/app/pages/pathSearchParam.ts b/src/app/pages/pathSearchParam.ts
new file mode 100644 (file)
index 0000000..8e4c93e
--- /dev/null
@@ -0,0 +1,13 @@
+import { _RoomSearchParams, DirectCreateSearchParams } from './paths';
+
+type SearchParamsGetter<T> = (searchParams: URLSearchParams) => T;
+
+export const getRoomSearchParams: SearchParamsGetter<_RoomSearchParams> = (searchParams) => ({
+  viaServers: searchParams.get('viaServers') ?? undefined,
+});
+
+export const getDirectCreateSearchParams: SearchParamsGetter<DirectCreateSearchParams> = (
+  searchParams
+) => ({
+  userId: searchParams.get('userId') ?? undefined,
+});
index fd3266e936a12e8157df6e4ee369881a3b56e340..57750383a515fabd0e297bf57123b413d5b6f9b7 100644 (file)
@@ -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 (file)
index 0000000..c9df0a8
--- /dev/null
@@ -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,
+  };
+};
index a8086687a48a68f31b338bd65995f4e573f65338..1670437452b74a8b2fda6a5ee24180072385f9d0 100644 (file)
@@ -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<HTMLElement>
+): 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 (
+      <a
+        href={href}
+        {...customProps}
+        className={css.Mention({ highlight: mx.getUserId() === userId })}
+        data-mention-id={userId}
+      >
+        {`@${
+          (currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId)
+        }`}
+      </a>
+    );
+  }
+
+  const matrixToRoom = parseMatrixToRoom(href);
+  if (matrixToRoom) {
+    const { roomIdOrAlias, viaServers } = matrixToRoom;
+    const mentionRoom = mx.getRoom(
+      isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias
+    );
+
+    return (
+      <a
+        href={href}
+        {...customProps}
+        className={css.Mention({
+          highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias),
+        })}
+        data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
+        data-mention-via={viaServers?.join(',')}
+      >
+        {mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias}
+      </a>
+    );
+  }
+
+  const matrixToRoomEvent = parseMatrixToRoomEvent(href);
+  if (matrixToRoomEvent) {
+    const { roomIdOrAlias, eventId, viaServers } = matrixToRoomEvent;
+    const mentionRoom = mx.getRoom(
+      isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias
+    );
+
+    return (
+      <a
+        href={href}
+        {...customProps}
+        className={css.Mention({
+          highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias),
+        })}
+        data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
+        data-mention-event-id={eventId}
+        data-mention-via={viaServers?.join(',')}
+      >
+        Message: {mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias}
+      </a>
+    );
+  }
+
+  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 <a {...attributes}>{content}</a>;
+  };
+  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<HTMLElement>;
     handleMentionClick?: ReactEventHandler<HTMLElement>;
@@ -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 (
-                <span
-                  {...props}
-                  className={css.Mention({
-                    highlight: room.roomId === (mentionRoom?.roomId ?? mentionId),
-                  })}
-                  data-mention-id={mentionRoom?.roomId ?? mentionId}
-                  data-mention-href={props.href}
-                  role="button"
-                  tabIndex={params.handleMentionClick ? 0 : -1}
-                  onKeyDown={params.handleMentionClick}
-                  onClick={params.handleMentionClick}
-                  style={{ cursor: 'pointer' }}
-                >
-                  {domToReact(children, opts)}
-                </span>
-              );
-            }
-            if (mentionPrefix === '@')
-              return (
-                <span
-                  {...props}
-                  className={css.Mention({ highlight: mx.getUserId() === mentionId })}
-                  data-mention-id={mentionId}
-                  data-mention-href={props.href}
-                  role="button"
-                  tabIndex={params.handleMentionClick ? 0 : -1}
-                  onKeyDown={params.handleMentionClick}
-                  onClick={params.handleMentionClick}
-                  style={{ cursor: 'pointer' }}
-                >
-                  {`@${getMemberDisplayName(room, mentionId) ?? getMxIdLocalPart(mentionId)}`}
-                </span>
-              );
-          }
+          if (mention) return mention;
         }
 
         if (name === 'span' && 'data-mx-spoiler' in props) {
@@ -316,7 +386,7 @@ export const getReactCustomHtmlParser = (
         }
 
         if (linkify) {
-          return <Linkify options={LINKIFY_OPTS}>{jsx}</Linkify>;
+          return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
         }
         return jsx;
       }
diff --git a/src/app/plugins/via-servers.ts b/src/app/plugins/via-servers.ts
new file mode 100644 (file)
index 0000000..7547099
--- /dev/null
@@ -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<IPowerLevels>();
+
+    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<string, number> => {
+    const members = room.getMembers();
+    const serverToPop: Record<string, number> = {};
+
+    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));
+};
index da3fe8cb5bdcf23c5775b3cb3176e451f45482b3..46a951ffc02cabbed7a632db2b7aaf75cd83b782 100644 (file)
@@ -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 =
+  <T>(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();
index 278bb46b6ff8f32b81adaec8a5c406bf7461fe58..f837ed459c71ef304248d20806573251b5ded581 100644 (file)
@@ -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;
 
index e4fd40c3971c04296eb4607a711c69a09398a3ac..9f1d9421269b84162cfda78acf11db80f7162283 100644 (file)
@@ -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());