mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user