feat: add room mentions and clickable room/relay refs
This commit is contained in:
+3
-1
@@ -12,6 +12,7 @@
|
|||||||
"tauri:icons": "tauri icon assets/logo.png --output src-tauri/icons",
|
"tauri:icons": "tauri icon assets/logo.png --output src-tauri/icons",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test": "vitest run",
|
||||||
"lint": "prettier --check src && eslint src",
|
"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": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||||
"format:all": "prettier --write src",
|
"format:all": "prettier --write src",
|
||||||
@@ -39,7 +40,8 @@
|
|||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.53.1",
|
||||||
"vite": "^5.4.21"
|
"vite": "^5.4.21",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ContentText from "@app/components/ContentText.svelte"
|
||||||
import ContentToken from "@app/components/ContentToken.svelte"
|
import ContentToken from "@app/components/ContentToken.svelte"
|
||||||
import ContentEmoji from "@app/components/ContentEmoji.svelte"
|
import ContentEmoji from "@app/components/ContentEmoji.svelte"
|
||||||
import ContentCode from "@app/components/ContentCode.svelte"
|
import ContentCode from "@app/components/ContentCode.svelte"
|
||||||
@@ -155,6 +156,8 @@
|
|||||||
{#each shortContent as parsed, i}
|
{#each shortContent as parsed, i}
|
||||||
{#if isNewline(parsed) && !isBlock(i - 1)}
|
{#if isNewline(parsed) && !isBlock(i - 1)}
|
||||||
<ContentNewline value={parsed.value} />
|
<ContentNewline value={parsed.value} />
|
||||||
|
{:else if isText(parsed)}
|
||||||
|
<ContentText value={parsed.value} />
|
||||||
{:else if isTopic(parsed)}
|
{:else if isTopic(parsed)}
|
||||||
<ContentTopic value={parsed.value} />
|
<ContentTopic value={parsed.value} />
|
||||||
{:else if isEmoji(parsed)}
|
{:else if isEmoji(parsed)}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ContentText from "@app/components/ContentText.svelte"
|
||||||
import ContentToken from "@app/components/ContentToken.svelte"
|
import ContentToken from "@app/components/ContentToken.svelte"
|
||||||
import ContentEmoji from "@app/components/ContentEmoji.svelte"
|
import ContentEmoji from "@app/components/ContentEmoji.svelte"
|
||||||
import ContentCode from "@app/components/ContentCode.svelte"
|
import ContentCode from "@app/components/ContentCode.svelte"
|
||||||
@@ -105,6 +106,8 @@
|
|||||||
{#each shortContent as parsed, i}
|
{#each shortContent as parsed, i}
|
||||||
{#if isNewline(parsed)}
|
{#if isNewline(parsed)}
|
||||||
<ContentNewline value={parsed.value} />
|
<ContentNewline value={parsed.value} />
|
||||||
|
{:else if isText(parsed)}
|
||||||
|
<ContentText value={parsed.value} />
|
||||||
{:else if isTopic(parsed)}
|
{:else if isTopic(parsed)}
|
||||||
<ContentTopic value={parsed.value} />
|
<ContentTopic value={parsed.value} />
|
||||||
{:else if isEmoji(parsed)}
|
{:else if isEmoji(parsed)}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import {parseContentTextParts} from "@lib/content-text"
|
||||||
|
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {value}: Props = $props()
|
||||||
|
|
||||||
|
const parts = $derived(parseContentTextParts(value))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each parts as part, i (i)}
|
||||||
|
{#if part.type === "room"}
|
||||||
|
<Link href={makeRoomPath(part.url, part.h)} class="link-content whitespace-nowrap"
|
||||||
|
>{part.value}</Link>
|
||||||
|
{:else if part.type === "relay"}
|
||||||
|
<Link href={makeSpacePath(part.url)} class="link-content whitespace-nowrap">{part.value}</Link>
|
||||||
|
{:else}
|
||||||
|
{part.value}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
|
import {deriveRoom, splitRoomId} from "@app/core/state"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {value}: Props = $props()
|
||||||
|
const [url = "", h = ""] = splitRoomId(value)
|
||||||
|
const room = deriveRoom(url, h)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex max-w-full flex-col gap-1">
|
||||||
|
<div class="overflow-hidden text-ellipsis text-base font-semibold">~{$room.name || h}</div>
|
||||||
|
<div class="overflow-hidden text-ellipsis text-sm opacity-75">{displayRelayUrl(url)}'{h}</div>
|
||||||
|
</div>
|
||||||
+65
-3
@@ -4,7 +4,7 @@ import {get, derived} from "svelte/store"
|
|||||||
import {Router} from "@welshman/router"
|
import {Router} from "@welshman/router"
|
||||||
import {dec, inc} from "@welshman/lib"
|
import {dec, inc} from "@welshman/lib"
|
||||||
import {throttled} from "@welshman/store"
|
import {throttled} from "@welshman/store"
|
||||||
import type {PublishedProfile} from "@welshman/util"
|
import type {PublishedProfile, RoomMeta} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
createSearch,
|
createSearch,
|
||||||
profiles,
|
profiles,
|
||||||
@@ -14,12 +14,26 @@ import {
|
|||||||
getWotGraph,
|
getWotGraph,
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
import type {FileAttributes} from "@welshman/editor"
|
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 {escapeHtml} from "@lib/html"
|
||||||
import {makeMentionNodeView} from "@app/editor/MentionNodeView"
|
import {makeMentionNodeView} from "@app/editor/MentionNodeView"
|
||||||
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
|
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 {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"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
export const makeEditor = async ({
|
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<RoomMeta, string>()
|
||||||
|
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({
|
const ed = new Editor({
|
||||||
content: typeof content === "string" ? escapeHtml(content) : content,
|
content: typeof content === "string" ? escapeHtml(content) : content,
|
||||||
editorProps,
|
editorProps,
|
||||||
element: document.createElement("div"),
|
element: document.createElement("div"),
|
||||||
extensions: [
|
extensions: [
|
||||||
|
RoomReferenceExtension,
|
||||||
WelshmanExtension.configure({
|
WelshmanExtension.configure({
|
||||||
submit,
|
submit,
|
||||||
extensions: {
|
extensions: {
|
||||||
@@ -128,6 +167,29 @@ export const makeEditor = async ({
|
|||||||
|
|
||||||
mount(ProfileSuggestion, {target, props: {value, url}})
|
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
|
return target
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"}])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user