diff --git a/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/MembersBadge.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/MembersBadge.tsx index ff237d50..ff47f29b 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/MembersBadge.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/MembersBadge.tsx @@ -1,23 +1,28 @@ +"use client"; import { LockTiny } from "components/Icons/LockTiny"; +import { UnlockedTiny } from "components/Icons/UnlockedTiny"; +import { useViewerSubscription } from "components/Subscribe/viewerSubscription"; +import { memberTierUnlocksGatedPost } from "src/membership"; -export function MembersBadge() { - return ( - + ); } diff --git a/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemLarge.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemLarge.tsx index d8a79ab7..4f4789c1 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemLarge.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemLarge.tsx @@ -41,7 +41,12 @@ export function PublicationPostItemLarge(props: LargeProps) { className={`relative flex flex-col pt-2 ${widePage ? "sm:py-2 sm:px-4 " : ""} ${!props.inList && "px-3 py-2"}`} > {props.pubInfo} - {props.membersOnly && } + {props.membersOnly && ( + + )}
{props.pubInfo && props.pubInfo} - {props.membersOnly && } + {props.membersOnly && ( + + )} {/* Budget for title + description: the cover image's height (w-24 / sm:w-48, square). useFitToHeight measures against it and diff --git a/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemSmall.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemSmall.tsx index c768c86e..4875c6f8 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemSmall.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemSmall.tsx @@ -16,7 +16,12 @@ export function PublicationPostItemSmall(props: CommonProps) { // the h3-scale size the base-layer heading rules would otherwise bump.

{props.title} - {props.membersOnly && } + {props.membersOnly && ( + + )}

)} ); + const unlockingTierIds = post.membersOnly + ? getGatedPostTierIds(doc_record) + : null; + const isHighlightedFirst = highlightFirstPost && index === 0; const Variant = isHighlightedFirst ? "large" @@ -203,6 +208,8 @@ export function PublicationPostsList({ inList={inList ?? true} href={disableLinks ? undefined : docUrl} membersOnly={post.membersOnly} + publicationUri={publication.uri} + unlockingTierIds={unlockingTierIds} title={doc_record.title} description={ doc_record.description || getFirstParagraph(doc_record) @@ -226,6 +233,8 @@ export function PublicationPostsList({ inList={inList ?? true} href={disableLinks ? undefined : docUrl} membersOnly={post.membersOnly} + publicationUri={publication.uri} + unlockingTierIds={unlockingTierIds} title={doc_record.title} author={authorByUri.get(post.uri)} date={date} @@ -242,6 +251,8 @@ export function PublicationPostsList({ inList={inList ?? true} href={disableLinks ? undefined : docUrl} membersOnly={post.membersOnly} + publicationUri={publication.uri} + unlockingTierIds={unlockingTierIds} title={doc_record.title} description={ doc_record.description || getFirstParagraph(doc_record) diff --git a/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx b/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx index 4bbcb570..0ad28d20 100644 --- a/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx +++ b/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx @@ -16,7 +16,7 @@ import { InteractionPreview } from "components/Interactions/InteractionsPreview" import { PubIcon } from "components/ActionBar/Publications"; import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; import { formatBylineNames } from "src/utils/byline"; -import { postHasMembersDelimiter } from "src/membership"; +import { getGatedPostTierIds, postHasMembersDelimiter } from "src/membership"; export type StandardSitePostSize = "large" | "medium" | "small"; @@ -230,6 +230,8 @@ export function StandardSitePostItemView({ const commonProps = { href: docUrl, membersOnly: postHasMembersDelimiter(post.record), + publicationUri: post.publication?.uri, + unlockingTierIds: getGatedPostTierIds(post.record), title: post.record.title, author: authorLabel, date, diff --git a/components/Icons/UnlockedTiny.tsx b/components/Icons/UnlockedTiny.tsx new file mode 100644 index 00000000..7fbadf76 --- /dev/null +++ b/components/Icons/UnlockedTiny.tsx @@ -0,0 +1,21 @@ +import { Props } from "./Props"; + +export const UnlockedTiny = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Memberships/useMyMembership.ts b/components/Memberships/useMyMembership.ts index 35833cb2..82885bff 100644 --- a/components/Memberships/useMyMembership.ts +++ b/components/Memberships/useMyMembership.ts @@ -6,8 +6,6 @@ import { getMyMembershipForPublication } from "actions/memberships"; const membershipKey = (publicationUri: string) => `my-membership-${publicationUri}`; -// The identity's membership rows gate the fetch (a null key skips it) — most -// viewers aren't paying members of the publication and never hit the server. export function useMyMembership(publicationUri: string) { const { identity } = useIdentityData(); const hasMembership = !!identity?.publication_memberships?.some( diff --git a/components/PostListing.tsx b/components/PostListing.tsx index 97f22335..19563d57 100644 --- a/components/PostListing.tsx +++ b/components/PostListing.tsx @@ -8,7 +8,7 @@ import type { NormalizedPublication, } from "src/utils/normalizeRecords"; import { hasLeafletContent } from "lexicons/src/normalize"; -import { postHasMembersDelimiter } from "src/membership"; +import { getGatedPostTierIds, postHasMembersDelimiter } from "src/membership"; import type { Post } from "actions/reader/getReaderFeed"; import Link from "next/link"; @@ -96,7 +96,6 @@ export const PostListing = (props: Post & { selected?: boolean }) => { }) : undefined; - let author = namedContributors.length > 0 ? ( @@ -168,6 +167,8 @@ export const PostListing = (props: Post & { selected?: boolean }) => { { diff --git a/src/membership.ts b/src/membership.ts index 8a93ae5f..de964b8d 100644 --- a/src/membership.ts +++ b/src/membership.ts @@ -207,6 +207,28 @@ export type MembershipStatusFields = { current_period_end: string | null; }; +export function findActiveMembership< + T extends { publication: string } & MembershipStatusFields, +>( + memberships: T[] | null | undefined, + publicationUri: string | undefined, +): T | null { + if (!publicationUri) return null; + return ( + (memberships ?? []).find( + (m) => m.publication === publicationUri && isActiveMembership(m), + ) ?? null + ); +} + +export function memberTierUnlocksGatedPost( + memberTier: string | null | undefined, + unlockingTierIds: string[] | null | undefined, +): boolean { + if (!unlockingTierIds) return true; + return !!memberTier && unlockingTierIds.includes(memberTier); +} + export function isActiveMembership( m: MembershipStatusFields | null | undefined, ): boolean { @@ -257,7 +279,8 @@ export function isEntitledToGatedPost(input: { if (input.subscriptionUnlocks && input.isSubscriber) return true; if (!isActiveMembership(input.membership)) return false; if (input.subscriptionUnlocks) return true; - if (!input.unlockingTierIds) return true; - const memberTier = input.membership?.tier; - return !!memberTier && input.unlockingTierIds.includes(memberTier); + return memberTierUnlocksGatedPost( + input.membership?.tier, + input.unlockingTierIds, + ); } diff --git a/src/subscriptions/state.ts b/src/subscriptions/state.ts index 15d70533..4c71dafa 100644 --- a/src/subscriptions/state.ts +++ b/src/subscriptions/state.ts @@ -1,5 +1,5 @@ import { - isActiveMembership, + findActiveMembership, type MembershipStatusFields, } from "src/membership"; @@ -16,14 +16,21 @@ export type SubscriptionState = { // A confirmed email row = email delivery on. Muting email flips the row to // `unsubscribed`; there is no separate "muted" state. emailEnabled: boolean; + isMember: boolean; + memberTier: string | null; }; export function deriveSubscriptionState( - publicationUri: string, + publicationUri: string | undefined, rows: { subscriptions?: { publication: string }[] | null; emailSubscribers?: { publication: string; state: string }[] | null; - memberships?: ({ publication: string } & MembershipStatusFields)[] | null; + memberships?: + | ({ + publication: string; + tier?: string | null; + } & MembershipStatusFields)[] + | null; }, ): SubscriptionState { const atprotoSubscribed = (rows.subscriptions ?? []).some( @@ -32,12 +39,12 @@ export function deriveSubscriptionState( const emailEnabled = (rows.emailSubscribers ?? []).some( (s) => s.publication === publicationUri && s.state === "confirmed", ); - const isMember = (rows.memberships ?? []).some( - (m) => m.publication === publicationUri && isActiveMembership(m), - ); + const membership = findActiveMembership(rows.memberships, publicationUri); return { - subscribed: atprotoSubscribed || emailEnabled || isMember, + subscribed: atprotoSubscribed || emailEnabled || !!membership, atprotoSubscribed, emailEnabled, + isMember: !!membership, + memberTier: membership?.tier ?? null, }; }