diff --git a/src/actions/index.ts b/src/actions/index.ts index f634abd..991e642 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,108 +1,36 @@ -// 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 -// channel in events.astro + EventCard.astro. - -import { defineAction } from "astro:actions"; +import { ActionError, defineAction } from "astro:actions"; import { z } from "astro/zod"; import { AtUri } from "@atproto/api"; -import { - RSVP_STATUS_GOING, - RSVP_STATUS_NOT_GOING, - setRsvpStatus, - type RsvpStatus, -} from "../lib/rsvps"; import { getAtmosphereCommunityDid } from "../lib/community/atmosphere"; import { resolveHandleToDid } from "../lib/community/identity"; +import { type JoinStatusCode } from "../lib/community/join-status"; +import { + type ShareStatusCode, + type UnshareStatusCode, +} from "../lib/community/share-status"; +import { runJoin, runLeave } from "../lib/community/membership-mutations"; +import { parseSharedDocumentRef } from "../lib/community/shared-content"; +import { + getRepoRecordByUri, + getShareCandidateByUri, +} from "../lib/community/share-candidates"; import { OpenSocialCommunityError, - ensureUserMembershipRecord, getMembership, - 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"; - } -} - -// 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): JoinStatusCode { - switch (err.code) { - case "AlreadyMember": - return "already"; - case "AlreadyPending": - return "pending"; - case "CommunityNotFound": - return "missing"; - default: - return "error"; - } -} + SHARED_CONTENT_COLLECTION, + shareContentWithCommunity, + unshareContentWithCommunity, +} from "../lib/opensocial/content-sharing"; +import { + RSVP_STATUS_GOING, + RSVP_STATUS_NOT_GOING, + setRsvpStatus, + type RsvpStatus, +} from "../lib/rsvps"; -function leaveStatusFromError(err: OpenSocialCommunityError): JoinStatusCode { - switch (err.code) { - case "NotMember": - return "not-member"; - case "CommunityNotFound": - return "missing"; - case "CannotLeaveAsAdmin": - return "admin-block"; - default: - return "error"; - } -} function joinPayload(status: JoinStatusCode, community?: string) { return { status, community }; @@ -123,17 +51,32 @@ function isValidEventUri(uri: string): boolean { } } +function parseSharedContentRecordUri( + uri: string, + communityDid: string, +): AtUri | null { + try { + const parsed = new AtUri(uri); + return parsed.host === communityDid && + parsed.collection === SHARED_CONTENT_COLLECTION && + parsed.rkey.length > 0 + ? parsed + : null; + } catch { + return null; + } +} + const FORM_STATUS_TO_RSVP_STATUS: Record<"going" | "notgoing", RsvpStatus> = { going: RSVP_STATUS_GOING, notgoing: RSVP_STATUS_NOT_GOING, }; -function redirectWithStatus(status: string): string { - return `/events?rsvp=${encodeURIComponent(status)}`; -} - -function shareRedirect(status: string): string { - return `/community-content?share=${encodeURIComponent(status)}`; +function throwActionError( + code: "UNAUTHORIZED" | "FORBIDDEN" | "BAD_REQUEST" | "INTERNAL_SERVER_ERROR", + message: string, +): never { + throw new ActionError({ code, message }); } function isPermissionError(error: unknown): boolean { @@ -185,6 +128,26 @@ export const server = { }, }), + // Listing-page leave for any opensocial community, addressed by handle. + leaveOpenSocialCommunity: 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 runLeave(loggedInUser, communityDid); + return joinPayload(status, input.handle); + }, + }), + leaveAtmosphereCommunity: defineAction({ accept: "form", handler: async (_input, ctx) => { @@ -203,10 +166,10 @@ export const server = { candidateUri: z.string().min(1), sourceRepo: z.string().min(1).optional(), }), - handler: async (input, ctx) => { + handler: async (input, ctx): Promise<{ status: ShareStatusCode }> => { const loggedInUser = ctx.locals.loggedInUser; if (!loggedInUser) { - return { redirectUrl: shareRedirect("signin") }; + return { status: "signin" }; } try { @@ -216,7 +179,7 @@ export const server = { userDid: loggedInUser.did, }); if (!membership.isMember && !membership.isAdmin) { - return { redirectUrl: shareRedirect("not-member") }; + return { status: "not-member" }; } const sourceRepo = input.sourceRepo ?? loggedInUser.handle; @@ -225,7 +188,7 @@ export const server = { input.candidateUri, ); if (!candidate) { - return { redirectUrl: shareRedirect("invalid") }; + return { status: "not-found" }; } await shareContentWithCommunity({ @@ -233,60 +196,120 @@ export const server = { userDid: loggedInUser.did, candidate, }); - return { redirectUrl: shareRedirect("ok") }; + return { status: "ok" }; } catch (err) { if (err instanceof OpenSocialCommunityError) { - return { - redirectUrl: shareRedirect( - err.code === "PermissionDenied" ? "permission" : "error", - ), - }; + if (err.code === "PermissionDenied") { + return { status: "no-permission" }; + } + console.warn("[shareAtmosphereContent] OpenSocial error", err); + return { status: "failed" }; } if (isPermissionError(err)) { - return { redirectUrl: shareRedirect("permission") }; + return { status: "no-permission" }; } console.warn("[shareAtmosphereContent] unexpected error", err); - return { redirectUrl: shareRedirect("error") }; + return { status: "failed" }; } }, }), - // Listing-page leave for any opensocial community, addressed by handle. - leaveOpenSocialCommunity: defineAction({ + unshareAtmosphereContent: defineAction({ accept: "form", - input: z.object({ handle: z.string().min(1) }), - handler: async (input, ctx) => { + input: z.object({ + shareRecordUri: z.string().min(1), + }), + handler: async ( + input, + ctx, + ): Promise<{ status: UnshareStatusCode; shareRecordUri: string }> => { const loggedInUser = ctx.locals.loggedInUser; if (!loggedInUser) { - return joinPayload("signin", input.handle); + return { status: "signin", shareRecordUri: input.shareRecordUri }; } - let communityDid: string; + try { - communityDid = await resolveHandleToDid(input.handle); - } catch { - return joinPayload("missing", input.handle); + const communityDid = await getAtmosphereCommunityDid(); + const parsedShareRecordUri = parseSharedContentRecordUri( + input.shareRecordUri, + communityDid, + ); + if (!parsedShareRecordUri) { + return { status: "not-found", shareRecordUri: input.shareRecordUri }; + } + + const response = await getRepoRecordByUri(input.shareRecordUri); + if (!response || typeof response.value !== "object" || response.value === null) { + return { status: "not-found", shareRecordUri: input.shareRecordUri }; + } + + const sharedRecord = parseSharedDocumentRef( + response.value as Record, + { + source: communityDid, + shareRecordUri: response.uri, + shareRecordRkey: parsedShareRecordUri.rkey, + }, + ); + if (!sharedRecord) { + return { status: "not-found", shareRecordUri: input.shareRecordUri }; + } + + const isOriginalSharer = sharedRecord.sharedBy === loggedInUser.did; + if (!isOriginalSharer) { + const membership = await getMembership({ + communityDid, + userDid: loggedInUser.did, + }); + if (!membership.isAdmin) { + return { status: "not-author", shareRecordUri: input.shareRecordUri }; + } + } + + await unshareContentWithCommunity({ + communityDid, + userDid: loggedInUser.did, + shareRecordRkey: sharedRecord.shareRecordRkey, + }); + return { status: "ok", shareRecordUri: input.shareRecordUri }; + } catch (err) { + if (err instanceof OpenSocialCommunityError) { + if (err.code === "RecordNotFound") { + return { status: "missing", shareRecordUri: input.shareRecordUri }; + } + if (err.code === "PermissionDenied") { + return { status: "no-permission", shareRecordUri: input.shareRecordUri }; + } + console.warn("[unshareAtmosphereContent] OpenSocial error", err); + return { status: "failed", shareRecordUri: input.shareRecordUri }; + } + if (isPermissionError(err)) { + return { status: "no-permission", shareRecordUri: input.shareRecordUri }; + } + console.warn("[unshareAtmosphereContent] unexpected error", err); + return { status: "failed", shareRecordUri: input.shareRecordUri }; } - const status = await runLeave(loggedInUser, communityDid); - return joinPayload(status, input.handle); }, }), + rsvpEvent: defineAction({ accept: "form", input: z.object({ eventUri: z.string(), eventCid: z.string(), + eventName: z.string().optional(), status: z.enum(["going", "notgoing"]), }), handler: async (input, ctx) => { const loggedInUser = ctx.locals.loggedInUser; if (!loggedInUser) { - return { redirectUrl: redirectWithStatus("error") }; + throwActionError("UNAUTHORIZED", "You need to sign in to RSVP."); } if (!isValidEventUri(input.eventUri) || input.eventCid.length === 0) { - return { redirectUrl: redirectWithStatus("error") }; + throwActionError("BAD_REQUEST", "That RSVP request is invalid."); } try { @@ -296,16 +319,15 @@ export const server = { FORM_STATUS_TO_RSVP_STATUS[input.status], ); } catch (error) { - return { - redirectUrl: redirectWithStatus( - isPermissionError(error) ? "permission" : "error", - ), - }; + throwActionError( + isPermissionError(error) ? "FORBIDDEN" : "INTERNAL_SERVER_ERROR", + isPermissionError(error) + ? "Your login is missing permission to RSVP to events." + : "We couldn't update your RSVP right now. Please try again.", + ); } - return { - redirectUrl: redirectWithStatus(input.status), - }; + return { status: input.status, eventName: input.eventName ?? null }; }, }), }; diff --git a/src/components/auth/AuthMenu.astro b/src/components/auth/AuthMenu.astro index f01ddc5..83c18bb 100644 --- a/src/components/auth/AuthMenu.astro +++ b/src/components/auth/AuthMenu.astro @@ -153,6 +153,8 @@ const avatarInitial = (displayName || "@").charAt(0).toUpperCase(); diff --git a/src/components/communities/ShareSourceMenu.astro b/src/components/communities/ShareSourceMenu.astro new file mode 100644 index 0000000..deb771f --- /dev/null +++ b/src/components/communities/ShareSourceMenu.astro @@ -0,0 +1,379 @@ +--- +import Combobox from '../auth/Combobox.astro'; +import AtprotoIcon from '../auth/icons/AtprotoIcon.astro'; + +interface Props { + shareRepo: string; + shareSourceProfile: { displayName?: string; avatar?: string } | null; + loggedInUserHandle: string | null; +} + +const { + shareRepo, + shareSourceProfile, + loggedInUserHandle, +} = Astro.props; + +const canResetShareSource = !!loggedInUserHandle && shareRepo !== loggedInUserHandle; +const resetSourceHref = loggedInUserHandle + ? `/community-content?source=${encodeURIComponent(loggedInUserHandle)}` + : '/community-content'; +--- + + + + + + diff --git a/src/components/content/PostCard.astro b/src/components/content/PostCard.astro index a5b3797..b65e55c 100644 --- a/src/components/content/PostCard.astro +++ b/src/components/content/PostCard.astro @@ -13,15 +13,26 @@ interface Props { displayName?: string; avatar: AvatarSource; }; + sharedBy?: { + did: string; + handle: string; + displayName?: string; + avatar: AvatarSource; + }; source: string; documentUri?: string; + shareRecordUri?: string; + shareRecordRkey?: string; tags?: string[]; } -const { title, url, excerpt, publishedAt, sharedAt, author, source, tags } = Astro.props; +const { title, url, excerpt, publishedAt, sharedAt, author, sharedBy, source, tags } = Astro.props; +const hasActions = Astro.slots.has("actions"); const date = publishedAt ?? sharedAt; const authorLabel = author.displayName || `@${author.handle}`; +const sharedByLabel = sharedBy ? sharedBy.displayName || `@${sharedBy.handle}` : null; +const shouldShowSharedBy = !!sharedBy && sharedBy.did !== author.did; const tag = tags?.[0]; const community = `@${source}`; @@ -51,7 +62,14 @@ const communityHue = hueFromString(source); class="badge badge--location" style={`--community-hue:${communityHue}`} >{community} - {authorLabel} + + by {authorLabel} + + {shouldShowSharedBy && sharedByLabel && ( + + shared by {sharedByLabel} + + )} @@ -59,6 +77,11 @@ const communityHue = hueFromString(source); + {hasActions && ( +
+ +
+ )} \ No newline at end of file diff --git a/src/components/content/ShareAtUriForm.astro b/src/components/content/ShareAtUriForm.astro new file mode 100644 index 0000000..9ab84fc --- /dev/null +++ b/src/components/content/ShareAtUriForm.astro @@ -0,0 +1,104 @@ +--- +import { actions } from 'astro:actions'; + +interface Props { + repo: string; + inputId: string; + placeholder: string; +} + +const { repo, inputId, placeholder } = Astro.props; +--- + + + + diff --git a/src/components/content/ShareableContent.astro b/src/components/content/ShareableContentList.astro similarity index 60% rename from src/components/content/ShareableContent.astro rename to src/components/content/ShareableContentList.astro index 2bd2e15..e8faa5c 100644 --- a/src/components/content/ShareableContent.astro +++ b/src/components/content/ShareableContentList.astro @@ -1,18 +1,29 @@ --- -import type { ShareCandidate, ShareCandidateList } from '../../lib/community/share-candidates'; +import type { ShareCandidateList } from '../../lib/community/share-candidates'; import { actions } from 'astro:actions'; +import SharableContentIcon from './SharableContentIcon.astro'; interface Props { candidates: ShareCandidateList | null; repo: string; error?: boolean; + initialVisible?: number; + loadStep?: number; } -const { candidates, repo, error = false } = Astro.props; -const INITIAL_VISIBLE = 5; -const LOAD_STEP = 5; -const items = candidates - ? [...candidates.events, ...candidates.documents] +const { + candidates, + repo, + error = false, + initialVisible = 5, + loadStep = 5, +} = Astro.props; + +const candidateList = candidates && (candidates.events.length > 0 || candidates.documents.length > 0) + ? candidates + : null; +const items = candidateList + ? [...candidateList.events, ...candidateList.documents] .sort((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0)) : []; @@ -25,52 +36,26 @@ function formatDate(date: Date | undefined): string { }).format(date); } -function previewUrl(candidate: ShareCandidate): string { - const parsed = parseAtUri(candidate.uri); - if (!parsed) return candidate.uri; - - if (candidate.kind === 'event') { - return `https://smokesignal.events/${parsed.repo}/${parsed.rkey}`; - } - return `https://leaflet.pub/profile/${parsed.repo}/${parsed.rkey}`; -} - -function typeLabel(kind: ShareCandidate['kind']): string { - return kind === 'event' ? 'Event' : 'Content'; -} - -function dateLabel(kind: ShareCandidate['kind']): string { - return kind === 'event' ? 'Held' : 'Published'; -} - -function countLabel(candidates: ShareCandidateList | null): string { - if (!candidates) return ''; - const count = candidates.events.length + candidates.documents.length; - return `${count} found`; -} - -function parseAtUri(uri: string): { repo: string; rkey: string } | null { - const parts = uri.replace(/^at:\/\//, '').split('/'); - const [repo, , rkey] = parts; - if (!repo || !rkey) return null; - return { repo, rkey }; -} --- -
- {error ? ( -

- Couldn’t load shareable records right now. -

- ) : items.length > 0 ? ( -
+{error && ( +

+ Couldn’t load shareable records right now. +

+)} + +{!error && candidateList && ( +
+
- {countLabel(candidates)} + + {candidateList.events.length + candidateList.documents.length} found + Events and Standard Site documents
@@ -84,21 +69,22 @@ function parseAtUri(uri: string): { repo: string; rkey: string } | null { Content
-
    - {items.map((candidate, index) => ( -
+
Smoke Signal and OpenMeet.

- + {rsvpReadFailed && (

We couldn't check which events you're going to right now.

)} diff --git a/src/pages/index.astro b/src/pages/index.astro index c6dd36b..548c4d2 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -8,14 +8,16 @@ import EventCard from "../components/events/EventCard.astro"; import CommunityCard from "../components/communities/CommunityCard.astro"; import GetInvolved from "../components/layout/GetInvolved.astro"; import { getLiveCollection } from "astro:content"; +import { dedupeFeedEntries } from "../lib/community/dedup-feed"; const communitiesResult = await getLiveCollection("communities"); const communities = (communitiesResult.entries ?? []).map( (entry) => entry.data, ); const feedResult = await getLiveCollection("feed"); -const mergedPosts = (feedResult.entries ?? []) - .map((entry) => entry.data) +const mergedPosts = dedupeFeedEntries( + (feedResult.entries ?? []).map((entry) => entry.data), +) .sort( (a, b) => (b.publishedAt ?? b.sharedAt).getTime() -