From 28037a2849e0f7facc6aeb658c4c88a36ea2d09b Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 14:16:52 -0600 Subject: [PATCH 1/7] 4wswt4ex2: Add like and unlike for post cards --- src/app/api/posts/[id]/like/route.ts | 30 +++++ src/app/globals.css | 7 ++ src/components/Feed.tsx | 31 ++++++ src/components/FeedView.tsx | 12 +- src/components/PostCard.tsx | 36 +++++- src/lib/feed-model.ts | 3 + src/lib/like-client.ts | 52 +++++++++ src/lib/post-like.ts | 76 +++++++++++++ tests/like.test.ts | 160 +++++++++++++++++++++++++++ 9 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 src/app/api/posts/[id]/like/route.ts create mode 100644 src/lib/like-client.ts create mode 100644 src/lib/post-like.ts create mode 100644 tests/like.test.ts diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts new file mode 100644 index 0000000..0090194 --- /dev/null +++ b/src/app/api/posts/[id]/like/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE } from "@/lib/cookie"; +import { setPostLike } from "@/lib/post-like"; + +// POST /api/posts/:id/like -> feed POST /api/v1/posts/{id}/like +// DELETE /api/posts/:id/like -> feed DELETE /api/v1/posts/{id}/like +// No request body. Returns { isLiked, likesCount }; likesCount is null when the +// feed response carries no count. 401 { error: "sign_in" } without a session. + +export const runtime = "nodejs"; + +type Ctx = { params: Promise<{ id: string }> }; + +async function handle(req: NextRequest, ctx: Ctx, liked: boolean) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const { id } = await ctx.params; + const result = await setPostLike(sessionId, id, liked); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ isLiked: result.isLiked, likesCount: result.likesCount }); +} + +export function POST(req: NextRequest, ctx: Ctx) { + return handle(req, ctx, true); +} + +export function DELETE(req: NextRequest, ctx: Ctx) { + return handle(req, ctx, false); +} diff --git a/src/app/globals.css b/src/app/globals.css index f586a20..8897492 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -35,3 +35,10 @@ button:disabled { opacity: .5; cursor: not-allowed; } @keyframes spin { to { transform: rotate(360deg); } } .new-posts { position: sticky; top: 68px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; } .sentinel { height: 1px; } +.like-btn { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 4px 8px; background: transparent; color: var(--grey); border-radius: 8px; font-size: 14px; font-weight: 400; } +.like-btn:hover:not(:disabled) { color: var(--red); background: #fff3f3; } +.like-btn.liked { color: var(--red); } +.like-btn:disabled { opacity: 1; cursor: default; } +.like-btn:disabled:not(.liked) { color: var(--grey); } +.like-error { color: #a8141b; font-size: 13px; align-self: center; } +.post-stats { align-items: center; } diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 1721273..1e0409a 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client"; +import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client"; import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model"; import { FeedView, type FeedStatus } from "./FeedView"; @@ -14,6 +15,8 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) { const [loadingMore, setLoadingMore] = useState(false); const [loadMoreFailed, setLoadMoreFailed] = useState(false); const [pending, setPending] = useState([]); + const [likeBusy, setLikeBusy] = useState>(new Set()); + const [likeErrors, setLikeErrors] = useState>(new Set()); const sentinel = useRef(null); const busy = useRef(false); const shown = useRef>(new Set()); @@ -121,6 +124,31 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) { }; }, [status, fail]); + const toggleLike = useCallback( + async (post: CardPost) => { + if (!post.id) return; + const wanted = !post.isLiked; + const mark = (set: ReadonlySet, on: boolean) => { + const next = new Set(set); + if (on) next.add(post.key); + else next.delete(post.key); + return next; + }; + setLikeBusy((s) => mark(s, true)); + setLikeErrors((s) => mark(s, false)); + try { + const out = await sendLike(post.id, wanted); + setPosts((cur) => cur.map((p) => (p.key === post.key ? applyLikeOutcome(p, wanted, out) : p))); + } catch (e) { + fail(e instanceof LikeError && e.signedOut ? new FeedLoadError(true) : e); + setLikeErrors((s) => mark(s, true)); + } finally { + setLikeBusy((s) => mark(s, false)); + } + }, + [fail], + ); + function showNew() { setPosts((cur) => mergePosts(cur, pending)); setPending([]); @@ -136,6 +164,9 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) { newCount={pending.length} onRetry={() => (status === "error" ? void loadFirst() : void loadMore())} onShowNew={showNew} + onToggleLike={(p) => void toggleLike(p)} + likeBusy={likeBusy} + likeErrors={likeErrors} sentinelRef={sentinel} /> ); diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index 3b57da4..5aee56d 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -12,6 +12,9 @@ export interface FeedViewProps { newCount: number; onRetry: () => void; onShowNew: () => void; + onToggleLike?: (post: CardPost) => void; + likeBusy?: ReadonlySet; + likeErrors?: ReadonlySet; sentinelRef?: Ref; now?: number; } @@ -50,7 +53,14 @@ export function FeedView(p: FeedViewProps) { {p.posts.length === 0 ? (

No posts yet.

) : ( - p.posts.map((post) => ) + p.posts.map((post) => ) )} {p.loadMoreFailed ? (
diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index f36d04a..98c7fa3 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -1,7 +1,25 @@ import { VideoPlayer } from "@/components/VideoPlayer"; import { initials, relativeTime, type CardPost } from "@/lib/feed-model"; -export function PostCard({ post, now }: { post: CardPost; now?: number }) { +export interface PostCardProps { + post: CardPost; + now?: number; + onToggleLike?: (post: CardPost) => void; + likeBusy?: boolean; + likeError?: boolean; +} + +function Heart({ filled }: { filled: boolean }) { + return ( + + ); +} + +export function PostCard({ post, now, onToggleLike, likeBusy, likeError }: PostCardProps) { return (
@@ -32,10 +50,20 @@ export function PostCard({ post, now }: { post: CardPost; now?: number }) { ) : null, )}
- - {post.likesCount} - + {post.commentsCount} comments + {likeError ? ( + Could not update like. + ) : null}
); diff --git a/src/lib/feed-model.ts b/src/lib/feed-model.ts index d3363fa..225c3a5 100644 --- a/src/lib/feed-model.ts +++ b/src/lib/feed-model.ts @@ -18,6 +18,8 @@ export interface CardPost { time: number; text: string; likesCount: number; + /** Whether the signed-in person has liked the post. */ + isLiked: boolean; commentsCount: number; media: CardMedia[]; } @@ -99,6 +101,7 @@ export function toCardPost(item: unknown, index = 0): CardPost | null { time: Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY, text: typeof post.content === "string" ? post.content : "", likesCount: num(item.likesCount ?? post.likesCount), + isLiked: post.isLiked === true || item.isLiked === true, commentsCount: num(item.commentsCount ?? post.commentsCount), media: toMedia(post.mediaItems), }; diff --git a/src/lib/like-client.ts b/src/lib/like-client.ts new file mode 100644 index 0000000..aa4633b --- /dev/null +++ b/src/lib/like-client.ts @@ -0,0 +1,52 @@ +// Browser-side like / unlike calls and the pure card update they drive. + +import type { CardPost } from "./feed-model"; + +export interface LikeOutcome { + isLiked: boolean; + likesCount: number | null; +} + +export class LikeError extends Error { + constructor(public readonly signedOut: boolean) { + super(signedOut ? "sign_in" : "like_failed"); + } +} + +export async function sendLike( + postId: string, + liked: boolean, + fetchImpl: typeof fetch = fetch, +): Promise { + let res: Response; + try { + res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}/like`, { + method: liked ? "POST" : "DELETE", + credentials: "same-origin", + }); + } catch { + throw new LikeError(false); + } + if (res.status === 401) throw new LikeError(true); + if (!res.ok) throw new LikeError(false); + try { + const b = (await res.json()) as { isLiked?: unknown; likesCount?: unknown }; + return { + isLiked: typeof b.isLiked === "boolean" ? b.isLiked : liked, + likesCount: + typeof b.likesCount === "number" && Number.isInteger(b.likesCount) && b.likesCount >= 0 + ? b.likesCount + : null, + }; + } catch { + throw new LikeError(false); + } +} + +/** The card after a successful call: the server count when present, else flip and adjust by one. */ +export function applyLikeOutcome(post: CardPost, wanted: boolean, out: LikeOutcome): CardPost { + if (out.likesCount !== null) return { ...post, isLiked: out.isLiked, likesCount: out.likesCount }; + const changed = post.isLiked !== wanted; + const delta = changed ? (wanted ? 1 : -1) : 0; + return { ...post, isLiked: wanted, likesCount: Math.max(0, post.likesCount + delta) }; +} diff --git a/src/lib/post-like.ts b/src/lib/post-like.ts new file mode 100644 index 0000000..61869da --- /dev/null +++ b/src/lib/post-like.ts @@ -0,0 +1,76 @@ +import { FEED_BASE_URL } from "./env"; +import { ensureFreshSession, refreshSession } from "./session-refresh"; + +// Like / unlike proxy. One call to the feed per request, with the person's +// server-held feed token. Like is idempotent: a like is never retried in a +// loop. The only repeat is one refresh-and-retry after a 401 token rejection. + +export type LikeResult = + | { ok: true; isLiked: boolean; likesCount: number | null } + | { ok: false; status: 400 | 401 | 502; error: "sign_in" | "bad_post" | "like_failed" }; + +interface LikeOptions { + fetchImpl?: typeof fetch; + now?: number; +} + +const POST_ID = /^[A-Za-z0-9_-]{1,128}$/; + +function call(token: string, postId: string, liked: boolean, doFetch: typeof fetch) { + return doFetch(`${FEED_BASE_URL}/api/v1/posts/${encodeURIComponent(postId)}/like`, { + method: liked ? "POST" : "DELETE", + headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, + }); +} + +function rec(v: unknown): Record { + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; +} + +/** A like count from the response body, only when it is a non-negative integer. */ +function readCount(body: unknown): number | null { + const root = rec(body); + const data = rec(root.data); + for (const v of [data.likesCount, data.likeCount, root.likesCount, root.likeCount]) { + if (typeof v === "number" && Number.isInteger(v) && v >= 0) return v; + } + return null; +} + +export async function setPostLike( + sessionId: string | undefined, + postId: string, + liked: boolean, + options: LikeOptions = {}, +): Promise { + const signIn = { ok: false, status: 401, error: "sign_in" } as const; + const failed = { ok: false, status: 502, error: "like_failed" } as const; + const doFetch = options.fetchImpl ?? fetch; + + let session = await ensureFreshSession(sessionId, options); + if (!session || !sessionId) return signIn; + if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" }; + + let res: Response; + try { + res = await call(session.feedAccessToken, postId, liked, doFetch); + if (res.status === 401) { + session = await refreshSession(sessionId, options); + if (!session) return signIn; + res = await call(session.feedAccessToken, postId, liked, doFetch); + if (res.status === 401) return signIn; + } + } catch { + return failed; + } + if (res.status !== 200) return failed; + + let body: unknown = null; + try { + body = await res.json(); + } catch { + return failed; + } + if (rec(rec(body).data).success !== true) return failed; + return { ok: true, isLiked: liked, likesCount: readCount(body) }; +} diff --git a/tests/like.test.ts b/tests/like.test.ts new file mode 100644 index 0000000..2ecc882 --- /dev/null +++ b/tests/like.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { NextRequest } from "next/server"; +import { DELETE, POST } from "@/app/api/posts/[id]/like/route"; +import { PostCard } from "@/components/PostCard"; +import { toCardPost, type CardPost } from "@/lib/feed-model"; +import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client"; +import { createSession } from "@/lib/session-store"; + +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); +const card = (extra: Partial = {}): CardPost => ({ + ...toCardPost({ type: "post", publishedAt: "2026-10-19T10:00:00Z", likesCount: 3, post: { id: "p1", content: "hi", author: {}, isLiked: false } })!, + ...extra, +}); + +function session() { + return createSession({ + profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r", + feedAccessToken: "T", feedAccessTokenExpiresAt: null, + person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null }, createdAt: Date.now(), + }); +} + +/** A fake feed that keeps real like state: a second like never raises the count. */ +function fakeFeed(withCount: boolean) { + const state = { liked: false, count: 3, calls: [] as string[] }; + const fn = vi.fn(async (url: string, init?: RequestInit) => { + state.calls.push(`${init?.method} ${new URL(String(url), "http://x").pathname}`); + if (init?.method === "POST" && !state.liked) { state.liked = true; state.count += 1; } + if (init?.method === "DELETE" && state.liked) { state.liked = false; state.count -= 1; } + const data: Record = { success: true }; + if (withCount) data.likesCount = state.count; + return new Response(JSON.stringify({ data }), { status: 200 }); + }); + return { state, fn }; +} + +async function withFetch(fn: typeof fetch, run: () => Promise) { + const orig = globalThis.fetch; + globalThis.fetch = fn; + try { return await run(); } finally { globalThis.fetch = orig; } +} + +describe("like routes", () => { + it("401 sign_in without a session", async () => { + for (const h of [POST, DELETE]) { + const res = await h(new NextRequest("http://localhost/api/posts/p1/like", { method: "POST" }), ctx("p1")); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "sign_in" }); + } + }); + + it("like then unlike hit the matching feed routes and return server state", async () => { + const sid = session(); + const feed = fakeFeed(true); + const h = { cookie: `mfb_session=${sid}` }; + await withFetch(feed.fn as unknown as typeof fetch, async () => { + const a = await POST(new NextRequest("http://localhost/api/posts/p1/like", { method: "POST", headers: h }), ctx("p1")); + expect(await a.json()).toEqual({ isLiked: true, likesCount: 4 }); + const b = await DELETE(new NextRequest("http://localhost/api/posts/p1/like", { method: "DELETE", headers: h }), ctx("p1")); + expect(await b.json()).toEqual({ isLiked: false, likesCount: 3 }); + }); + expect(feed.state.calls).toEqual(["POST /api/v1/posts/p1/like", "DELETE /api/v1/posts/p1/like"]); + }); + + it("a second like does not raise the count and is not retried", async () => { + const sid = session(); + const feed = fakeFeed(true); + const h = { cookie: `mfb_session=${sid}` }; + await withFetch(feed.fn as unknown as typeof fetch, async () => { + await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1")); + const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1")); + expect(await r.json()).toEqual({ isLiked: true, likesCount: 4 }); + }); + expect(feed.fn).toHaveBeenCalledTimes(2); + }); + + it("returns likesCount null when the feed carries none; failure is 502 like_failed", async () => { + const sid = session(); + const h = { cookie: `mfb_session=${sid}` }; + await withFetch(fakeFeed(false).fn as unknown as typeof fetch, async () => { + const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1")); + expect(await r.json()).toEqual({ isLiked: true, likesCount: null }); + }); + const bad = vi.fn(async () => new Response("{}", { status: 500 })) as unknown as typeof fetch; + await withFetch(bad, async () => { + const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1")); + expect(r.status).toBe(502); + expect(await r.json()).toEqual({ error: "like_failed" }); + }); + const notOk = vi.fn(async () => new Response(JSON.stringify({ data: { success: false } }), { status: 200 })) as unknown as typeof fetch; + await withFetch(notOk, async () => { + const r = await DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("p1")); + expect(r.status).toBe(502); + }); + }); + + it("rejects a post id that is not a plain id", async () => { + const sid = session(); + const f = vi.fn(); + await withFetch(f as unknown as typeof fetch, async () => { + const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: { cookie: `mfb_session=${sid}` } }), ctx("../admin/x")); + expect(r.status).toBe(400); + }); + expect(f).not.toHaveBeenCalled(); + }); +}); + +describe("card like state", () => { + it("reads isLiked from the post", () => { + const c = toCardPost({ type: "post", post: { id: "1", isLiked: true, author: {} } }); + expect(c?.isLiked).toBe(true); + }); + + it("like then unlike through the client matches server state", async () => { + const feed = fakeFeed(true); + const proxy = vi.fn(async (url: string, init?: RequestInit) => { + const r = await feed.fn(url, init); + const b = (await r.json()) as { data: { likesCount: number } }; + return new Response(JSON.stringify({ isLiked: init?.method === "POST", likesCount: b.data.likesCount }), { status: 200 }); + }) as unknown as typeof fetch; + let c = card(); + c = applyLikeOutcome(c, true, await sendLike("p1", true, proxy)); + expect([c.isLiked, c.likesCount]).toEqual([true, 4]); + c = applyLikeOutcome(c, false, await sendLike("p1", false, proxy)); + expect([c.isLiked, c.likesCount]).toEqual([false, 3]); + }); + + it("without a server count, flips and adjusts by one; a repeat does not double count", () => { + let c = card(); + c = applyLikeOutcome(c, true, { isLiked: true, likesCount: null }); + expect([c.isLiked, c.likesCount]).toEqual([true, 4]); + c = applyLikeOutcome(c, true, { isLiked: true, likesCount: null }); + expect(c.likesCount).toBe(4); + c = applyLikeOutcome(c, false, { isLiked: false, likesCount: null }); + expect([c.isLiked, c.likesCount]).toEqual([false, 3]); + }); + + it("a failed call throws and the previous card is untouched", async () => { + const before = card(); + const f = vi.fn(async () => new Response("{}", { status: 502 })) as unknown as typeof fetch; + await expect(sendLike("p1", true, f)).rejects.toBeInstanceOf(LikeError); + expect(before.isLiked).toBe(false); + expect(before.likesCount).toBe(3); + const signedOut = vi.fn(async () => new Response("{}", { status: 401 })) as unknown as typeof fetch; + await expect(sendLike("p1", true, signedOut)).rejects.toMatchObject({ signedOut: true }); + }); + + it("renders outlined vs filled heart, pressed state, and the action error", () => { + const off = renderToStaticMarkup(createElement(PostCard, { post: card(), onToggleLike: () => {} })); + expect(off).toContain('aria-pressed="false"'); + expect(off).toContain('fill="none"'); + expect(off).not.toContain("Could not update like."); + const on = renderToStaticMarkup(createElement(PostCard, { post: card({ isLiked: true }), onToggleLike: () => {}, likeError: true })); + expect(on).toContain('aria-pressed="true"'); + expect(on).toContain('fill="currentColor"'); + expect(on).toContain("Could not update like."); + }); +}); From de535a500deedca1165e7500fdbd5536012069b0 Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 14:19:12 -0600 Subject: [PATCH 2/7] 3dom4wf7p: Publish public text posts from a feed composer --- src/app/api/posts/route.ts | 26 ++++++++ src/app/globals.css | 5 ++ src/components/Composer.tsx | 36 ++++++++++ src/components/Feed.tsx | 33 ++++++++++ src/components/FeedView.tsx | 3 + src/lib/post-client.ts | 80 +++++++++++++++++++++++ src/lib/post-create.ts | 94 ++++++++++++++++++++++++++ tests/post.test.ts | 127 ++++++++++++++++++++++++++++++++++++ 8 files changed, 404 insertions(+) create mode 100644 src/app/api/posts/route.ts create mode 100644 src/components/Composer.tsx create mode 100644 src/lib/post-client.ts create mode 100644 src/lib/post-create.ts create mode 100644 tests/post.test.ts diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts new file mode 100644 index 0000000..0164581 --- /dev/null +++ b/src/app/api/posts/route.ts @@ -0,0 +1,26 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE } from "@/lib/cookie"; +import { createTextPost } from "@/lib/post-create"; + +// POST /api/posts { content } +// Creates a public text draft on the feed, then publishes it. Returns { id }. +// The create call can report status "draft" for a post already on the home +// feed, so the browser reloads the first page and confirms the id there. +// 401 { error: "sign_in" } without a session. + +export const runtime = "nodejs"; + +export async function POST(req: NextRequest) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + let content: unknown = null; + try { + content = ((await req.json()) as { content?: unknown }).content; + } catch { + content = null; + } + const result = await createTextPost(sessionId, content); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ id: result.id }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 8897492..2c90508 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -42,3 +42,8 @@ button:disabled { opacity: .5; cursor: not-allowed; } .like-btn:disabled:not(.liked) { color: var(--grey); } .like-error { color: #a8141b; font-size: 13px; align-self: center; } .post-stats { align-items: center; } +.composer { background: #fff; border: 1px solid var(--line); border-radius: 0; padding: 16px; display: flex; flex-direction: column; gap: 8px; } +.composer-label { margin: 0; } +.composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid #999; border-radius: 0; resize: vertical; background: #fff; color: var(--ink); } +.composer textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } +.composer .composer-post { align-self: flex-end; margin: 0; padding: 10px 24px; border-radius: 0; } diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx new file mode 100644 index 0000000..02b367b --- /dev/null +++ b/src/components/Composer.tsx @@ -0,0 +1,36 @@ +import type { FormEvent } from "react"; + +export interface ComposerProps { + value: string; + busy: boolean; + error: boolean; + onChange: (value: string) => void; + onSubmit: () => void; +} + +export function Composer({ value, busy, error, onChange, onSubmit }: ComposerProps) { + const empty = value.trim().length === 0; + function submit(e: FormEvent) { + e.preventDefault(); + if (!empty && !busy) onSubmit(); + } + return ( +
+ +