diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 5252a3ce..f0f5a0fc 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -18,7 +18,7 @@ import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import toSearchableString from "@/util/searchablestring.ts" import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts" -import { getDisplayname } from "@/util/validation.ts" +import { getDisplayname, getServerName } from "@/util/validation.ts" import { ContentURI, DBReceipt, @@ -246,6 +246,35 @@ export class RoomStateStore { return this.#membersCache ?? [] } + getViaServers(): string[] { + const ownServerName = getServerName(this.parent.userID) + const vias = [ownServerName] + const members = this.getMembers() + const memberCount = new Map() + const powerLevels: PowerLevelEventContent = this.getStateEvent("m.room.power_levels", "")?.content ?? {} + const usersDefault = powerLevels.users_default ?? 0 + let powerServer: string | undefined = undefined + for (const member of members) { + const serverName = getServerName(member.userID) + if (serverName !== ownServerName) { + if (!powerServer && (powerLevels?.users?.[member.userID] ?? usersDefault) > usersDefault) { + powerServer = serverName + vias.push(powerServer) + } + memberCount.set(serverName, (memberCount.get(serverName) ?? 0) + 1) + } + } + const servers = Array.from(memberCount.entries()) + servers.sort(([, a], [, b]) => b - a) + for (const [serverName] of servers) { + if (serverName !== ownServerName && serverName !== powerServer) { + vias.push(serverName) + break + } + } + return vias + } + getPinnedEvents(): EventID[] { const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned if (Array.isArray(pinnedList)) { diff --git a/web/src/icons/share.svg b/web/src/icons/share.svg new file mode 100644 index 00000000..f86fdd46 --- /dev/null +++ b/web/src/icons/share.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/timeline/menu/ShareModal.tsx b/web/src/ui/timeline/menu/ShareModal.tsx new file mode 100644 index 00000000..9e531cc7 --- /dev/null +++ b/web/src/ui/timeline/menu/ShareModal.tsx @@ -0,0 +1,65 @@ +import React, { use, useState } from "react" +import { MemDBEvent } from "@/api/types" +import { ModalCloseContext } from "@/ui/modal" +import TimelineEvent from "@/ui/timeline/TimelineEvent.tsx" +import Toggle from "@/ui/util/Toggle.tsx" + +interface ConfirmWithMessageProps { + evt: MemDBEvent + title: string + confirmButton: string + onConfirm: (useMatrixTo: boolean, includeEvent: boolean) => void + generateLink: (useMatrixTo: boolean, includeEvent: boolean) => string +} + +const ShareModal = ({ evt, title, confirmButton, onConfirm, generateLink }: ConfirmWithMessageProps) => { + const [useMatrixTo, setUseMatrixTo] = useState(false) + const [includeEvent, setIncludeEvent] = useState(true) + const closeModal = use(ModalCloseContext) + const onConfirmWrapped = (evt: React.FormEvent) => { + evt.preventDefault() + closeModal() + onConfirm(useMatrixTo, includeEvent) + } + + const link = generateLink(useMatrixTo, includeEvent) + return
+

{title}

+
+ +
+ + + + + + + + + + + +
Use matrix.to link + setUseMatrixTo(evt.target.checked)} + /> +
Link to this specific event + setIncludeEvent(evt.target.checked)} + /> +
+
+ Preview: {link} +
+
+ + +
+
+} + +export default ShareModal diff --git a/web/src/ui/timeline/menu/index.css b/web/src/ui/timeline/menu/index.css index a78c222f..b9f8bcc0 100644 --- a/web/src/ui/timeline/menu/index.css +++ b/web/src/ui/timeline/menu/index.css @@ -101,6 +101,7 @@ div.confirm-message-modal > form { > div.timeline-event { margin: 0; + padding: 0; } } @@ -118,4 +119,14 @@ div.confirm-message-modal > form { padding: .5rem 1rem; } } + + > div.output-preview { + > span.no-select { + user-select: none; + } + + > code { + word-break: break-word; + } + } } diff --git a/web/src/ui/timeline/menu/useSecondaryItems.tsx b/web/src/ui/timeline/menu/useSecondaryItems.tsx index 134d4f7f..3c077bd9 100644 --- a/web/src/ui/timeline/menu/useSecondaryItems.tsx +++ b/web/src/ui/timeline/menu/useSecondaryItems.tsx @@ -21,11 +21,13 @@ import { ModalCloseContext, ModalContext } from "../../modal" import { RoomContext, RoomContextData } from "../../roomview/roomcontext.ts" import JSONView from "../../util/JSONView.tsx" import ConfirmWithMessageModal from "./ConfirmWithMessageModal.tsx" +import ShareModal from "./ShareModal.tsx" import { getPending, getPowerLevels } from "./util.ts" import ViewSourceIcon from "@/icons/code.svg?react" import DeleteIcon from "@/icons/delete.svg?react" import PinIcon from "@/icons/pin.svg?react" import ReportIcon from "@/icons/report.svg?react" +import ShareIcon from "@/icons/share.svg?react" import UnpinIcon from "@/icons/unpin.svg?react" export const useSecondaryItems = ( @@ -40,7 +42,7 @@ export const useSecondaryItems = ( openModal({ dimmed: true, boxed: true, - content: , + content: , }) } const onClickReport = () => { @@ -89,6 +91,49 @@ export const useSecondaryItems = ( .catch(err => window.alert(`Failed to ${pin ? "pin" : "unpin"} message: ${err}`)) } + const onClickShareEvent = () => { + const generateLink = (useMatrixTo: boolean, includeEvent: boolean) => { + const isRoomIDLink = true + let generatedURL = useMatrixTo ? "https://matrix.to/#/" : "matrix:roomid/" + if (useMatrixTo) { + generatedURL += evt.room_id + } else { + generatedURL += `${evt.room_id.slice(1)}` + } + if (includeEvent) { + if (useMatrixTo) { + generatedURL += `/${evt.event_id}` + } else { + generatedURL += `/e/${evt.event_id.slice(1)}` + } + } + if (isRoomIDLink) { + generatedURL += "?" + new URLSearchParams( + roomCtx.store.getViaServers().map(server => ["via", server]), + ).toString() + } + return generatedURL + } + openModal({ + dimmed: true, + boxed: true, + innerBoxClass: "confirm-message-modal", + content: + { + navigator.clipboard.writeText(generateLink(useMatrixTo, includeEvent)).catch( + err => window.alert(`Failed to copy link: ${err}`), + ) + }} + generateLink={generateLink} + /> + , + }) + } + const [isPending, pendingTitle] = getPending(evt) useRoomState(roomCtx.store, "m.room.power_levels", "") // We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes @@ -104,6 +149,7 @@ export const useSecondaryItems = ( return <> + {ownPL >= pinPL && (pins.includes(evt.event_id) ?