From 39afc00b6e82dcd9c102cd1edac2baf7a729473d Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 18:21:38 +0700 Subject: [PATCH] Crew chat: unread dot on the Chat tab (cheap, honest, no bell spam) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/hooks/useCrewChatUnread.ts | 72 ++++++++++++++++++++++++++++++++++ src/pages/CrewDetailPage.tsx | 16 +++++++- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useCrewChatUnread.ts diff --git a/src/hooks/useCrewChatUnread.ts b/src/hooks/useCrewChatUnread.ts new file mode 100644 index 0000000..cb5f303 --- /dev/null +++ b/src/hooks/useCrewChatUnread.ts @@ -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 => { + 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 }; +} diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 67cfac3..9ed4b0f 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -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 */} - + Events @@ -358,6 +365,13 @@ export default function CrewDetailPage() { Chat + {chatHasUnread && activeTab !== 'chat' && ( + + )}