From bf7769ee950d27524489f2af3308bd7900678f63 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 6 Dec 2024 15:52:35 +0200 Subject: [PATCH] web: add matrix: URI handler Fixes #509 --- web/src/api/client.ts | 17 +++++++- web/src/api/statestore/hooks.ts | 5 ++- web/src/ui/MainScreen.tsx | 43 +++++++++++++++++++ web/src/ui/rightpanel/RightPanel.tsx | 5 ++- web/src/ui/settings/SettingsView.css | 14 +++--- web/src/ui/settings/SettingsView.tsx | 8 +++- .../ui/timeline/content/TextMessageBody.tsx | 22 +++++----- web/src/util/validation.ts | 38 ++++++++++++++++ 8 files changed, 129 insertions(+), 23 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c31468d..07bf47c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import type { MouseEvent } from "react" import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts" import RPCClient, { SendMessageParams } from "./rpc.ts" import { RoomStateStore, StateStore } from "./statestore" @@ -66,8 +67,20 @@ export default class Client { } console.log("Successfully authenticated, connecting to websocket") this.rpc.start() - window.Notification?.requestPermission() - .then(permission => console.log("Notification permission:", permission)) + this.requestNotificationPermission() + } + + requestNotificationPermission = (evt?: MouseEvent) => { + window.Notification?.requestPermission().then(permission => { + console.log("Notification permission:", permission) + if (evt) { + window.alert(`Notification permission: ${permission}`) + } + }) + } + + registerURIHandler = () => { + navigator.registerProtocolHandler("matrix", "#/uri/%s") } start(): () => void { diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index 3de9ab5..97fc16f 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -37,15 +37,18 @@ export function useRoomState( ) } + export function useRoomMembers(room?: RoomStateStore): MemDBEvent[] { return useSyncExternalStore( room ? room.stateSubs.getSubscriber("m.room.member") : noopSubscribe, - room ? room.getMembers : () => [], + room ? room.getMembers : returnEmptyArray, ) } const noopSubscribe = () => () => {} const returnNull = () => null +const emptyArray: never[] = [] +const returnEmptyArray = () => emptyArray export function useRoomEvent(room: RoomStateStore, eventID: EventID | null): MemDBEvent | null { return useSyncExternalStore( diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 9edecdc..c0071cf 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -19,6 +19,7 @@ import Client from "@/api/client.ts" import { RoomStateStore } from "@/api/statestore" import type { RoomID } from "@/api/types" import { useEventAsState } from "@/util/eventdispatcher.ts" +import { parseMatrixURI } from "@/util/validation.ts" import ClientContext from "./ClientContext.ts" import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts" import StylePreferences from "./StylePreferences.tsx" @@ -61,6 +62,9 @@ class ContextFields implements MainScreenContextFields { } setRightPanel = (props: RightPanelProps | null, pushState = true) => { + if ((props?.type === "members" || props?.type === "pinned-messages") && !this.client.store.activeRoomID) { + props = null + } const isEqual = objectIsEqual(this.currentRightPanel, props) if (isEqual && !pushState) { return @@ -145,6 +149,42 @@ class ContextFields implements MainScreenContextFields { const SYNC_ERROR_HIDE_DELAY = 30 * 1000 +const handleURLHash = (client: Client, context: ContextFields) => { + if (!location.hash.startsWith("#/uri/")) { + return + } + const decodedURI = decodeURIComponent(location.hash.slice("#/uri/".length)) + const uri = parseMatrixURI(decodedURI) + if (!uri) { + console.error("Invalid matrix URI", decodedURI) + return + } + const newURL = new URL(location.href) + newURL.hash = "" + if (uri.identifier.startsWith("@")) { + const right_panel = { + type: "user", + userID: uri.identifier, + } as RightPanelProps + history.replaceState({ right_panel }, "", newURL.toString()) + context.setRightPanel(right_panel, false) + } else if (uri.identifier.startsWith("!")) { + history.replaceState({ room_id: uri.identifier }, "", newURL.toString()) + context.setActiveRoom(uri.identifier, false) + } else if (uri.identifier.startsWith("#")) { + // TODO loading indicator or something for this? + client.rpc.resolveAlias(uri.identifier).then( + res => { + history.replaceState({ room_id: res.room_id }, "", newURL.toString()) + context.setActiveRoom(res.room_id, false) + }, + err => window.alert(`Failed to resolve room alias ${uri.identifier}: ${err}`), + ) + } else { + console.error("Invalid matrix URI", uri) + } +} + const MainScreen = () => { const [activeRoom, directSetActiveRoom] = useState(null) const [rightPanel, directSetRightPanel] = useState(null) @@ -166,6 +206,8 @@ const MainScreen = () => { context.setRightPanel(evt.state?.right_panel ?? null, false) } window.addEventListener("popstate", listener) + listener({ state: history.state } as PopStateEvent) + handleURLHash(client, context) return () => window.removeEventListener("popstate", listener) }, [context, client]) useEffect(() => context.keybindings.listen(), [context]) @@ -228,6 +270,7 @@ const MainScreen = () => { rightPanelResizeHandle={resizeHandle2} /> : rightPanel && <> +
{resizeHandle2} {rightPanel && } } diff --git a/web/src/ui/rightpanel/RightPanel.tsx b/web/src/ui/rightpanel/RightPanel.tsx index f264181..86ce5d4 100644 --- a/web/src/ui/rightpanel/RightPanel.tsx +++ b/web/src/ui/rightpanel/RightPanel.tsx @@ -59,11 +59,12 @@ function renderRightPanelContent(props: RightPanelProps): JSX.Element | null { } const RightPanel = (props: RightPanelProps) => { + const mainScreen = use(MainScreenContext) let backButton: JSX.Element | null = null if (props.type === "user") { backButton = } return
@@ -72,7 +73,7 @@ const RightPanel = (props: RightPanelProps) => { {backButton}
{getTitle(props.type)}
- +
{renderRightPanelContent(props)} diff --git a/web/src/ui/settings/SettingsView.css b/web/src/ui/settings/SettingsView.css index 1cda143..b680e22 100644 --- a/web/src/ui/settings/SettingsView.css +++ b/web/src/ui/settings/SettingsView.css @@ -88,13 +88,17 @@ div.settings-view { } } - button.logout { - margin-top: 1rem; + > div.misc-buttons > button { padding: .5rem 1rem; + display: block; - &:hover, &:focus { - background-color: var(--error-color); - color: var(--inverted-text-color); + &.logout { + margin-top: 2rem; + + &:hover, &:focus { + background-color: var(--error-color); + color: var(--inverted-text-color); + } } } } diff --git a/web/src/ui/settings/SettingsView.tsx b/web/src/ui/settings/SettingsView.tsx index 131a542..99a8b3e 100644 --- a/web/src/ui/settings/SettingsView.tsx +++ b/web/src/ui/settings/SettingsView.tsx @@ -357,7 +357,13 @@ const SettingsView = ({ room }: SettingsViewProps) => { - +
+ {window.Notification && } + + +
} diff --git a/web/src/ui/timeline/content/TextMessageBody.tsx b/web/src/ui/timeline/content/TextMessageBody.tsx index 90655bc..e8b0a79 100644 --- a/web/src/ui/timeline/content/TextMessageBody.tsx +++ b/web/src/ui/timeline/content/TextMessageBody.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { MessageEventContent } from "@/api/types" -import { getDisplayname } from "@/util/validation.ts" +import { getDisplayname, parseMatrixURI } from "@/util/validation.ts" import EventContentProps from "./props.ts" function isImageElement(elem: EventTarget): elem is HTMLImageElement { @@ -26,21 +26,19 @@ function isAnchorElement(elem: EventTarget): elem is HTMLAnchorElement { } function onClickMatrixURI(href: string) { - const url = new URL(href) - const pathParts = url.pathname.split("/") - const decodedPart = decodeURIComponent(pathParts[1]) - switch (pathParts[0]) { - case "u": + const uri = parseMatrixURI(href) + switch (uri?.identifier[0]) { + case "@": return window.mainScreenContext.setRightPanel({ type: "user", - userID: `@${decodedPart}`, + userID: uri.identifier, }) - case "roomid": - return window.mainScreenContext.setActiveRoom(`!${decodedPart}`) - case "r": - return window.client.rpc.resolveAlias(`#${decodedPart}`).then( + case "!": + return window.mainScreenContext.setActiveRoom(uri.identifier) + case "#": + return window.client.rpc.resolveAlias(uri.identifier).then( res => window.mainScreenContext.setActiveRoom(res.room_id), - err => window.alert(`Failed to resolve room alias #${decodedPart}: ${err}`), + err => window.alert(`Failed to resolve room alias ${uri.identifier}: ${err}`), ) } } diff --git a/web/src/util/validation.ts b/web/src/util/validation.ts index 0eca5fd..8554282 100644 --- a/web/src/util/validation.ts +++ b/web/src/util/validation.ts @@ -39,6 +39,44 @@ export const isRoomID = (roomID: unknown) => isIdentifier(roomID, "!", t export const isRoomAlias = (roomAlias: unknown) => isIdentifier(roomAlias, "#", true) export const isMXC = (mxc: unknown): mxc is ContentURI => typeof mxc === "string" && mediaRegex.test(mxc) +export interface ParsedMatrixURI { + identifier: UserID | RoomID | RoomAlias + eventID?: EventID + params: URLSearchParams +} + +export function parseMatrixURI(uri: unknown): ParsedMatrixURI | undefined { + if (typeof uri !== "string") { + return + } + let parsed: URL + try { + parsed = new URL(uri) + } catch { + return + } + if (parsed.protocol !== "matrix:") { + return + } + const [type, ident1, subtype, ident2] = parsed.pathname.split("/") + const output: Partial = { + params: parsed.searchParams, + } + if (type === "u") { + output.identifier = `@${ident1}` + } else if (type === "r") { + output.identifier = `#${ident1}` + } else if (type === "roomid") { + output.identifier = `!${ident1}` + if (subtype === "e") { + output.eventID = `$${ident2}` + } + } else { + return + } + return output as ParsedMatrixURI +} + export function getLocalpart(userID: UserID): string { const idx = userID.indexOf(":") return idx > 0 ? userID.slice(1, idx) : userID.slice(1)