Allow to post new discussions
This commit is contained in:
parent
3fe32d8d24
commit
b5b2f97d35
9 changed files with 673 additions and 0 deletions
|
|
@ -2,3 +2,5 @@ PUBLIC_RELAY_URL=ws://localhost:3334
|
|||
PUBLIC_GROUP_ID=mygrouprandomid
|
||||
PUBLIC_MODE=simple # simple | full
|
||||
PUBLIC_JOINCODE=no # yes | no — show invite-code field on join failure
|
||||
PUBLIC_LABELS= # comma-separated labels (e.g., bug,feature,question)
|
||||
PUBLIC_BLOSSOM_URL= # Blossom server URL (e.g., https://blossom.primal.net)
|
||||
|
|
|
|||
112
src/lib/blossom.ts
Normal file
112
src/lib/blossom.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
391
src/lib/components/NewDiscussionModal.svelte
Normal file
391
src/lib/components/NewDiscussionModal.svelte
Normal 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}
|
||||
|
|
@ -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
125
src/lib/draft.svelte.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
import ChatSidebar from "$lib/components/ChatSidebar.svelte";
|
||||
import LoginModal from "$lib/components/LoginModal.svelte";
|
||||
import JoinModal from "$lib/components/JoinModal.svelte";
|
||||
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { onMount } from "svelte";
|
||||
import { restoreSession } from "$lib/auth.svelte";
|
||||
|
|
@ -50,3 +51,4 @@
|
|||
|
||||
<LoginModal />
|
||||
<JoinModal />
|
||||
<NewDiscussionModal />
|
||||
|
|
|
|||
|
|
@ -10,9 +10,19 @@
|
|||
type Author,
|
||||
} from "$lib/components/ThreadItem.svelte";
|
||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { openDraft } from "$lib/draft.svelte";
|
||||
|
||||
onMount(loadThreads);
|
||||
|
||||
function onNewTopic() {
|
||||
if (!auth.user) {
|
||||
openLogin();
|
||||
return;
|
||||
}
|
||||
openDraft();
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
|
|
@ -61,6 +71,7 @@
|
|||
<div class="flex items-center justify-between py-2">
|
||||
<h1 class="text-[1.65rem] text-brand">Discussions</h1>
|
||||
<button
|
||||
onclick={onNewTopic}
|
||||
class="rounded bg-brand px-6 py-1.5 text-sm font-medium text-white hover:bg-brand-hover"
|
||||
>
|
||||
New Topic
|
||||
|
|
|
|||
|
|
@ -4,8 +4,18 @@
|
|||
type ThreadRow,
|
||||
} from "$lib/components/ThreadItem.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { openDraft } from "$lib/draft.svelte";
|
||||
|
||||
const slug = $derived(page.params.slug);
|
||||
|
||||
function onNewTopic() {
|
||||
if (!auth.user) {
|
||||
openLogin();
|
||||
return;
|
||||
}
|
||||
openDraft();
|
||||
}
|
||||
const room = $derived(rooms.find((r) => r.slug === slug));
|
||||
|
||||
function mockToRow(t: Thread): ThreadRow {
|
||||
|
|
@ -34,6 +44,7 @@
|
|||
<div class="flex items-center justify-between pb-2">
|
||||
<h1 class="text-[1.65rem] text-brand leading-7">{room?.name ?? slug}</h1>
|
||||
<button
|
||||
onclick={onNewTopic}
|
||||
class="rounded bg-brand px-6 py-1.5 text-sm font-medium text-white hover:bg-brand-hover"
|
||||
>
|
||||
New Topic
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue