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:
parent
a9a5966714
commit
bcc3eceeee
8 changed files with 141 additions and 7 deletions
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
Loading…
Reference in a new issue