From bcc3eceeee1ae847a83fae52f19a5dc3dec304b5 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 12 Oct 2026 22:52:33 +0700 Subject: [PATCH] Cordn native: default crew tab to the private chat + founder nudge; release 1.6.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- AGENTS.md | 6 ++++- package-lock.json | 4 +-- package.json | 2 +- release-notes.txt | 40 +++++++++++++++++++++++++++ src/hooks/useCordnNative.ts | 33 ++++++++++++++++++++++- src/i18n/legacy/cordnUi.ts | 6 +++++ src/lib/cordn/native/idbStore.ts | 11 ++++++++ src/pages/CrewDetailPage.tsx | 46 ++++++++++++++++++++++++++++++-- 8 files changed, 141 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e620f19..2f093be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 08a61b1..f371b1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 12828d8..862ef87 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/release-notes.txt b/release-notes.txt index c645792..0f65587 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -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 diff --git a/src/hooks/useCordnNative.ts b/src/hooks/useCordnNative.ts index 238c9e7..af7c7b4 100644 --- a/src/hooks/useCordnNative.ts +++ b/src/hooks/useCordnNative.ts @@ -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(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; +} diff --git a/src/i18n/legacy/cordnUi.ts b/src/i18n/legacy/cordnUi.ts index 50887d4..025928f 100644 --- a/src/i18n/legacy/cordnUi.ts +++ b/src/i18n/legacy/cordnUi.ts @@ -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: diff --git a/src/lib/cordn/native/idbStore.ts b/src/lib/cordn/native/idbStore.ts index 02b2138..a7c7036 100644 --- a/src/lib/cordn/native/idbStore.ts +++ b/src/lib/cordn/native/idbStore.ts @@ -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 { + if (!accountPubkey || !gid) return false; + const snapshot = await createCordnStore(accountPubkey).load(); + return Boolean(snapshot?.groups.some((g) => g.gid === gid)); +} diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 50ef2d8..4d67ab4 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -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() { )} + {/* Founder/admin nudge: no private (MLS) chat yet. */} + {canManage && nativeGroupExists === false && activeTab !== 'mls' && ( + + +
+ +
+
{t('cordnUi.bannerTitle')}
+
{t('cordnUi.bannerBody')}
+
+
+ +
+
+ )} + {/* Activity — one clean tabbed surface instead of three stacked cards */} - + { + tabTouchedRef.current = true; + setActiveTab(value); + }} + className="w-full" + > {t('socialUi.crew.eventsTab')}