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 | 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 = ( {highlight ? "@" : null} {children} ); return pubkey ? ( {node} ) : ( 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 = ( ); if (singlePubkey) { return ( ); } return avatar; } const targetLabel = resolveUserLabel({ pubkey: targetPubkey, currentPubkey, profiles, preferResolvedSelfLabel: true, }); const dualAvatar = (
); return ( ); } function describeSystemEvent( payload: SystemMessagePayload, currentPubkey: string | undefined, profiles: UserProfileLookup | undefined, personaLookup?: Map, ): 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 = ( {actorLabel} ); const targetName = ( {targetLabel} ); 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; onToggleReaction?: ( message: TimelineMessage, emoji: string, remove: boolean, ) => Promise; }) { const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( 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 (
{description.title}

{description.action}

{ setBadgeBurstEmoji((current) => current === emoji ? null : current, ); }} onSelect={(emoji) => { void handleReactionSelect(emoji); }} /> {reactionErrorMessage ? (

{reactionErrorMessage}

) : null}
{canToggleReactions ? (
React {reactionErrorMessage ? (

{reactionErrorMessage}

) : null} { if ( !reactionPending && wouldAddReaction(value) && isPositiveEmojiParticle(value) ) { setBadgeBurstEmoji(value); } void handleReactionSelect(value).finally(() => { setIsReactionPickerOpen(false); }); }} />
) : null}
); });