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 { CrewVideoSection } from '@/components/crew/CrewVideoSection';
|
||||||
import { crewAccent } from '@/lib/crewHelpers';
|
import { crewAccent } from '@/lib/crewHelpers';
|
||||||
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
||||||
|
import { useCrewChatUnread } from '@/hooks/useCrewChatUnread';
|
||||||
import { CrewChat } from '@/components/CrewChat';
|
import { CrewChat } from '@/components/CrewChat';
|
||||||
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
|
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
|
||||||
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
|
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
|
||||||
|
|
@ -142,6 +143,7 @@ export default function CrewDetailPage() {
|
||||||
const { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
|
const { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [agentOpen, setAgentOpen] = useState(false);
|
const [agentOpen, setAgentOpen] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('events');
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
|
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
|
||||||
|
|
@ -165,6 +167,11 @@ export default function CrewDetailPage() {
|
||||||
});
|
});
|
||||||
const { mutateAsync: hidePost, isPending: isHiding } = useHideCrewPost(crewCoord);
|
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) => {
|
const handleHidePost = async (ev: NostrEvent) => {
|
||||||
try {
|
try {
|
||||||
await hidePost({ postId: ev.id, postAuthor: ev.pubkey });
|
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 */}
|
{/* 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">
|
<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]">
|
<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
|
<Calendar className="h-4 w-4" /> Events
|
||||||
|
|
@ -358,6 +365,13 @@ export default function CrewDetailPage() {
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="chat" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
|
<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
|
<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>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue