Skip to content

Commit 1557070

Browse files
authored
Merge pull request #384 from andotherstuff/delete
Ability for Group Owners to Delete Groups
2 parents 722f838 + 19220f7 commit 1557070

11 files changed

Lines changed: 574 additions & 20 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Badge } from "@/components/ui/badge";
2+
import { AlertTriangle } from "lucide-react";
3+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
4+
5+
interface DeletedGroupBadgeProps {
6+
deletionDate?: Date;
7+
reason?: string;
8+
className?: string;
9+
}
10+
11+
export function DeletedGroupBadge({ deletionDate, reason, className }: DeletedGroupBadgeProps) {
12+
const tooltipContent = (
13+
<div className="space-y-1">
14+
<div className="font-medium">Group Deleted</div>
15+
{deletionDate && (
16+
<div className="text-xs">
17+
Deleted on {deletionDate.toLocaleDateString()}
18+
</div>
19+
)}
20+
{reason && (
21+
<div className="text-xs">
22+
<strong>Reason:</strong> {reason}
23+
</div>
24+
)}
25+
</div>
26+
);
27+
28+
return (
29+
<TooltipProvider>
30+
<Tooltip>
31+
<TooltipTrigger asChild>
32+
<Badge variant="destructive" className={className}>
33+
<AlertTriangle className="h-3 w-3 mr-1" />
34+
Deleted
35+
</Badge>
36+
</TooltipTrigger>
37+
<TooltipContent>
38+
{tooltipContent}
39+
</TooltipContent>
40+
</Tooltip>
41+
</TooltipProvider>
42+
);
43+
}

src/components/profile/CommonGroupsListImproved.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useAuthor } from "@/hooks/useAuthor";
1313
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
1414
import { Separator } from "@/components/ui/separator";
1515
import { KINDS } from "@/lib/nostr-kinds";
16+
import { useGroupDeletionRequests } from "@/hooks/useGroupDeletionRequests";
1617

1718
interface CommonGroup {
1819
id: string;
@@ -263,6 +264,17 @@ export function CommonGroupsListImproved({ profileUserPubkey }: CommonGroupsList
263264
enabled: !!nostr && !!user && !!profileUserPubkey && user.pubkey !== profileUserPubkey,
264265
});
265266

267+
// Get group IDs for deletion checking
268+
const groupIds = commonGroups?.map(group => group.id) || [];
269+
const { data: deletionRequests } = useGroupDeletionRequests(groupIds);
270+
271+
// Filter out deleted groups
272+
const filteredCommonGroups = commonGroups?.filter(group => {
273+
if (!deletionRequests) return true;
274+
const deletionRequest = deletionRequests.get(group.id);
275+
return !(deletionRequest?.isValid || false);
276+
}) || [];
277+
266278
// Don't show anything if viewing own profile
267279
if (!user || !profileUserPubkey || user.pubkey === profileUserPubkey) {
268280
return null;
@@ -292,7 +304,7 @@ export function CommonGroupsListImproved({ profileUserPubkey }: CommonGroupsList
292304
);
293305
}
294306

295-
if (!commonGroups || commonGroups.length === 0) {
307+
if (!filteredCommonGroups || filteredCommonGroups.length === 0) {
296308
return null; // Don't show section if no common groups
297309
}
298310

@@ -304,12 +316,12 @@ export function CommonGroupsListImproved({ profileUserPubkey }: CommonGroupsList
304316
<h3 className="text-lg font-semibold">Shared Groups</h3>
305317
</div>
306318
<Badge variant="secondary" className="text-xs font-medium">
307-
{commonGroups.length} {commonGroups.length === 1 ? 'group' : 'groups'}
319+
{filteredCommonGroups.length} {filteredCommonGroups.length === 1 ? 'group' : 'groups'}
308320
</Badge>
309321
</div>
310322

311323
<div className="space-y-3">
312-
{commonGroups.map((group) => (
324+
{filteredCommonGroups.map((group) => (
313325
<Link
314326
key={group.id}
315327
to={`/group/${encodeURIComponent(group.id)}`}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useMemo } from "react";
2+
import { useGroupDeletionRequests } from "./useGroupDeletionRequests";
3+
import type { NostrEvent } from "@nostrify/nostrify";
4+
import { KINDS } from "@/lib/nostr-kinds";
5+
6+
// Helper function to get a unique community ID
7+
function getCommunityId(community: NostrEvent): string {
8+
const dTag = community.tags.find(tag => tag[0] === "d");
9+
return `${KINDS.GROUP}:${community.pubkey}:${dTag ? dTag[1] : ""}`;
10+
}
11+
12+
/**
13+
* Hook to filter out deleted groups from a list of groups
14+
* @param groups Array of group events to filter
15+
* @returns Filtered array with deleted groups removed
16+
*/
17+
export function useFilterDeletedGroups(groups: NostrEvent[] | undefined) {
18+
// Get group IDs for deletion checking
19+
const groupIds = useMemo(() => {
20+
if (!groups) return [];
21+
return groups.map(getCommunityId);
22+
}, [groups]);
23+
24+
// Check for deletion requests
25+
const { data: deletionRequests } = useGroupDeletionRequests(groupIds);
26+
27+
// Filter out deleted groups
28+
const filteredGroups = useMemo(() => {
29+
if (!groups || !deletionRequests) {
30+
return groups;
31+
}
32+
33+
return groups.filter(group => {
34+
const groupId = getCommunityId(group);
35+
const deletionRequest = deletionRequests.get(groupId);
36+
return !(deletionRequest?.isValid || false);
37+
});
38+
}, [groups, deletionRequests]);
39+
40+
return filteredGroups;
41+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { useNostr } from "@/hooks/useNostr";
3+
import { KINDS } from "@/lib/nostr-kinds";
4+
import type { NostrEvent } from "@nostrify/nostrify";
5+
6+
interface GroupDeletionRequest {
7+
deletionEvent: NostrEvent;
8+
groupId: string;
9+
isValid: boolean;
10+
reason?: string;
11+
}
12+
13+
/**
14+
* Hook to fetch and validate deletion requests for groups
15+
* Returns a map of groupId -> deletion request info
16+
*/
17+
export function useGroupDeletionRequests(groupIds: string[]) {
18+
const { nostr } = useNostr();
19+
20+
return useQuery({
21+
queryKey: ["group-deletion-requests", groupIds.sort()],
22+
queryFn: async (c) => {
23+
if (groupIds.length === 0) {
24+
return new Map<string, GroupDeletionRequest>();
25+
}
26+
27+
try {
28+
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
29+
30+
// Query for kind 5 deletion events that reference groups via 'a' tags or 'e' tags
31+
// We need to extract event IDs from group IDs for 'e' tag filtering
32+
const groupEventIds: string[] = [];
33+
34+
// For each group ID, we need to query for the actual group event to get its event ID
35+
// This is needed for 'e' tag compatibility
36+
for (const groupId of groupIds) {
37+
const parts = groupId.split(":");
38+
if (parts.length === 3 && parts[0] === KINDS.GROUP.toString()) {
39+
const [, pubkey, identifier] = parts;
40+
try {
41+
const groupEvents = await nostr.query([{
42+
kinds: [KINDS.GROUP],
43+
authors: [pubkey],
44+
"#d": [identifier],
45+
limit: 1
46+
}], { signal: AbortSignal.timeout(2000) });
47+
48+
if (groupEvents.length > 0) {
49+
groupEventIds.push(groupEvents[0].id);
50+
}
51+
} catch (error) {
52+
// If we can't fetch the group event, skip it for 'e' tag filtering
53+
console.warn("Could not fetch group event for deletion filtering:", groupId, error);
54+
}
55+
}
56+
}
57+
58+
// Query for deletion events using both 'a' tags and 'e' tags
59+
const deletionEvents = await nostr.query([
60+
{
61+
kinds: [KINDS.DELETION],
62+
"#a": groupIds,
63+
limit: 500
64+
},
65+
...(groupEventIds.length > 0 ? [{
66+
kinds: [KINDS.DELETION],
67+
"#e": groupEventIds,
68+
limit: 500
69+
}] : [])
70+
], { signal });
71+
72+
const deletionMap = new Map<string, GroupDeletionRequest>();
73+
74+
// Process each deletion event
75+
for (const deletionEvent of deletionEvents) {
76+
// Find the 'a' tags that reference groups (kind 34550)
77+
const aTags = deletionEvent.tags.filter(tag =>
78+
tag[0] === "a" && tag[1] && tag[1].startsWith(`${KINDS.GROUP}:`)
79+
);
80+
81+
// Process 'a' tags (addressable event references)
82+
for (const aTag of aTags) {
83+
const groupId = aTag[1];
84+
85+
// Parse the group ID to get the owner's pubkey
86+
const parts = groupId.split(":");
87+
if (parts.length !== 3 || parts[0] !== KINDS.GROUP.toString()) {
88+
continue;
89+
}
90+
91+
const [, groupOwnerPubkey] = parts;
92+
93+
// Validate that the deletion request is from the group owner
94+
const isValid = deletionEvent.pubkey === groupOwnerPubkey;
95+
96+
// Only store the most recent valid deletion request for each group
97+
const existing = deletionMap.get(groupId);
98+
if (!existing || (isValid && deletionEvent.created_at > existing.deletionEvent.created_at)) {
99+
deletionMap.set(groupId, {
100+
deletionEvent,
101+
groupId,
102+
isValid,
103+
reason: deletionEvent.content || undefined
104+
});
105+
}
106+
}
107+
108+
// Also check 'e' tags for event ID references
109+
// This provides compatibility with relays that primarily use 'e' tags for deletions
110+
const eTags = deletionEvent.tags.filter(tag => tag[0] === "e" && tag[1]);
111+
112+
if (eTags.length > 0) {
113+
// For 'e' tag processing, we need to be more careful since we need to match
114+
// event IDs to group IDs. We'll query for the referenced events to validate them.
115+
for (const eTag of eTags) {
116+
const eventId = eTag[1];
117+
118+
try {
119+
// Query for the referenced event to see if it's a group event
120+
const referencedEvents = await nostr.query([{
121+
ids: [eventId],
122+
kinds: [KINDS.GROUP],
123+
limit: 1
124+
}], { signal: AbortSignal.timeout(2000) });
125+
126+
if (referencedEvents.length > 0) {
127+
const groupEvent = referencedEvents[0];
128+
const dTag = groupEvent.tags.find(tag => tag[0] === "d");
129+
130+
if (dTag) {
131+
const groupId = `${KINDS.GROUP}:${groupEvent.pubkey}:${dTag[1]}`;
132+
133+
// Check if this group is in our target list
134+
if (groupIds.includes(groupId)) {
135+
// Validate that the deletion request is from the group owner
136+
const isValid = deletionEvent.pubkey === groupEvent.pubkey;
137+
138+
// Only store the most recent valid deletion request for each group
139+
const existing = deletionMap.get(groupId);
140+
if (!existing || (isValid && deletionEvent.created_at > existing.deletionEvent.created_at)) {
141+
deletionMap.set(groupId, {
142+
deletionEvent,
143+
groupId,
144+
isValid,
145+
reason: deletionEvent.content || undefined
146+
});
147+
}
148+
}
149+
}
150+
}
151+
} catch (error) {
152+
// If we can't fetch the referenced event, skip this 'e' tag
153+
console.warn("Could not fetch referenced event for deletion validation:", eventId, error);
154+
continue;
155+
}
156+
}
157+
}
158+
}
159+
160+
return deletionMap;
161+
} catch (error) {
162+
console.error("Error fetching group deletion requests:", error);
163+
return new Map<string, GroupDeletionRequest>();
164+
}
165+
},
166+
enabled: groupIds.length > 0,
167+
staleTime: 30000, // 30 seconds
168+
gcTime: 300000, // 5 minutes
169+
});
170+
}
171+
172+
/**
173+
* Hook to check if a specific group has been deleted
174+
*/
175+
export function useIsGroupDeleted(groupId: string | undefined) {
176+
const { data: deletionRequests } = useGroupDeletionRequests(
177+
groupId ? [groupId] : []
178+
);
179+
180+
if (!groupId || !deletionRequests) {
181+
return {
182+
isDeleted: false,
183+
deletionRequest: undefined,
184+
isLoading: false
185+
};
186+
}
187+
188+
const deletionRequest = deletionRequests.get(groupId);
189+
190+
return {
191+
isDeleted: deletionRequest?.isValid || false,
192+
deletionRequest: deletionRequest?.isValid ? deletionRequest : undefined,
193+
isLoading: false
194+
};
195+
}

src/hooks/useNostrPublish.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,24 @@ export function useNostrPublish(options?: UseNostrPublishOptions) {
201201
break;
202202
}
203203

204+
case KINDS.DELETION: {
205+
// Find groups being deleted via 'a' tags
206+
const groupATags = event.tags.filter(tag =>
207+
tag[0] === "a" && tag[1] && tag[1].startsWith(`${KINDS.GROUP}:`)
208+
);
209+
210+
if (groupATags.length > 0) {
211+
const groupIds = groupATags.map(tag => tag[1]);
212+
// Invalidate deletion request queries
213+
queryClient.invalidateQueries({ queryKey: ["group-deletion-requests"] });
214+
// Invalidate communities list to remove deleted groups
215+
queryClient.invalidateQueries({ queryKey: ["communities"] });
216+
// Invalidate user groups
217+
queryClient.invalidateQueries({ queryKey: ["user-groups"] });
218+
}
219+
break;
220+
}
221+
204222
case CASHU_EVENT_KINDS.ZAP: {
205223
// Find the event being zapped
206224
const zappedEventId = event.tags.find(tag => tag[0] === "e")?.[1];

src/hooks/useUserGroups.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
44
import type { NostrEvent, NostrFilter } from "@nostrify/nostrify";
55
import { usePinnedGroups } from "./usePinnedGroups";
66
import { KINDS } from "@/lib/nostr-kinds";
7+
import { useGroupDeletionRequests } from "./useGroupDeletionRequests";
78

89
// Helper function to get a unique community ID
910
function getCommunityId(community: NostrEvent): string {

0 commit comments

Comments
 (0)