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
},
}),