Crews page
- "Near me" geolocation toggle with haversine distance sort + distance chips
- Crew count + cities social-proof stat header
- Deterministic per-crew vibe-colour accents (no more wall of teal)
- Activity pulse ("🔥 active this week") + OG/Verified/Established rep badges
- Rich empty state ("Claim your local scene") instead of dead "No crews yet"
Crew synergies
- Crew treasury: transparent, non-custodial NIP-75 zap goal (kind 9041) with
live progress bar tallied from zap receipts; funds settle to founder's own
Lightning wallet — no shared wallet or new wallet settings needed
- Crew-hosted events: host a jam/comp as the crew (event a-tag → crew coord),
"Hosted by [crew]" on event pages, Crew Events list on the crew page
- Crew ↔ Lab hiring pipeline: "Open Call / looking-for" flags (filmer, editor,
coach, builder, members) surfaced on the crew page
- Request to join: encrypted NIP-17 DM to founder + admins (no founder-push
bottleneck, no public join event)
- Reverse links: "Crews that skate here" on spot pages
Contacts (0xchat-style + Armada-style privacy)
- New /contacts page with Crew-mates / Follows / Recent DMs / Saved tabs
- Per-contact quick actions: message, zap, view profile, star
- Private encrypted contacts list (NIP-51 kind 30000, NIP-44 encrypted to self)
- Added to burger menu + desktop More menu (gated to logged-in users)
Docs: NIP.md updated with crew treasury, crew-hosted events, looking-for tags,
and the private encrypted contacts list.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
104 lines
3.8 KiB
TypeScript
104 lines
3.8 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
|
import {
|
|
SKATE_CREW_KIND,
|
|
CALENDAR_EVENT_KIND,
|
|
type SkateCrew,
|
|
} from '@/lib/types';
|
|
import { deriveActivity, type CrewActivity } from '@/lib/crewHelpers';
|
|
|
|
/** Build the addressable coordinate for a crew: `35006:pubkey:d`. */
|
|
export function crewCoordinate(crew: Pick<SkateCrew, 'pubkey' | 'identifier'>): string {
|
|
return `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
|
|
}
|
|
|
|
/**
|
|
* Activity/health signals for a set of crews, in ONE batched query.
|
|
*
|
|
* We tally two signals per crew:
|
|
* 1. **Feed freshness** — the most recent kind-1 note authored by the founder or
|
|
* a listed member (proxy for "the crew is skating & posting").
|
|
* 2. **Upcoming events** — future calendar events (kind 31923) that carry an
|
|
* `a` tag pointing back at the crew coordinate (crew-hosted jams/comps).
|
|
*
|
|
* Returns a map keyed by crew coordinate → {@link CrewActivity}.
|
|
*/
|
|
export function useCrewsActivity(crews: SkateCrew[] | undefined) {
|
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
|
|
|
// Stable key from the crew set (coords only).
|
|
const coords = (crews ?? []).map(crewCoordinate).sort();
|
|
const authorKey = (crews ?? [])
|
|
.flatMap((c) => [c.pubkey, ...c.members])
|
|
.sort()
|
|
.join(',')
|
|
.slice(0, 512);
|
|
|
|
return useQuery({
|
|
queryKey: ['crews-activity', coords.join(','), authorKey, getQueryKeySuffix()],
|
|
enabled: !!crews && crews.length > 0,
|
|
staleTime: 5 * 60 * 1000,
|
|
gcTime: 30 * 60 * 1000,
|
|
queryFn: async () => {
|
|
const signal = AbortSignal.timeout(10000);
|
|
const list = crews ?? [];
|
|
|
|
// All feed authors (founder + members), de-duplicated.
|
|
const authors = Array.from(
|
|
new Set(list.flatMap((c) => [c.pubkey, ...c.members])),
|
|
);
|
|
|
|
// Batched query: recent notes from any crew member + crew-hosted events.
|
|
const [notes, events] = await Promise.all([
|
|
authors.length > 0
|
|
? querySkateContent(
|
|
[{ kinds: [1], authors, limit: 200 }],
|
|
{ signal },
|
|
)
|
|
: Promise.resolve([]),
|
|
querySkateContent(
|
|
[{ kinds: [CALENDAR_EVENT_KIND], '#a': coords, limit: 200 }],
|
|
{ signal },
|
|
),
|
|
]);
|
|
|
|
// Fastest lookup: author pubkey → newest note timestamp.
|
|
const newestByAuthor = new Map<string, number>();
|
|
for (const n of notes) {
|
|
const prev = newestByAuthor.get(n.pubkey) ?? 0;
|
|
if (n.created_at > prev) newestByAuthor.set(n.pubkey, n.created_at);
|
|
}
|
|
|
|
// Crew coord → { upcoming count, newest event ts }
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const eventStats = new Map<string, { upcoming: number; newest: number }>();
|
|
for (const ev of events) {
|
|
const startTag = ev.tags.find(([t]) => t === 'start')?.[1];
|
|
const start = startTag ? parseInt(startTag, 10) : 0;
|
|
const crewA = ev.tags
|
|
.filter(([t]) => t === 'a')
|
|
.map(([, v]) => v)
|
|
.find((v) => v?.startsWith(`${SKATE_CREW_KIND}:`));
|
|
if (!crewA) continue;
|
|
const cur = eventStats.get(crewA) ?? { upcoming: 0, newest: 0 };
|
|
if (start >= now) cur.upcoming += 1;
|
|
if (ev.created_at > cur.newest) cur.newest = ev.created_at;
|
|
eventStats.set(crewA, cur);
|
|
}
|
|
|
|
const result = new Map<string, CrewActivity>();
|
|
for (const crew of list) {
|
|
const coord = crewCoordinate(crew);
|
|
const feedTs = Math.max(
|
|
crew.createdAt,
|
|
...[crew.pubkey, ...crew.members].map((pk) => newestByAuthor.get(pk) ?? 0),
|
|
);
|
|
const evStat = eventStats.get(coord);
|
|
const lastActiveAt = Math.max(feedTs, evStat?.newest ?? 0) || undefined;
|
|
result.set(coord, deriveActivity(lastActiveAt, evStat?.upcoming ?? 0));
|
|
}
|
|
|
|
return result;
|
|
},
|
|
});
|
|
}
|