Cordn native: default crew tab to the private chat + founder nudge; release 1.6.1

- Crew page defaults to the Private-chat (MLS) tab when this device already has
  the group (useCordnGroupExists reads the IndexedDB snapshot — local only, no
  signer prompt); a manual tab click always wins.
- Founder/admin banner ('Set up') points at the tab when the crew has no group.
- Bump 1.6.0 -> 1.6.1 and add release notes for everything since 1.6.0 (private
  crew chat/MLS, self-hosted relay + media, marketplace close-the-deal, trust
  controls, map/weather, Clothing Designer, UI fixes).
This commit is contained in:
SkateSpots 2026-10-12 22:52:33 +07:00
parent a9a5966714
commit bcc3eceeee
8 changed files with 141 additions and 7 deletions

View file

@ -1380,7 +1380,11 @@ session with **no other memory of this project**. So:
`CordnChatMessage.imageUrl`, rendered with `ImageWithFallback`. Verified in the
live test (image URL decrypts to the other member). ⚠️ **Unread badge for the
tab is deliberately NOT built** — it needs a background signed request (pops on
Amber) or a poll; revisit only if the feature gets real use.
Amber) or a poll; revisit only if the feature gets real use. ④ **Tab promotion +
nudge** — the crew page defaults to the Private-chat tab when this device
already holds the group (`useCordnGroupExists` reads the IndexedDB snapshot:
local only, no signer), and a founder/admin banner ("Set up") points at the tab
when it doesn't. A manual tab click always wins (`tabTouchedRef`).
- **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "skate-spots",
"version": "1.6.0",
"version": "1.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "skate-spots",
"version": "1.6.0",
"version": "1.6.1",
"license": "AGPL-3.0-only",
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.6.0",
"version": "1.6.1",
"license": "AGPL-3.0-only",
"type": "module",
"homepage": "https://skate-spots.org",

View file

@ -1,3 +1,43 @@
🆕 Private crew chat, end to end (MLS)
- Crews now get a real encrypted group chat: RFC 9420 MLS through our own
self-hosted coordinator. Open a crew → "Private chat". Founders create it and
approve join requests; members tap "Ask to join". Roughly a second to join.
- Send photos, copy the group link to open the same chat in Cordn or Staircase,
and your messages stay between members. The coordinator routes ciphertext and
metadata only — it never sees what you write.
🔒 Our infrastructure, not someone else's
- The community relay and photo/media storage now run on our own servers, so
your spots, crews and photos no longer depend on a third party. Same data,
faster and more independent.
🛒 Marketplace: close the deal in-app
- Accept an offer and pay right there (NWC / WebLN / Cashu), with the invoice
amount verified so you can't be overcharged.
- Escrow now uses the mint the seller advertises, and confirmation is safer.
🛡 Trust you can steer
- Mute an account and it disappears everywhere (NIP-51 list).
- "Alerts" flags accounts you follow that trusted people reported.
- Choose your trust provider in Settings → Trust, and every badge says who
decided.
🗺 Map & weather
- Search now suggests spots and places as you type.
- Spots show a 6-day forecast with a colour legend, sunrise/sunset, and a
manual refresh.
🧵 New in The Lab
- Clothing Designer joins the craft roles.
🛠 Fixes & polish
- Long menus scroll properly, and the language picker is searchable.
- Spot cards are tappable across the whole card again.
- Deleted spots no longer flash back from cache after a refresh.
- Lighter on first load (CJK fonts download only if you pick that language).
---
🌍 Ten new languages
- Simplified Chinese, Traditional Chinese, Ukrainian, Turkish, Vietnamese,
Indonesian, Swedish, Czech, Romanian and Greek are now selectable — full

View file

@ -9,7 +9,7 @@ import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
// Dexie-only (light); the heavy MLS/ContextVM module is imported lazily so it
// loads only when the private-chat tab is opened, and stays out of the crew-page
// chunk.
import { createCordnStore } from '@/lib/cordn/native/idbStore';
import { createCordnStore, cordnGroupExists } from '@/lib/cordn/native/idbStore';
import type {
CordnChatMessage,
CordnGroupInfo,
@ -296,3 +296,34 @@ export function useCordnNative({
groupRef,
};
}
/**
* Purely local check: does this account already have a saved MLS group for
* `gid`? No coordinator request and no signer prompt — it reads the IndexedDB
* snapshot. Re-runs when `refreshKey` changes. `undefined` until known.
*/
export function useCordnGroupExists(
accountPubkey: string | undefined,
gid: string,
refreshKey?: unknown,
): boolean | undefined {
const [exists, setExists] = useState<boolean | undefined>(undefined);
useEffect(() => {
let cancelled = false;
if (!accountPubkey || !gid) {
setExists(undefined);
return;
}
cordnGroupExists(accountPubkey, gid)
.then((value) => {
if (!cancelled) setExists(value);
})
.catch(() => {
if (!cancelled) setExists(false);
});
return () => {
cancelled = true;
};
}, [accountPubkey, gid, refreshKey]);
return exists;
}

View file

@ -34,6 +34,9 @@ const cordnUi = {
attachImage: 'Attach image',
uploading: 'Uploading…',
uploadFailed: 'Couldn’t upload the image.',
bannerTitle: 'This crew has no private chat yet',
bannerBody: 'Set up end-to-end encrypted (MLS) chat for your crew — members join from the tab.',
bannerCta: 'Set up',
ready: 'Connected · MLS',
errorPrefix: 'Couldn’t reach the coordinator',
interactiveHint:
@ -69,6 +72,9 @@ const cordnUi = {
attachImage: 'Adjuntar imagen',
uploading: 'Subiendo…',
uploadFailed: 'No se pudo subir la imagen.',
bannerTitle: 'Esta peña aún no tiene chat privado',
bannerBody: 'Configura el chat cifrado de extremo a extremo (MLS) de tu peña: los miembros se unen desde la pestaña.',
bannerCta: 'Configurar',
ready: 'Conectado · MLS',
errorPrefix: 'No se pudo contactar con el coordinador',
interactiveHint:

View file

@ -52,3 +52,14 @@ export function createCordnStore(accountPubkey: string): CordnStore {
},
};
}
/**
* Whether this account already has a saved MLS group for `gid` — a purely local
* check (no coordinator request, no signer prompt). Used to decide whether the
* crew page can default to the private-chat tab.
*/
export async function cordnGroupExists(accountPubkey: string, gid: string): Promise<boolean> {
if (!accountPubkey || !gid) return false;
const snapshot = await createCordnStore(accountPubkey).load();
return Boolean(snapshot?.groups.some((g) => g.gid === gid));
}

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
@ -42,6 +42,7 @@ import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { useCrew } from '@/hooks/useCrews';
import { useCrewFeed } from '@/hooks/useCrewFeed';
import { useCordnGroupExists } from '@/hooks/useCordnNative';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
@ -62,6 +63,15 @@ export default function CrewDetailPage() {
const { toast } = useToast();
const [agentOpen, setAgentOpen] = useState(false);
const [activeTab, setActiveTab] = useState('events');
const tabTouchedRef = useRef(false);
// The crew's native MLS group id (the crew coordinate) and whether this device
// already holds the group — used to default the tab and to nudge the founder.
const crewGid = crew ? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}` : '';
const nativeGroupExists = useCordnGroupExists(user?.pubkey, crewGid, activeTab);
useEffect(() => {
if (nativeGroupExists && !tabTouchedRef.current) setActiveTab('mls');
}, [nativeGroupExists]);
useSeoMeta({
title: crew ? t('socialUi.crew.seoTitleNamed', { name: crew.name }) : t('socialUi.crew.seoTitle'),
@ -273,8 +283,40 @@ export default function CrewDetailPage() {
</button>
)}
{/* Founder/admin nudge: no private (MLS) chat yet. */}
{canManage && nativeGroupExists === false && activeTab !== 'mls' && (
<Card className="border-teal-500/30 bg-teal-500/5">
<CardContent className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="flex items-center gap-3 text-sm">
<Lock className="h-4 w-4 shrink-0 text-teal-400" />
<div>
<div className="font-medium text-gray-200">{t('cordnUi.bannerTitle')}</div>
<div className="text-xs text-gray-500">{t('cordnUi.bannerBody')}</div>
</div>
</div>
<Button
size="sm"
onClick={() => {
tabTouchedRef.current = true;
setActiveTab('mls');
}}
className="gap-1.5 bg-teal-500 font-semibold text-black hover:bg-teal-400"
>
{t('cordnUi.bannerCta')}
</Button>
</CardContent>
</Card>
)}
{/* Activity — one clean tabbed surface instead of three stacked cards */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<Tabs
value={activeTab}
onValueChange={(value) => {
tabTouchedRef.current = true;
setActiveTab(value);
}}
className="w-full"
>
<TabsList className="grid w-full grid-cols-3 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" /> {t('socialUi.crew.eventsTab')}