diff --git a/package.json b/package.json
index caa76285..bad0504b 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"tauri:icons": "tauri icon assets/logo.png --output src-tauri/icons",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "test": "vitest run",
"lint": "prettier --check src && eslint src",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src",
@@ -39,7 +40,8 @@
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
- "vite": "^5.4.21"
+ "vite": "^5.4.21",
+ "vitest": "^2.1.9"
},
"type": "module",
"dependencies": {
diff --git a/src/app/components/Content.svelte b/src/app/components/Content.svelte
index e252b0bb..6af6d8d0 100644
--- a/src/app/components/Content.svelte
+++ b/src/app/components/Content.svelte
@@ -24,6 +24,7 @@
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
+ import ContentText from "@app/components/ContentText.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
@@ -155,6 +156,8 @@
{#each shortContent as parsed, i}
{#if isNewline(parsed) && !isBlock(i - 1)}
+ {:else if isText(parsed)}
+
{:else if isTopic(parsed)}
{:else if isEmoji(parsed)}
diff --git a/src/app/components/ContentMinimal.svelte b/src/app/components/ContentMinimal.svelte
index e2e22bfa..a5a98439 100644
--- a/src/app/components/ContentMinimal.svelte
+++ b/src/app/components/ContentMinimal.svelte
@@ -22,6 +22,7 @@
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
+ import ContentText from "@app/components/ContentText.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
@@ -105,6 +106,8 @@
{#each shortContent as parsed, i}
{#if isNewline(parsed)}
+ {:else if isText(parsed)}
+
{:else if isTopic(parsed)}
{:else if isEmoji(parsed)}
diff --git a/src/app/components/ContentText.svelte b/src/app/components/ContentText.svelte
new file mode 100644
index 00000000..f3347347
--- /dev/null
+++ b/src/app/components/ContentText.svelte
@@ -0,0 +1,24 @@
+
+
+{#each parts as part, i (i)}
+ {#if part.type === "room"}
+ {part.value}
+ {:else if part.type === "relay"}
+ {part.value}
+ {:else}
+ {part.value}
+ {/if}
+{/each}
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..c6503d4e
--- /dev/null
+++ b/src/app/editor/RoomReferenceNodeView.ts
@@ -0,0 +1,28 @@
+import type {NodeViewRendererProps} from "@tiptap/core"
+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 = `~${$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..87cc80ca
--- /dev/null
+++ b/src/app/editor/RoomSuggestion.svelte
@@ -0,0 +1,17 @@
+
+
+
+
~{$room.name || h}
+
{displayRelayUrl(url)}'{h}
+
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
},
}),
diff --git a/src/lib/content-text.ts b/src/lib/content-text.ts
new file mode 100644
index 00000000..27e2ab7d
--- /dev/null
+++ b/src/lib/content-text.ts
@@ -0,0 +1,95 @@
+import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
+
+export type ContentTextPart =
+ | {type: "text"; value: string}
+ | {type: "room"; value: string; url: string; h: string}
+ | {type: "relay"; value: string; url: string}
+
+const CONTENT_URL_PATTERN = /wss?:\/\/\S+/g
+const TRAILING_PUNCTUATION_PATTERN = /[),.!?;:\]}]+$/
+
+const appendTextPart = (parts: ContentTextPart[], text: string) => {
+ if (!text) {
+ return
+ }
+
+ const last = parts.at(-1)
+
+ if (last?.type === "text") {
+ last.value += text
+
+ return
+ }
+
+ parts.push({type: "text", value: text})
+}
+
+const splitTokenSuffix = (token: string) => {
+ const suffixMatch = token.match(TRAILING_PUNCTUATION_PATTERN)
+ const suffix = suffixMatch?.[0] || ""
+
+ if (!suffix) {
+ return {trimmed: token, suffix}
+ }
+
+ return {
+ trimmed: token.slice(0, -suffix.length),
+ suffix,
+ }
+}
+
+const toRoomPart = (token: string) => {
+ const separatorIndex = token.indexOf("'")
+
+ if (separatorIndex === -1) {
+ return undefined
+ }
+
+ const url = token.slice(0, separatorIndex)
+ const h = token.slice(separatorIndex + 1)
+
+ if (!h || !isRelayUrl(url)) {
+ return undefined
+ }
+
+ return {type: "room", value: token, url: normalizeRelayUrl(url), h} as const
+}
+
+const toRelayPart = (token: string) => {
+ if (!isRelayUrl(token)) {
+ return undefined
+ }
+
+ return {type: "relay", value: token, url: normalizeRelayUrl(token)} as const
+}
+
+export const parseContentTextParts = (text: string) => {
+ const parts: ContentTextPart[] = []
+ let lastIndex = 0
+
+ for (const match of text.matchAll(CONTENT_URL_PATTERN)) {
+ const start = match.index || 0
+
+ appendTextPart(parts, text.slice(lastIndex, start))
+
+ const token = match[0]
+ const {trimmed, suffix} = splitTokenSuffix(token)
+ const roomPart = toRoomPart(trimmed)
+ const relayPart = toRelayPart(trimmed)
+
+ if (roomPart) {
+ parts.push(roomPart)
+ } else if (relayPart) {
+ parts.push(relayPart)
+ } else {
+ appendTextPart(parts, trimmed)
+ }
+
+ appendTextPart(parts, suffix)
+ lastIndex = start + token.length
+ }
+
+ appendTextPart(parts, text.slice(lastIndex))
+
+ return parts
+}
diff --git a/tests/content-text.test.ts b/tests/content-text.test.ts
new file mode 100644
index 00000000..b04e29b2
--- /dev/null
+++ b/tests/content-text.test.ts
@@ -0,0 +1,47 @@
+import {describe, expect, it} from "vitest"
+import {parseContentTextParts} from "../src/lib/content-text"
+
+describe("parseContentTextParts", () => {
+ it("parses room references as relay_url'room_id", () => {
+ const parts = parseContentTextParts("Join wss://relay.example.com'general now")
+
+ expect(parts).toHaveLength(3)
+ expect(parts[0]).toEqual({type: "text", value: "Join "})
+ expect(parts[1]).toMatchObject({
+ type: "room",
+ value: "wss://relay.example.com'general",
+ h: "general",
+ })
+ expect(parts[2]).toEqual({type: "text", value: " now"})
+ })
+
+ it("parses relay urls as relay parts", () => {
+ const parts = parseContentTextParts("Try wss://relay.example.com")
+
+ expect(parts).toHaveLength(2)
+ expect(parts[0]).toEqual({type: "text", value: "Try "})
+ expect(parts[1]).toMatchObject({
+ type: "relay",
+ value: "wss://relay.example.com",
+ })
+ })
+
+ it("keeps trailing punctuation outside links", () => {
+ const parts = parseContentTextParts("See wss://relay.example.com'chat), thanks")
+
+ expect(parts).toHaveLength(3)
+ expect(parts[0]).toEqual({type: "text", value: "See "})
+ expect(parts[1]).toMatchObject({
+ type: "room",
+ value: "wss://relay.example.com'chat",
+ h: "chat",
+ })
+ expect(parts[2]).toEqual({type: "text", value: "), thanks"})
+ })
+
+ it("leaves non-relay urls as plain text", () => {
+ const parts = parseContentTextParts("https://example.com/path")
+
+ expect(parts).toEqual([{type: "text", value: "https://example.com/path"}])
+ })
+})
\ No newline at end of file