diff options
| author | Adam Soltys <[email protected]> | 2026-03-02 19:26:41 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-02 19:26:41 -0800 |
| commit | 90f29dce3d675f850e48ede2edb187e6c53b84da (patch) | |
| tree | 104f3f5a397b94571be6272bc28329f24ee2a893 /src | |
| parent | 9e7ea9ad86fcd7ac11d4b5f845ebe558089ddd2d (diff) | |
| download | coinos-ui-90f29dce3d675f850e48ede2edb187e6c53b84da.tar.gz coinos-ui-90f29dce3d675f850e48ede2edb187e6c53b84da.zip | |
parse links
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/ChatMessage.svelte | 87 | ||||
| -rw-r--r-- | src/components/Event.svelte | 2 | ||||
| -rw-r--r-- | src/lib/parseContent.ts | 162 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[pubkey]/+page.svelte | 7 | ||||
| -rw-r--r-- | src/routes/(app)/tag/[tag]/+page.svelte | 50 |
5 files changed, 306 insertions, 2 deletions
diff --git a/src/components/ChatMessage.svelte b/src/components/ChatMessage.svelte new file mode 100644 index 00000000..7283430f --- /dev/null +++ b/src/components/ChatMessage.svelte @@ -0,0 +1,87 @@ +<script lang="ts"> + import { parseContent, isImage, isVideo, isAudio } from "$lib/parseContent"; + + let { content, tags = [] }: { content: string; tags?: any[] } = $props(); + + let parts = $derived(parseContent({ content, tags })); + + const truncUrl = (url: string) => { + const short = url.replace(/^https?:\/\/(www\.)?/, ""); + return short.length > 40 ? short.slice(0, 40) + "..." : short; + }; + + const resolveName = async (pubkey: string): Promise<string> => { + try { + const res = await fetch(`/api/users/${pubkey}`); + if (res.ok) { + const info = await res.json(); + if (!info.anon && info.username) return `@${info.username}`; + } + } catch {} + return `@${pubkey.slice(0, 8)}...`; + }; +</script> + +{#each parts as { type, value }} + {#if type === "text"} + {value} + {:else if type === "newline"} + <br /> + {:else if type === "link"} + {#if isImage(value.url)} + <a href={value.url} target="_blank" rel="noopener noreferrer"> + <img src={value.url} alt="" class="chat-img" /> + </a> + {:else if isVideo(value.url)} + <!-- svelte-ignore a11y_media_has_caption --> + <video src={value.url} controls class="chat-video"></video> + {:else if isAudio(value.url)} + <audio src={value.url} controls> + <a href={value.url}>{truncUrl(value.url)}</a> + </audio> + {:else} + <a href={value.url} target="_blank" rel="noopener noreferrer" class="chat-link">{truncUrl(value.url)}</a> + {/if} + {:else if type === "topic"} + <a href={`/tag/${encodeURIComponent(value)}`} class="chat-topic">#{value}</a> + {:else if type.match(/^nostr:n(profile|pub)$/)} + {#await resolveName(value.pubkey)} + <a href={`/${value.pubkey}`} class="chat-mention">@{value.pubkey.slice(0, 8)}...</a> + {:then name} + <a href={`/${value.pubkey}`} class="chat-mention">{name}</a> + {/await} + {:else if type === "nostr:note"} + <a href={`/e/${value.id}`} class="chat-link">nostr:{value.entity?.slice(0, 16)}...</a> + {:else if type === "nostr:nevent"} + <a href={`/e/${value.id}`} class="chat-link">nostr:{value.entity?.slice(0, 16)}...</a> + {/if} +{/each} + +<style> + .chat-img { + max-width: 100%; + border-radius: 8px; + margin: 4px 0; + } + + .chat-video { + max-width: 100%; + border-radius: 8px; + margin: 4px 0; + } + + .chat-link { + text-decoration: underline; + word-break: break-all; + } + + .chat-topic { + font-weight: bold; + color: var(--accent, #6366f1); + } + + .chat-mention { + font-weight: bold; + color: var(--accent, #6366f1); + } +</style> diff --git a/src/components/Event.svelte b/src/components/Event.svelte index 7a9c75d4..9d25e26e 100644 --- a/src/components/Event.svelte +++ b/src/components/Event.svelte @@ -70,6 +70,8 @@ {value} {:else if type === "link"} <Media {value} {minimal} /> + {:else if type === "topic"} + <a href={`/tag/${encodeURIComponent(value)}`} class="font-bold text-accent">#{value}</a> {:else if type.match(/^nostr:np(rofile|ub)$/)} <a href={`/${value.pubkey}`} class="font-bold">@{names[value.pubkey]}</a> {/if} diff --git a/src/lib/parseContent.ts b/src/lib/parseContent.ts new file mode 100644 index 00000000..13f33b83 --- /dev/null +++ b/src/lib/parseContent.ts @@ -0,0 +1,162 @@ +import { nip19 } from "nostr-tools"; + +export const NEWLINE = "newline"; +export const TEXT = "text"; +export const TOPIC = "topic"; +export const LINK = "link"; +export const INVOICE = "invoice"; +export const NOSTR_NOTE = "nostr:note"; +export const NOSTR_NEVENT = "nostr:nevent"; +export const NOSTR_NPUB = "nostr:npub"; +export const NOSTR_NPROFILE = "nostr:nprofile"; +export const NOSTR_NADDR = "nostr:naddr"; + +const first = (list: any) => (list ? list[0] : undefined); + +export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, ""); + +export const urlIsMedia = (url: string) => + !url.match(/\.(apk|docx|xlsx|csv|dmg)/) && url.split("://").pop()?.includes("/"); + +export type ParsedPart = { + type: string; + value: any; +}; + +export const isImage = (url: string) => url?.match(/^.*\.(jpg|jpeg|png|webp|gif|avif|svg)/gi); +export const isVideo = (url: string) => url?.match(/^.*\.(mov|mkv|mp4|avi|m4v|webm)/gi); +export const isAudio = (url: string) => url?.match(/^.*\.(ogg|mp3|wav)/gi); + +type ContentArgs = { + content: string; + tags?: Array<[string, string, string]>; +}; + +export const parseContent = ({ content, tags = [] }: ContentArgs): ParsedPart[] => { + const result: ParsedPart[] = []; + let text = content.trim(); + let buffer = ""; + + const parseNewline = () => { + const newline = first(text.match(/^\n+/)); + if (newline) { + return [NEWLINE, newline, newline]; + } + }; + + const parseMention = () => { + const mentionMatch = text.match(/^#\[(\d+)\]/i); + if (mentionMatch) { + const i = parseInt(mentionMatch[1]); + if (tags[i]) { + const [tag, value, url] = tags[i]; + const relays = url ? [url] : []; + + let type, data, entity; + try { + if (tag === "p") { + type = "nprofile"; + data = { pubkey: value, relays }; + entity = nip19.nprofileEncode(data); + } else { + type = "nevent"; + data = { id: value, relays, pubkey: null }; + entity = nip19.neventEncode(data); + } + } catch { + /**/ + } + + return [`nostr:${type}`, mentionMatch[0], { ...data, entity }]; + } + } + }; + + const parseTopic = () => { + const topic = first(text.match(/^#\w+/i)); + if (topic && !topic.match(/^#\d+$/)) { + return [TOPIC, topic, topic.slice(1)]; + } + }; + + const parseBech32 = () => { + const bech32 = first( + text.match(/^(web\+)?(nostr:)?\/?\/?n(event|ote|profile|pub|addr)1[\d\w]+/i), + ); + + if (bech32) { + try { + const entity = fromNostrURI(bech32); + const { type, data } = nip19.decode(entity) as { + type: string; + data: object; + }; + + let value = data; + if (type === "note") { + value = { id: data }; + } else if (type === "npub") { + value = { pubkey: data }; + } + + return [`nostr:${type}`, bech32, { ...value, entity }]; + } catch (e) { + console.log(e); + } + } + }; + + const parseUrl = () => { + const raw = first(text.match(/^([a-z+:]{2,30}:\/\/)?[^\s]+\.[a-z]{2,6}[^\s]*[^.!?,:\s]/gi)); + + if (raw) { + const prev = result[result.length - 1]; + + if (prev?.type === "text" && prev.value.endsWith("/")) { + return; + } + + let url = raw; + + if (url.match(/\.\./)) { + return; + } + + if (!url.match("://")) { + url = "https://" + url; + } + + return [LINK, raw, { url, isMedia: urlIsMedia(url) }]; + } + }; + + while (text) { + const part = + parseNewline() || + parseMention() || + parseTopic() || + parseBech32() || + parseUrl(); + + if (part) { + if (buffer) { + result.push({ type: "text", value: buffer }); + buffer = ""; + } + + const [type, raw, value] = part; + result.push({ type, value }); + text = text.slice(raw.length); + } else { + const match = first(text.match(/^[\w\d]+ ?/i)) || text[0]; + buffer += match; + text = text.slice(match.length); + } + } + + if (buffer) { + result.push({ type: TEXT, value: buffer }); + } + + return result; +}; diff --git a/src/routes/(app)/messages/[pubkey]/+page.svelte b/src/routes/(app)/messages/[pubkey]/+page.svelte index 6f0ad7f3..52e032be 100644 --- a/src/routes/(app)/messages/[pubkey]/+page.svelte +++ b/src/routes/(app)/messages/[pubkey]/+page.svelte @@ -9,6 +9,7 @@ import { pTagKeys } from "$lib/nostr"; import { t } from "$lib/translations"; import { theme } from "$lib/store"; + import ChatMessage from "$comp/ChatMessage.svelte"; const focus = (el: HTMLElement) => el.focus(); @@ -238,7 +239,7 @@ <div class={($theme === "light" ? "light-message " : "dark-message ") + "message"} > - {rumour.content} + <ChatMessage content={rumour.content} tags={rumour.tags} /> <span class="timestamp secondary text-xs"> {TIME.format(new Date(rumour.created_at * 1000))} </span> @@ -259,7 +260,7 @@ <div class={($theme === "light" ? "light-message " : "dark-message ") + "message"} > - {rumour.content} + <ChatMessage content={rumour.content} tags={rumour.tags} /> <span class="timestamp secondary text-xs"> {TIME.format(new Date(rumour.created_at * 1000))} </span> @@ -432,6 +433,8 @@ width: fit-content; max-width: 75%; border-radius: 10px; + overflow: hidden; + word-break: break-word; } .light-message { diff --git a/src/routes/(app)/tag/[tag]/+page.svelte b/src/routes/(app)/tag/[tag]/+page.svelte new file mode 100644 index 00000000..f2e50c6c --- /dev/null +++ b/src/routes/(app)/tag/[tag]/+page.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + import Event from "$comp/Event.svelte"; + import { get, post } from "$lib/utils"; + import { onMount, onDestroy } from "svelte"; + import { page } from "$app/state"; + + let tag = $derived(decodeURIComponent(page.params.tag!)); + let events: any[] = $state([]); + let paused = $state(false); + let pause = (e) => { + e.preventDefault(); + e.stopPropagation(); + paused = true; + }; + let unpause = (e) => { + e.preventDefault(); + e.stopPropagation(); + paused = false; + }; + + let sub: any; + + onMount(async () => { + const { Relay } = await import("nostr-tools/relay"); + const relay = await Relay.connect("wss://relay.primal.net"); + sub = relay.subscribe([{ kinds: [1], "#t": [tag], limit: 50 }], { + async onevent(event: any) { + if (paused) return; + if (events.find((e) => e.id === event.id)) return; + const { parts, names } = await post("/post/parseEvent", { event }); + event.author = await get(`/api/users/${event.pubkey}`); + event.parts = parts; + event.names = names; + events = [...events, event].sort((a, b) => b.created_at - a.created_at).slice(0, 50); + }, + }); + }); + + onDestroy(() => sub?.close()); +</script> + +<div class="container px-4 max-w-xl mx-auto space-y-5"> + <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold">#{tag}</h1> + + <div onpointerenter={pause} onpointerleave={unpause} role="feed"> + {#each events as event (event.id)} + <Event {event} minimal={true} /> + {/each} + </div> +</div> |
