Fix forum post card bugs on desktop and mobile (#370)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-20 11:14:57 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent eda85c8446
commit 1df5f186a6
5 changed files with 172 additions and 191 deletions
@@ -0,0 +1,57 @@
import { MoreHorizontal, Trash2 } from "lucide-react";
import * as React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
type DeleteActionMenuProps = {
label: string;
onConfirm: () => void;
iconSize?: "sm" | "md";
};
export function DeleteActionMenu({
label,
onConfirm,
iconSize = "md",
}: DeleteActionMenuProps) {
const [isOpen, setIsOpen] = React.useState(false);
const iconClass = iconSize === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
return (
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
tabIndex={-1}
type="button"
>
<MoreHorizontal className={iconClass} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setIsOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete {label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DeleteConfirmDialog
label={label}
onConfirm={onConfirm}
onOpenChange={setIsOpen}
open={isOpen}
/>
</div>
);
}
@@ -0,0 +1,48 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
export function DeleteConfirmDialog({
open,
onOpenChange,
onConfirm,
label,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
label: string;
}) {
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {label}?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this {label} and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={onConfirm} type="button" variant="destructive">
Delete {label}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+20 -75
View File
@@ -1,5 +1,4 @@
import { MessageSquare, MoreHorizontal, Trash2 } from "lucide-react";
import * as React from "react";
import { MessageSquare } from "lucide-react";
import {
resolveUserLabel,
@@ -9,26 +8,10 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { ForumPost } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Markdown } from "@/shared/ui/markdown";
import { formatRelativeTime } from "../lib/time";
import { DeleteActionMenu } from "./DeleteActionMenu";
type ForumPostCardProps = {
post: ForumPost;
@@ -51,7 +34,6 @@ export function ForumPostCard({
onClick,
onDelete,
}: ForumPostCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const authorLabel = resolveUserLabel({
pubkey: post.pubkey,
currentPubkey,
@@ -67,14 +49,22 @@ export function ForumPostCard({
: post.content;
return (
<button
// biome-ignore lint/a11y/useSemanticElements: Cannot use <button> because DeleteActionMenu renders a nested <button> via DropdownMenuTrigger, which is invalid HTML
<div
role="button"
tabIndex={0}
className={cn(
"group w-full cursor-pointer rounded-xl border border-border/60 bg-card p-4 text-left transition-colors hover:border-border hover:bg-accent/40",
isActive && "border-primary/40 bg-accent/60",
isDeleting && "pointer-events-none opacity-50",
)}
onClick={() => onClick(post)}
type="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick(post);
}
}}
>
<div className="flex items-center gap-2">
<UserAvatar avatarUrl={avatarUrl} displayName={authorLabel} size="sm" />
@@ -86,61 +76,16 @@ export function ForumPostCard({
</span>
{canDelete && onDelete ? (
// biome-ignore lint/a11y/noStaticElementInteractions: presentation wrapper only stops click propagation to parent card link
<div
className="ml-auto opacity-0 transition-opacity group-hover:opacity-100"
onClickCapture={(e) => e.stopPropagation()}
className="ml-auto"
onClick={(e) => e.stopPropagation()}
role="presentation"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
tabIndex={-1}
type="button"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setIsDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete post
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog
onOpenChange={setIsDeleteDialogOpen}
open={isDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete post?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this post and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => onDelete(post.eventId)}
type="button"
variant="destructive"
>
Delete post
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteActionMenu
label="post"
onConfirm={() => onDelete(post.eventId)}
/>
</div>
) : null}
</div>
@@ -168,6 +113,6 @@ export function ForumPostCard({
) : null}
</div>
) : null}
</button>
</div>
);
}
@@ -1,4 +1,4 @@
import { ArrowLeft, MessageSquare, MoreHorizontal, Trash2 } from "lucide-react";
import { ArrowLeft, MessageSquare } from "lucide-react";
import * as React from "react";
import {
@@ -10,27 +10,12 @@ import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Markdown } from "@/shared/ui/markdown";
import { Skeleton } from "@/shared/ui/skeleton";
import { formatRelativeTime } from "../lib/time";
import { DeleteActionMenu } from "./DeleteActionMenu";
import { ForumComposer } from "./ForumComposer";
type ForumThreadPanelProps = {
@@ -62,43 +47,6 @@ function canDeleteReply(
return reply.pubkey.toLowerCase() === currentPubkey.toLowerCase();
}
function DeleteConfirmDialog({
open,
onOpenChange,
onConfirm,
label,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
label: string;
}) {
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {label}?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this {label} and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={onConfirm} type="button" variant="destructive">
Delete {label}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function ReplyRow({
reply,
currentPubkey,
@@ -112,7 +60,6 @@ function ReplyRow({
channelNames?: string[];
onDelete?: (eventId: string) => void;
}) {
const [isDeleteOpen, setIsDeleteOpen] = React.useState(false);
const replyAuthorLabel = resolveUserLabel({
pubkey: reply.pubkey,
currentPubkey,
@@ -140,33 +87,11 @@ function ReplyRow({
</span>
{showDelete ? (
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
type="button"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setIsDeleteOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete reply
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DeleteConfirmDialog
label="reply"
onConfirm={() => onDelete(reply.eventId)}
onOpenChange={setIsDeleteOpen}
open={isDeleteOpen}
/>
</div>
<DeleteActionMenu
iconSize="sm"
label="reply"
onConfirm={() => onDelete(reply.eventId)}
/>
) : null}
</div>
<div className="mt-1.5 pl-8">
@@ -198,7 +123,6 @@ export function ForumThreadPanel({
targetEventId,
}: ForumThreadPanelProps) {
const scrollRef = React.useRef<HTMLDivElement>(null);
const [isDeletePostOpen, setIsDeletePostOpen] = React.useState(false);
const { channels } = useChannelNavigation();
const channelNames = React.useMemo(
() => channels.filter((c) => c.channelType !== "dm").map((c) => c.name),
@@ -298,33 +222,10 @@ export function ForumThreadPanel({
</div>
{canDeletePost && onDeletePost ? (
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
type="button"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setIsDeletePostOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete post
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DeleteConfirmDialog
label="post"
onConfirm={() => onDeletePost(post.eventId)}
onOpenChange={setIsDeletePostOpen}
open={isDeletePostOpen}
/>
</div>
<DeleteActionMenu
label="post"
onConfirm={() => onDeletePost(post.eventId)}
/>
) : null}
</div>
<div className="mt-3">
+36 -6
View File
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../channels/message_content.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'forum_models.dart';
@@ -33,6 +34,11 @@ class ForumPostCard extends ConsumerWidget {
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? _shortPubkey(post.pubkey);
final mentionNames = ref.watch(
userCacheProvider.select(
(cache) => _buildMentionNames(post.mentionPubkeys, cache),
),
);
final preview = post.content.length > 200
? '${post.content.substring(0, 200)}...'
: post.content;
@@ -94,13 +100,23 @@ class ForumPostCard extends ConsumerWidget {
const SizedBox(height: Grid.xxs),
// Content preview
Text(
preview,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.white, Colors.transparent],
stops: [0.0, 0.75, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 120),
child: IgnorePointer(
child: MessageContent(
content: preview,
mentionNames: mentionNames,
),
),
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
// Thread summary
@@ -251,3 +267,17 @@ String _shortPubkey(String pubkey) {
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026';
return pubkey;
}
Map<String, String> _buildMentionNames(
List<String> mentionPubkeys,
Map<String, UserProfile> userCache,
) {
final names = <String, String>{};
for (final pk in mentionPubkeys) {
final p = userCache[pk.toLowerCase()];
if (p?.displayName != null) {
names[pk.toLowerCase()] = p!.displayName!;
}
}
return names;
}