diff --git a/src/app/components/ContentLinkBlock.svelte b/src/app/components/ContentLinkBlock.svelte index c818f1ad..a9bdd995 100644 --- a/src/app/components/ContentLinkBlock.svelte +++ b/src/app/components/ContentLinkBlock.svelte @@ -5,30 +5,32 @@ import {preventDefault, stopPropagation} from "@lib/html" import Link from "@lib/components/Link.svelte" import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte" + import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte" import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte" import {pushModal} from "@app/util/modal" import { dufflepud, - PLATFORM_URL, IMAGE_CONTENT_TYPES, + PLATFORM_URL, VIDEO_CONTENT_TYPES, THUMBNAIL_URL, + isRoomId, } from "@app/core/state" - import {makeSpacePath} from "@app/util/routes" const {value, event} = $props() let hideImage = $state(false) const url = value.url.toString() - const fileType = getTagValue("file-type", event.tags) || "" + const isRoomOrRelay = isRoomId(url) || isRelayUrl(url) const [href, external] = call(() => { - if (isRelayUrl(url)) return [makeSpacePath(url), false] if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false] return [url, true] }) + const fileType = getTagValue("file-type", event.tags) || "" + const getVideoPoster = (videoUrl: string): string | undefined => { if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) { return `${THUMBNAIL_URL}/thumbnail?url=${encodeURIComponent(videoUrl)}` @@ -54,46 +56,50 @@ const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true}) - -
- {#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)} - - {:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)} - - {:else} - {#await loadPreview()} -
- -
- {:then preview} -
- {#if preview.image && !hideImage} - - {/if} -
- {preview.title || displayUrl(url)} -

{ellipsize(preview.description, 140)}

+{#if isRoomOrRelay} + +{:else} + +
+ {#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)} + + {:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)} + + {:else} + {#await loadPreview()} +
+
-
- {:catch} -

- Unable to load a preview for {url} -

- {/await} - {/if} -
- + {:then preview} +
+ {#if preview.image && !hideImage} + + {/if} +
+ {preview.title || displayUrl(url)} +

{ellipsize(preview.description, 140)}

+
+
+ {:catch} +

+ Unable to load a preview for {url} +

+ {/await} + {/if} +
+ +{/if} diff --git a/src/app/components/ContentLinkInline.svelte b/src/app/components/ContentLinkInline.svelte index 949c5910..fec1055c 100644 --- a/src/app/components/ContentLinkInline.svelte +++ b/src/app/components/ContentLinkInline.svelte @@ -1,25 +1,18 @@ @@ -34,8 +27,5 @@ {displayUrl(url)} {:else} - - - {displayUrl(url)} - + {/if} diff --git a/src/app/components/ContentLinkUrl.svelte b/src/app/components/ContentLinkUrl.svelte new file mode 100644 index 00000000..e039ad57 --- /dev/null +++ b/src/app/components/ContentLinkUrl.svelte @@ -0,0 +1,67 @@ + + + + + {label} + diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 69672d04..3f23a2d0 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -596,6 +596,8 @@ export const getRoomType = (room: RoomMeta): RoomType => export const makeRoomId = (url: string, h: string) => `${url}'${h}` +export const isRoomId = (id: string) => id.includes("'") + export const splitRoomId = (id: string) => id.split("'") export const hasNip29 = (relay?: RelayProfile) => diff --git a/src/app/editor/RoomReferenceExtension.ts b/src/app/editor/RoomReferenceExtension.ts new file mode 100644 index 00000000..5a134764 --- /dev/null +++ b/src/app/editor/RoomReferenceExtension.ts @@ -0,0 +1,42 @@ +import {mergeAttributes, Node} from "@tiptap/core" +import {RoomReferenceNodeView} from "@app/editor/RoomReferenceNodeView" + +export const RoomReferenceExtension = Node.create({ + name: "roomref", + + atom: true, + + inline: true, + + group: "inline", + + selectable: true, + + priority: 1000, + + addAttributes() { + return { + url: {default: undefined}, + h: {default: undefined}, + } + }, + + parseHTML() { + return [{tag: `span[data-type="${this.name}"]`}] + }, + + renderHTML({HTMLAttributes}) { + return ["span", mergeAttributes(HTMLAttributes, {"data-type": this.name}), "~"] + }, + + renderText({node}) { + const url = typeof node.attrs.url === "string" ? node.attrs.url : "" + const h = typeof node.attrs.h === "string" ? node.attrs.h : "" + + return `${url}'${h}` + }, + + addNodeView() { + return RoomReferenceNodeView + }, +}) diff --git a/src/app/editor/RoomReferenceNodeView.ts b/src/app/editor/RoomReferenceNodeView.ts new file mode 100644 index 00000000..146e51f5 --- /dev/null +++ b/src/app/editor/RoomReferenceNodeView.ts @@ -0,0 +1,29 @@ +import type {NodeViewRendererProps} from "@tiptap/core" +import {displayRelayUrl} from "@welshman/util" +import {deriveRoom} from "@app/core/state" + +export const RoomReferenceNodeView = ({node}: NodeViewRendererProps) => { + const dom = document.createElement("span") + const url = typeof node.attrs.url === "string" ? node.attrs.url : "" + const h = typeof node.attrs.h === "string" ? node.attrs.h : "" + const room = deriveRoom(url, h) + + dom.classList.add("tiptap-object") + + const unsubRoom = room.subscribe($room => { + dom.textContent = `~${displayRelayUrl(url)} / ${$room.name || h}` + }) + + return { + dom, + destroy: () => { + unsubRoom() + }, + selectNode() { + dom.classList.add("tiptap-active") + }, + deselectNode() { + dom.classList.remove("tiptap-active") + }, + } +} diff --git a/src/app/editor/RoomSuggestion.svelte b/src/app/editor/RoomSuggestion.svelte new file mode 100644 index 00000000..cfdd7f56 --- /dev/null +++ b/src/app/editor/RoomSuggestion.svelte @@ -0,0 +1,18 @@ + + +
+ {label} +
diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index f56fd4b1..e2568b3e 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -4,7 +4,7 @@ import {get, derived} from "svelte/store" import {Router} from "@welshman/router" import {dec, inc} from "@welshman/lib" import {throttled} from "@welshman/store" -import type {PublishedProfile} from "@welshman/util" +import type {PublishedProfile, RoomMeta} from "@welshman/util" import { createSearch, profiles, @@ -14,12 +14,26 @@ import { getWotGraph, } from "@welshman/app" import type {FileAttributes} from "@welshman/editor" -import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor" +import { + Editor, + MentionSuggestion, + TippySuggestion, + WelshmanExtension, + editorProps, +} from "@welshman/editor" import {escapeHtml} from "@lib/html" import {makeMentionNodeView} from "@app/editor/MentionNodeView" import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte" +import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension" +import RoomSuggestion from "@app/editor/RoomSuggestion.svelte" import {uploadFile} from "@app/core/commands" -import {deriveSpaceMembers} from "@app/core/state" +import { + deriveSpaceMembers, + makeRoomId, + splitRoomId, + userSpaceUrls, + roomsByUrl, +} from "@app/core/state" import {pushToast} from "@app/util/toast" export const makeEditor = async ({ @@ -82,11 +96,36 @@ export const makeEditor = async ({ }, ) + const roomReferenceSearch = derived( + [throttled(800, userSpaceUrls), throttled(800, roomsByUrl)], + ([$userSpaceUrls, $roomsByUrl]) => { + const roomIdByMeta = new WeakMap() + const options: RoomMeta[] = [] + + for (const roomUrl of $userSpaceUrls) { + for (const room of $roomsByUrl.get(roomUrl) || []) { + roomIdByMeta.set(room, makeRoomId(roomUrl, room.h)) + options.push(room) + } + } + + return createSearch(options, { + getValue: item => roomIdByMeta.get(item) || item.h, + fuseOptions: { + keys: ["name", "h"], + threshold: 0.3, + shouldSort: false, + }, + }) + }, + ) + const ed = new Editor({ content: typeof content === "string" ? escapeHtml(content) : content, editorProps, element: document.createElement("div"), extensions: [ + RoomReferenceExtension, WelshmanExtension.configure({ submit, extensions: { @@ -128,6 +167,29 @@ export const makeEditor = async ({ mount(ProfileSuggestion, {target, props: {value, url}}) + return target + }, + }), + TippySuggestion({ + char: "~", + name: "roomref", + editor: (this as any).editor, + search: (term: string) => get(roomReferenceSearch).searchValues(term), + updateSignal: roomReferenceSearch, + select: (id: string, props) => { + const [roomUrl, h] = splitRoomId(id) + + if (!roomUrl || !h) { + return + } + + return props.command({url: roomUrl, h}) + }, + createSuggestion: (value: string) => { + const target = document.createElement("div") + + mount(RoomSuggestion, {target, props: {value}}) + return target }, }),