mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
444 lines
13 KiB
TypeScript
444 lines
13 KiB
TypeScript
import { SmilePlus } from "lucide-react";
|
|
import * as React from "react";
|
|
|
|
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
|
import type { TimelineMessage } from "@/features/messages/types";
|
|
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
|
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
|
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
|
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
|
|
import {
|
|
MENTION_CHIP_BASE_CLASSES,
|
|
MENTION_CHIP_HOVER_CLASSES,
|
|
} from "@/shared/ui/mentionChip";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
|
import { MessageTimestamp } from "./MessageTimestamp";
|
|
|
|
type SystemMessagePayload = {
|
|
type: string;
|
|
actor?: string;
|
|
target?: string;
|
|
topic?: string;
|
|
purpose?: string;
|
|
};
|
|
|
|
type SystemMessageDescription = {
|
|
action: React.ReactNode;
|
|
title: React.ReactNode;
|
|
};
|
|
|
|
function resolveLabel(
|
|
pubkey: string | undefined,
|
|
currentPubkey: string | undefined,
|
|
profiles: UserProfileLookup | undefined,
|
|
): string {
|
|
if (!pubkey) {
|
|
return "Someone";
|
|
}
|
|
return resolveUserLabel({ pubkey, currentPubkey, profiles });
|
|
}
|
|
|
|
function resolvePersonaSuffix(
|
|
pubkey: string | undefined,
|
|
personaLookup: Map<string, string> | undefined,
|
|
): string {
|
|
if (!pubkey || !personaLookup) return "";
|
|
const personaName = personaLookup.get(pubkey.toLowerCase());
|
|
return personaName ? ` (${personaName})` : "";
|
|
}
|
|
|
|
function resolveAvatarUrl(
|
|
pubkey: string | undefined,
|
|
profiles: UserProfileLookup | undefined,
|
|
): string | null {
|
|
if (!pubkey || !profiles) return null;
|
|
return profiles[pubkey.toLowerCase()]?.avatarUrl ?? null;
|
|
}
|
|
|
|
function resolveLabelWithSuffix(
|
|
pubkey: string | undefined,
|
|
currentPubkey: string | undefined,
|
|
profiles: UserProfileLookup | undefined,
|
|
suffix = "",
|
|
): string {
|
|
return `${resolveLabel(pubkey, currentPubkey, profiles)}${suffix}`;
|
|
}
|
|
|
|
function ProfileName({
|
|
children,
|
|
highlight = false,
|
|
pubkey,
|
|
}: {
|
|
children: React.ReactNode;
|
|
highlight?: boolean;
|
|
pubkey: string | undefined;
|
|
}) {
|
|
const node = (
|
|
<span
|
|
className={cn(
|
|
pubkey && "cursor-pointer",
|
|
highlight
|
|
? cn(MENTION_CHIP_BASE_CLASSES, MENTION_CHIP_HOVER_CLASSES)
|
|
: "rounded-xs transition-colors hover:text-foreground",
|
|
)}
|
|
>
|
|
{highlight ? "@" : null}
|
|
{children}
|
|
</span>
|
|
);
|
|
|
|
return pubkey ? (
|
|
<UserProfilePopover pubkey={pubkey} triggerElement="span">
|
|
{node}
|
|
</UserProfilePopover>
|
|
) : (
|
|
node
|
|
);
|
|
}
|
|
|
|
function SystemMessageAvatar({
|
|
actorPubkey,
|
|
currentPubkey,
|
|
profiles,
|
|
targetPubkey,
|
|
}: {
|
|
actorPubkey: string | undefined;
|
|
currentPubkey: string | undefined;
|
|
profiles: UserProfileLookup | undefined;
|
|
targetPubkey: string | undefined;
|
|
}) {
|
|
const hasActorAndTarget =
|
|
actorPubkey && targetPubkey && actorPubkey !== targetPubkey;
|
|
const actorLabel = actorPubkey
|
|
? resolveUserLabel({
|
|
pubkey: actorPubkey,
|
|
currentPubkey,
|
|
profiles,
|
|
preferResolvedSelfLabel: true,
|
|
})
|
|
: "Someone";
|
|
|
|
const singlePubkey = actorPubkey ?? targetPubkey;
|
|
|
|
if (!hasActorAndTarget) {
|
|
const avatar = (
|
|
<UserAvatar
|
|
avatarUrl={resolveAvatarUrl(singlePubkey, profiles)}
|
|
className="!h-9 !w-9 shrink-0 text-[10px]"
|
|
displayName={actorLabel}
|
|
testId="system-message-avatar"
|
|
/>
|
|
);
|
|
|
|
if (singlePubkey) {
|
|
return (
|
|
<UserProfilePopover pubkey={singlePubkey}>
|
|
<button
|
|
className="shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
|
type="button"
|
|
>
|
|
{avatar}
|
|
</button>
|
|
</UserProfilePopover>
|
|
);
|
|
}
|
|
|
|
return avatar;
|
|
}
|
|
|
|
const targetLabel = resolveUserLabel({
|
|
pubkey: targetPubkey,
|
|
currentPubkey,
|
|
profiles,
|
|
preferResolvedSelfLabel: true,
|
|
});
|
|
|
|
const dualAvatar = (
|
|
<div
|
|
className="relative h-9 w-9 shrink-0"
|
|
data-testid="system-message-avatar"
|
|
>
|
|
<UserAvatar
|
|
avatarUrl={resolveAvatarUrl(actorPubkey, profiles)}
|
|
className="!h-7 !w-7 border-2 border-background text-[9px]"
|
|
displayName={actorLabel}
|
|
/>
|
|
<UserAvatar
|
|
avatarUrl={resolveAvatarUrl(targetPubkey, profiles)}
|
|
className="!absolute !bottom-0 !right-0 !h-7 !w-7 border-2 border-background text-[9px]"
|
|
displayName={targetLabel}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<UserProfilePopover pubkey={actorPubkey}>
|
|
<button
|
|
className="shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
|
type="button"
|
|
>
|
|
{dualAvatar}
|
|
</button>
|
|
</UserProfilePopover>
|
|
);
|
|
}
|
|
|
|
function describeSystemEvent(
|
|
payload: SystemMessagePayload,
|
|
currentPubkey: string | undefined,
|
|
profiles: UserProfileLookup | undefined,
|
|
personaLookup?: Map<string, string>,
|
|
): SystemMessageDescription | null {
|
|
const personaSuffix = resolvePersonaSuffix(payload.target, personaLookup);
|
|
const actorLabel = resolveLabelWithSuffix(
|
|
payload.actor,
|
|
currentPubkey,
|
|
profiles,
|
|
);
|
|
const targetLabel = resolveLabelWithSuffix(
|
|
payload.target,
|
|
currentPubkey,
|
|
profiles,
|
|
personaSuffix,
|
|
);
|
|
const actorName = (
|
|
<ProfileName pubkey={payload.actor}>{actorLabel}</ProfileName>
|
|
);
|
|
const targetName = (
|
|
<ProfileName highlight pubkey={payload.target}>
|
|
{targetLabel}
|
|
</ProfileName>
|
|
);
|
|
|
|
switch (payload.type) {
|
|
case "member_joined": {
|
|
if (payload.actor === payload.target) {
|
|
return {
|
|
title: targetName,
|
|
action: "joined the channel",
|
|
};
|
|
}
|
|
return {
|
|
title: actorName,
|
|
action: <>added {targetName} to the channel</>,
|
|
};
|
|
}
|
|
case "member_left":
|
|
return {
|
|
title: actorName,
|
|
action: "left the channel",
|
|
};
|
|
case "member_removed":
|
|
return {
|
|
title: actorName,
|
|
action: <>removed {targetName} from the channel</>,
|
|
};
|
|
case "topic_changed":
|
|
return {
|
|
title: actorName,
|
|
action: <>changed the topic to “{payload.topic}”</>,
|
|
};
|
|
case "purpose_changed":
|
|
return {
|
|
title: actorName,
|
|
action: <>changed the purpose to “{payload.purpose}”</>,
|
|
};
|
|
case "channel_created":
|
|
return {
|
|
title: actorName,
|
|
action: "created this channel",
|
|
};
|
|
case "channel_archived":
|
|
return {
|
|
title: actorName,
|
|
action: "archived this channel",
|
|
};
|
|
case "channel_unarchived":
|
|
return {
|
|
title: actorName,
|
|
action: "unarchived this channel",
|
|
};
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export const SystemMessageRow = React.memo(function SystemMessageRow({
|
|
message,
|
|
currentPubkey,
|
|
profiles,
|
|
personaLookup,
|
|
onToggleReaction,
|
|
}: {
|
|
message: TimelineMessage;
|
|
currentPubkey?: string;
|
|
profiles?: UserProfileLookup;
|
|
/** Map from lowercase pubkey → persona display name for bot members. */
|
|
personaLookup?: Map<string, string>;
|
|
onToggleReaction?: (
|
|
message: TimelineMessage,
|
|
emoji: string,
|
|
remove: boolean,
|
|
) => Promise<void>;
|
|
}) {
|
|
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
|
|
null,
|
|
);
|
|
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
|
const {
|
|
reactions,
|
|
canToggle: canToggleReactions,
|
|
pending: reactionPending,
|
|
errorMessage: reactionErrorMessage,
|
|
select: handleReactionSelect,
|
|
} = useReactionHandler(message, onToggleReaction);
|
|
|
|
let payload: SystemMessagePayload;
|
|
try {
|
|
payload = JSON.parse(message.body);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const description = describeSystemEvent(
|
|
payload,
|
|
currentPubkey,
|
|
profiles,
|
|
personaLookup,
|
|
);
|
|
if (!description) {
|
|
return null;
|
|
}
|
|
|
|
const wouldAddReaction = (emoji: string) =>
|
|
!reactions.some(
|
|
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="group/message relative rounded-2xl px-3 py-2 transition-colors hover:bg-muted/50 focus-within:bg-muted/50"
|
|
data-testid="system-message-row"
|
|
>
|
|
<div className="flex items-start gap-2.5">
|
|
<SystemMessageAvatar
|
|
actorPubkey={payload.actor}
|
|
currentPubkey={currentPubkey}
|
|
profiles={profiles}
|
|
targetPubkey={payload.target}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
<div className="truncate text-sm font-semibold leading-none tracking-tight text-foreground/90">
|
|
{description.title}
|
|
</div>
|
|
<MessageTimestamp
|
|
createdAt={message.createdAt}
|
|
time={message.time}
|
|
/>
|
|
</div>
|
|
<p className="mt-1 text-sm leading-snug text-muted-foreground/70">
|
|
{description.action}
|
|
</p>
|
|
<div>
|
|
<MessageReactions
|
|
messageId={message.id}
|
|
reactions={reactions}
|
|
canToggle={canToggleReactions}
|
|
pending={reactionPending}
|
|
className="mt-0.5 pt-0.5"
|
|
burstEmojiOnRender={badgeBurstEmoji}
|
|
onBurstEmojiRendered={(emoji) => {
|
|
setBadgeBurstEmoji((current) =>
|
|
current === emoji ? null : current,
|
|
);
|
|
}}
|
|
onSelect={(emoji) => {
|
|
void handleReactionSelect(emoji);
|
|
}}
|
|
/>
|
|
{reactionErrorMessage ? (
|
|
<p className="mt-1.5 text-xs text-destructive">
|
|
{reactionErrorMessage}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="absolute right-2 top-1 z-10">
|
|
{canToggleReactions ? (
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
|
|
"max-w-0 border-0 shadow-none translate-y-1 opacity-0",
|
|
"group-hover/message:max-w-9 group-hover/message:border group-hover/message:border-border/70 group-hover/message:shadow-xs group-hover/message:translate-y-0 group-hover/message:opacity-100",
|
|
"group-focus-within/message:max-w-9 group-focus-within/message:border group-focus-within/message:border-border/70 group-focus-within/message:shadow-xs group-focus-within/message:translate-y-0 group-focus-within/message:opacity-100",
|
|
isReactionPickerOpen
|
|
? "max-w-9 border border-border/70 shadow-xs translate-y-0 opacity-100"
|
|
: "",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-1 p-1">
|
|
<Popover
|
|
onOpenChange={setIsReactionPickerOpen}
|
|
open={isReactionPickerOpen}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
aria-label="Open reactions"
|
|
className="h-6 w-6 rounded-full p-0"
|
|
size="sm"
|
|
type="button"
|
|
variant={isReactionPickerOpen ? "secondary" : "ghost"}
|
|
>
|
|
<SmilePlus className="h-3 w-3" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent>React</TooltipContent>
|
|
</Tooltip>
|
|
<PopoverContent
|
|
align="end"
|
|
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
|
|
side="top"
|
|
sideOffset={10}
|
|
>
|
|
{reactionErrorMessage ? (
|
|
<div className="px-3 pt-3 pb-0">
|
|
<p className="text-xs text-destructive">
|
|
{reactionErrorMessage}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
<EmojiPicker
|
|
onSelect={(value) => {
|
|
if (
|
|
!reactionPending &&
|
|
wouldAddReaction(value) &&
|
|
isPositiveEmojiParticle(value)
|
|
) {
|
|
setBadgeBurstEmoji(value);
|
|
}
|
|
void handleReactionSelect(value).finally(() => {
|
|
setIsReactionPickerOpen(false);
|
|
});
|
|
}}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|