diff --git a/.gitignore b/.gitignore index 16d54bb..5cd6785 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ dist/ # generated types .astro/ +# local auth/session data +.astro-session-dev/ +.authproto-dev/ + # dependencies node_modules/ diff --git a/.impeccable.md b/PRODUCT.md similarity index 100% rename from .impeccable.md rename to PRODUCT.md diff --git a/astro.config.mjs b/astro.config.mjs index 9a25ce0..9d04272 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,8 +1,10 @@ // @ts-check -import { defineConfig } from "astro/config"; +import { defineConfig, sessionDrivers } from "astro/config"; import node from "@astrojs/node"; -import authproto from "@fujocoded/authproto"; +import authproto, { REDIRECT_TO_REFERER_TEMPLATE } from "@fujocoded/authproto"; + +const isDev = process.env.NODE_ENV !== "production"; // https://astro.build/config export default defineConfig({ @@ -16,7 +18,10 @@ export default defineConfig({ mode: "standalone", }), session: { - driver: { entrypoint: "unstorage/drivers/memory" }, + // Persist sessions in dev so local server restarts do not force another login. + driver: isDev + ? sessionDrivers.fs({ base: ".astro-session-dev" }) + : { entrypoint: "unstorage/drivers/memory" }, }, security: { allowedDomains: [{ hostname: "atmosphere.community", protocol: "https" }], @@ -25,15 +30,22 @@ export default defineConfig({ authproto({ applicationName: "Atmosphere.community", applicationDomain: "https://atmosphere.community", + redirects: { + afterLogin: REDIRECT_TO_REFERER_TEMPLATE, + afterLogout: REDIRECT_TO_REFERER_TEMPLATE, + }, scopes: { additionalScopes: [ "repo:community.lexicon.calendar.rsvp?action=create&action=update", "repo:community.opensocial.membership?action=create&action=update", ], }, - driver: { - name: "memory", - }, + driver: isDev + ? { + name: "fs", + options: { base: ".authproto-dev" }, + } + : { name: "memory" }, }), ], }); diff --git a/package-lock.json b/package-lock.json index 2cfd691..1bd1675 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@atproto/lex": "^0.1.0", "@atproto/syntax": "^0.4.3", "@fujocoded/astro-atproto-loader": "^0.2.1", + "@fujocoded/atproto-lex-client": "file:../mdx-plugins-experimental/atproto-lex-client", "@fujocoded/authproto": "^0.3.1", "astro": "^6.1.7", "js-yaml": "^4.1.0", @@ -30,6 +31,19 @@ "node": ">=22.12.0" } }, + "../mdx-plugins-experimental/atproto-lex-client": { + "name": "@fujocoded/atproto-lex-client", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@atproto/lex": "^0.1.3", + "@atproto/syntax": "^0.6.1" + }, + "devDependencies": { + "tsdown": "^0.14.2", + "vitest": "^3.2.4" + } + }, "node_modules/@astrojs/check": { "version": "0.9.9", "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.9.tgz", @@ -1915,6 +1929,10 @@ "astro": "^5.13.0 || ^6.0.0" } }, + "node_modules/@fujocoded/atproto-lex-client": { + "resolved": "../mdx-plugins-experimental/atproto-lex-client", + "link": true + }, "node_modules/@fujocoded/authproto": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@fujocoded/authproto/-/authproto-0.3.1.tgz", diff --git a/package.json b/package.json index 9eda5e8..8fef65e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@atproto/identity": "^0.4.8", "@atproto/lex": "^0.1.0", "@atproto/syntax": "^0.4.3", + "@fujocoded/atproto-lex-client": "file:../mdx-plugins-experimental/atproto-lex-client", "@fujocoded/astro-atproto-loader": "^0.2.1", "@fujocoded/authproto": "^0.3.1", "astro": "^6.1.7", diff --git a/src/actions/index.ts b/src/actions/index.ts index e29b1eb..f634abd 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,4 +1,4 @@ -// TODO: refactor onto @fujocoded/astro-smooth-action once it's officially out. +// TODO: refactor onto @fujocoded/astro-smooth-actions. // Drops cleanRedirect / redirectWithStatus / isPermissionError / redirectUrl return shape: // throw ActionError for failures, return { status, eventName }, let middleware do PRG via // session storage. Also remove the `redirect` form field and the `?rsvp=&event=` query-param @@ -15,6 +15,7 @@ import { type RsvpStatus, } from "../lib/rsvps"; import { getAtmosphereCommunityDid } from "../lib/community/atmosphere"; +import { resolveHandleToDid } from "../lib/community/identity"; import { OpenSocialCommunityError, ensureUserMembershipRecord, @@ -22,12 +23,62 @@ import { joinCommunity, leaveCommunity, } from "../lib/opensocial/membership"; +import { + getShareCandidateByUri, +} from "../lib/community/share-candidates"; +import { shareContentWithCommunity } from "../lib/opensocial/content-sharing"; +import { type JoinStatusCode } from "../lib/community/join-status"; + +type LoggedInUser = NonNullable; + +// Shared join flow for any opensocial community. Returns a status key (never +// throws) so each entry point can map it onto its own redirect target. +async function runJoin( + loggedInUser: LoggedInUser, + communityDid: string, +): Promise { + try { + const membership = await getMembership({ + communityDid, + userDid: loggedInUser.did, + }); + if (membership.isMember || membership.isAdmin) return "already"; + const membershipRecord = await ensureUserMembershipRecord({ + loggedInUser, + communityDid, + }); + const result = await joinCommunity({ + communityDid, + userDid: loggedInUser.did, + membershipCid: membershipRecord.cid, + }); + return result.status === "pending" ? "pending" : "ok"; + } catch (err) { + if (err instanceof OpenSocialCommunityError) return joinStatusFromError(err); + if (isPermissionError(err)) return "permission"; + console.warn("[runJoin] unexpected error", err); + return "error"; + } +} -function joinRedirect(status: string): string { - return `/community-content?join=${encodeURIComponent(status)}`; +// Shared leave flow for any opensocial community. Returns a status key (never +// throws). Admins get "admin-block" because the appview rejects their leave. +async function runLeave( + loggedInUser: LoggedInUser, + communityDid: string, +): Promise { + try { + await leaveCommunity({ communityDid, userDid: loggedInUser.did }); + return "left"; + } catch (err) { + if (err instanceof OpenSocialCommunityError) return leaveStatusFromError(err); + if (isPermissionError(err)) return "permission"; + console.warn("[runLeave] unexpected error", err); + return "error"; + } } -function joinStatusFromError(err: OpenSocialCommunityError): string { +function joinStatusFromError(err: OpenSocialCommunityError): JoinStatusCode { switch (err.code) { case "AlreadyMember": return "already"; @@ -40,7 +91,7 @@ function joinStatusFromError(err: OpenSocialCommunityError): string { } } -function leaveStatusFromError(err: OpenSocialCommunityError): string { +function leaveStatusFromError(err: OpenSocialCommunityError): JoinStatusCode { switch (err.code) { case "NotMember": return "not-member"; @@ -53,6 +104,10 @@ function leaveStatusFromError(err: OpenSocialCommunityError): string { } } +function joinPayload(status: JoinStatusCode, community?: string) { + return { status, community }; +} + const EVENT_COLLECTION = "community.lexicon.calendar.event"; function isValidEventUri(uri: string): boolean { @@ -77,6 +132,10 @@ function redirectWithStatus(status: string): string { return `/events?rsvp=${encodeURIComponent(status)}`; } +function shareRedirect(status: string): string { + return `/community-content?share=${encodeURIComponent(status)}`; +} + function isPermissionError(error: unknown): boolean { const maybeError = error as { status?: number; @@ -99,63 +158,116 @@ export const server = { handler: async (_input, ctx) => { const loggedInUser = ctx.locals.loggedInUser; if (!loggedInUser) { - return { redirectUrl: joinRedirect("signin") }; + return joinPayload("signin"); } + const status = await runJoin(loggedInUser, await getAtmosphereCommunityDid()); + return joinPayload(status); + }, + }), + + // Listing-page join for any opensocial community, addressed by handle. + joinOpenSocialCommunity: defineAction({ + accept: "form", + input: z.object({ handle: z.string().min(1) }), + handler: async (input, ctx) => { + const loggedInUser = ctx.locals.loggedInUser; + if (!loggedInUser) { + return joinPayload("signin", input.handle); + } + let communityDid: string; + try { + communityDid = await resolveHandleToDid(input.handle); + } catch { + return joinPayload("missing", input.handle); + } + const status = await runJoin(loggedInUser, communityDid); + return joinPayload(status, input.handle); + }, + }), + + leaveAtmosphereCommunity: defineAction({ + accept: "form", + handler: async (_input, ctx) => { + const loggedInUser = ctx.locals.loggedInUser; + if (!loggedInUser) { + return joinPayload("signin"); + } + const status = await runLeave(loggedInUser, await getAtmosphereCommunityDid()); + return joinPayload(status); + }, + }), + + shareAtmosphereContent: defineAction({ + accept: "form", + input: z.object({ + candidateUri: z.string().min(1), + sourceRepo: z.string().min(1).optional(), + }), + handler: async (input, ctx) => { + const loggedInUser = ctx.locals.loggedInUser; + if (!loggedInUser) { + return { redirectUrl: shareRedirect("signin") }; + } + try { const communityDid = await getAtmosphereCommunityDid(); const membership = await getMembership({ communityDid, userDid: loggedInUser.did, }); - if (membership.isMember) { - return { redirectUrl: joinRedirect("already") }; + if (!membership.isMember && !membership.isAdmin) { + return { redirectUrl: shareRedirect("not-member") }; } - const membershipRecord = await ensureUserMembershipRecord({ - loggedInUser, - communityDid, - }); - const result = await joinCommunity({ + + const sourceRepo = input.sourceRepo ?? loggedInUser.handle; + const candidate = await getShareCandidateByUri( + sourceRepo, + input.candidateUri, + ); + if (!candidate) { + return { redirectUrl: shareRedirect("invalid") }; + } + + await shareContentWithCommunity({ communityDid, userDid: loggedInUser.did, - membershipCid: membershipRecord.cid, + candidate, }); - return { - redirectUrl: joinRedirect(result.status === "pending" ? "pending" : "ok"), - }; + return { redirectUrl: shareRedirect("ok") }; } catch (err) { if (err instanceof OpenSocialCommunityError) { - return { redirectUrl: joinRedirect(joinStatusFromError(err)) }; + return { + redirectUrl: shareRedirect( + err.code === "PermissionDenied" ? "permission" : "error", + ), + }; } if (isPermissionError(err)) { - return { redirectUrl: joinRedirect("permission") }; + return { redirectUrl: shareRedirect("permission") }; } - console.warn("[joinAtmosphereCommunity] unexpected error", err); - return { redirectUrl: joinRedirect("error") }; + console.warn("[shareAtmosphereContent] unexpected error", err); + return { redirectUrl: shareRedirect("error") }; } }, }), - leaveAtmosphereCommunity: defineAction({ + // Listing-page leave for any opensocial community, addressed by handle. + leaveOpenSocialCommunity: defineAction({ accept: "form", - handler: async (_input, ctx) => { + input: z.object({ handle: z.string().min(1) }), + handler: async (input, ctx) => { const loggedInUser = ctx.locals.loggedInUser; if (!loggedInUser) { - return { redirectUrl: joinRedirect("signin") }; + return joinPayload("signin", input.handle); } + let communityDid: string; try { - const communityDid = await getAtmosphereCommunityDid(); - await leaveCommunity({ - communityDid, - userDid: loggedInUser.did, - }); - return { redirectUrl: joinRedirect("left") }; - } catch (err) { - if (err instanceof OpenSocialCommunityError) { - return { redirectUrl: joinRedirect(leaveStatusFromError(err)) }; - } - console.warn("[leaveAtmosphereCommunity] unexpected error", err); - return { redirectUrl: joinRedirect("error") }; + communityDid = await resolveHandleToDid(input.handle); + } catch { + return joinPayload("missing", input.handle); } + const status = await runLeave(loggedInUser, communityDid); + return joinPayload(status, input.handle); }, }), diff --git a/src/components/auth/AuthMenu.astro b/src/components/auth/AuthMenu.astro index f0e798e..f01ddc5 100644 --- a/src/components/auth/AuthMenu.astro +++ b/src/components/auth/AuthMenu.astro @@ -2,28 +2,49 @@ import { getBlueskyAgent } from "@fujocoded/authproto/helpers"; import Combobox from "./Combobox.astro"; import AtprotoIcon from "./icons/AtprotoIcon.astro"; +import { getAtmosphereCommunityDid } from "../../lib/community/atmosphere"; +import { getMembership } from "../../lib/opensocial/membership"; const PROFILE_TTL_MS = 60_000; type CachedProfile = { expiresAt: number; avatar?: string; displayName?: string; + // Admin of this site's own (Atmosphere) community. The dropdown is global, so + // "admin" here means admin of atmosphere.community, not of any community. + isAdmin: boolean; }; const profileCache: Map = (globalThis as any).__authMenuProfileCache ?? ((globalThis as any).__authMenuProfileCache = new Map()); +async function loadAdminStatus(did: string): Promise { + try { + const communityDid = await getAtmosphereCommunityDid(); + const membership = await getMembership({ communityDid, userDid: did }); + return membership.isAdmin; + } catch (err) { + // A degraded appview shouldn't break the header; just hide the badge. + console.warn("[AuthMenu] failed to load admin status", did, err); + return false; + } +} + async function loadProfile(did: string): Promise { const now = Date.now(); const cached = profileCache.get(did); if (cached && cached.expiresAt > now) return cached; try { const agent = await getBlueskyAgent(); - const profile = await agent?.app.bsky.actor.getProfile({ actor: did }); + const [profile, isAdmin] = await Promise.all([ + agent?.app.bsky.actor.getProfile({ actor: did }), + loadAdminStatus(did), + ]); const entry: CachedProfile = { expiresAt: now + PROFILE_TTL_MS, avatar: profile?.data.avatar, displayName: profile?.data.displayName, + isAdmin, }; profileCache.set(did, entry); return entry; @@ -42,6 +63,7 @@ const authError = const profile = loggedInUser ? await loadProfile(loggedInUser.did) : null; const displayName = profile?.displayName?.trim() || loggedInUser?.handle || ""; const avatarUrl = profile?.avatar; +const isAdmin = profile?.isAdmin ?? false; const avatarInitial = (displayName || "@").charAt(0).toUpperCase(); --- @@ -73,6 +95,7 @@ const avatarInitial = (displayName || "@").charAt(0).toUpperCase();

Signed in as @{loggedInUser.handle} + {isAdmin && Admin}

+ + +
+ + + + + + ) : ( +
+

+ No shareable events or Standard Site documents were found for {repo}. +

+ +
+ )} + + + + + diff --git a/src/components/events/HappeningNow.astro b/src/components/events/HappeningNow.astro index 5da6864..12f6520 100644 --- a/src/components/events/HappeningNow.astro +++ b/src/components/events/HappeningNow.astro @@ -66,12 +66,7 @@ const startTimeFmt = new Intl.DateTimeFormat("en-US", {

- {groups.now.length > 0 && ( -

Live and upcoming community events.

@@ -89,35 +84,35 @@ const startTimeFmt = new Intl.DateTimeFormat("en-US", {
{section.items.map((item, itemIndex) => { - const status = item.isLive ? "live" : "soon"; - const relative = item.isLive - ? formatTimeLeft(item.endsAt.getTime() - nowMs) - : formatStartsIn(item.startsAt.getTime() - nowMs); - const startIso = item.startsAt.toISOString(); - const endIso = item.endsAt.toISOString(); - return ( - - + +

{item.event.name}

+ +
+ + {item.event.location && ( + <> + + + {item.event.location} + + + )} +
+ + ); + })} +
@@ -218,22 +218,33 @@ const startTimeFmt = new Intl.DateTimeFormat("en-US", { max-width: 55ch; } - /* Pulsing indicator next to "Happening now" heading */ - .now-pulse { + .logo-cloud { + display: block; + width: 22px; + height: 12px; position: relative; - display: inline-flex; - width: 8px; - height: 8px; - flex-shrink: 0; + background: var(--header-link-hover-bg); + border-radius: 12px; } - .now-pulse__dot { + .logo-cloud::before { + content: ""; position: absolute; - inset: 0; + width: 10px; + height: 10px; border-radius: 50%; - background: var(--primary); + background: var(--header-link-hover-bg); + top: -5px; + left: 4px; } - .now-pulse__ring { - display: none; + .logo-cloud::after { + content: ""; + position: absolute; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--header-link-hover-bg); + top: -3px; + left: 12px; } .now-groups { @@ -472,9 +483,9 @@ const startTimeFmt = new Intl.DateTimeFormat("en-US", { function regroupNowStrip() { const now = new Date(); const sections = new Map( - Array.from(document.querySelectorAll("[data-now-section]")).map( - (section) => [section.dataset.nowSection, section], - ), + Array.from( + document.querySelectorAll("[data-now-section]"), + ).map((section) => [section.dataset.nowSection, section]), ); const buckets = { now: [] as HTMLElement[], diff --git a/src/components/layout/Footer.astro b/src/components/layout/Footer.astro index b754243..4ebc3b7 100644 --- a/src/components/layout/Footer.astro +++ b/src/components/layout/Footer.astro @@ -1,7 +1,3 @@ ---- -const year = new Date().getFullYear(); ---- -