diff --git a/src/features/community-membership/action.ts b/src/features/community-membership/action.ts index 769ee4f..e717eb1 100644 --- a/src/features/community-membership/action.ts +++ b/src/features/community-membership/action.ts @@ -4,22 +4,14 @@ import { z } from "astro/zod"; import { getAtmosphereCommunityDid } from "../../lib/community/atmosphere"; import { resolveHandleToDid } from "../../lib/community/identity"; import { OpenSocialCommunityError } from "../../lib/opensocial/client"; +import { isPermissionError } from "../../lib/action-result"; import { runJoin, runLeave } from "./mutations"; import { getJoinErrorMessage, type JoinOutcomeCode } from "./notice"; -interface AtmosphereJoinResult { - outcome: JoinOutcomeCode; -} - -interface ListingJoinResult { - outcome: JoinOutcomeCode; - community: string; -} - export const membershipActions = { joinAtmosphereCommunity: defineAction({ accept: "form", - handler: async (_input, ctx): Promise => { + handler: async (_input, ctx): Promise<{ outcome: JoinOutcomeCode }> => { const loggedInUser = ctx.locals.loggedInUser; if (!loggedInUser) { throw new ActionError({ @@ -43,7 +35,10 @@ export const membershipActions = { joinOpenSocialCommunity: defineAction({ accept: "form", input: z.object({ handle: z.string().min(1) }), - handler: async (input, ctx): Promise => { + handler: async ( + input, + ctx, + ): Promise<{ outcome: JoinOutcomeCode; community: string }> => { const loggedInUser = ctx.locals.loggedInUser; if (!loggedInUser) { throw new ActionError({ @@ -74,7 +69,10 @@ export const membershipActions = { leaveOpenSocialCommunity: defineAction({ accept: "form", input: z.object({ handle: z.string().min(1) }), - handler: async (input, ctx): Promise => { + handler: async ( + input, + ctx, + ): Promise<{ outcome: JoinOutcomeCode; community: string }> => { const loggedInUser = ctx.locals.loggedInUser; if (!loggedInUser) { throw new ActionError({ @@ -104,7 +102,7 @@ export const membershipActions = { leaveAtmosphereCommunity: defineAction({ accept: "form", - handler: async (_input, ctx): Promise => { + handler: async (_input, ctx): Promise<{ outcome: JoinOutcomeCode }> => { const loggedInUser = ctx.locals.loggedInUser; if (!loggedInUser) { throw new ActionError({ @@ -153,17 +151,3 @@ function toMembershipActionError(error: unknown): ActionError { message: getJoinErrorMessage("INTERNAL_SERVER_ERROR"), }); } - -function isPermissionError(error: unknown): boolean { - const maybeError = error as { - status?: number; - error?: string; - message?: string; - }; - const text = `${maybeError.error ?? ""} ${maybeError.message ?? ""}`.toLowerCase(); - return ( - maybeError.status === 401 || - maybeError.status === 403 || - text.includes("scope") - ); -} diff --git a/src/features/community-membership/notice.ts b/src/features/community-membership/notice.ts index 69a5324..23ca205 100644 --- a/src/features/community-membership/notice.ts +++ b/src/features/community-membership/notice.ts @@ -1,3 +1,8 @@ +import { + getActionErrorNotice, + type ActionResultLike, +} from "../../lib/action-result"; + export type JoinOutcomeCode = | "ok" | "pending" @@ -11,15 +16,9 @@ export interface JoinNotice { message: string; } -type JoinResultLike = { - data?: { - outcome?: JoinOutcomeCode | null; - } | null; - error?: { - code?: string; - message?: string; - } | null; -} | null | undefined; +type JoinResultLike = ActionResultLike<{ + outcome?: JoinOutcomeCode | null; +}>; type JoinActionErrorCode = | "UNAUTHORIZED" @@ -76,28 +75,13 @@ export function getJoinNotice(result: JoinResultLike): JoinNotice | null { return JOIN_OUTCOME_NOTICE[outcome]; } - return getJoinErrorNotice(result?.error?.code, result?.error?.message); + return getActionErrorNotice( + JOIN_ERROR_NOTICE, + result?.error?.code, + result?.error?.message, + ); } export function getJoinErrorMessage(code: JoinActionErrorCode): string { return JOIN_ERROR_NOTICE[code].message; } - -function getJoinErrorNotice( - code?: string, - message?: string, -): JoinNotice | null { - if (!code) { - return message ? { tone: "error", message } : null; - } - - const fallback = JOIN_ERROR_NOTICE[code as JoinActionErrorCode]; - if (!fallback) { - return message ? { tone: "error", message } : null; - } - - return { - tone: fallback.tone, - message: message || fallback.message, - }; -} diff --git a/src/features/community-membership/page.ts b/src/features/community-membership/page.ts index 779e741..baefeb2 100644 --- a/src/features/community-membership/page.ts +++ b/src/features/community-membership/page.ts @@ -1,32 +1,23 @@ import { getAtmosphereCommunityDid } from "../../lib/community/atmosphere"; +import { resolveHandleToDid } from "../../lib/community/identity"; import { getMembership } from "../../lib/opensocial/membership"; import { pickFirstActionResult } from "../../lib/action-result"; import { getJoinNotice, type JoinNotice, type JoinOutcomeCode } from "./notice"; +import { type ActionResultLike } from "../../lib/action-result"; -type JoinResultLike = { - data?: { - outcome?: JoinOutcomeCode | null; - community?: string | null; - } | null; - error?: { - code?: string; - message?: string; - } | null; -} | null | undefined; +type JoinResultLike = ActionResultLike<{ + outcome?: JoinOutcomeCode | null; + community?: string | null; +}>; interface CommunitySummary { handle: string; name: string; } -interface CommunityListingJoinState { - notice: JoinNotice | null; - communityName?: string; -} - -interface CommunityViewerMembershipState { - isMember: boolean; - isAdmin: boolean; +interface OpenSocialCommunitySummary { + handle: string; + isOpenSocialCommunity?: boolean; } export function getCommunityListingJoinState( @@ -39,7 +30,7 @@ export function getCommunityListingJoinState( joinResult: JoinResultLike; leaveResult: JoinResultLike; }, -): CommunityListingJoinState { +) { const result = pickFirstActionResult({ items: [joinResult, leaveResult], hasMeaningfulData: (data) => Boolean(data?.outcome), @@ -69,7 +60,7 @@ export function getJoinActionNotice( export async function getAtmosphereViewerMembershipState( loggedInUser: App.Locals["loggedInUser"], -): Promise { +) { if (!loggedInUser) { return { isMember: false, @@ -95,3 +86,46 @@ export async function getAtmosphereViewerMembershipState( }; } } + +export async function getCommunityListingMembershipState( + loggedInUser: App.Locals["loggedInUser"], + communities: OpenSocialCommunitySummary[], +) { + const memberHandles = new Set(); + const adminHandles = new Set(); + + if (!loggedInUser) { + return { memberHandles, adminHandles }; + } + + const results = await Promise.allSettled( + communities + .filter((community) => community.isOpenSocialCommunity) + .map(async (community) => { + const communityDid = await resolveHandleToDid(community.handle); + const membership = await getMembership({ + communityDid, + userDid: loggedInUser.did, + }); + return { + handle: community.handle, + isMember: membership.isMember, + isAdmin: membership.isAdmin, + }; + }), + ); + + for (const result of results) { + if (result.status !== "fulfilled") { + continue; + } + if (result.value.isMember) { + memberHandles.add(result.value.handle); + } + if (result.value.isAdmin) { + adminHandles.add(result.value.handle); + } + } + + return { memberHandles, adminHandles }; +} diff --git a/src/features/community-sharing/action.ts b/src/features/community-sharing/action.ts index 46304e5..bb31497 100644 --- a/src/features/community-sharing/action.ts +++ b/src/features/community-sharing/action.ts @@ -7,7 +7,10 @@ import { getRepoRecordByUri, getShareCandidateByUri, } from "../../lib/community/share-candidates"; -import { parseSharedDocumentRef } from "../../lib/community/shared-content"; +import { + parseSharedDocumentRef, + type SharedDocumentRef, +} from "../../lib/community/shared-content"; import { SHARED_CONTENT_COLLECTION, shareContentWithCommunity, @@ -15,16 +18,22 @@ import { } from "../../lib/opensocial/content-sharing"; import { OpenSocialCommunityError } from "../../lib/opensocial/client"; import { getMembership } from "../../lib/opensocial/membership"; +import { isPermissionError } from "../../lib/action-result"; import { getShareErrorMessage, type ShareOutcomeCode, type UnshareOutcomeCode, } from "./notice"; -function parseSharedContentRecordUri( - uri: string, - communityDid: string, -): AtUri | null { +type LoggedInUser = NonNullable; + +function parseSharedContentRecordUri({ + uri, + communityDid, +}: { + uri: string; + communityDid: string; +}): AtUri | null { try { const parsed = new AtUri(uri); return parsed.host === communityDid && @@ -37,18 +46,64 @@ function parseSharedContentRecordUri( } } -function isPermissionError(error: unknown): boolean { - const maybeError = error as { - status?: number; - error?: string; - message?: string; - }; - const text = `${maybeError.error ?? ""} ${maybeError.message ?? ""}`.toLowerCase(); - return ( - maybeError.status === 401 || - maybeError.status === 403 || - text.includes("scope") +async function loadSharedDocumentRef({ + shareRecordUri, + communityDid, + shareRecordRkey, +}: { + shareRecordUri: string; + communityDid: string; + shareRecordRkey: string; +}): Promise { + const response = await getRepoRecordByUri(shareRecordUri); + if (!response || typeof response.value !== "object" || response.value === null) { + throw new ActionError({ + code: "NOT_FOUND", + message: "That item couldn't be removed. Refresh the page and try again.", + }); + } + + const sharedRecord = parseSharedDocumentRef( + response.value as Record, + { + source: communityDid, + shareRecordUri: response.uri, + shareRecordRkey, + }, ); + if (!sharedRecord) { + throw new ActionError({ + code: "NOT_FOUND", + message: "That item couldn't be removed. Refresh the page and try again.", + }); + } + + return sharedRecord; +} + +async function assertCanUnshare({ + sharedRecord, + loggedInUser, + communityDid, +}: { + sharedRecord: SharedDocumentRef; + loggedInUser: LoggedInUser; + communityDid: string; +}): Promise { + if (sharedRecord.sharedBy === loggedInUser.did) { + return; + } + + const membership = await getMembership({ + communityDid, + userDid: loggedInUser.did, + }); + if (!membership.isAdmin) { + throw new ActionError({ + code: "FORBIDDEN", + message: "Only the original sharer can remove that item.", + }); + } } export const sharingActions = { @@ -141,10 +196,10 @@ export const sharingActions = { try { const communityDid = await getAtmosphereCommunityDid(); - const parsedShareRecordUri = parseSharedContentRecordUri( - input.shareRecordUri, + const parsedShareRecordUri = parseSharedContentRecordUri({ + uri: input.shareRecordUri, communityDid, - ); + }); if (!parsedShareRecordUri) { throw new ActionError({ code: "BAD_REQUEST", @@ -152,42 +207,16 @@ export const sharingActions = { }); } - const response = await getRepoRecordByUri(input.shareRecordUri); - if (!response || typeof response.value !== "object" || response.value === null) { - throw new ActionError({ - code: "NOT_FOUND", - message: "That item couldn't be removed. Refresh the page and try again.", - }); - } - - const sharedRecord = parseSharedDocumentRef( - response.value as Record, - { - source: communityDid, - shareRecordUri: response.uri, - shareRecordRkey: parsedShareRecordUri.rkey, - }, - ); - if (!sharedRecord) { - throw new ActionError({ - code: "NOT_FOUND", - message: "That item couldn't be removed. Refresh the page and try again.", - }); - } - - const isOriginalSharer = sharedRecord.sharedBy === loggedInUser.did; - if (!isOriginalSharer) { - const membership = await getMembership({ - communityDid, - userDid: loggedInUser.did, - }); - if (!membership.isAdmin) { - throw new ActionError({ - code: "FORBIDDEN", - message: "Only the original sharer can remove that item.", - }); - } - } + const sharedRecord = await loadSharedDocumentRef({ + shareRecordUri: input.shareRecordUri, + communityDid, + shareRecordRkey: parsedShareRecordUri.rkey, + }); + await assertCanUnshare({ + sharedRecord, + loggedInUser, + communityDid, + }); await unshareContentWithCommunity({ communityDid, diff --git a/src/features/community-sharing/notice.ts b/src/features/community-sharing/notice.ts index 71e7544..b135e4b 100644 --- a/src/features/community-sharing/notice.ts +++ b/src/features/community-sharing/notice.ts @@ -1,4 +1,8 @@ import { pickFirstActionResult } from "../../lib/action-result"; +import { + getActionErrorNotice, + type ActionResultLike, +} from "../../lib/action-result"; export type ShareOutcomeCode = "ok" | "not-member"; @@ -9,25 +13,13 @@ export interface ShareNotice { message: string; } -type ShareResultLike = { - data?: { - outcome?: ShareOutcomeCode | null; - } | null; - error?: { - code?: string; - message?: string; - } | null; -} | null | undefined; +type ShareResultLike = ActionResultLike<{ + outcome?: ShareOutcomeCode | null; +}>; -type UnshareResultLike = { - data?: { - outcome?: UnshareOutcomeCode | null; - } | null; - error?: { - code?: string; - message?: string; - } | null; -} | null | undefined; +type UnshareResultLike = ActionResultLike<{ + outcome?: UnshareOutcomeCode | null; +}>; type ShareActionErrorCode = | "UNAUTHORIZED" @@ -94,28 +86,13 @@ export function getShareNotice( return UNSHARE_OUTCOME_NOTICE[result.data.outcome]; } - return getShareErrorNotice(result?.error?.code, result?.error?.message); + return getActionErrorNotice( + SHARE_ERROR_NOTICE, + result?.error?.code, + result?.error?.message, + ); } export function getShareErrorMessage(code: ShareActionErrorCode): string { return SHARE_ERROR_NOTICE[code].message; } - -function getShareErrorNotice( - code?: string, - message?: string, -): ShareNotice | null { - if (!code) { - return message ? { tone: "error", message } : null; - } - - const fallback = SHARE_ERROR_NOTICE[code as ShareActionErrorCode]; - if (!fallback) { - return message ? { tone: "error", message } : null; - } - - return { - tone: fallback.tone, - message: message || fallback.message, - }; -} diff --git a/src/features/community-sharing/page.ts b/src/features/community-sharing/page.ts index f2c1f7f..a6bcd0d 100644 --- a/src/features/community-sharing/page.ts +++ b/src/features/community-sharing/page.ts @@ -2,20 +2,6 @@ import { getBlueskyAgent } from "@fujocoded/authproto/helpers"; import { getShareCandidates } from "../../lib/community/share-candidates"; -type ShareCandidates = Awaited>; - -interface ShareSourceProfile { - displayName?: string; - avatar?: string; -} - -interface SharePanelState { - shareRepo: string; - shareSourceProfile: ShareSourceProfile | null; - shareCandidates: ShareCandidates | null; - shareCandidatesError: boolean; -} - export async function getCommunitySharePanelState({ sourceParam, fallbackRepo, @@ -24,7 +10,7 @@ export async function getCommunitySharePanelState({ sourceParam: string | null | undefined; fallbackRepo: string | null | undefined; canShareContent: boolean; -}): Promise { +}) { const shareRepo = sourceParam?.trim() || fallbackRepo || ""; if (!shareRepo || !canShareContent) { return { diff --git a/src/features/event-rsvp/action.ts b/src/features/event-rsvp/action.ts index a2be851..604fe33 100644 --- a/src/features/event-rsvp/action.ts +++ b/src/features/event-rsvp/action.ts @@ -6,8 +6,10 @@ import { RSVP_STATUS_GOING, RSVP_STATUS_NOT_GOING, setRsvpStatus, + type RsvpSubjectRef, type RsvpStatus, } from "./data"; +import { isPermissionError } from "../../lib/action-result"; import { getRsvpErrorMessage, type RsvpOutcomeCode } from "./notice"; const EVENT_COLLECTION = "community.lexicon.calendar.event"; @@ -30,20 +32,6 @@ const FORM_STATUS_TO_RSVP_STATUS: Record<"going" | "notgoing", RsvpStatus> = { notgoing: RSVP_STATUS_NOT_GOING, }; -function isPermissionError(error: unknown): boolean { - const maybeError = error as { - status?: number; - error?: string; - message?: string; - }; - const text = `${maybeError.error ?? ""} ${maybeError.message ?? ""}`.toLowerCase(); - return ( - maybeError.status === 401 || - maybeError.status === 403 || - text.includes("scope") - ); -} - export const rsvpActions = { rsvpEvent: defineAction({ accept: "form", @@ -74,9 +62,14 @@ export const rsvpActions = { } try { + const subject: RsvpSubjectRef = { + uri: input.eventUri, + cid: input.eventCid, + }; + await setRsvpStatus( loggedInUser, - { uri: input.eventUri, cid: input.eventCid }, + subject, FORM_STATUS_TO_RSVP_STATUS[input.status], ); } catch (error) { diff --git a/src/features/event-rsvp/data.ts b/src/features/event-rsvp/data.ts index 663151f..5b947d1 100644 --- a/src/features/event-rsvp/data.ts +++ b/src/features/event-rsvp/data.ts @@ -12,9 +12,14 @@ export type RsvpStatus = | typeof RSVP_STATUS_INTERESTED | typeof RSVP_STATUS_NOT_GOING; +export interface RsvpSubjectRef { + uri: string; + cid: string; +} + export interface CalendarRsvpRecord { $type: "community.lexicon.calendar.rsvp"; - subject: { uri: string; cid: string }; + subject: RsvpSubjectRef; status: RsvpStatus; createdAt: string; } @@ -26,7 +31,7 @@ export interface CalendarRsvp { record: CalendarRsvpRecord; } -export type LoggedInUser = NonNullable; +type LoggedInUser = NonNullable; interface EventLike { uri: string; @@ -113,7 +118,7 @@ export async function getRsvpsForEvents( export async function setRsvpStatus( loggedInUser: LoggedInUser, - subject: { uri: string; cid: string }, + subject: RsvpSubjectRef, status: RsvpStatus, ): Promise { const agent = await getLoggedInAgent(loggedInUser); diff --git a/src/features/event-rsvp/notice.ts b/src/features/event-rsvp/notice.ts index 673cf73..4c626b7 100644 --- a/src/features/event-rsvp/notice.ts +++ b/src/features/event-rsvp/notice.ts @@ -1,3 +1,8 @@ +import { + getActionErrorNotice, + type ActionResultLike, +} from "../../lib/action-result"; + export type RsvpOutcomeCode = "going" | "notgoing"; export interface RsvpNotice { @@ -5,16 +10,10 @@ export interface RsvpNotice { message: string; } -type RsvpResultLike = { - data?: { - outcome?: RsvpOutcomeCode | null; - eventName?: string | null; - } | null; - error?: { - code?: string; - message?: string; - } | null; -} | null | undefined; +type RsvpResultLike = ActionResultLike<{ + outcome?: RsvpOutcomeCode | null; + eventName?: string | null; +}>; type RsvpActionErrorCode = | "UNAUTHORIZED" @@ -79,28 +78,13 @@ export function getRsvpNotice( }; } - return getRsvpErrorNotice(result?.error?.code, result?.error?.message); + return getActionErrorNotice( + RSVP_ERROR_NOTICE, + result?.error?.code, + result?.error?.message, + ); } export function getRsvpErrorMessage(code: RsvpActionErrorCode): string { return RSVP_ERROR_NOTICE[code].message; } - -function getRsvpErrorNotice( - code?: string, - message?: string, -): RsvpNotice | null { - if (!code) { - return message ? { tone: "error", message } : null; - } - - const fallback = RSVP_ERROR_NOTICE[code as RsvpActionErrorCode]; - if (!fallback) { - return message ? { tone: "error", message } : null; - } - - return { - tone: fallback.tone, - message: message || fallback.message, - }; -} diff --git a/src/lib/action-result.ts b/src/lib/action-result.ts index 260eb21..c3e365b 100644 --- a/src/lib/action-result.ts +++ b/src/lib/action-result.ts @@ -33,3 +33,59 @@ export function pickFirstActionResult = { + data?: TData | null; + error?: { + code?: string; + message?: string; + } | null; +} | null | undefined; + +interface NoticeMessage { + tone: "success" | "info" | "neutral" | "error"; + message: string; +} + +export function getActionErrorNotice< + TCode extends string, + TNotice extends NoticeMessage, +>( + notices: Record, + code?: string, + message?: string, +): TNotice | { tone: "error"; message: string } | null { + if (!code) { + return message ? { tone: "error", message } : null; + } + + const fallback = notices[code as TCode]; + if (!fallback) { + return message ? { tone: "error", message } : null; + } + + return { + ...fallback, + message: message || fallback.message, + }; +} + +export function isPermissionError(error: unknown): boolean { + if (typeof error !== "object" || error === null) { + return false; + } + + const maybeError = error as { + status?: number; + error?: string; + message?: string; + }; + const text = + `${maybeError.error ?? ""} ${maybeError.message ?? ""}`.toLowerCase(); + + return ( + maybeError.status === 401 || + maybeError.status === 403 || + text.includes("scope") + ); +} diff --git a/src/pages/communities.astro b/src/pages/communities.astro index 5e416b9..b6c6e2d 100644 --- a/src/pages/communities.astro +++ b/src/pages/communities.astro @@ -6,9 +6,10 @@ import JoinStatusNotice from "../components/communities/JoinStatusNotice.astro"; import CommunityCard from "../components/communities/CommunityCard.astro"; import { getLiveCollection } from "astro:content"; import { actions } from "astro:actions"; -import { getMembership } from "../lib/opensocial/membership"; -import { resolveHandleToDid } from "../lib/community/identity"; -import { getCommunityListingJoinState } from "../features/community-membership/page"; +import { + getCommunityListingJoinState, + getCommunityListingMembershipState, +} from "../features/community-membership/page"; const communitiesResult = await getLiveCollection("communities"); const communities = communitiesResult.entries?.map((entry) => entry.data) ?? []; @@ -22,34 +23,8 @@ const { notice: joinNotice, communityName } = getCommunityListingJoinState({ const loggedInUser = Astro.locals.loggedInUser; -// Resolve which opensocial communities the signed-in viewer already belongs to, -// so members see a "Joined" badge instead of a redundant Join button. Only -// opensocial communities are probed (others have no join endpoint), and each -// lookup is isolated: a failed probe just falls back to showing Join. -const memberHandles = new Set(); -const adminHandles = new Set(); -if (loggedInUser) { - const openSocial = communities.filter((c) => c.isOpenSocialCommunity); - const results = await Promise.allSettled( - openSocial.map(async (c) => { - const communityDid = await resolveHandleToDid(c.handle); - const membership = await getMembership({ - communityDid, - userDid: loggedInUser.did, - }); - return { - handle: c.handle, - isMember: membership.isMember, - isAdmin: membership.isAdmin, - }; - }), - ); - for (const result of results) { - if (result.status !== "fulfilled") continue; - if (result.value.isMember) memberHandles.add(result.value.handle); - if (result.value.isAdmin) adminHandles.add(result.value.handle); - } -} +const { memberHandles, adminHandles } = + await getCommunityListingMembershipState(loggedInUser, communities); const REGION_ORDER = ["North America — USA", "North America — Canada", "South America", "Europe", "Online", "Other"]; const grouped = new Map();