}
diff --git a/web/src/ui/timeline/content/MemberBody.tsx b/web/src/ui/timeline/content/MemberBody.tsx
index e86ce34..4b7c501 100644
--- a/web/src/ui/timeline/content/MemberBody.tsx
+++ b/web/src/ui/timeline/content/MemberBody.tsx
@@ -14,28 +14,45 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
import React, { use } from "react"
-import { getAvatarURL } from "@/api/media.ts"
+import { getAvatarThumbnailURL, getAvatarURL } from "@/api/media.ts"
import { MemberEventContent, UserID } from "@/api/types"
+import MainScreenContext from "../../MainScreenContext.ts"
import { LightboxContext } from "../../modal"
import EventContentProps from "./props.ts"
function useChangeDescription(
sender: UserID, target: UserID, content: MemberEventContent, prevContent?: MemberEventContent,
): string | React.ReactElement {
- const targetAvatar =
- const targetElem = <>
- {content.avatar_url && targetAvatar}
- {content.displayname ?? target}
-
- >
+ const makeTargetElem = () => {
+ return <>
+
+ {content.displayname ?? target}
+
+ >
+ }
if (content.membership === prevContent?.membership) {
- if (content.displayname !== prevContent.displayname) {
+ if (sender !== target) {
+ return <>made no change to {makeTargetElem()}>
+ } else if (content.displayname !== prevContent.displayname) {
if (content.avatar_url !== prevContent.avatar_url) {
return <>changed their displayname and avatar>
} else if (!content.displayname) {
@@ -52,41 +69,47 @@ function useChangeDescription(
if (!content.avatar_url) {
return "removed their avatar"
} else if (!prevContent.avatar_url) {
- return <>set their avatar to {targetAvatar}>
+ return <>set their avatar to {makeTargetAvatar()}>
}
return <>
changed their avatar from to {targetAvatar}
+ /> to {makeTargetAvatar()}
>
}
return "made no change"
} else if (content.membership === "join") {
return "joined the room"
} else if (content.membership === "invite") {
- return <>invited {targetElem}>
+ if (prevContent?.membership === "knock") {
+ return <>accepted {makeTargetElem()}'s join request>
+ }
+ return <>invited {makeTargetElem()}>
} else if (content.membership === "ban") {
- return <>banned {targetElem}>
+ return <>banned {makeTargetElem()}>
} else if (content.membership === "knock") {
- return "knocked on the room"
+ return "requested to join the room"
} else if (content.membership === "leave") {
if (sender === target) {
if (prevContent?.membership === "knock") {
- return "cancelled their knock"
+ return "cancelled their join request"
}
return "left the room"
}
if (prevContent?.membership === "ban") {
- return <>unbanned {targetElem}>
+ return <>unbanned {makeTargetElem()}>
} else if (prevContent?.membership === "invite") {
- return <>disinvited {targetElem}>
+ return <>disinvited {makeTargetElem()}>
+ } else if (prevContent?.membership === "knock") {
+ return <>rejected {makeTargetElem()}'s join request>
}
- return <>kicked {targetElem}>
+ return <>kicked {makeTargetElem()}>
}
return "made an unknown membership change"
}
diff --git a/web/src/ui/timeline/content/RoomAvatarBody.tsx b/web/src/ui/timeline/content/RoomAvatarBody.tsx
index 7878bbb..3b1e225 100644
--- a/web/src/ui/timeline/content/RoomAvatarBody.tsx
+++ b/web/src/ui/timeline/content/RoomAvatarBody.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 { JSX, use } from "react"
-import { getRoomAvatarURL } from "@/api/media.ts"
+import { getRoomAvatarThumbnailURL, getRoomAvatarURL } from "@/api/media.ts"
import { ContentURI, RoomAvatarEventContent } from "@/api/types"
import { ensureString } from "@/util/validation.ts"
import { LightboxContext } from "../../modal"
@@ -31,7 +31,8 @@ const RoomAvatarBody = ({ event, sender, room }: EventContentProps) => {
className="small avatar"
loading="lazy"
height={16}
- src={getRoomAvatarURL(room.meta.current, url)}
+ src={getRoomAvatarThumbnailURL(room.meta.current, url)}
+ data-full-src={getRoomAvatarURL(room.meta.current, url)}
onClick={openLightbox}
alt=""
/>
diff --git a/web/src/ui/timeline/content/TextMessageBody.tsx b/web/src/ui/timeline/content/TextMessageBody.tsx
index 03cfdd2..6002d86 100644
--- a/web/src/ui/timeline/content/TextMessageBody.tsx
+++ b/web/src/ui/timeline/content/TextMessageBody.tsx
@@ -58,9 +58,11 @@ const onClickHTML = (evt: React.MouseEvent) => {
} else if (targetElem.closest?.("span.hicli-spoiler")?.classList.toggle("spoiler-revealed")) {
// When unspoilering, don't trigger links and other clickables inside the spoiler
evt.preventDefault()
+ evt.stopPropagation()
} else if (isAnchorElement(targetElem) && targetElem.href.startsWith("matrix:")) {
onClickMatrixURI(targetElem.href)
evt.preventDefault()
+ evt.stopPropagation()
}
}
diff --git a/web/src/ui/timeline/content/index.ts b/web/src/ui/timeline/content/index.ts
index 7bd981b..c3931e9 100644
--- a/web/src/ui/timeline/content/index.ts
+++ b/web/src/ui/timeline/content/index.ts
@@ -38,62 +38,74 @@ export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionCo
if (evt.relation_type === "m.replace") {
return HiddenEvent
}
- switch (evt.type) {
- case "m.room.message":
- if (evt.redacted_by) {
- return RedactedBody
+ if (evt.state_key === "") {
+ // State events which must have an empty state key
+ switch (evt.type) {
+ case "m.room.name":
+ return RoomNameBody
+ case "m.room.avatar":
+ return RoomAvatarBody
+ case "m.room.server_acl":
+ return ACLBody
+ case "m.room.pinned_events":
+ return PinnedEventsBody
+ case "m.room.power_levels":
+ return PowerLevelBody
}
- switch (evt.content?.msgtype) {
- case "m.text":
- case "m.notice":
- case "m.emote":
- return TextMessageBody
- case "m.image":
- case "m.video":
- case "m.audio":
- case "m.file":
- if (forReply) {
+ } else if (evt.state_key !== undefined) {
+ // State events which must have a non-empty state key
+ switch (evt.type) {
+ case "m.room.member":
+ return MemberBody
+ case "m.policy.rule.user":
+ return PolicyRuleBody
+ case "m.policy.rule.room":
+ return PolicyRuleBody
+ case "m.policy.rule.server":
+ return PolicyRuleBody
+ }
+ } else {
+ const isRedacted = evt.redacted_by && !evt.viewing_redacted
+ // Non-state events
+ switch (evt.type) {
+ case "m.room.message":
+ if (isRedacted) {
+ return RedactedBody
+ }
+ switch (evt.content?.msgtype) {
+ case "m.text":
+ case "m.notice":
+ case "m.emote":
+ return TextMessageBody
+ case "m.image":
+ case "m.video":
+ case "m.audio":
+ case "m.file":
+ if (forReply) {
+ return TextMessageBody
+ }
+ return MediaMessageBody
+ case "m.location":
+ if (forReply) {
+ return TextMessageBody
+ }
+ return LocationMessageBody
+ default:
+ return UnknownMessageBody
+ }
+ case "m.sticker":
+ if (isRedacted) {
+ return RedactedBody
+ } else if (forReply) {
return TextMessageBody
}
return MediaMessageBody
- case "m.location":
- if (forReply) {
- return TextMessageBody
+ case "m.room.encrypted":
+ if (isRedacted) {
+ return RedactedBody
}
- return LocationMessageBody
- default:
- return UnknownMessageBody
+ return EncryptedBody
}
- case "m.sticker":
- if (evt.redacted_by) {
- return RedactedBody
- } else if (forReply) {
- return TextMessageBody
- }
- return MediaMessageBody
- case "m.room.encrypted":
- if (evt.redacted_by) {
- return RedactedBody
- }
- return EncryptedBody
- case "m.room.member":
- return MemberBody
- case "m.room.name":
- return RoomNameBody
- case "m.room.avatar":
- return RoomAvatarBody
- case "m.room.server_acl":
- return ACLBody
- case "m.policy.rule.user":
- return PolicyRuleBody
- case "m.policy.rule.room":
- return PolicyRuleBody
- case "m.policy.rule.server":
- return PolicyRuleBody
- case "m.room.pinned_events":
- return PinnedEventsBody
- case "m.room.power_levels":
- return PowerLevelBody
}
return HiddenEvent
}
@@ -118,5 +130,13 @@ export function getPerMessageProfile(evt: MemDBEvent | null): BeeperPerMessagePr
if (evt === null || evt.type !== "m.room.message" && evt.type !== "m.sticker") {
return undefined
}
- return (evt.content as MessageEventContent)["com.beeper.per_message_profile"]
+ const profile = (evt.content as MessageEventContent)["com.beeper.per_message_profile"]
+ if (profile?.displayname && typeof profile.displayname !== "string") {
+ return undefined
+ } else if (profile?.avatar_url && typeof profile.avatar_url !== "string") {
+ return undefined
+ } else if (profile?.id && typeof profile.id !== "string") {
+ return undefined
+ }
+ return profile
}
diff --git a/web/src/ui/util/ErrorBoundary.tsx b/web/src/ui/util/ErrorBoundary.tsx
new file mode 100644
index 0000000..1c98bc2
--- /dev/null
+++ b/web/src/ui/util/ErrorBoundary.tsx
@@ -0,0 +1,52 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React from "react"
+
+export interface ErrorBoundaryProps {
+ thing?: string
+ wrapperClassName?: string
+ children: React.ReactNode
+}
+
+export default class ErrorBoundary extends React.Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props)
+ this.state = { error: undefined }
+ }
+
+ static getDerivedStateFromError(error: unknown) {
+ return {
+ error: `${error}`.replace(/^Error: /, ""),
+ }
+ }
+
+ renderError(message: string) {
+ const inner = <>
+ Failed to render {this.props.thing ?? "component"}: {message}
+ >
+ if (this.props.wrapperClassName) {
+ return
{inner}
+ }
+ return inner
+ }
+
+ render() {
+ if (this.state.error) {
+ return this.renderError(this.state.error)
+ }
+ return this.props.children
+ }
+}
diff --git a/web/src/ui/widget/ElementCall.tsx b/web/src/ui/widget/ElementCall.tsx
new file mode 100644
index 0000000..3f66b4e
--- /dev/null
+++ b/web/src/ui/widget/ElementCall.tsx
@@ -0,0 +1,57 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2025 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import { use, useMemo } from "react"
+import { usePreference } from "@/api/statestore"
+import ClientContext from "../ClientContext"
+import { RoomContext } from "../roomview/roomcontext"
+import LazyWidget from "./LazyWidget"
+
+const elementCallParams = new URLSearchParams({
+ roomId: "$matrix_room_id",
+ theme: "$org.matrix.msc2873.client_theme",
+ userId: "$matrix_user_id",
+ deviceId: "$org.matrix.msc3819.matrix_device_id",
+ widgetId: "$matrix_widget_id",
+ perParticipantE2EE: "$perParticipantE2EE",
+ baseUrl: "$homeserverBaseURL",
+ intent: "join_existing",
+ hideHeader: "true",
+ confineToRoom: "true",
+ appPrompt: "false",
+}).toString().replaceAll("%24", "$")
+
+const ElementCall = () => {
+ const room = use(RoomContext)?.store ?? null
+ const client = use(ClientContext)!
+ const baseURL = usePreference(client.store, room, "element_call_base_url")
+ const widgetInfo = useMemo(() => ({
+ id: `fi.mau.gomuks.call.${crypto.randomUUID().replaceAll("-", "")}`,
+ creatorUserId: client.userID,
+ type: "m.call",
+ url: `${baseURL}/room?${elementCallParams}`,
+ waitForIframeLoad: false,
+ data: {
+ perParticipantE2EE: !!room?.meta.current.encryption_event,
+ homeserverBaseURL: client.state.current?.is_logged_in ? client.state.current.homeserver_url : "",
+ },
+ }), [room, client, baseURL])
+ if (!room || !client) {
+ return null
+ }
+ return
+}
+
+export default ElementCall
diff --git a/web/src/ui/widget/LazyWidget.tsx b/web/src/ui/widget/LazyWidget.tsx
new file mode 100644
index 0000000..75e981a
--- /dev/null
+++ b/web/src/ui/widget/LazyWidget.tsx
@@ -0,0 +1,46 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2025 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import type { IWidget } from "matrix-widget-api"
+import { Suspense, lazy, use } from "react"
+import { GridLoader } from "react-spinners"
+import ClientContext from "../ClientContext"
+import { RoomContext } from "../roomview/roomcontext"
+
+const Widget = lazy(() => import("./widget"))
+
+const widgetLoader =
+
+
+
+
+export interface LazyWidgetProps {
+ info: IWidget
+}
+
+const LazyWidget = ({ info }: LazyWidgetProps) => {
+ const room = use(RoomContext)?.store
+ const client = use(ClientContext)
+ if (!room || !client) {
+ return null
+ }
+ return (
+
+
+
+ )
+}
+
+export default LazyWidget
diff --git a/web/src/ui/widget/Widget.css b/web/src/ui/widget/Widget.css
new file mode 100644
index 0000000..9563544
--- /dev/null
+++ b/web/src/ui/widget/Widget.css
@@ -0,0 +1,9 @@
+div.right-panel-content.widget, div.right-panel-content.element-call {
+ overflow: hidden !important;
+
+ > iframe.widget-iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ }
+}
diff --git a/web/src/ui/widget/util.ts b/web/src/ui/widget/util.ts
new file mode 100644
index 0000000..f463e2b
--- /dev/null
+++ b/web/src/ui/widget/util.ts
@@ -0,0 +1,56 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2025 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import type { IRoomEvent } from "matrix-widget-api"
+import type { RoomStateStore } from "@/api/statestore"
+import type { MemDBEvent } from "@/api/types"
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null
+}
+
+export function notNull(value: T | null | undefined): value is T {
+ return value !== null && value !== undefined
+}
+
+export function memDBEventToIRoomEvent(evt: MemDBEvent): IRoomEvent {
+ return {
+ type: evt.type,
+ sender: evt.sender,
+ event_id: evt.event_id,
+ room_id: evt.room_id,
+ state_key: evt.state_key,
+ origin_server_ts: evt.timestamp,
+ content: evt.content,
+ unsigned: evt.unsigned,
+ }
+}
+
+export function * iterRoomTimeline(room: RoomStateStore, since: string | undefined) {
+ const tc = room.timelineCache
+ for (let i = tc.length - 1; i >= 0; i--) {
+ const evt = tc[i]!
+ if (evt.event_id === since) {
+ return
+ }
+ yield evt
+ }
+}
+
+export function filterEvent(eventType: string, msgtype: string | undefined, stateKey: string | undefined) {
+ return (evt: MemDBEvent) => evt.type === eventType
+ && (!msgtype || evt.content.msgtype === msgtype)
+ && (!stateKey || evt.state_key === stateKey)
+}
diff --git a/web/src/ui/widget/widget.tsx b/web/src/ui/widget/widget.tsx
new file mode 100644
index 0000000..9589414
--- /dev/null
+++ b/web/src/ui/widget/widget.tsx
@@ -0,0 +1,122 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2025 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import { ClientWidgetApi, IWidget, Widget as WrappedWidget } from "matrix-widget-api"
+import { memo } from "react"
+import type Client from "@/api/client"
+import type { RoomStateStore, WidgetListener } from "@/api/statestore"
+import type { MemDBEvent, RoomID, SyncToDevice } from "@/api/types"
+import { getDisplayname } from "@/util/validation"
+import { memDBEventToIRoomEvent } from "./util"
+import GomuksWidgetDriver from "./widgetDriver"
+import "./Widget.css"
+
+export interface WidgetProps {
+ info: IWidget
+ room: RoomStateStore
+ client: Client
+}
+
+// TODO remove this after widgets start using a parameter for it
+const addLegacyParams = (url: string, widgetID: string) => {
+ const urlObj = new URL(url)
+ urlObj.searchParams.set("parentUrl", window.location.href)
+ urlObj.searchParams.set("widgetId", widgetID)
+ return urlObj.toString()
+}
+
+class WidgetListenerImpl implements WidgetListener {
+ constructor(private api: ClientWidgetApi) {}
+
+ onTimelineEvent = (evt: MemDBEvent) => {
+ this.api.feedEvent(memDBEventToIRoomEvent(evt))
+ .catch(err => console.error("Failed to feed event", memDBEventToIRoomEvent(evt), err))
+ }
+
+ onStateEvent = (evt: MemDBEvent) => {
+ this.api.feedStateUpdate(memDBEventToIRoomEvent(evt))
+ .catch(err => console.error("Failed to feed state update", memDBEventToIRoomEvent(evt), err))
+ }
+
+ onRoomChange = (roomID: RoomID | null) => {
+ this.api.setViewedRoomId(roomID)
+ }
+
+ onToDeviceEvent = (evt: SyncToDevice) => {
+ this.api.feedToDevice({
+ sender: evt.sender,
+ type: evt.type,
+ content: evt.content,
+ // Why does this use the IRoomEvent interface??
+ event_id: "",
+ room_id: "",
+ origin_server_ts: 0,
+ unsigned: {},
+ }, evt.encrypted).catch(err => console.error("Failed to feed to-device event", evt, err))
+ }
+}
+
+const ReactWidget = ({ room, info, client }: WidgetProps) => {
+ const wrappedWidget = new WrappedWidget(info)
+ const driver = new GomuksWidgetDriver(client, room)
+ const widgetURL = addLegacyParams(wrappedWidget.getCompleteUrl({
+ widgetRoomId: room.roomID,
+ currentUserId: client.userID,
+ deviceId: client.state.current?.is_logged_in ? client.state.current.device_id : "",
+ userDisplayName: getDisplayname(client.userID, room.getStateEvent("m.room.member", client.userID)?.content),
+ clientId: "fi.mau.gomuks",
+ clientTheme: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
+ clientLanguage: navigator.language,
+ }), wrappedWidget.id)
+
+ const handleIframe = (iframe: HTMLIFrameElement) => {
+ console.info("Setting up widget API for", iframe)
+ const clientAPI = new ClientWidgetApi(wrappedWidget, iframe, driver)
+ clientAPI.setViewedRoomId(room.roomID)
+
+ clientAPI.on("ready", () => console.info("Widget is ready"))
+ // Suppress unnecessary events to avoid errors
+ const noopReply = (evt: CustomEvent) => {
+ evt.preventDefault()
+ clientAPI.transport.reply(evt.detail, {})
+ }
+ clientAPI.on("action:io.element.join", noopReply)
+ clientAPI.on("action:im.vector.hangup", noopReply)
+ clientAPI.on("action:io.element.device_mute", noopReply)
+ clientAPI.on("action:io.element.tile_layout", noopReply)
+ clientAPI.on("action:io.element.spotlight_layout", noopReply)
+ // TODO handle this one?
+ clientAPI.on("action:io.element.close", noopReply)
+ clientAPI.on("action:set_always_on_screen", noopReply)
+ const removeListener = client.addWidgetListener(new WidgetListenerImpl(clientAPI))
+
+ return () => {
+ console.info("Removing widget API")
+ removeListener()
+ clientAPI.stop()
+ clientAPI.removeAllListeners()
+ }
+ }
+
+ return
+}
+
+export default memo(ReactWidget)
diff --git a/web/src/ui/widget/widgetDriver.ts b/web/src/ui/widget/widgetDriver.ts
new file mode 100644
index 0000000..82e058d
--- /dev/null
+++ b/web/src/ui/widget/widgetDriver.ts
@@ -0,0 +1,270 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2025 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import {
+ IGetMediaConfigResult,
+ IOpenIDCredentials,
+ IOpenIDUpdate,
+ IRoomAccountData,
+ IRoomEvent,
+ ISendEventDetails,
+ ITurnServer,
+ OpenIDRequestState,
+ SimpleObservable,
+ Symbols,
+ WidgetDriver,
+} from "matrix-widget-api"
+import Client from "@/api/client.ts"
+import { RoomStateStore } from "@/api/statestore"
+import { EventRowID, RoomID } from "@/api/types"
+import { filterEvent, isRecord, iterRoomTimeline, memDBEventToIRoomEvent, notNull } from "./util"
+
+class GomuksWidgetDriver extends WidgetDriver {
+ private openIDToken: IOpenIDCredentials | null = null
+ private openIDExpiry: number | null = null
+
+ constructor(private client: Client, private room: RoomStateStore) {
+ super()
+ }
+
+ async validateCapabilities(requested: Set): Promise> {
+ return new Set(requested)
+ }
+
+ async sendEvent(
+ eventType: string,
+ content: unknown,
+ stateKey: string | null = null,
+ roomID: string | null = null,
+ ): Promise {
+ if (!isRecord(content)) {
+ throw new Error("Content must be an object")
+ }
+ roomID = roomID ?? this.room.roomID
+ if (stateKey) {
+ const eventID = await this.client.rpc.setState(roomID, eventType, stateKey, content)
+ return { eventId: eventID, roomId: roomID }
+ } else {
+ const rawDBEvt = await this.client.rpc.sendEvent(roomID, eventType, content, false, true)
+ return { eventId: rawDBEvt.event_id, roomId: rawDBEvt.room_id }
+ }
+ }
+
+ // async sendDelayedEvent(
+ // delay: number | null,
+ // parentDelayID: string | null,
+ // eventType: string,
+ // content: unknown,
+ // stateKey: string | null = null,
+ // roomID: string | null = null,
+ // ): Promise {
+ // if (!isRecord(content)) {
+ // throw new Error("Content must be an object")
+ // }
+ // throw new Error("Delayed events are not supported")
+ // }
+
+ // async updateDelayedEvent(delayID: string, action: UpdateDelayedEventAction): Promise {
+ // throw new Error("Delayed events are not supported")
+ // }
+
+ async sendToDevice(
+ eventType: string,
+ encrypted: boolean,
+ content: { [userId: string]: { [deviceId: string]: object } },
+ ): Promise {
+ await this.client.rpc.sendToDevice(eventType, content, encrypted)
+ }
+
+ private readRoomData(
+ roomIDs: RoomID[] | null,
+ reader: (room: RoomStateStore) => T | null,
+ ): T[] {
+ if (roomIDs === null || (roomIDs.length === 1 && roomIDs[0] === this.room.roomID)) {
+ const val = reader(this.room)
+ return val ? [val] : []
+ } else if (roomIDs.includes(Symbols.AnyRoom)) {
+ return Array.from(this.client.store.rooms.values().map(reader).filter(notNull))
+ } else {
+ return roomIDs.map(roomID => {
+ const room = this.client.store.rooms.get(roomID)
+ if (!room) {
+ return null
+ }
+ return reader(room)
+ }).filter(notNull)
+ }
+ }
+
+ async readRoomTimeline(
+ roomID: string,
+ eventType: string,
+ msgtype: string | undefined,
+ stateKey: string | undefined,
+ limit: number,
+ since: string | undefined,
+ ): Promise {
+ const room = this.client.store.rooms.get(roomID)
+ if (!room) {
+ return []
+ }
+ if (room.timeline.length === 0) {
+ await this.client.loadMoreHistory(roomID)
+ }
+ return iterRoomTimeline(room, since)
+ .filter(filterEvent(eventType, msgtype, stateKey))
+ .take(limit)
+ .map(memDBEventToIRoomEvent)
+ .toArray()
+ }
+
+ async readRoomState(roomID: string, eventType: string, stateKey?: string): Promise {
+ const room = this.client.store.rooms.get(roomID)
+ if (!room) {
+ return []
+ }
+ if (
+ stateKey === undefined
+ && eventType === "m.room.member"
+ && !room.fullMembersLoaded
+ && !room.membersRequested
+ ) {
+ room.membersRequested = true
+ this.client.loadRoomState(room.roomID, { omitMembers: false, refetch: false })
+ }
+ const stateEvts = room.state.get(eventType)
+ if (!stateEvts) {
+ return []
+ }
+ let stateRowIDs: EventRowID[] = []
+ if (stateKey !== undefined) {
+ const stateEvtID = stateEvts.get(stateKey)
+ if (!stateEvtID) {
+ return []
+ }
+ stateRowIDs = [stateEvtID]
+ } else {
+ stateRowIDs = Array.from(stateEvts.values())
+ }
+ return stateRowIDs.map(rowID => {
+ const evt = room.eventsByRowID.get(rowID)
+ if (!evt) {
+ return null
+ }
+ return memDBEventToIRoomEvent(evt)
+ }).filter(notNull)
+ }
+
+ async readStateEvents(
+ eventType: string,
+ stateKey: string | undefined,
+ limit: number,
+ roomIDs: RoomID[] | null = null,
+ ): Promise {
+ console.warn(`Deprecated call to readStateEvents(${eventType}, ${stateKey}, ${limit}, ${roomIDs})`)
+ return (await Promise.all(
+ this.readRoomData(roomIDs, room => this.readRoomState(room.roomID, eventType, stateKey)),
+ )).flatMap(evts => evts)
+ }
+
+ async readRoomAccountData(type: string, roomIDs: string[] | null = null): Promise {
+ return this.readRoomData(roomIDs, room => {
+ const content = room.accountData.get(type)
+ if (!content) {
+ return null
+ }
+ return {
+ type,
+ room_id: room.roomID,
+ content,
+ }
+ })
+ }
+
+ async askOpenID(observer: SimpleObservable): Promise {
+ if (!this.openIDToken || (this.openIDExpiry ?? 0) < Date.now()) {
+ const openID = await this.client.rpc.requestOpenIDToken()
+ if (!openID) {
+ return
+ }
+ this.openIDToken = openID
+ this.openIDExpiry = Date.now() + (openID.expires_in / 2) * 1000
+ }
+ observer.update({
+ state: OpenIDRequestState.Allowed,
+ token: this.openIDToken,
+ })
+ }
+
+ async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> {
+ const res = await fetch("_gomuks/upload?encrypt=false", {
+ method: "POST",
+ body: file,
+ })
+ const json = await res.json()
+ if (!res.ok) {
+ throw new Error(json.error)
+ }
+ return { contentUri: json.url }
+ }
+
+ async downloadFile(url: string): Promise<{ file: XMLHttpRequestBodyInit }> {
+ const res = await fetch(url)
+ if (!res.ok) {
+ throw new Error(res.statusText)
+ }
+ return { file: await res.blob() }
+ }
+
+ async getMediaConfig(): Promise {
+ return await this.client.rpc.getMediaConfig()
+ }
+
+ getKnownRooms(): string[] {
+ return Array.from(this.client.store.rooms.keys())
+ }
+
+ async navigate(uri: string): Promise {
+ if (uri.startsWith("https://matrix.to/")) {
+ const parsedURL = new URL(uri)
+ const parts = parsedURL.hash.split("/")
+ if (parts[1][0] === "#") {
+ uri = `matrix:r/${parts[1].slice(1)}`
+ } else if (parts[1][0] === "!") {
+ if (parts.length >= 4 && parts[3][0] === "$") {
+ uri = `matrix:roomid/${parts[1].slice(1)}/e/${parts[4].slice(1)}`
+ } else {
+ uri = `matrix:roomid/${parts[1].slice(1)}`
+ }
+ } else if (parts[1][0] === "@") {
+ uri = `matrix:u/${parts[1].slice(1)}`
+ }
+ }
+ if (uri.startsWith("matrix:")) {
+ window.location.hash = `#/uri/${encodeURIComponent(uri)}`
+ } else {
+ throw new Error("Unsupported URI: " + uri)
+ }
+ }
+
+ async * getTurnServers(): AsyncGenerator {
+ const res = await this.client.rpc.getTurnServers()
+ yield res
+ }
+
+ // TODO: searchUserDirectory, readEventRelations
+}
+
+export default GomuksWidgetDriver
diff --git a/web/src/util/validation.ts b/web/src/util/validation.ts
index 260f94d..db5933a 100644
--- a/web/src/util/validation.ts
+++ b/web/src/util/validation.ts
@@ -88,7 +88,7 @@ export function getServerName(userID: UserID): string {
}
export function getDisplayname(userID: UserID, profile?: UserProfile | null): string {
- return profile?.displayname || getLocalpart(userID)
+ return ensureString(profile?.displayname) || getLocalpart(userID)
}
export function parseMXC(mxc: unknown): [string, string] | [] {
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
index ae9d0a8..19f446a 100644
--- a/web/src/vite-env.d.ts
+++ b/web/src/vite-env.d.ts
@@ -4,6 +4,7 @@
import type Client from "@/api/client.ts"
import type { GCSettings, RoomStateStore } from "@/api/statestore"
import type { MainScreenContextFields } from "@/ui/MainScreenContext.ts"
+import type { openModal } from "@/ui/modal/contexts.ts"
import type { RoomContextData } from "@/ui/roomview/roomcontext.ts"
declare global {
@@ -16,6 +17,7 @@ declare global {
gcSettings: GCSettings
hackyOpenEventContextMenu?: string
closeModal: () => void
+ openNestableModal: openModal
gomuksAndroid?: true
}
}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 5d88b2d..6f86035 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -2,7 +2,7 @@ import react from "@vitejs/plugin-react-swc"
import { defineConfig } from "vite"
import svgr from "vite-plugin-svgr"
-const splitDeps = ["katex", "leaflet", "monaco-editor"]
+const splitDeps = ["katex", "leaflet", "monaco-editor", "matrix-widget-api"]
export default defineConfig({
base: "./",
@@ -39,6 +39,7 @@ export default defineConfig({
},
},
server: {
+ allowedHosts: true,
proxy: {
"/_gomuks/websocket": {
target: "http://localhost:29325",