aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-02 19:26:41 -0800
committerAdam Soltys <[email protected]>2026-03-02 19:26:41 -0800
commit90f29dce3d675f850e48ede2edb187e6c53b84da (patch)
tree104f3f5a397b94571be6272bc28329f24ee2a893 /src
parent9e7ea9ad86fcd7ac11d4b5f845ebe558089ddd2d (diff)
downloadcoinos-ui-90f29dce3d675f850e48ede2edb187e6c53b84da.tar.gz
coinos-ui-90f29dce3d675f850e48ede2edb187e6c53b84da.zip
parse links
Diffstat (limited to 'src')
-rw-r--r--src/components/ChatMessage.svelte87
-rw-r--r--src/components/Event.svelte2
-rw-r--r--src/lib/parseContent.ts162
-rw-r--r--src/routes/(app)/messages/[pubkey]/+page.svelte7
-rw-r--r--src/routes/(app)/tag/[tag]/+page.svelte50
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>