forked from coracle/flotilla
refactor: move room and space mention rendering into link components
This commit is contained in:
+1
-3
@@ -12,7 +12,6 @@
|
|||||||
"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",
|
||||||
@@ -40,8 +39,7 @@
|
|||||||
"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,7 +24,6 @@
|
|||||||
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"
|
||||||
@@ -156,8 +155,6 @@
|
|||||||
{#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)}
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
import {isRelayUrl, getTagValue, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
|
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
|
||||||
import {makeSpacePath} from "@app/util/routes"
|
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
const {value, event} = $props()
|
const {value, event} = $props()
|
||||||
|
|
||||||
let hideImage = $state(false)
|
let hideImage = $state(false)
|
||||||
|
|
||||||
const url = value.url.toString()
|
const url = value.url.toString()
|
||||||
|
const splitRoomReference = (input: string) => {
|
||||||
|
const separatorIndex = input.indexOf("'")
|
||||||
|
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomUrl = input.slice(0, separatorIndex)
|
||||||
|
const h = input.slice(separatorIndex + 1)
|
||||||
|
|
||||||
|
if (!h || !isRelayUrl(roomUrl)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {url: normalizeRelayUrl(roomUrl), h}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomReference = splitRoomReference(url)
|
||||||
|
const relayReference = !roomReference && isRelayUrl(url) ? normalizeRelayUrl(url) : undefined
|
||||||
const fileType = getTagValue("file-type", event.tags) || ""
|
const fileType = getTagValue("file-type", event.tags) || ""
|
||||||
const [href, external] = call(() => {
|
const [href, external] = call(() => {
|
||||||
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false]
|
||||||
|
if (relayReference) return [makeSpacePath(relayReference), false]
|
||||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||||
|
|
||||||
return [url, true]
|
return [url, true]
|
||||||
@@ -49,6 +71,11 @@
|
|||||||
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
|
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
|
||||||
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
|
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
|
||||||
</button>
|
</button>
|
||||||
|
{:else if roomReference || relayReference}
|
||||||
|
<div class="bg-alt p-4 leading-normal">
|
||||||
|
<Icon icon={LinkRound} size={3} class="inline-block" />
|
||||||
|
<span class="ml-2">{displayUrl(url)}</span>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#await loadPreview()}
|
{#await loadPreview()}
|
||||||
<div class="center my-12 w-full">
|
<div class="center my-12 w-full">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {call, displayUrl} from "@welshman/lib"
|
import {call, displayUrl} from "@welshman/lib"
|
||||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
import {isRelayUrl, getTagValue, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
@@ -8,14 +8,33 @@
|
|||||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
|
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
|
||||||
import {makeSpacePath} from "@app/util/routes"
|
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
const {value, event} = $props()
|
const {value, event} = $props()
|
||||||
|
|
||||||
const url = value.url.toString()
|
const url = value.url.toString()
|
||||||
|
const splitRoomReference = (input: string) => {
|
||||||
|
const separatorIndex = input.indexOf("'")
|
||||||
|
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomUrl = input.slice(0, separatorIndex)
|
||||||
|
const h = input.slice(separatorIndex + 1)
|
||||||
|
|
||||||
|
if (!h || !isRelayUrl(roomUrl)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {url: normalizeRelayUrl(roomUrl), h}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomReference = splitRoomReference(url)
|
||||||
const fileType = getTagValue("file-type", event.tags) || ""
|
const fileType = getTagValue("file-type", event.tags) || ""
|
||||||
const [href, external] = call(() => {
|
const [href, external] = call(() => {
|
||||||
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false]
|
||||||
|
if (isRelayUrl(url)) return [makeSpacePath(normalizeRelayUrl(url)), false]
|
||||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||||
|
|
||||||
return [url, true]
|
return [url, true]
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
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"
|
||||||
@@ -106,8 +105,6 @@
|
|||||||
{#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)}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
<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}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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