Files
maarcadetweet/crates/tauri-app/src/lib/components/ComposeBox.svelte
T
tomdebone b912132a05 tauri-app: 8a review fixes
- fetchBlob cache keyed by (did, cid), not just cid.
  Security: future per-DID access control on getBlob would
  otherwise leak the first responder's bytes to subsequent
  viewers.
- EmbedImage: pass did to releaseBlob, release previous cid
  on cid change (no leaked URLs).
- ComposeBox: releaseBlob called with both did and cid.
- pds-server: rename test
  get_blob_after_upload_with_different_did ->
  get_blob_returns_404_for_cross_did_cid_lookup. The
  docstring was misleading — the test only verifies the
  (did,cid) PK on the PDS row, not auth. The renamed name
  matches what the test actually checks.
- vitest: update releaseBlob call sites to the new
  (did, cid) signature.
2026-07-06 18:53:09 +02:00

302 lines
9.0 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import {
createPost,
fetchBlob,
pickAndUploadImage,
makeImagesEmbed,
showError,
releaseBlob,
session,
type Post,
} from "../api/client";
const MAX = 160;
let { onPosted }: { onPosted?: () => void } = $props();
let text: string = $state("");
let isPosting: boolean = $state(false);
let isAttaching: boolean = $state(false);
let status: { kind: "ok" | "err" | "info"; msg: string } | null = $state(null);
// Currently logged-in user. We need the DID for `fetchBlob` (the
// PDS endpoint keys blobs by `(did, cid)`), so the compose box
// subscribes to the session store rather than taking a prop.
let did: string = $state("");
$effect(() => {
const u = $session;
did = u?.did ?? "";
});
// The currently-attached image. `null` = no attachment. We hold
// the blob reference + a local object URL for the preview so the
// user sees the image before they post.
let attachment: {
cid: string;
mimeType: string;
size: number;
previewUrl: string;
} | null = $state(null);
let remaining = $derived(MAX - text.length);
let counterClass = $derived(
remaining < 0 ? "counter counter--err" :
remaining < 40 ? "counter counter--warn" : "counter"
);
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
post();
}
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
}
async function attach() {
if (isAttaching || attachment) return;
if (!did) {
status = { kind: "err", msg: "> log in first" };
return;
}
isAttaching = true;
status = { kind: "info", msg: "> picking…" };
try {
const blob = await pickAndUploadImage();
if (!blob) {
// User cancelled — restore the previous status rather than
// leaving the "picking…" message on screen.
status = null;
return;
}
// Fetch the bytes back from the PDS so we can render the
// preview. `fetchBlob` caches by CID, so re-rendering the
// preview after a re-attach is cheap.
const previewUrl = await fetchBlob(did, blob.cid);
attachment = { ...blob, previewUrl };
status = { kind: "info", msg: `> attached (${fmtBytes(blob.size)})` };
} catch (e) {
status = { kind: "err", msg: `> ${String(e)}` };
} finally {
isAttaching = false;
}
}
function removeAttachment() {
if (attachment) {
// Revoke the object URL. `fetchBlob` may have evicted the
// cache entry for a different reason, so tolerate a no-op.
// The user can re-attach — the next fetch will allocate a
// fresh URL.
releaseBlob(did, attachment.cid);
attachment = null;
}
}
async function post() {
if (!text.trim() || remaining < 0 || isPosting) return;
isPosting = true;
status = { kind: "info", msg: "> posting…" };
try {
const embed = attachment ? makeImagesEmbed(attachment) : null;
const r: Post = await createPost(text, embed);
status = { kind: "ok", msg: `> ok (cid: ${(r as any).cid?.slice?.(0, 8) ?? "?"}…)` };
text = "";
removeAttachment();
onPosted?.();
} catch (e) {
status = { kind: "err", msg: `> ${String(e)}` };
showError(`post failed: ${e}`);
} finally {
isPosting = false;
}
}
</script>
<div class="compose">
<div class="compose__head">
<span class="title">// compose</span>
<span class="handle">@you</span>
<span class={counterClass}>{remaining}</span>
</div>
<div class="compose__body">
<span class="prompt">$</span>
<textarea
bind:value={text}
onkeydown={handleKeydown}
placeholder="// what's happening in 160 chars?"
rows="3"
maxlength="500"
></textarea>
</div>
{#if attachment}
<div class="compose__attach">
<img
class="compose__preview"
src={attachment.previewUrl}
alt="attachment preview"
/>
<div class="compose__attach-meta">
<span class="compose__attach-cid" title={attachment.cid}>cid: {attachment.cid.slice(0, 10)}…</span>
<span class="compose__attach-mime">{attachment.mimeType}</span>
<span class="compose__attach-size">{fmtBytes(attachment.size)}</span>
</div>
<button
type="button"
class="compose__attach-remove"
onclick={removeAttachment}
disabled={isPosting}
title="remove attachment"
>×</button>
</div>
{/if}
<div class="compose__foot">
<span class="hint">⌘↵ to post</span>
<div class="actions">
<button
type="button"
class="btn btn--ghost"
onclick={attach}
disabled={isAttaching || !!attachment || isPosting}
title={attachment ? "image already attached" : "attach image"}
>
{isAttaching ? "picking…" : "📎"}
</button>
<button class="btn btn--ghost" onclick={() => (text = "")} disabled={!text || isPosting}>draft</button>
<button class="btn btn--primary" onclick={post} disabled={!text.trim() || remaining < 0 || isPosting}>
{isPosting ? "posting…" : "post"}
</button>
</div>
</div>
{#if status}
<div class="status status--{status.kind}">{status.msg}</div>
{/if}
</div>
<style>
.compose {
background: var(--bg-elev);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
margin: var(--s-4) var(--s-5);
}
.compose__head {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
background: var(--bg-deep);
border-bottom: 1px solid var(--line);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.title { color: var(--orange); }
.handle { color: var(--text-dim); flex: 1; }
.counter { color: var(--text-dim); font-variant-numeric: tabular-nums; }
.counter--warn { color: var(--orange); }
.counter--err { color: var(--red); letter-spacing: 0.05em; }
.compose__body {
display: flex;
align-items: flex-start;
gap: var(--s-2);
padding: var(--s-3) var(--s-4);
}
.prompt {
color: var(--orange);
font-family: var(--font-mono);
font-size: var(--fs-100);
line-height: 1.7;
}
textarea {
flex: 1;
background: transparent;
border: 0;
color: var(--text);
font-family: var(--font-sans);
font-size: var(--fs-100);
line-height: 1.6;
resize: vertical;
outline: none;
padding: 0;
}
textarea::placeholder { color: var(--text-dim); }
.compose__attach {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: var(--bg-deep);
border-top: 1px dashed var(--line);
}
.compose__preview {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--bg);
}
.compose__attach-meta {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
}
.compose__attach-cid { color: var(--cid-fg); }
.compose__attach-mime,
.compose__attach-size { font-variant-numeric: tabular-nums; }
.compose__attach-remove {
background: transparent;
border: 1px solid var(--line-2);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-100);
width: 28px;
height: 28px;
border-radius: var(--r-sm);
cursor: pointer;
line-height: 1;
}
.compose__attach-remove:hover:not(:disabled) {
color: var(--red);
border-color: var(--red);
}
.compose__attach-remove:disabled { opacity: 0.4; cursor: not-allowed; }
.compose__foot {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-2) var(--s-4);
border-top: 1px solid var(--line);
}
.hint { font-family: var(--font-mono); font-size: var(--fs-50); color: var(--text-dim); }
.actions { display: flex; gap: var(--s-2); }
.btn {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.4rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid transparent;
cursor: pointer;
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.btn--ghost { color: var(--text-dim); border-color: var(--line-2); background: transparent; }
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.status {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: var(--s-2) var(--s-4);
border-top: 1px solid var(--line);
}
.status--ok { color: var(--green); }
.status--err { color: var(--red); }
.status--info { color: var(--orange); }
</style>