Crew chat: unread dot on the Chat tab (cheap, honest, no bell spam)

Instead of wiring encrypted crew chat into the global notification bell — which
would mean background-decrypting every crew's messages to power a badge (slow,
battery-hungry, unreliable with NIP-17's random timestamps) — add a subtle
unread dot to the crew's own Chat tab.

- useCrewChatUnread: member-only, single-crew, runs only while the crew page is
  open. Uses a pure metadata query (newest Cordn envelope by created_at, no
  decryption) vs. the local last-seen timestamp. For the built-in NIP-17
  transport (random timestamps, needs decryption) it deliberately returns false
  rather than fire false pings — the in-chat "New" divider already covers
  "what did I miss" once the tab is opened.
- Tabs made controlled; the dot is suppressed while the Chat tab is active, so
  it clears the moment you look, and is gone on the next visit (last-seen
  advances). No global notification noise.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 18:21:38 +07:00
parent e843bd526a
commit 39afc00b6e
2 changed files with 87 additions and 1 deletions

View file

@ -0,0 +1,72 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCrewChatRead } from '@/hooks/useCrewChatRead';
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
import { CORDN_APP_TAG, CORDN_MESSAGE_KIND } from '@/lib/cordn/types';
import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types';
/**
* A cheap, honest "is there new crew chat since I last looked?" signal for the
* Chat tab's unread dot.
*
* Design constraints (kept idiot-proof and light):
* - It must NOT decrypt every message in the background just to power a dot.
* - It must NOT fire false pings.
*
* So it only reports unread for **Cordn** crews, where the newest message time
* is a pure metadata query (`#a` group + newest `created_at`) — no decryption.
* For the built-in NIP-17 transport, gift wraps use randomized timestamps and
* require per-message decryption, so a reliable cheap peek isn't possible; there
* we return `false` (the in-chat "New" divider already covers "what did I miss"
* once the tab is opened). Only members query at all.
*
* Runs only while a crew page is mounted, for a single crew — never fanned out
* across all crews.
*/
export function useCrewChatUnread(crew: SkateCrew | undefined, isMember: boolean) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const crewCoord = crew
? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`
: undefined;
const { lastSeen } = useCrewChatRead(crewCoord);
const endpoint = useMemo(
() => (crew ? resolveCordnEndpoint(crew) : null),
[crew],
);
// Only meaningful for Cordn crews the viewer belongs to.
const enabled = !!user && isMember && !!endpoint;
const { data: newestAt = 0 } = useQuery({
queryKey: ['crew-chat-unread', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey],
enabled,
staleTime: 15_000,
refetchInterval: enabled ? 30_000 : false,
queryFn: async (): Promise<number> => {
if (!endpoint) return 0;
const relay = nostr.relay(endpoint.relayUrl);
const signal = AbortSignal.timeout(8000);
// Metadata-only: newest envelope for this group. No decryption.
const events = await relay.query(
[{
kinds: [CORDN_MESSAGE_KIND],
'#a': [endpoint.groupId],
'#t': [CORDN_APP_TAG],
limit: 1,
}],
{ signal },
);
return events.reduce((max, e) => Math.max(max, e.created_at), 0);
},
});
// Unread only when there's a genuinely newer message than the last visit.
const hasUnread = enabled && newestAt > (lastSeen ?? 0);
return { hasUnread };
}

View file

@ -19,6 +19,7 @@ import { CrewCoordinatorCard } from '@/components/crew/CrewCoordinatorCard';
import { CrewVideoSection } from '@/components/crew/CrewVideoSection';
import { crewAccent } from '@/lib/crewHelpers';
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
import { useCrewChatUnread } from '@/hooks/useCrewChatUnread';
import { CrewChat } from '@/components/CrewChat';
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
@ -142,6 +143,7 @@ export default function CrewDetailPage() {
const { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
const { toast } = useToast();
const [agentOpen, setAgentOpen] = useState(false);
const [activeTab, setActiveTab] = useState('events');
useSeoMeta({
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
@ -165,6 +167,11 @@ export default function CrewDetailPage() {
});
const { mutateAsync: hidePost, isPending: isHiding } = useHideCrewPost(crewCoord);
// Unread dot on the Chat tab — cheap, member-only, no background decryption.
const isCrewMember = !!user && !!crew &&
(user.pubkey === crew.pubkey || crew.members.includes(user.pubkey));
const { hasUnread: chatHasUnread } = useCrewChatUnread(crew ?? undefined, isCrewMember);
const handleHidePost = async (ev: NostrEvent) => {
try {
await hidePost({ postId: ev.id, postAuthor: ev.pubkey });
@ -348,7 +355,7 @@ export default function CrewDetailPage() {
)}
{/* Activity — one clean tabbed surface instead of three stacked cards */}
<Tabs defaultValue="events" className="w-full">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 bg-black/60 border border-white/10">
<TabsTrigger value="events" className="gap-1.5 data-[state=active]:bg-[#ccff00]/20 data-[state=active]:text-[#ccff00]">
<Calendar className="h-4 w-4" /> Events
@ -358,6 +365,13 @@ export default function CrewDetailPage() {
</TabsTrigger>
<TabsTrigger value="chat" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<MessageSquare className="h-4 w-4" /> Chat
{chatHasUnread && activeTab !== 'chat' && (
<span
className="ml-0.5 h-2 w-2 rounded-full bg-teal-400 shadow-[0_0_6px_rgba(45,212,191,0.9)]"
aria-label="New messages"
title="New messages"
/>
)}
</TabsTrigger>
</TabsList>