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:
parent
e843bd526a
commit
39afc00b6e
2 changed files with 87 additions and 1 deletions
72
src/hooks/useCrewChatUnread.ts
Normal file
72
src/hooks/useCrewChatUnread.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue