feat(crews): support multiple NIP-05 handles per crew
A crew is our own tag convention (unlike a kind-0 profile, whose nip05 is a single string), so it can now advertise up to 5 handles, each verified independently against the founder key. - parseSkateCrew: collect all nip05 tags -> SkateCrew.nip05s (normalised, deduped, regex-validated, cap 5); nip05 stays = first (back-compat). - CrewForm: add/remove handle rows with a live verified/mismatch/loading status (useCrewNip05 per row); format validation + max-5 hint; Enter adds. - CrewNip05Badge renders every handle; CrewHero passes nip05s. - NIP.md: nip05 documented as repeatable on kind 35006. i18n EN/ES.
This commit is contained in:
parent
45d021fedb
commit
2daa0a9832
8 changed files with 174 additions and 58 deletions
|
|
@ -63,6 +63,13 @@ session with **no other memory of this project**. So:
|
||||||
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
|
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
|
||||||
`AddPhotoToSpot`, replacing the old one-file-at-a-time inputs.
|
`AddPhotoToSpot`, replacing the old one-file-at-a-time inputs.
|
||||||
- `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates
|
- `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates
|
||||||
|
- **Crew handles (2026-10-03):** a crew (kind `35006`) may carry **up to 5
|
||||||
|
`nip05` tags**, each verified against the **founder** key (`SkateCrew.nip05s`;
|
||||||
|
`nip05` = first, back-compat). `CrewForm` adds/removes handles with a live
|
||||||
|
verified/mismatch status; `CrewNip05Badge` renders them all. Unlike a kind-0
|
||||||
|
profile (a single `nip05` string), a crew is our own tag convention, so it
|
||||||
|
isn't limited to one. Normalise → dedupe → regex-validate → cap 5 in
|
||||||
|
`parseSkateCrew`.
|
||||||
- **Rider Cards (2026-10-02) — now REAL + verifiable.** `32271` **Rider Card**
|
- **Rider Cards (2026-10-02) — now REAL + verifiable.** `32271` **Rider Card**
|
||||||
(addressable, `d=rider-card`) — the skater signs their own card customization
|
(addressable, `d=rider-card`) — the skater signs their own card customization
|
||||||
(theme/style/skills/sponsors/rides-for/show-flags; `v:1` marks show flags
|
(theme/style/skills/sponsors/rides-for/show-flags; `v:1` marks show flags
|
||||||
|
|
|
||||||
8
NIP.md
8
NIP.md
|
|
@ -2387,7 +2387,7 @@ bare `p` tag on the crew is only a claim until the member self-labels.
|
||||||
| `t: crew-agent` | Optional marker that the crew has enabled its **AI Crew Agent** ("🐝 Sasquatch"). When present, clients may derive the crew's agent identity (see below) and expose an "Ask Sasquatch" experience for members. Absent = no agent. |
|
| `t: crew-agent` | Optional marker that the crew has enabled its **AI Crew Agent** ("🐝 Sasquatch"). When present, clients may derive the crew's agent identity (see below) and expose an "Ask Sasquatch" experience for members. Absent = no agent. |
|
||||||
| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). |
|
| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). |
|
||||||
| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. |
|
| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. |
|
||||||
| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. |
|
| `nip05` | Optional crew NIP-05 identifier (`local@domain`). **Repeatable** (up to 5): a crew may advertise several handles, each verified independently against the **founder** pubkey; the first is canonical. (Unlike a kind-0 profile, whose `nip05` is a single string — a crew is our own tag convention, so it isn't limited to one.) See NIP-05 Verification. |
|
||||||
| `website`, `instagram`, `social` | Links |
|
| `website`, `instagram`, `social` | Links |
|
||||||
| `band` | Optional name of the crew's **band / music project**. Skate scenes and local music are tightly linked, so a crew may double as a band. Freeform text (≤100 chars). For a full band profile (genres, instruments, gigs) a crew should instead create a **Musician** Lab profile (kind `35011`), set its focus to `crew-focus-music`, and cross-link the profile via an `a` tag. |
|
| `band` | Optional name of the crew's **band / music project**. Skate scenes and local music are tightly linked, so a crew may double as a band. Freeform text (≤100 chars). For a full band profile (genres, instruments, gigs) a crew should instead create a **Musician** Lab profile (kind `35011`), set its focus to `crew-focus-music`, and cross-link the profile via an `a` tag. |
|
||||||
| `band-url` | Optional link to the crew band's music (Spotify, Bandcamp, YouTube, a Nostr music profile, etc.). Must be an `http(s)` URL. Only meaningful alongside `band`. |
|
| `band-url` | Optional link to the crew band's music (Spotify, Bandcamp, YouTube, a Nostr music profile, etc.). Must be an `http(s)` URL. Only meaningful alongside `band`. |
|
||||||
|
|
@ -2838,8 +2838,10 @@ anonymous**, and a proof can't be replayed into a different crew.
|
||||||
### NIP-05 Verification
|
### NIP-05 Verification
|
||||||
|
|
||||||
The optional `nip05` tag is a crew handle on **any domain the crew controls**
|
The optional `nip05` tag is a crew handle on **any domain the crew controls**
|
||||||
(fully decentralized — SkateSpots does not gatekeep the handle). Because the
|
(fully decentralized — SkateSpots does not gatekeep the handle). The tag is
|
||||||
crew event is authored by the founder, a client verifies the handle by fetching
|
**repeatable (up to 5)**: a crew may advertise several handles and clients verify
|
||||||
|
each one independently (the first is canonical / used for back-compat). Because
|
||||||
|
the crew event is authored by the founder, a client verifies a handle by fetching
|
||||||
`https://<domain>/.well-known/nostr.json?name=<local>` and checking that the
|
`https://<domain>/.well-known/nostr.json?name=<local>` and checking that the
|
||||||
returned pubkey equals the **founder** pubkey:
|
returned pubkey equals the **founder** pubkey:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,11 @@ import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_
|
||||||
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys';
|
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys';
|
||||||
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||||
import { useCoordinator } from '@/hooks/useCoordinator';
|
import { useCoordinator } from '@/hooks/useCoordinator';
|
||||||
|
import { useCrewNip05 } from '@/hooks/useCrewNip05';
|
||||||
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator';
|
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator';
|
||||||
import { useRelays } from '@/lib/relays';
|
import { useRelays } from '@/lib/relays';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server } from 'lucide-react';
|
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, BadgeCheck, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
|
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
|
||||||
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
||||||
|
|
@ -236,7 +237,8 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
const [bandUrl, setBandUrl] = useState(existing?.bandUrl ?? '');
|
const [bandUrl, setBandUrl] = useState(existing?.bandUrl ?? '');
|
||||||
const [socials, setSocials] = useState<string[]>(existing?.socials ?? []);
|
const [socials, setSocials] = useState<string[]>(existing?.socials ?? []);
|
||||||
const [socialInput, setSocialInput] = useState('');
|
const [socialInput, setSocialInput] = useState('');
|
||||||
const [nip05, setNip05] = useState(existing?.nip05 ?? '');
|
const [nip05s, setNip05s] = useState<string[]>(existing?.nip05s ?? (existing?.nip05 ? [existing.nip05] : []));
|
||||||
|
const [nip05Input, setNip05Input] = useState('');
|
||||||
const [noffer, setNoffer] = useState(existing?.noffer ?? '');
|
const [noffer, setNoffer] = useState(existing?.noffer ?? '');
|
||||||
const [members, setMembers] = useState<string[]>(existing?.members ?? []);
|
const [members, setMembers] = useState<string[]>(existing?.members ?? []);
|
||||||
const [admins, setAdmins] = useState<string[]>(existing?.admins ?? []);
|
const [admins, setAdmins] = useState<string[]>(existing?.admins ?? []);
|
||||||
|
|
@ -346,6 +348,23 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
setSocials((prev) => [...prev, val]);
|
setSocials((prev) => [...prev, val]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Crew NIP-05 handles — up to 5, each verified against the founder's key.
|
||||||
|
const NIP05_RE = /^[a-z0-9\-_.]+@[a-z0-9.-]+\.[a-z]{2,}$/;
|
||||||
|
const MAX_NIP05 = 5;
|
||||||
|
const founderPubkey = existing?.pubkey ?? user?.pubkey;
|
||||||
|
const [nip05Error, setNip05Error] = useState<string | null>(null);
|
||||||
|
const addNip05 = () => {
|
||||||
|
const val = nip05Input.trim().toLowerCase();
|
||||||
|
setNip05Input('');
|
||||||
|
if (!val) return;
|
||||||
|
if (!NIP05_RE.test(val)) return setNip05Error(t('socialUi.form.nip05Invalid'));
|
||||||
|
if (nip05s.includes(val)) return setNip05Error(null);
|
||||||
|
if (nip05s.length >= MAX_NIP05) return setNip05Error(t('socialUi.form.nip05TooMany', { count: MAX_NIP05 }));
|
||||||
|
setNip05Error(null);
|
||||||
|
setNip05s((prev) => [...prev, val]);
|
||||||
|
};
|
||||||
|
const removeNip05 = (handle: string) => setNip05s((prev) => prev.filter((h) => h !== handle));
|
||||||
|
|
||||||
// Live-decode whatever's typed in the member field for the preview chip.
|
// Live-decode whatever's typed in the member field for the preview chip.
|
||||||
const memberPreviewPk = decodePubkeyInput(memberInput);
|
const memberPreviewPk = decodePubkeyInput(memberInput);
|
||||||
const memberInputDirty = memberInput.trim().length > 0;
|
const memberInputDirty = memberInput.trim().length > 0;
|
||||||
|
|
@ -490,7 +509,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
for (const s of socials) {
|
for (const s of socials) {
|
||||||
if (s.trim()) tags.push(['social', s.trim()]);
|
if (s.trim()) tags.push(['social', s.trim()]);
|
||||||
}
|
}
|
||||||
if (nip05.trim()) tags.push(['nip05', nip05.trim().toLowerCase()]);
|
for (const handle of nip05s) tags.push(['nip05', handle]);
|
||||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||||
|
|
||||||
// Members: admins get an `admin` role marker on their `p` tag.
|
// Members: admins get an `admin` role marker on their `p` tag.
|
||||||
|
|
@ -1134,22 +1153,36 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const nip05Block = (
|
const nip05Block = (
|
||||||
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||||
<Label htmlFor="crew-nip05" className="flex items-center gap-2 text-teal-400">
|
<Label className="flex items-center gap-2 text-teal-400">
|
||||||
<AtSign className="h-4 w-4" /> {t('socialUi.form.nip05Label')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
<AtSign className="h-4 w-4" /> {t('socialUi.form.nip05Label')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{t('socialUi.form.nip05DescA')} <code className="text-teal-300">crew@yourdomain.com</code>{t('socialUi.form.nip05DescB')}
|
{t('socialUi.form.nip05DescA')} <code className="text-teal-300">crew@yourdomain.com</code>{t('socialUi.form.nip05DescB')}
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
id="crew-nip05"
|
<Input
|
||||||
placeholder="crew@yourdomain.com"
|
id="crew-nip05"
|
||||||
value={nip05}
|
placeholder={t('socialUi.form.nip05Placeholder')}
|
||||||
onChange={(e) => setNip05(e.target.value)}
|
value={nip05Input}
|
||||||
spellCheck={false}
|
onChange={(e) => { setNip05Input(e.target.value); if (nip05Error) setNip05Error(null); }}
|
||||||
autoCapitalize="none"
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNip05(); } }}
|
||||||
className="bg-gray-800 border-gray-700 font-mono text-sm"
|
spellCheck={false}
|
||||||
/>
|
autoCapitalize="none"
|
||||||
|
className="bg-gray-800 border-gray-700 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={addNip05} className={cn('shrink-0 gap-1', TEAL)} disabled={nip05s.length >= MAX_NIP05}>
|
||||||
|
<Plus className="h-4 w-4" /> {t('socialUi.form.nip05Add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{nip05Error && <p className="text-[11px] text-amber-400">{nip05Error}</p>}
|
||||||
|
{nip05s.length > 0 && (
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
{nip05s.map((handle) => (
|
||||||
|
<CrewNip05Row key={handle} handle={handle} founderPubkey={founderPubkey} onRemove={() => removeNip05(handle)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1315,7 +1348,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
const hasExtras =
|
const hasExtras =
|
||||||
!!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 ||
|
!!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 ||
|
||||||
socials.length > 0 || !!website.trim() || !!instagram.trim() ||
|
socials.length > 0 || !!website.trim() || !!instagram.trim() ||
|
||||||
!!bandName.trim() || !!nip05.trim() || agentEnabled || !!coordinatorInput.trim() ||
|
!!bandName.trim() || nip05s.length > 0 || agentEnabled || !!coordinatorInput.trim() ||
|
||||||
(goalEnabled && !!goalTitle.trim());
|
(goalEnabled && !!goalTitle.trim());
|
||||||
|
|
||||||
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [
|
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [
|
||||||
|
|
@ -1550,3 +1583,58 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One crew NIP-05 handle row with a live verification status (verified / mismatch / unchecked). */
|
||||||
|
function CrewNip05Row({
|
||||||
|
handle,
|
||||||
|
founderPubkey,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
handle: string;
|
||||||
|
founderPubkey?: string;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useCrewNip05(handle, founderPubkey);
|
||||||
|
const status = data?.status ?? (isLoading ? 'loading' : 'unreachable');
|
||||||
|
|
||||||
|
const icon =
|
||||||
|
status === 'verified' ? (
|
||||||
|
<BadgeCheck className="h-3.5 w-3.5 text-teal-300" />
|
||||||
|
) : status === 'mismatch' ? (
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 text-red-400" />
|
||||||
|
) : status === 'loading' ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<AtSign className="h-3.5 w-3.5 text-gray-500" />
|
||||||
|
);
|
||||||
|
const label =
|
||||||
|
status === 'verified'
|
||||||
|
? t('socialUi.nip05.verified')
|
||||||
|
: status === 'mismatch'
|
||||||
|
? t('socialUi.nip05.mismatch')
|
||||||
|
: t('socialUi.form.nip05Unverified');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
|
||||||
|
{icon}
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-200">{handle}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] uppercase tracking-wide',
|
||||||
|
status === 'verified' ? 'text-teal-300' : status === 'mismatch' ? 'text-red-400' : 'text-gray-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label={t('socialUi.form.nip05Remove', { handle })}
|
||||||
|
className="rounded p-1 text-gray-500 transition-colors hover:bg-white/5 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,45 +10,55 @@ import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface CrewNip05BadgeProps {
|
interface CrewNip05BadgeProps {
|
||||||
|
/** A single handle (back-compat). */
|
||||||
nip05?: string;
|
nip05?: string;
|
||||||
|
/** All crew handles. Takes precedence over `nip05` when present. */
|
||||||
|
nip05s?: string[];
|
||||||
founderPubkey?: string;
|
founderPubkey?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verified-handle badge for a crew's optional NIP-05.
|
* Verified-handle badge(s) for a crew's optional NIP-05. A crew may carry
|
||||||
|
* several handles (our own tag convention — unlike the single kind-0 `nip05`),
|
||||||
|
* each verified against the founder key.
|
||||||
*
|
*
|
||||||
* - `verified`: green check + the handle. The domain confirms the founder key.
|
* - `verified`: green check + the handle. The domain confirms the founder key.
|
||||||
* - `mismatch`: red warning — the domain points the handle at a different key.
|
* - `mismatch`: red warning — the domain points the handle at a different key.
|
||||||
* - `unreachable`: muted handle (shown but unverified) while the domain is down.
|
* - `unreachable`: muted handle (shown but unverified) while the domain is down.
|
||||||
* - `none`: renders nothing.
|
|
||||||
*/
|
*/
|
||||||
export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05BadgeProps) {
|
export function CrewNip05Badge({ nip05, nip05s, founderPubkey, className }: CrewNip05BadgeProps) {
|
||||||
|
const handles = (nip05s && nip05s.length > 0 ? nip05s : nip05 ? [nip05] : [])
|
||||||
|
.map((h) => h.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((h, i, arr) => arr.indexOf(h) === i);
|
||||||
|
|
||||||
|
if (handles.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('inline-flex flex-wrap items-center gap-1.5', className)}>
|
||||||
|
{handles.map((handle) => (
|
||||||
|
<Nip05BadgeRow key={handle} handle={handle} founderPubkey={founderPubkey} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nip05BadgeRow({ handle, founderPubkey }: { handle: string; founderPubkey?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useCrewNip05(nip05, founderPubkey);
|
const { data, isLoading } = useCrewNip05(handle, founderPubkey);
|
||||||
|
|
||||||
if (!nip05) return null;
|
|
||||||
|
|
||||||
const status = data?.status ?? (isLoading ? 'loading' : 'unreachable');
|
const status = data?.status ?? (isLoading ? 'loading' : 'unreachable');
|
||||||
const handle = nip05.trim().toLowerCase();
|
|
||||||
|
|
||||||
if (status === 'mismatch') {
|
if (status === 'mismatch') {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-bold text-red-400">
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-bold text-red-400',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="h-3 w-3" /> {handle}
|
<AlertTriangle className="h-3 w-3" /> {handle}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>{t('socialUi.nip05.mismatch')}</TooltipContent>
|
||||||
{t('socialUi.nip05.mismatch')}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|
@ -59,18 +69,11 @@ export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05Bad
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300">
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BadgeCheck className="h-3 w-3" /> {handle}
|
<BadgeCheck className="h-3 w-3" /> {handle}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>{t('socialUi.nip05.verified')}</TooltipContent>
|
||||||
{t('socialUi.nip05.verified')}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|
@ -78,15 +81,8 @@ export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05Bad
|
||||||
|
|
||||||
// loading / unreachable — show the handle, muted.
|
// loading / unreachable — show the handle, muted.
|
||||||
return (
|
return (
|
||||||
<span
|
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-medium text-gray-400">
|
||||||
className={cn(
|
{status === 'loading' ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||||
'inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-medium text-gray-400',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{status === 'loading' ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : null}
|
|
||||||
{handle}
|
{handle}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ export function CrewHero({
|
||||||
>
|
>
|
||||||
{crew.name}
|
{crew.name}
|
||||||
</h1>
|
</h1>
|
||||||
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
|
<CrewNip05Badge nip05s={crew.nip05s} founderPubkey={crew.pubkey} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm text-gray-400">
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm text-gray-400">
|
||||||
|
|
|
||||||
|
|
@ -3843,8 +3843,14 @@ socialUi: {
|
||||||
nameRequired: 'Name required',
|
nameRequired: 'Name required',
|
||||||
nameRequiredDesc: 'Give your crew a name.',
|
nameRequiredDesc: 'Give your crew a name.',
|
||||||
nip05DescA: 'A verifiable handle you control on any domain, e.g.',
|
nip05DescA: 'A verifiable handle you control on any domain, e.g.',
|
||||||
nip05DescB: ". It's verified against this crew's founder key — a green check shows when the domain confirms it.",
|
nip05DescB: ". It's verified against this crew's founder key, and you can add a few — each shows a green check when its domain confirms it.",
|
||||||
nip05Label: 'Crew NIP-05',
|
nip05Label: 'Crew NIP-05',
|
||||||
|
nip05Placeholder: 'crew@yourdomain.com',
|
||||||
|
nip05Add: 'Add',
|
||||||
|
nip05Invalid: 'Enter a handle like crew@yourdomain.com',
|
||||||
|
nip05TooMany: 'You can add up to {{count}} handles.',
|
||||||
|
nip05Unverified: 'Unchecked',
|
||||||
|
nip05Remove: 'Remove {{handle}}',
|
||||||
notAKeyA: "That doesn't look like a Nostr key yet — paste an",
|
notAKeyA: "That doesn't look like a Nostr key yet — paste an",
|
||||||
notAKeyB: ',',
|
notAKeyB: ',',
|
||||||
notAKeyC: ', or hex pubkey.',
|
notAKeyC: ', or hex pubkey.',
|
||||||
|
|
|
||||||
|
|
@ -3843,8 +3843,14 @@ socialUi: {
|
||||||
nameRequired: 'Nombre obligatorio',
|
nameRequired: 'Nombre obligatorio',
|
||||||
nameRequiredDesc: 'Ponle un nombre a tu cuadrilla.',
|
nameRequiredDesc: 'Ponle un nombre a tu cuadrilla.',
|
||||||
nip05DescA: 'Un identificador verificable que controlas en cualquier dominio, p. ej.',
|
nip05DescA: 'Un identificador verificable que controlas en cualquier dominio, p. ej.',
|
||||||
nip05DescB: '. Se verifica contra la clave del fundador de esta cuadrilla — aparece un check verde cuando el dominio lo confirma.',
|
nip05DescB: '. Se verifica contra la clave del fundador de la cuadrilla y puedes añadir varios: cada uno muestra un check verde cuando su dominio lo confirma.',
|
||||||
nip05Label: 'NIP-05 de la cuadrilla',
|
nip05Label: 'NIP-05 de la cuadrilla',
|
||||||
|
nip05Placeholder: 'cuadrilla@tudominio.com',
|
||||||
|
nip05Add: 'Añadir',
|
||||||
|
nip05Invalid: 'Introduce un identificador como cuadrilla@tudominio.com',
|
||||||
|
nip05TooMany: 'Puedes añadir hasta {{count}} identificadores.',
|
||||||
|
nip05Unverified: 'Sin verificar',
|
||||||
|
nip05Remove: 'Quitar {{handle}}',
|
||||||
notAKeyA: 'Eso todavía no parece una clave de Nostr — pega un',
|
notAKeyA: 'Eso todavía no parece una clave de Nostr — pega un',
|
||||||
notAKeyB: ',',
|
notAKeyB: ',',
|
||||||
notAKeyC: ', o una pubkey hex.',
|
notAKeyC: ', o una pubkey hex.',
|
||||||
|
|
|
||||||
|
|
@ -2156,7 +2156,8 @@ export interface SkateCrew {
|
||||||
bandUrl?: string; // Link to the band's music (Spotify/Bandcamp/YT/Nostr) (`band-url` tag)
|
bandUrl?: string; // Link to the band's music (Spotify/Bandcamp/YT/Nostr) (`band-url` tag)
|
||||||
social?: string; // First social link (backward compat)
|
social?: string; // First social link (backward compat)
|
||||||
socials: string[]; // All additional social links (`social` tags)
|
socials: string[]; // All additional social links (`social` tags)
|
||||||
nip05?: string; // Crew's NIP-05 identifier (verified against founder pubkey)
|
nip05?: string; // Crew's NIP-05 identifier (first handle; verified against founder pubkey)
|
||||||
|
nip05s: string[]; // ALL crew NIP-05 handles (each verified against the founder pubkey)
|
||||||
noffer?: string; // Optional shared tip jar (CLINK)
|
noffer?: string; // Optional shared tip jar (CLINK)
|
||||||
members: string[]; // Member pubkeys (hex) via `p` tags
|
members: string[]; // Member pubkeys (hex) via `p` tags
|
||||||
admins: string[]; // Delegated admin pubkeys (subset of members, `admin` marker)
|
admins: string[]; // Delegated admin pubkeys (subset of members, `admin` marker)
|
||||||
|
|
@ -3051,7 +3052,16 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
||||||
const social = socials[0];
|
const social = socials[0];
|
||||||
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
||||||
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
|
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
|
||||||
const nip05 = clampStr(event.tags.find(([name]) => name === 'nip05')?.[1], 100);
|
// Crew NIP-05 handles. Unlike a kind-0 profile (one `nip05` string), a crew is
|
||||||
|
// our own tag convention, so it can carry several — each verified against the
|
||||||
|
// founder's pubkey. Normalise, dedupe, drop malformed, cap at 5.
|
||||||
|
const nip05s = [...new Set(
|
||||||
|
event.tags
|
||||||
|
.filter(([name, value]) => name === 'nip05' && !!value?.trim())
|
||||||
|
.map(([, value]) => value.trim().toLowerCase())
|
||||||
|
.filter((v) => /^[a-z0-9\-_.]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(v)),
|
||||||
|
)].slice(0, 5);
|
||||||
|
const nip05 = clampStr(nip05s[0], 100);
|
||||||
const members = parseMemberPubkeys(event);
|
const members = parseMemberPubkeys(event);
|
||||||
// Admins must be a subset of claimed members.
|
// Admins must be a subset of claimed members.
|
||||||
const admins = parseAdminPubkeys(event).filter((pk) => members.includes(pk));
|
const admins = parseAdminPubkeys(event).filter((pk) => members.includes(pk));
|
||||||
|
|
@ -3106,6 +3116,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
||||||
social,
|
social,
|
||||||
socials,
|
socials,
|
||||||
nip05,
|
nip05,
|
||||||
|
nip05s,
|
||||||
noffer,
|
noffer,
|
||||||
members,
|
members,
|
||||||
admins,
|
admins,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue