a18hvcryt: Delete the signed-in person's own post from its menu

This commit is contained in:
2026-10-19 14:32:06 -06:00
parent 5a33546af2
commit 0861663bea
14 changed files with 383 additions and 8 deletions
+8 -2
View File
@@ -38,7 +38,13 @@ export function App() {
);
}
if (state.kind === "out") {
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
return <SignInForm onSignedIn={(person) => {
setState({ kind: "in", person });
// POST /api/session keeps its shape; the feed user id comes from GET.
void loadSession().then((full) => {
if (full) setState({ kind: "in", person: full });
});
}} />;
}
return (
<main className="shell">
@@ -52,7 +58,7 @@ export function App() {
{signingOut ? "Signing out..." : "Sign out"}
</button>
</header>
<Feed onSignedOut={() => setState({ kind: "out" })} />
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
</main>
);
}
+34 -1
View File
@@ -5,11 +5,12 @@ 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 { PostError, publishAndConfirm } from "@/lib/post-client";
import { DeleteError, sendDelete } from "@/lib/delete-client";
import { FeedView, type FeedStatus } from "./FeedView";
const POLL_MS = 45_000;
export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; feedUserId?: string | null }) {
const [posts, setPosts] = useState<CardPost[]>([]);
const [status, setStatus] = useState<FeedStatus>("loading");
const [nextCursor, setNextCursor] = useState<string | null>(null);
@@ -18,6 +19,8 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
const [pending, setPending] = useState<CardPost[]>([]);
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
const [likeErrors, setLikeErrors] = useState<ReadonlySet<string>>(new Set());
const [deleteBusy, setDeleteBusy] = useState<ReadonlySet<string>>(new Set());
const [deleteErrors, setDeleteErrors] = useState<ReadonlySet<string>>(new Set());
const [draft, setDraft] = useState("");
const [posting, setPosting] = useState(false);
const [postFailed, setPostFailed] = useState(false);
@@ -156,6 +159,32 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
[fail],
);
const removePost = useCallback(
async (post: CardPost) => {
const mark = (set: ReadonlySet<string>, on: boolean) => {
const next = new Set(set);
if (on) next.add(post.key);
else next.delete(post.key);
return next;
};
setDeleteBusy((s) => mark(s, true));
setDeleteErrors((s) => mark(s, false));
try {
// Removes the card only after the feed answered 200.
if (!post.id) throw new DeleteError(false);
await sendDelete(post.id);
setPosts((cur) => cur.filter((p) => p.key !== post.key));
setPending((cur) => cur.filter((p) => p.key !== post.key));
} catch (e) {
if (e instanceof DeleteError && e.signedOut) onSignedOut();
setDeleteErrors((s) => mark(s, true));
} finally {
setDeleteBusy((s) => mark(s, false));
}
},
[onSignedOut],
);
const publish = useCallback(async () => {
const text = draft.trim();
if (!text || posting) return;
@@ -199,6 +228,10 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
onToggleLike={(p) => void toggleLike(p)}
likeBusy={likeBusy}
likeErrors={likeErrors}
feedUserId={feedUserId}
onDelete={(p) => void removePost(p)}
deleteBusy={deleteBusy}
deleteErrors={deleteErrors}
onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }}
sentinelRef={sentinel}
+10 -1
View File
@@ -1,5 +1,5 @@
import type { Ref } from "react";
import type { CardPost } from "@/lib/feed-model";
import { canDelete, type CardPost } from "@/lib/feed-model";
import { Composer, type ComposerProps } from "./Composer";
import { PostCard } from "./PostCard";
@@ -17,6 +17,11 @@ export interface FeedViewProps {
likeBusy?: ReadonlySet<string>;
likeErrors?: ReadonlySet<string>;
onSignedOut?: () => void;
/** The signed-in person's feed user id; delete is offered only on their own posts. */
feedUserId?: string | null;
onDelete?: (post: CardPost) => void;
deleteBusy?: ReadonlySet<string>;
deleteErrors?: ReadonlySet<string>;
composer?: ComposerProps;
sentinelRef?: Ref<HTMLDivElement>;
now?: number;
@@ -65,6 +70,10 @@ export function FeedView(p: FeedViewProps) {
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
canDelete={canDelete(post, p.feedUserId)}
onDelete={p.onDelete}
deleteBusy={p.deleteBusy?.has(post.key)}
deleteError={p.deleteErrors?.has(post.key)}
/>)
)}
{p.loadMoreFailed ? (
+44 -1
View File
@@ -12,6 +12,11 @@ export interface PostCardProps {
likeBusy?: boolean;
likeError?: boolean;
onSignedOut?: () => void;
/** True only on the signed-in person's own post. */
canDelete?: boolean;
onDelete?: (post: CardPost) => void;
deleteBusy?: boolean;
deleteError?: boolean;
}
function Heart({ filled }: { filled: boolean }) {
@@ -24,8 +29,10 @@ function Heart({ filled }: { filled: boolean }) {
);
}
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut }: PostCardProps) {
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
const [open, setOpen] = useState(false);
const [menu, setMenu] = useState(false);
const [confirming, setConfirming] = useState(false);
return (
<article className="post" data-testid="post-card">
<header className="post-head">
@@ -41,7 +48,43 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
<strong>{post.authorName}</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
</div>
{canDelete && onDelete ? (
<div className="post-menu">
<button
type="button"
className="menu-btn"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menu}
disabled={deleteBusy}
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
>
...
</button>
{menu ? (
<div className="menu" role="menu">
{confirming ? (
<>
<span className="menu-ask">Delete this post?</span>
<button type="button" role="menuitem" className="menu-danger" disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete(post); }}>
Confirm delete
</button>
<button type="button" role="menuitem" onClick={() => { setConfirming(false); setMenu(false); }}>
Cancel
</button>
</>
) : (
<button type="button" role="menuitem" className="menu-danger" onClick={() => setConfirming(true)}>
Delete post
</button>
)}
</div>
) : null}
</div>
) : null}
</header>
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
{post.text ? <p className="post-text">{post.text}</p> : null}
{post.media.map((m, i) =>
m.kind === "video" && m.hlsUrl ? (