Files
eranos/src/components/ProfileReactionButton.tsx
T
mkfain 6ccdeefdee Fill in partial query invalidation for pledges, campaigns, and moderation
Phase 2 of the invalidation cleanup. Each mutation below already invalidated
some of its consumer queries but missed sibling surfaces that display the
same data, so users had to refresh to see their action reflected everywhere.

* ProfileReactionButton (kind 7 on a kind 0 profile) didn't refresh any
  stats. Bump the profile's nip85-event-stats and nip85-addr-stats keys
  via invalidateEventStats + an explicit '0:<pubkey>:' addr sweep.

* BanConfirmDialog only invalidated ['community-members', aTag], so a
  removed post remained visible in the org's activity feed until refresh.
  Mirror CommunityReportDialog's predicate-match on
  ['community-activity-feed', aTagsKey] and also refresh
  ['organization-activity', aTag].

* CreateActionDialog (the quick-create pledge dialog inside an org)
  refreshed community-actions and the activity feed but skipped
  ['organization-activity', communityATag], so the new pledge didn't
  appear on the same org-detail page that launched the dialog.

* ActionsPage delete handler only invalidated ['agora-actions'] and
  ['agora-action']. Extract any organization 'A' tag from the pledge
  event and cascade to organization-activity, community-actions, and
  the community-activity-feed predicate.

* CampaignDetailPage delete handler skipped campaigns-all (the discover
  list), the campaign's organization shelf, and the country feed if the
  campaign carried a country code. Add all three.

* CreateCampaignPage onSuccess refreshed only the single-campaign key
  and one org-activity key. Add the campaigns/campaigns-all list keys
  and the country-feed keys so newly launched or edited campaigns show
  up everywhere they're displayed.
2026-05-22 15:49:40 -05:00

137 lines
4.5 KiB
TypeScript

import { useState, useRef, useCallback } from 'react';
import { SmilePlus } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { QuickReactMenu } from '@/components/QuickReactMenu';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useEmojiUsage } from '@/hooks/useEmojiUsage';
import { useToast } from '@/hooks/useToast';
import { impactLight } from '@/lib/haptics';
import { invalidateEventStats } from '@/lib/invalidateEventStats';
import type { NostrEvent } from '@nostrify/nostrify';
interface ProfileReactionButtonProps {
/** The kind 0 metadata event for the profile being reacted to. */
profileEvent: NostrEvent;
/** Optional extra class names for the trigger button. */
className?: string;
}
/**
* Emoji reaction button for user profiles.
* Opens an emoji picker and publishes a kind 7 reaction targeting
* the user's kind 0 profile event with `a`, `e`, and `p` tags.
*/
export function ProfileReactionButton({ profileEvent, className }: ProfileReactionButtonProps) {
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const { trackEmojiUsage } = useEmojiUsage();
const { toast } = useToast();
const queryClient = useQueryClient();
const { config } = useAppContext();
const statsPubkey = config.nip85StatsPubkey;
const [menuOpen, setMenuOpen] = useState(false);
const pickerExpandedRef = useRef(false);
const justClosedRef = useRef(false);
const handleReact = useCallback((emoji: string, emojiTag?: string[]) => {
if (!user) return;
impactLight();
trackEmojiUsage(emoji);
const tags: string[][] = [
['e', profileEvent.id],
['p', profileEvent.pubkey],
['a', `0:${profileEvent.pubkey}:`],
['k', '0'],
];
if (emojiTag) tags.push(emojiTag);
publishEvent(
{
kind: 7,
content: emoji,
created_at: Math.floor(Date.now() / 1000),
tags,
},
{
onSuccess: () => {
toast({ title: 'Reaction sent!' });
// Bump reaction count on the profile. Profile reactions target the
// kind 0 event by id, so useNip85EventStats refresh covers it. Also
// refresh the addressable `0:<pubkey>:` key in case any consumer
// reads profile stats via useNip85AddrStats.
setTimeout(() => {
invalidateEventStats(queryClient, profileEvent, statsPubkey);
queryClient.invalidateQueries({
queryKey: ['nip85-addr-stats', `0:${profileEvent.pubkey}:`, statsPubkey],
});
queryClient.invalidateQueries({
queryKey: ['nip85-addr-stats', `0:${profileEvent.pubkey}:`],
});
}, 3000);
},
},
);
}, [user, profileEvent, publishEvent, trackEmojiUsage, toast, queryClient, statsPubkey]);
if (!user) return null;
return (
<Popover
open={menuOpen}
onOpenChange={(open) => {
if (open && justClosedRef.current) return;
if (!open) pickerExpandedRef.current = false;
setMenuOpen(open);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className={className ?? 'rounded-full size-10'}
title="React to this profile"
onClick={(e) => {
e.stopPropagation();
if (justClosedRef.current) return;
setMenuOpen((prev) => !prev);
}}
>
<SmilePlus className="size-5" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0 border-0 bg-transparent shadow-none"
side="top"
align="start"
onClick={(e) => e.stopPropagation()}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<QuickReactMenu
eventId={profileEvent.id}
eventPubkey={profileEvent.pubkey}
eventKind={0}
onReact={handleReact}
onExpandChange={(expanded) => {
pickerExpandedRef.current = expanded;
}}
onClose={() => {
pickerExpandedRef.current = false;
justClosedRef.current = true;
setMenuOpen(false);
setTimeout(() => {
justClosedRef.current = false;
}, 300);
}}
/>
</PopoverContent>
</Popover>
);
}