diff --git a/src/css/style.css b/src/css/style.css index d9fcb47a..f0992b1e 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -6896,6 +6896,10 @@ sidebar-search { line-height: 1.3; } +.list-detail-creator a { + color: inherit; +} + .list-detail-description { padding: 0 12px 14px; font-size: 15px; diff --git a/src/js/api.js b/src/js/api.js index 8de24ab3..4fa1efd2 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -352,6 +352,37 @@ export class Api { return res.data; } + async createReferenceListOptOutRecord(listUri) { + const res = await this.request("com.atproto.repo.createRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.graph.referencelistoptout", + record: { + createdAt: getCurrentTimestamp(), + subject: listUri, + }, + }, + }); + return res.data; + } + + async deleteReferenceListOptOutRecord(optOutUri) { + const { collection, rkey } = parseUri(optOutUri); + if (collection !== "app.bsky.graph.referencelistoptout" || !rkey) { + throw new Error(`Invalid reference list opt-out uri: ${optOutUri}`); + } + const res = await this.request("com.atproto.repo.deleteRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.graph.referencelistoptout", + rkey, + }, + }); + return res.data; + } + async getListItems({ limit = 100, cursor = "" } = {}) { const query = { repo: this.session.did, diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index b7b1b5d9..19a9d545 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -13,6 +13,14 @@ export function isModerationList(list) { return list?.purpose === "app.bsky.graph.defs#modlist"; } +export function isReferenceList(list) { + return list?.purpose === "app.bsky.graph.defs#referencelist"; +} + +export function isCurateList(list) { + return list?.purpose === "app.bsky.graph.defs#curatelist"; +} + export function hasValidHandle(profile) { return ( !!profile.handle && diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index 65a5253f..fe701115 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -70,6 +70,8 @@ export class DataStore extends ReactiveStore { this.$feedGenerators = new SignalMap(); this.$lists = new SignalMap(); this.$starterPacks = new SignalMap(); + this.$referenceListOptOuts = new SignalMap(); + this.$starterPackUrisByList = new SignalMap(); this.$listMembers = new SignalMap(); this.$actorFeeds = new SignalMap(); this.$actorLists = new SignalMap(); diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index cc99bde5..fc9df88c 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -361,10 +361,18 @@ export class Derived extends ReactiveStore { this.dataStore.$feedGenerators.get(feedUri), ); this.$lists = new ComputedMap((listUri) => - this.dataStore.$lists.get(listUri), + this.applyReferenceListOptOut(this.dataStore.$lists.get(listUri)), ); - this.$starterPacks = new ComputedMap((starterPackUri) => - this.dataStore.$starterPacks.get(starterPackUri), + this.$starterPacks = new ComputedMap((starterPackUri) => { + const starterPack = this.dataStore.$starterPacks.get(starterPackUri); + if (!starterPack?.list) return starterPack; + return { + ...starterPack, + list: this.applyReferenceListOptOut(starterPack.list), + }; + }); + this.$starterPackUriForList = new ComputedMap( + (listUri) => this.dataStore.$starterPackUrisByList.get(listUri) ?? null, ); this.$listMembers = new ComputedMap((listUri) => { const data = this.dataStore.$listMembers.get(listUri); @@ -834,6 +842,18 @@ export class Derived extends ReactiveStore { return { ...item, embed: updated }; } + applyReferenceListOptOut(list) { + if (!list) return list; + if (!this.dataStore.$referenceListOptOuts.has(list.uri)) return list; + return { + ...list, + viewer: { + ...list.viewer, + referenceListOptOut: this.dataStore.$referenceListOptOuts.get(list.uri), + }, + }; + } + hydrateProfile(profile) { const preferences = this.$preferences.get(); let result = profile; diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 2785f878..47005acb 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -12,7 +12,7 @@ import { valueForPinnedItem, buildCdnUrl, } from "/js/dataHelpers.js"; -import { batch, getCurrentTimestamp } from "/js/utils.js"; +import { batch, getCurrentTimestamp, wait } from "/js/utils.js"; import { fetchAndCompressLinkCardImage } from "/js/embedHelpers.js"; import { PostCreator } from "/js/postCreator.js"; import { untrack } from "/js/signals.js"; @@ -1009,6 +1009,52 @@ export class Mutations { } } + async optOutOfReferenceList(list) { + try { + const created = await this.api.createReferenceListOptOutRecord(list.uri); + await this._pollReferenceListOptOut(list.uri, true); + this.dataStore.$referenceListOptOuts.set(list.uri, created.uri); + } catch (error) { + console.error(error); + throw error; + } + } + + async undoReferenceListOptOut(list) { + const optOutUri = list.viewer?.referenceListOptOut; + if (!optOutUri) return; + try { + try { + await this.api.deleteReferenceListOptOutRecord(optOutUri); + } catch (error) { + if (!isRecordNotFoundError(error)) throw error; + } + await this._pollReferenceListOptOut(list.uri, false); + this.dataStore.$referenceListOptOuts.set(list.uri, null); + } catch (error) { + console.error(error); + throw error; + } + } + + // Check that opted out state has reached the appview + async _pollReferenceListOptOut(listUri, expectOptedOut) { + const maxTries = 5; + for (let tries = 0; tries < maxTries; tries++) { + try { + const data = await this.api.getList(listUri, { limit: 1 }); + const isOptedOut = !!data.list?.viewer?.referenceListOptOut; + if (isOptedOut === expectOptedOut) return true; + } catch (error) { + console.warn(error); + } + if (tries < maxTries - 1) { + await wait(1000); + } + } + return false; + } + async updateProfile( profile, { diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index 68efc071..f283919c 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -271,6 +271,10 @@ export class Requests { this.loadStarterPack, (starterPackUri) => "loadStarterPack-" + starterPackUri, ); + this.enableStatus( + this.loadStarterPackUriForList, + (list) => "loadStarterPackUriForList-" + list.uri, + ); this.enableStatus( this.loadAllListMembers, (listUri) => "loadAllListMembers-" + listUri, @@ -1255,6 +1259,27 @@ export class Requests { this.dataStore.$lists.set(listUri, data.list); } + async loadStarterPackUriForList(list) { + const backlinks = await this.constellation.getLinks({ + subject: list.uri, + source: "app.bsky.graph.starterpack:list", + timeout: 2000, + }); + const backlink = backlinks.find( + (candidate) => candidate.did === list.creator.did, + ); + this.dataStore.$starterPackUrisByList.set( + list.uri, + backlink + ? buildUri({ + repo: backlink.did, + collection: backlink.collection, + rkey: backlink.rkey, + }) + : null, + ); + } + async loadStarterPack(starterPackUri) { const starterPack = await this.api.getStarterPack(starterPackUri); this.dataStore.$starterPacks.set(starterPackUri, starterPack); diff --git a/src/js/listInteractionHandler.js b/src/js/listInteractionHandler.js index fff33e21..97915d01 100644 --- a/src/js/listInteractionHandler.js +++ b/src/js/listInteractionHandler.js @@ -87,6 +87,53 @@ export class ListInteractionHandler { } } + async handleOptOutOfReferenceList(list) { + return await confirmModal( + "You will no longer appear in this starter pack. The creator will be able to see that you've opted out and remove you if they wish.", + { + title: "Opt out of this starter pack?", + confirmButtonText: "Opt out", + pendingText: "Opting out…", + confirmButtonStyle: "danger", + onConfirm: async () => { + try { + hapticsImpactMedium(); + await this.dataLayer.mutations.optOutOfReferenceList(list); + showToast("Opted out of starter pack"); + } catch (error) { + showToast("Failed to update starter pack opt-out", { + style: "error", + }); + throw error; + } + }, + }, + ); + } + + async handleUndoReferenceListOptOut(list) { + return await confirmModal( + "You will be eligible to appear in this starter pack again.", + { + title: "Undo opt-out?", + confirmButtonText: "Undo opt-out", + pendingText: "Undoing…", + onConfirm: async () => { + try { + hapticsImpactMedium(); + await this.dataLayer.mutations.undoReferenceListOptOut(list); + showToast("Opt-out undone"); + } catch (error) { + showToast("Failed to update starter pack opt-out", { + style: "error", + }); + throw error; + } + }, + }, + ); + } + async handleDeleteList(list) { return await confirmModal( "This list will be permanently deleted. This action cannot be undone.", diff --git a/src/js/views/listDetail.view.js b/src/js/views/listDetail.view.js index 2e5dc492..2573ef72 100644 --- a/src/js/views/listDetail.view.js +++ b/src/js/views/listDetail.view.js @@ -2,7 +2,12 @@ import { html, render } from "/js/lib/lit-html.js"; import { resolveDidFromHandleOrDid } from "/js/atproto.js"; import { Signal, ReactiveStore } from "/js/signals.js"; import { classnames } from "/js/utils.js"; -import { cdnImageUrl, isModerationList } from "/js/dataHelpers.js"; +import { + cdnImageUrl, + isCurateList, + isModerationList, + isReferenceList, +} from "/js/dataHelpers.js"; import { postFeedTemplate } from "/js/templates/postFeed.template.js"; import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; import { headerTemplate } from "/js/templates/header.template.js"; @@ -15,6 +20,7 @@ import { bindPageTitle, onPageShow, } from "/js/router.js"; +import { linkToStarterPack } from "/js/navigation.js"; import { FEED_PAGE_SIZE } from "/js/config.js"; import { showToast } from "/js/toasts.js"; import "/js/components/infinite-scroll-container.js"; @@ -28,6 +34,7 @@ export default async function listDetailView({ root, params, context: { + auth, dataLayer, identityResolver, isAuthenticated, @@ -36,6 +43,9 @@ export default async function listDetailView({ }, }) { const { handleOrDid, rkey } = params; + const optOutEnabled = + isAuthenticated && + (await auth.hasScope("repo:app.bsky.graph.referencelistoptout")); const profileDid = await resolveDidFromHandleOrDid( handleOrDid, @@ -124,8 +134,15 @@ export default async function listDetailView({ const members = membersEntry?.members ?? null; const hasMoreMembers = membersEntry?.cursor != null; const activeTab = state.$activeTab.get(); - const isCurateList = !isModerationList(list); + const isCurate = isCurateList(list); + const isModeration = isModerationList(list); + const isReference = isReferenceList(list); const isCurrentUserList = listCreator?.did === currentUser?.did; + const starterPackUri = isReference + ? dataLayer.derived.$starterPackUriForList.get(listUri) + : null; + const showOptOut = optOutEnabled && !isCurrentUserList && isReference; + const isOptedOut = !!list?.viewer?.referenceListOptOut; const listPermalink = `https://bsky.app/profile/${listCreatorHandle || handleOrDid}/lists/${rkey}`; render( html`