Add crew admin moderation + invite actions
- useCrewModeration: fetch NIP-32 `hidden` labels authored by founder+admins for a crew; useHideCrewPost publishes a kind-1985 hidden label (L/l org.skatespots.moderation, e=post, a=crew) - CrewDetailPage feed: moderators can hide posts via an EyeOff button + confirm dialog; hidden posts filtered from the feed for everyone, shown greyed/"Hidden from feed" to moderators (reversible/auditable) - CrewInviteDialog: founder+admins share an invite link/message (native share + copy); founder gets a shortcut to add a member key in crew settings - Gate all admin actions on useCrewRole().canManage; moderators = founder + delegated admins - Add MODERATION_LABEL_NAMESPACE/HIDDEN constants - NIP.md: document feed moderation label format (author must be founder/admin) + invite semantics Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
0078f75f94
commit
f5483a0dbc
5 changed files with 427 additions and 10 deletions
33
NIP.md
33
NIP.md
|
|
@ -1784,6 +1784,39 @@ also a confirmed member is treated as a plain (pending) member until it attests.
|
|||
> model (like GitHub orgs, Discord, and NIP-72 communities): the founder owns
|
||||
> the record, admins wield delegated powers with their own keys.
|
||||
|
||||
#### Feed Moderation (NIP-32 `hidden` labels)
|
||||
|
||||
Moderators (founder + admins) can hide a post from the **crew feed** by
|
||||
publishing a kind `1985` label with their **own key**:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"tags": [
|
||||
["L", "org.skatespots.moderation"],
|
||||
["l", "hidden", "org.skatespots.moderation"],
|
||||
["e", "<hiddenPostId>"],
|
||||
["a", "35006:<founder>:<crewDtag>"],
|
||||
["p", "<postAuthor>"],
|
||||
["alt", "Crew moderation: post hidden"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A client hides a post only when a matching label's **author is the crew founder
|
||||
or a listed admin** — so outsiders can't forge moderation. This hides the post
|
||||
from *this crew's feed only*; the underlying note remains on Nostr. Moderators
|
||||
still see hidden posts (greyed, marked "Hidden from feed") so the action is
|
||||
reversible/auditable.
|
||||
|
||||
#### Invites
|
||||
|
||||
Because only the founder can write the crew's `p` (member) list, an admin cannot
|
||||
silently add members. Every moderator can **invite** — share a link/message to
|
||||
the crew page where the invitee, once listed by the founder, confirms membership
|
||||
with their own key (the standard bilateral attestation above). Founders get an
|
||||
extra shortcut to add a member key directly in crew settings.
|
||||
|
||||
### NIP-05 Verification
|
||||
|
||||
The optional `nip05` tag is a crew handle on **any domain the crew controls**
|
||||
|
|
|
|||
147
src/components/CrewInviteDialog.tsx
Normal file
147
src/components/CrewInviteDialog.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { useState } from 'react';
|
||||
import { UserPlus, Copy, Check, Share2, Edit } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CrewInviteDialogProps {
|
||||
crewName: string;
|
||||
founderPubkey: string;
|
||||
identifier: string;
|
||||
/** Whether the viewer is the founder (gets the extra "add member" edit link). */
|
||||
isFounder: boolean;
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite flow for crew moderators (founder + admins).
|
||||
*
|
||||
* On Nostr, only the founder can write the crew's member (`p`) list, so an admin
|
||||
* can't silently add someone. What every moderator CAN do is invite: share a
|
||||
* link/message that brings the invitee to the crew page, where — once the
|
||||
* founder lists them — they confirm membership with their own key.
|
||||
*
|
||||
* Founders additionally get a direct "add member" shortcut to the edit form.
|
||||
*/
|
||||
export function CrewInviteDialog({
|
||||
crewName,
|
||||
founderPubkey,
|
||||
identifier,
|
||||
isFounder,
|
||||
triggerClassName,
|
||||
}: CrewInviteDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [copiedMsg, setCopiedMsg] = useState(false);
|
||||
|
||||
const crewUrl = `${window.location.origin}/crew/${founderPubkey}/${identifier}`;
|
||||
const inviteMessage =
|
||||
`Yo — join the ${crewName} crew on SkateSpots 🛹\n${crewUrl}\n\n` +
|
||||
`Log in with your Nostr key, then confirm your membership on the crew page.`;
|
||||
|
||||
const copy = async (text: string, which: 'link' | 'msg') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (which === 'link') {
|
||||
setCopiedLink(true);
|
||||
setTimeout(() => setCopiedLink(false), 2000);
|
||||
} else {
|
||||
setCopiedMsg(true);
|
||||
setTimeout(() => setCopiedMsg(false), 2000);
|
||||
}
|
||||
toast({ title: 'Copied!' });
|
||||
} catch {
|
||||
toast({ title: 'Copy failed', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const nativeShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: `Join ${crewName}`, text: inviteMessage, url: crewUrl });
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
} else {
|
||||
copy(inviteMessage, 'msg');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10', triggerClassName)}
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" /> Invite
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5 text-teal-400" /> Invite to {crewName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share this with someone you want in the crew. They'll land on the crew page to confirm.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Invite link */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Invite link</label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={crewUrl} className="font-mono text-xs" onFocus={(e) => e.target.select()} />
|
||||
<Button type="button" variant="outline" size="icon" className="shrink-0" onClick={() => copy(crewUrl, 'link')}>
|
||||
{copiedLink ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite message */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Message</label>
|
||||
<Textarea readOnly value={inviteMessage} rows={4} className="text-sm" />
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => copy(inviteMessage, 'msg')}>
|
||||
{copiedMsg ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
Copy message
|
||||
</Button>
|
||||
<Button type="button" className="flex-1 gap-1.5 bg-teal-500 hover:bg-teal-600 text-black" onClick={nativeShare}>
|
||||
<Share2 className="h-4 w-4" /> Share
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Founder-only: add member directly via edit */}
|
||||
{isFounder && (
|
||||
<div className="rounded-lg border border-teal-500/30 bg-teal-500/5 p-3">
|
||||
<p className="mb-2 text-xs text-gray-400">
|
||||
As founder you can also add a member's key directly, then they confirm.
|
||||
</p>
|
||||
<Link to={`/edit-crew/${founderPubkey}/${identifier}`}>
|
||||
<Button type="button" variant="outline" size="sm" className="w-full gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
||||
<Edit className="h-3.5 w-3.5" /> Add member in crew settings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
108
src/hooks/useCrewModeration.ts
Normal file
108
src/hooks/useCrewModeration.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import {
|
||||
MODERATION_LABEL_NAMESPACE,
|
||||
MODERATION_LABEL_HIDDEN,
|
||||
} from '@/lib/types';
|
||||
|
||||
interface UseCrewModerationParams {
|
||||
/** Crew addressable coordinate `kind:pubkey:d`. */
|
||||
crewCoord: string;
|
||||
/** Pubkeys allowed to moderate: the founder + delegated admins. */
|
||||
moderators: string[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the set of crew-feed post IDs hidden by the crew's moderators
|
||||
* (founder + admins) via NIP-32 `hidden` labels.
|
||||
*
|
||||
* Only labels authored by a listed moderator are honored, so a random user
|
||||
* can't hide someone else's post.
|
||||
*/
|
||||
export function useCrewModeration({
|
||||
crewCoord,
|
||||
moderators,
|
||||
enabled = true,
|
||||
}: UseCrewModerationParams) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['crew-moderation', crewCoord, moderators.slice().sort().join(','), getQueryKeySuffix()],
|
||||
queryFn: async (): Promise<Set<string>> => {
|
||||
if (moderators.length === 0) return new Set<string>();
|
||||
const signal = AbortSignal.timeout(8000);
|
||||
|
||||
const events = await querySkateContent(
|
||||
[
|
||||
{
|
||||
kinds: [1985],
|
||||
authors: moderators,
|
||||
'#a': [crewCoord],
|
||||
'#l': [MODERATION_LABEL_HIDDEN],
|
||||
limit: 200,
|
||||
},
|
||||
],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const modSet = new Set(moderators.map((m) => m.toLowerCase()));
|
||||
const hidden = new Set<string>();
|
||||
|
||||
for (const ev of events) {
|
||||
// Validate namespace + label + moderator authorship.
|
||||
const hasNamespace = ev.tags.some(
|
||||
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
|
||||
);
|
||||
const hasLabel = ev.tags.some(
|
||||
([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
|
||||
);
|
||||
const refsCrew = ev.tags.some(([n, v]) => n === 'a' && v === crewCoord);
|
||||
if (!hasNamespace || !hasLabel || !refsCrew) continue;
|
||||
if (!modSet.has(ev.pubkey.toLowerCase())) continue;
|
||||
|
||||
for (const [n, v] of ev.tags) {
|
||||
if (n === 'e' && v) hidden.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
return hidden;
|
||||
},
|
||||
enabled: enabled && moderators.length > 0 && !!crewCoord,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
interface HidePostVars {
|
||||
postId: string;
|
||||
/** Author of the post being hidden (added as a `p` hint). */
|
||||
postAuthor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a NIP-32 `hidden` moderation label for a crew-feed post.
|
||||
* The caller must be a crew moderator (gate on `useCrewRole().canManage`).
|
||||
*/
|
||||
export function useHideCrewPost(crewCoord: string) {
|
||||
const { mutateAsync: publish } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ postId, postAuthor }: HidePostVars) => {
|
||||
const tags: string[][] = [
|
||||
['L', MODERATION_LABEL_NAMESPACE],
|
||||
['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
|
||||
['e', postId],
|
||||
['a', crewCoord],
|
||||
['alt', 'Crew moderation: post hidden'],
|
||||
];
|
||||
if (postAuthor) tags.push(['p', postAuthor]);
|
||||
|
||||
await publish({ kind: 1985, content: '', tags });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crew-moderation', crewCoord] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -78,6 +78,21 @@ export function parseEntityType(value: string | undefined | null): EntityType {
|
|||
export const MEMBERSHIP_LABEL_NAMESPACE = 'org.skatespots.membership';
|
||||
export const MEMBERSHIP_LABEL_VALUE = 'member';
|
||||
|
||||
/**
|
||||
* Crew feed/chat moderation (NIP-32).
|
||||
*
|
||||
* A founder or delegated admin can hide a crew-feed post by publishing a kind
|
||||
* 1985 label with their OWN key:
|
||||
* ["L", "org.skatespots.moderation"]
|
||||
* ["l", "hidden", "org.skatespots.moderation"]
|
||||
* ["e", <postId>] // the moderated post
|
||||
* ["a", <crewCoord>] // scopes moderation to this crew
|
||||
* Clients hide the post only if the label's author is the crew founder or a
|
||||
* listed admin — so moderation power can't be forged by outsiders.
|
||||
*/
|
||||
export const MODERATION_LABEL_NAMESPACE = 'org.skatespots.moderation';
|
||||
export const MODERATION_LABEL_HIDDEN = 'hidden';
|
||||
|
||||
/** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */
|
||||
export function parseMemberPubkeys(event: NostrEvent): string[] {
|
||||
return event.tags
|
||||
|
|
|
|||
|
|
@ -15,8 +15,22 @@ import { CrewNip05Badge } from '@/components/CrewNip05Badge';
|
|||
import { CrewChat } from '@/components/CrewChat';
|
||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
||||
import { useCrewRole } from '@/hooks/useCrewRole';
|
||||
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
|
||||
import { CrewInviteDialog } from '@/components/CrewInviteDialog';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { useCrew } from '@/hooks/useCrews';
|
||||
import { useCrewFeed } from '@/hooks/useCrewFeed';
|
||||
|
|
@ -24,11 +38,22 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { genUserName } from '@/lib/genUserName';
|
||||
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types';
|
||||
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import NotFound from './NotFound';
|
||||
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck } from 'lucide-react';
|
||||
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, EyeOff } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
function FeedItem({ event }: { event: NostrEvent }) {
|
||||
function FeedItem({
|
||||
event,
|
||||
canModerate,
|
||||
onHide,
|
||||
isHiding,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
canModerate?: boolean;
|
||||
onHide?: (event: NostrEvent) => void;
|
||||
isHiding?: boolean;
|
||||
}) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? genUserName(event.pubkey);
|
||||
|
|
@ -49,6 +74,41 @@ function FeedItem({ event }: { event: NostrEvent }) {
|
|||
<span className="ml-auto text-[11px] text-gray-500">
|
||||
{new Date(event.created_at * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
{canModerate && onHide && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-gray-500 hover:text-red-400"
|
||||
disabled={isHiding}
|
||||
aria-label="Hide this post"
|
||||
title="Hide from crew feed (admin)"
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Hide this post?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
As a crew moderator you can hide this post from the crew feed for everyone.
|
||||
The original note stays on Nostr — this only removes it from this crew's feed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onHide(event)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Hide post
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words text-sm text-gray-300">
|
||||
<NoteContent event={event} className="text-sm" />
|
||||
|
|
@ -61,7 +121,8 @@ export default function CrewDetailPage() {
|
|||
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||
const { data: crew, isLoading, error } = useCrew(identifier || '', pubkey);
|
||||
const author = useAuthor(crew?.pubkey);
|
||||
const { isFounder, isAdmin, role } = useCrewRole(crew);
|
||||
const { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
|
||||
const { toast } = useToast();
|
||||
|
||||
useSeoMeta({
|
||||
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
|
||||
|
|
@ -72,6 +133,29 @@ export default function CrewDetailPage() {
|
|||
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
|
||||
const { data: feed, isLoading: feedLoading } = useCrewFeed(feedAuthors);
|
||||
|
||||
// Moderation — founder + admins can hide feed posts (NIP-32 labels).
|
||||
const crewCoord = crew ? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}` : '';
|
||||
const moderators = crew ? Array.from(new Set([crew.pubkey, ...crew.admins])) : [];
|
||||
const { data: hiddenIds } = useCrewModeration({
|
||||
crewCoord,
|
||||
moderators,
|
||||
enabled: !!crew,
|
||||
});
|
||||
const { mutateAsync: hidePost, isPending: isHiding } = useHideCrewPost(crewCoord);
|
||||
|
||||
const handleHidePost = async (ev: NostrEvent) => {
|
||||
try {
|
||||
await hidePost({ postId: ev.id, postAuthor: ev.pubkey });
|
||||
toast({ title: 'Post hidden', description: 'Removed from the crew feed for everyone.' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to hide post',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Confirmed members (for "Zap the Crew" — only pay people who vouched in)
|
||||
const { data: memberStatuses } = useCollectiveMembers({
|
||||
kind: SKATE_CREW_KIND,
|
||||
|
|
@ -169,6 +253,14 @@ export default function CrewDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{canManage && (
|
||||
<CrewInviteDialog
|
||||
crewName={crew.name}
|
||||
founderPubkey={crew.pubkey}
|
||||
identifier={crew.identifier}
|
||||
isFounder={isFounder}
|
||||
/>
|
||||
)}
|
||||
{isFounder && (
|
||||
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
||||
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
||||
|
|
@ -221,13 +313,35 @@ export default function CrewDetailPage() {
|
|||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))
|
||||
) : feed && feed.length > 0 ? (
|
||||
feed.map((ev) => <FeedItem key={ev.id} event={ev} />)
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
No recent posts from the crew yet.
|
||||
</p>
|
||||
)}
|
||||
) : (() => {
|
||||
// Moderators still see hidden posts (greyed via the badge on hide),
|
||||
// but for everyone the hidden set is filtered out.
|
||||
const visible = (feed ?? []).filter(
|
||||
(ev) => canManage || !hiddenIds?.has(ev.id),
|
||||
);
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
No recent posts from the crew yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return visible.map((ev) => (
|
||||
<div key={ev.id} className={cn(hiddenIds?.has(ev.id) && 'opacity-50')}>
|
||||
{hiddenIds?.has(ev.id) && (
|
||||
<p className="mb-1 flex items-center gap-1 text-[11px] font-bold uppercase text-red-400">
|
||||
<EyeOff className="h-3 w-3" /> Hidden from feed
|
||||
</p>
|
||||
)}
|
||||
<FeedItem
|
||||
event={ev}
|
||||
canModerate={canManage && !hiddenIds?.has(ev.id)}
|
||||
onHide={handleHidePost}
|
||||
isHiding={isHiding}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue