Something went wrong. Try again.
Central platform for European atproto.<cc> country community websites atproto.eu
community atproto
Something went wrong. Try again.
11 kB · 248 lines
Astro
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249---// Community membership. Signed in: on first sign-in a country site auto-writes an// `eu.atcommons.member` record to the user's own PDS (subject = the commons DID), asserting that// site's country, so logging in makes you a member. The /members "Become a member" card then shows// "You're a member · Leave". Leaving deletes the record and remembers the choice (localStorage), so// we never silently re-join someone who left. Enumeration for the members list happens at build// time via the backlink index (see member-index.ts).//// The record's `countries[]` is accumulating and sticky (see the lexicon): joining a second country// ADDS to the list, and we never remove one. So `join()` merges rather than replaces.//// The .eu aggregate has no country of its own, so it never auto-joins — it would have nothing// truthful to put in the required `countries[]`. Instead it shows a country picker; the member// chooses one or more of the communities we run, and we write (or merge) those. "eu" is never a// value: the lexicon forbids region/continent codes.---
<script> import { handleActionError } from '../lib/auth-actions'; import { COMMUNITY_DID, MEMBER_COLLECTION, SITE_COUNTRIES, CAN_AUTO_JOIN } from '../data/membership';
const COLLECTION = MEMBER_COLLECTION; const COMMUNITY = COMMUNITY_DID; const MAX_COUNTRIES = 8; // lexicon cap on countries[]
const leftKey = (did: string) => `atn-left-community:${did}`; const memberKey = (did: string) => `atn-member:${did}`; const card = () => document.getElementById('membership-card'); const picker = () => document.querySelector<HTMLElement>('.membership-picker');
// After a member leaves, drop their entries from the baked-at-build lists so the Leave is visibly // confirmed: the static /members grid and the homepage avatar strip only lose them on the next // rebuild. Runs at most once per page load. Builder cards share .member-card but carry no follow // button, so matching on the DID-bearing follow button keeps this to the members grid only. let purgedSelf = false; function purgeSelfFromLists(did: string) { if (purgedSelf) return; purgedSelf = true; for (const cardEl of document.querySelectorAll<HTMLElement>('.member-card')) { const btn = cardEl.querySelector<HTMLElement>('.follow-btn'); if (btn && btn.dataset.did === did) cardEl.remove(); } let removedDot = false; for (const dot of document.querySelectorAll<HTMLElement>('.member-strip .avatar-dot')) { if (dot.dataset.did === did) { dot.remove(); removedDot = true; } } // Only adjust the homepage member count when this member was actually shown in the strip; a // not-yet-baked leaver was never in the count, so leave it untouched for them. if (removedDot) { for (const el of document.querySelectorAll<HTMLElement>('[data-member-count]')) { const n = parseInt(el.textContent || '', 10); if (Number.isFinite(n) && n > 0) el.textContent = String(n - 1); } } }
type Rec = { uri: string; countries: string[] } | null; type Existing = { uri: string; rkey: string; countries: string[]; createdAt?: string } | null;
async function existing(atn: any): Promise<Existing> { const res = await atn.agent.com.atproto.repo.listRecords({ repo: atn.did, collection: COLLECTION, limit: 100, }); const rec = res.data.records.find((r: any) => r.value?.community === COMMUNITY); if (!rec) return null; const countries = Array.isArray(rec.value?.countries) ? (rec.value.countries as string[]) : []; return { uri: rec.uri, rkey: rec.uri.split('/').pop() ?? '', countries, createdAt: rec.value?.createdAt }; }
// Join the community in one or more countries. Merges into any existing record (countries[] is // additive), so joining a second country keeps the first. Returns the record, or null when there // is nothing truthful to write (no countries asked, e.g. the aggregate with an empty picker). async function join(atn: any, countries: string[]): Promise<Rec> { const want = [...new Set(countries.filter(Boolean))]; if (!want.length) return null;
const cur = await existing(atn); if (cur) { const merged = [...new Set([...cur.countries, ...want])].slice(0, MAX_COUNTRIES); if (merged.length === cur.countries.length) return { uri: cur.uri, countries: merged }; // nothing new to add await atn.agent.com.atproto.repo.putRecord({ repo: atn.did, collection: COLLECTION, rkey: cur.rkey, record: { $type: COLLECTION, community: COMMUNITY, countries: merged, createdAt: cur.createdAt ?? new Date().toISOString(), }, }); return { uri: cur.uri, countries: merged }; }
const res = await atn.agent.com.atproto.repo.createRecord({ repo: atn.did, collection: COLLECTION, record: { $type: COLLECTION, community: COMMUNITY, countries: want, createdAt: new Date().toISOString(), }, }); return { uri: res.data.uri, countries: want }; }
// The countries a Join click should assert: the picker's checked boxes on the aggregate, or this // build's own country on a national site. function chosenCountries(): string[] { const p = picker(); if (p) { return Array.from(p.querySelectorAll<HTMLInputElement>('input[name="member-cc"]:checked')).map( (i) => i.value, ); } return [...SITE_COUNTRIES]; }
function render(rec: Rec, signedOut = false) { const el = card(); if (!el) return; const outs = el.querySelectorAll<HTMLElement>('.membership-signedout'); const status = el.querySelector<HTMLElement>('.membership-status'); const toggle = el.querySelector<HTMLButtonElement>('.membership-toggle'); const p = picker(); if (!status || !toggle) return; if (signedOut) { outs.forEach((n) => (n.hidden = false)); status.hidden = true; toggle.hidden = true; if (p) p.hidden = true; return; } outs.forEach((n) => (n.hidden = true)); // A member of another country's community signing in on this national site: their record // exists but does not assert this country, so they are not listed here. Offer an explicit // "add this country" (merge) rather than claiming they are a member, and never auto-add it. const elsewhere = rec && CAN_AUTO_JOIN && !SITE_COUNTRIES.some((c) => rec.countries.includes(c)); if (elsewhere) { status.textContent = el.dataset.labelElsewhere ?? "You're a member, but not in this country yet."; status.hidden = false; toggle.textContent = el.dataset.labelAdd ?? 'Add this country'; toggle.dataset.mode = 'join'; delete toggle.dataset.rkey; toggle.hidden = false; } else if (rec) { status.textContent = el.dataset.labelMember ?? "You're a member"; status.hidden = false; toggle.textContent = el.dataset.labelLeave ?? 'Leave'; toggle.dataset.mode = 'leave'; toggle.dataset.rkey = rec.uri.split('/').pop() ?? ''; toggle.hidden = false; if (p) p.hidden = true; // already a member: hide the picker } else { status.hidden = true; // The aggregate shows the country picker; a national site just shows a Join button. if (p) p.hidden = false; toggle.textContent = el.dataset.labelJoin ?? 'Join the community'; toggle.dataset.mode = 'join'; delete toggle.dataset.rkey; toggle.hidden = false; } }
async function onAuth() { const atn = (window as any).atn; if (!atn?.agent) { render(null, true); return; } const hasCard = Boolean(card()); const left = localStorage.getItem(leftKey(atn.did)) === '1'; // A member who left stays purged from the baked lists on every page they load, until the next // rebuild drops them for real. Runs before the fast-path return so it also covers the homepage. if (left) purgeSelfFromLists(atn.did); // Fast path: a known member browsing a page without the card needs no API call. if (!hasCard && (left || localStorage.getItem(memberKey(atn.did)) === '1')) return;
let rec: Rec = null; try { const cur = await existing(atn); rec = cur ? { uri: cur.uri, countries: cur.countries } : null; } catch { /* index/PDS hiccup — leave the card in its signed-out default */ return; } // Auto-join only on a country site, and only to CREATE the first record — never an automatic // write-on-visit for someone who is already a member (adding a country is an explicit choice, // made on the .eu picker). The aggregate never auto-joins. if (!rec && !left && CAN_AUTO_JOIN) { try { rec = await join(atn, [...SITE_COUNTRIES]); } catch { /* couldn't write (e.g. scope not granted) — show the Join button instead */ } } if (rec) localStorage.setItem(memberKey(atn.did), '1'); else localStorage.removeItem(memberKey(atn.did)); render(rec); }
document.addEventListener('click', async (e) => { const btn = (e.target as HTMLElement)?.closest('.membership-toggle') as HTMLButtonElement | null; if (!btn) return; const atn = (window as any).atn; if (!atn?.agent) return; btn.setAttribute('disabled', ''); try { if (btn.dataset.mode === 'leave' && btn.dataset.rkey) { await atn.agent.com.atproto.repo.deleteRecord({ repo: atn.did, collection: COLLECTION, rkey: btn.dataset.rkey, }); localStorage.setItem(leftKey(atn.did), '1'); localStorage.removeItem(memberKey(atn.did)); purgeSelfFromLists(atn.did); render(null); } else { const countries = chosenCountries(); if (!countries.length) { // Aggregate join with nothing ticked: nudge instead of writing an empty record. const el = card(); window.alert(el?.dataset.pickPrompt ?? 'Pick at least one country to join.'); return; } const rec = await join(atn, countries); localStorage.removeItem(leftKey(atn.did)); if (rec) localStorage.setItem(memberKey(atn.did), '1'); render(rec); } } catch (err: any) { await handleActionError(err, 'Membership'); } finally { btn.removeAttribute('disabled'); } });
document.addEventListener('atn:signedin', onAuth); document.addEventListener('atn:signedout', onAuth); if ((window as any).atn) onAuth();</script>