web: add matrix: URI handler

Fixes #509
This commit is contained in:
Tulir Asokan 2024-12-06 15:52:35 +02:00
parent a3873643ec
commit bf7769ee95
8 changed files with 129 additions and 23 deletions

View file

@ -13,6 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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 {

View file

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

View file

@ -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<RoomStateStore | null>(null)
const [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(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 && <>
<div className="room-view placeholder"/>
{resizeHandle2}
{rightPanel && <RightPanel {...rightPanel}/>}
</>}

View file

@ -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 = <button
data-target-panel="members"
onClick={use(MainScreenContext).clickRightPanelOpener}
onClick={mainScreen.clickRightPanelOpener}
><BackIcon/></button>
}
return <div className="right-panel">
@ -72,7 +73,7 @@ const RightPanel = (props: RightPanelProps) => {
{backButton}
<div className="panel-name">{getTitle(props.type)}</div>
</div>
<button onClick={use(MainScreenContext).closeRightPanel}><CloseIcon/></button>
<button onClick={mainScreen.closeRightPanel}><CloseIcon/></button>
</div>
<div className={`right-panel-content ${props.type}`}>
{renderRightPanelContent(props)}

View file

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

View file

@ -357,7 +357,13 @@ const SettingsView = ({ room }: SettingsViewProps) => {
</table>
<CustomCSSInput setPref={setPref} room={room} />
<AppliedSettingsView room={room} />
<button className="logout" onClick={onClickLogout}>Logout</button>
<div className="misc-buttons">
{window.Notification && <button onClick={client.requestNotificationPermission}>
Request notification permission
</button>}
<button onClick={client.registerURIHandler}>Register <code>matrix:</code> URI handler</button>
<button className="logout" onClick={onClickLogout}>Logout</button>
</div>
</>
}

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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}`),
)
}
}

View file

@ -39,6 +39,44 @@ export const isRoomID = (roomID: unknown) => isIdentifier<RoomID>(roomID, "!", t
export const isRoomAlias = (roomAlias: unknown) => isIdentifier<RoomAlias>(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<ParsedMatrixURI> = {
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)