Allow to post new discussions

This commit is contained in:
dtonon 2026-04-09 16:28:01 +01:00
parent 3fe32d8d24
commit b5b2f97d35
9 changed files with 673 additions and 0 deletions

112
src/lib/blossom.ts Normal file
View file

@ -0,0 +1,112 @@
import { auth } from "$lib/auth.svelte";
import { BLOSSOM_URL } from "$lib/config";
async function sha256Hex(data: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// base64url-encode a UTF-8 string, the format expected by Blossom auth headers.
function b64url(s: string): string {
const bytes = new TextEncoder().encode(s);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
export type BlobDescriptor = {
url: string;
sha256: string;
size: number;
type?: string;
};
export async function uploadImage(file: File): Promise<BlobDescriptor> {
if (!BLOSSOM_URL) throw new Error("Blossom server not configured");
if (!auth.signer) throw new Error("Not logged in");
console.log("[blossom] hashing", file.name, file.size, file.type);
const buf = await file.arrayBuffer();
const x = await sha256Hex(buf);
console.log("[blossom] sha256:", x);
console.log("[blossom] signing auth event…");
const signPromise = auth.signer.signEvent({
kind: 24242,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "upload"],
["x", x],
["expiration", String(Math.floor(Date.now() / 1000) + 300)],
],
content: `Upload ${file.name}`,
});
// Some extensions silently swallow signEvent if a popup is blocked or a
// previous prompt is still pending. Time out so the UI can recover.
const signTimeout = new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
"Signer didn't respond — check your Nostr extension for a pending approval popup",
),
),
60000,
),
);
const event = await Promise.race([signPromise, signTimeout]);
console.log("[blossom] auth event signed:", event.id);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
console.log("[blossom] PUT", `${BLOSSOM_URL}/upload`);
let res: Response;
try {
res = await fetch(`${BLOSSOM_URL}/upload`, {
method: "PUT",
headers: {
Authorization: "Nostr " + b64url(JSON.stringify(event)),
"Content-Type": file.type || "application/octet-stream",
},
body: buf,
signal: controller.signal,
});
} catch (e) {
const err = e as Error;
console.error("[blossom] fetch failed:", err);
if (err.name === "AbortError") {
throw new Error(`Upload timed out — is ${BLOSSOM_URL} reachable?`);
}
// A bare TypeError from fetch means the request never completed: server
// unreachable, mixed content, or CORS preflight rejected. Browsers don't
// surface CORS rejections as a distinct error, so we hint at it.
if (err.name === "TypeError") {
throw new Error(
`Cannot reach ${BLOSSOM_URL}. Check that the server is running and that CORS allows PUT /upload from this origin.`,
);
}
throw new Error(`Upload request failed: ${err.message}`);
} finally {
clearTimeout(timer);
}
console.log("[blossom] response", res.status);
if (!res.ok) {
const reason =
res.headers.get("X-Reason") ||
(await res.text().catch(() => "")) ||
res.statusText;
throw new Error(`Upload failed (${res.status}): ${reason}`);
}
try {
const blob = (await res.json()) as BlobDescriptor;
console.log("[blossom] uploaded:", blob.url);
return blob;
} catch {
throw new Error("Upload succeeded but response was not valid JSON");
}
}

View file

@ -2,6 +2,7 @@
import { rooms } from "$lib/mock";
import { auth, openLogin, logout } from "$lib/auth.svelte";
import { groupStore } from "$lib/group.svelte";
import { draftState, resumeDraft } from "$lib/draft.svelte";
type Props = {
mode: "simple" | "full";
@ -68,6 +69,16 @@
</nav>
</div>
<div class="flex flex-col gap-2">
{#if draftState.iconized}
<button
type="button"
onclick={resumeDraft}
class="w-full rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-hover"
>
Resume draft
</button>
{/if}
{#if auth.user}
<button
onclick={() => {
@ -100,4 +111,5 @@
Login
</button>
{/if}
</div>
</aside>

View file

@ -0,0 +1,391 @@
<script lang="ts">
import { tick } from "svelte";
import { goto } from "$app/navigation";
import {
draftState,
iconizeDraft,
discardDraft,
addLabel,
removeLabel,
publishDraft,
} from "$lib/draft.svelte";
import { LABELS, BLOSSOM_URL } from "$lib/config";
import { groupStore } from "$lib/group.svelte";
import { uploadImage } from "$lib/blossom";
let labelInput = $state("");
let labelInputEl = $state<HTMLInputElement | null>(null);
let suggestOpen = $state(false);
let titleEl = $state<HTMLInputElement | null>(null);
let contentEl = $state<HTMLTextAreaElement | null>(null);
let fileInputEl = $state<HTMLInputElement | null>(null);
let uploading = $state(false);
let uploadError = $state<string | null>(null);
const available = $derived(
LABELS.filter((l) => !draftState.labels.includes(l)),
);
const filtered = $derived(
labelInput.trim()
? available.filter((l) =>
l.toLowerCase().startsWith(labelInput.trim().toLowerCase()),
)
: available,
);
// First prefix match becomes the inline ghost suggestion
const suggestion = $derived(
labelInput.trim() &&
filtered[0] &&
filtered[0].toLowerCase() !== labelInput.trim().toLowerCase()
? filtered[0]
: null,
);
$effect(() => {
if (draftState.modalOpen) {
tick().then(() => titleEl?.focus());
} else {
// Modal stays mounted in the layout — reset transient local state so a
// hung "Uploading…" or stale error doesn't carry over to the next open.
uploading = false;
uploadError = null;
labelInput = "";
suggestOpen = false;
}
});
function commitSuggestion() {
if (suggestion) {
addLabel(suggestion);
labelInput = "";
return true;
}
// Exact match (typed full label that exists)
const exact = available.find(
(l) => l.toLowerCase() === labelInput.trim().toLowerCase(),
);
if (exact) {
addLabel(exact);
labelInput = "";
return true;
}
return false;
}
function onLabelKeydown(e: KeyboardEvent) {
if (e.key === "Tab" || e.key === "Enter") {
if (commitSuggestion()) e.preventDefault();
} else if (
e.key === "Backspace" &&
!labelInput &&
draftState.labels.length > 0
) {
removeLabel(draftState.labels[draftState.labels.length - 1]);
} else if (e.key === "Escape") {
suggestOpen = false;
labelInputEl?.blur();
}
}
function onSuggestionClick(l: string) {
addLabel(l);
labelInput = "";
labelInputEl?.focus();
}
function onLabelFocus() {
suggestOpen = true;
}
function onLabelBlur() {
// Delay so click on suggestion fires first
setTimeout(() => {
suggestOpen = false;
}, 150);
}
async function onUploadClick() {
fileInputEl?.click();
}
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
console.log("[upload] picked file:", file.name, file.size, file.type);
uploadError = null;
uploading = true;
try {
const blob = await uploadImage(file);
const ta = contentEl;
if (ta) {
const start = ta.selectionStart;
const end = ta.selectionEnd;
const before = draftState.content.slice(0, start);
const after = draftState.content.slice(end);
const sep = before.length > 0 && !before.endsWith("\n") ? "\n" : "";
const insertion = sep + blob.url + "\n";
draftState.content = before + insertion + after;
await tick();
ta.focus();
const pos = (before + insertion).length;
ta.setSelectionRange(pos, pos);
} else {
draftState.content =
(draftState.content ? draftState.content + "\n" : "") +
blob.url +
"\n";
}
} catch (err) {
uploadError = err instanceof Error ? err.message : "Upload failed";
} finally {
uploading = false;
input.value = "";
}
}
async function onPublish() {
const res = await publishDraft();
if (res.ok && res.threadId) {
await goto(`/thread/${res.threadId}`);
}
}
function onDiscard() {
if (draftState.hasDraft && !confirm("Discard this draft?")) return;
discardDraft();
}
function onKeydown(e: KeyboardEvent) {
if (!draftState.modalOpen) return;
if (e.key === "Escape") iconizeDraft();
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if draftState.modalOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
aria-label="Minimize draft"
tabindex="-1"
class="absolute inset-0 bg-black/40"
onclick={iconizeDraft}
></button>
<div
class="relative w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl flex flex-col gap-4 max-h-[90vh]"
role="dialog"
aria-modal="true"
aria-labelledby="newdisc-title"
>
<!-- Header -->
<div class="flex items-start justify-between">
<div>
{#if groupStore.data?.name}
<p class="text-sm text-gray-700">{groupStore.data.name}</p>
{/if}
<h2 id="newdisc-title" class="text-2xl text-brand">New discussion</h2>
</div>
<button
type="button"
onclick={iconizeDraft}
aria-label="Minimize draft"
class="rounded bg-gray-50 p-1.5 text-gray-700 hover:bg-gray-100"
>
<svg
width="20"
height="20"
viewBox="0 0 37 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M10.8656 23.2958C11.0971 23.0642 11.4726 23.0642 11.7042 23.2958C11.9358 23.5274 11.9358 23.9029 11.7042 24.1344L11.0124 24.8263C10.7808 25.0579 10.4053 25.0579 10.1737 24.8263C9.9421 24.5947 9.9421 24.2192 10.1737 23.9876L10.8656 23.2958ZM25.9876 8.17369C26.2192 7.9421 26.5947 7.9421 26.8263 8.17369C27.0579 8.40528 27.0579 8.78077 26.8263 9.01235L22.3038 13.5349H25.5033L25.5186 13.5351C25.839 13.5432 26.0963 13.8055 26.0963 14.1279C26.0963 14.4503 25.839 14.7126 25.5186 14.7207L25.5033 14.7209H20.8721C20.5446 14.7209 20.2791 14.4554 20.2791 14.1279V9.49669C20.2791 9.16917 20.5446 8.90367 20.8721 8.90366C21.1996 8.90366 21.4651 9.16917 21.4651 9.49669L21.4651 12.6962L25.9876 8.17369ZM16.7209 23.5033C16.7209 23.8308 16.4554 24.0963 16.1279 24.0963C15.8004 24.0963 15.5349 23.8308 15.5349 23.5033V20.3038L13.7798 22.0589C13.5482 22.2905 13.1727 22.2904 12.9411 22.0589C12.7095 21.8273 12.7095 21.4518 12.9411 21.2202L14.6962 19.4651H11.4967C11.1692 19.4651 10.9037 19.1996 10.9037 18.8721C10.9037 18.5446 11.1692 18.2791 11.4967 18.2791H16.1279C16.4554 18.2791 16.7209 18.5446 16.7209 18.8721V23.5033Z"
fill="currentColor"
/>
</svg>
</button>
</div>
<!-- Title -->
<div>
<label
for="newdisc-title-input"
class="block text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1"
>
Title
</label>
<input
id="newdisc-title-input"
type="text"
bind:this={titleEl}
bind:value={draftState.title}
disabled={draftState.publishing}
maxlength="72"
class="w-full rounded border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50"
/>
</div>
<!-- Labels -->
{#if LABELS.length > 0}
<div class="relative">
<label
for="newdisc-labels-input"
class="block text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1"
>
Labels
</label>
<div
class="flex flex-wrap gap-1.5 items-center rounded border border-gray-200 px-3 py-1.5 min-h-[2.5rem] focus-within:ring-1 focus-within:ring-brand"
>
{#each draftState.labels as l}
<span
class="inline-flex items-center gap-1 rounded-lg bg-accent px-2 py-0.5 text-sm text-white"
>
{l}
<button
type="button"
onclick={() => removeLabel(l)}
aria-label="Remove label {l}"
class="leading-none text-white/80 hover:text-white">×</button
>
</span>
{/each}
<div class="relative flex-1 min-w-[6rem]">
<input
id="newdisc-labels-input"
type="text"
bind:this={labelInputEl}
bind:value={labelInput}
onfocus={onLabelFocus}
onblur={onLabelBlur}
onkeydown={onLabelKeydown}
disabled={draftState.publishing}
autocomplete="off"
spellcheck="false"
placeholder={draftState.labels.length === 0
? "Click to choose…"
: ""}
class="relative z-10 w-full border-0 bg-transparent p-0 outline-none focus:ring-0 text-sm py-0.5 disabled:opacity-50"
/>
{#if suggestion}
<span
class="pointer-events-none absolute inset-0 flex items-center text-sm text-gray-400"
aria-hidden="true"
>
<span class="invisible">{labelInput}</span><span
>{suggestion.slice(labelInput.length)}</span
>
</span>
{/if}
</div>
</div>
{#if suggestOpen && filtered.length > 0}
<div
class="absolute left-0 right-0 z-20 mt-1 rounded border border-gray-200 bg-white shadow-lg p-3 flex flex-wrap gap-1.5"
>
{#each filtered as l}
<button
type="button"
onmousedown={(e) => e.preventDefault()}
onclick={() => onSuggestionClick(l)}
class="rounded-lg bg-accent hover:bg-accent-hover px-2.5 py-0.5 text-sm text-white"
>
{l}
</button>
{/each}
</div>
{/if}
{#if suggestion}
<p class="mt-1 text-xs text-gray-400">
Press Tab or Enter to add “{suggestion}
</p>
{/if}
</div>
{/if}
<!-- Content -->
<div class="flex flex-col">
<textarea
bind:this={contentEl}
bind:value={draftState.content}
disabled={draftState.publishing}
rows="8"
class="w-full rounded-t border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50 resize-none min-h-[12rem]"
></textarea>
<div
class="flex items-center gap-4 rounded-b border border-t-0 border-gray-200 bg-gray-50 px-3 py-2 text-sm"
>
<button
type="button"
onclick={onUploadClick}
disabled={uploading || draftState.publishing || !BLOSSOM_URL}
title={!BLOSSOM_URL ? "Blossom server not configured" : ""}
class="inline-flex items-center gap-1.5 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{uploading ? "Uploading…" : "Upload image"}
</button>
{#if uploadError}
<span class="text-xs text-red-600">{uploadError}</span>
{/if}
</div>
<input
type="file"
bind:this={fileInputEl}
onchange={onFileChange}
accept="image/*"
class="hidden"
/>
</div>
{#if draftState.publishError}
<div
class="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
role="alert"
>
{draftState.publishError}
</div>
{/if}
<div class="flex items-center justify-between pt-2">
<button
type="button"
onclick={onDiscard}
disabled={draftState.publishing}
class="rounded bg-gray-700 px-5 py-1.5 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-50"
>
Discard
</button>
<button
type="button"
onclick={onPublish}
disabled={draftState.publishing ||
!draftState.title.trim() ||
!draftState.content.trim()}
class="rounded bg-brand px-5 py-1.5 text-sm font-medium text-white hover:bg-brand-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{draftState.publishing ? "Publishing…" : "Publish discussion"}
</button>
</div>
</div>
</div>
{/if}

View file

@ -3,6 +3,8 @@ import {
PUBLIC_GROUP_ID,
PUBLIC_MODE,
PUBLIC_JOINCODE,
PUBLIC_LABELS,
PUBLIC_BLOSSOM_URL,
} from "$env/static/public";
export const RELAY_URL = PUBLIC_RELAY_URL;
@ -10,3 +12,8 @@ export const GROUP_ID = PUBLIC_GROUP_ID;
export const MODE: "simple" | "full" =
PUBLIC_MODE === "full" ? "full" : "simple";
export const JOINCODE_REQUIRED = PUBLIC_JOINCODE === "yes";
export const LABELS = (PUBLIC_LABELS ?? "")
.split(",")
.map((l) => l.trim())
.filter(Boolean);
export const BLOSSOM_URL = (PUBLIC_BLOSSOM_URL ?? "").replace(/\/$/, "");

125
src/lib/draft.svelte.ts Normal file
View file

@ -0,0 +1,125 @@
import { SimplePool } from "@nostr/tools";
import { auth } from "$lib/auth.svelte";
import { withJoin } from "$lib/join.svelte";
import { GROUP_ID, RELAY_URL } from "$lib/config";
let modalOpen = $state(false);
let iconized = $state(false);
let title = $state("");
let labels = $state<string[]>([]);
let content = $state("");
let publishing = $state(false);
let publishError = $state<string | null>(null);
export const draftState = {
get modalOpen() { return modalOpen; },
get iconized() { return iconized; },
get title() { return title; },
set title(v: string) { title = v; },
get labels() { return labels; },
get content() { return content; },
set content(v: string) { content = v; },
get publishing() { return publishing; },
get publishError() { return publishError; },
get hasDraft() {
return title.trim().length > 0 ||
content.trim().length > 0 ||
labels.length > 0;
},
};
export function openDraft() {
modalOpen = true;
iconized = false;
publishError = null;
}
export function iconizeDraft() {
modalOpen = false;
iconized = true;
}
export function resumeDraft() {
modalOpen = true;
iconized = false;
}
export function discardDraft() {
title = "";
labels = [];
content = "";
publishError = null;
modalOpen = false;
iconized = false;
}
export function addLabel(l: string) {
const v = l.trim();
if (!v) return;
if (labels.includes(v)) return;
labels = [...labels, v];
}
export function removeLabel(l: string) {
labels = labels.filter((x) => x !== l);
}
export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }> {
if (!auth.signer) {
publishError = "Not logged in";
return { ok: false };
}
const t = title.trim();
const c = content.trim();
if (!t || !c) {
publishError = "Title and content are required";
return { ok: false };
}
publishing = true;
publishError = null;
let threadId: string | undefined;
try {
const success = await withJoin(async () => {
const tags: string[][] = [
["h", GROUP_ID],
["title", t],
];
for (const l of labels) tags.push(["t", l]);
const event = await auth.signer!.signEvent({
kind: 11,
created_at: Math.floor(Date.now() / 1000),
tags,
content: c,
});
const pool = new SimplePool();
try {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], event)),
timeout,
]);
} finally {
pool.destroy();
}
threadId = event.id;
});
if (success && threadId) {
discardDraft();
return { ok: true, threadId };
}
return { ok: false };
} catch (e) {
publishError = e instanceof Error ? e.message : "Failed to publish";
return { ok: false };
} finally {
publishing = false;
}
}