From 1de85ee35e1da5821eb3cf851aef4cb41253dab0 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 21 Sep 2026 19:10:43 -0500 Subject: [PATCH] Add starter pack detail view --- src/css/style.css | 78 ++- src/img/starter-pack-avatar-fallback.svg | 7 + src/js/api.js | 13 + src/js/app.js | 9 + src/js/dataLayer/derived.js | 3 + src/js/dataLayer/mutations.js | 52 ++ src/js/dataLayer/requests.js | 19 + src/js/navigation.js | 12 + src/js/templates/postEmbed.template.js | 13 +- src/js/templates/profileFeed.template.js | 10 +- src/js/views/feedDetail.view.js | 23 +- src/js/views/listDetail.view.js | 44 +- src/js/views/starterPackDetail.view.js | 400 ++++++++++++++ tests/e2e/specs/views/feedDetail.view.test.js | 30 +- tests/e2e/specs/views/listDetail.view.test.js | 30 +- .../views/starterPackDetail.view.test.js | 503 ++++++++++++++++++ tests/shared/factories.js | 28 +- tests/unit/specs/api.test.js | 45 ++ tests/unit/specs/dataLayer/derived.test.js | 19 + tests/unit/specs/dataLayer/mutations.test.js | 131 +++++ tests/unit/specs/dataLayer/requests.test.js | 68 +++ tests/unit/specs/navigation.test.js | 33 ++ .../templates/postEmbed.template.test.js | 11 +- .../templates/profileFeed.template.test.js | 42 ++ 24 files changed, 1569 insertions(+), 54 deletions(-) create mode 100644 src/img/starter-pack-avatar-fallback.svg create mode 100644 src/js/views/starterPackDetail.view.js create mode 100644 tests/e2e/specs/views/starterPackDetail.view.test.js diff --git a/src/css/style.css b/src/css/style.css index 088c0f0c..284e90fe 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1402,7 +1402,8 @@ tab-bar:not([full-width]) { } .profile-tab-bar, -.list-detail-tab-bar { +.list-detail-tab-bar, +.starter-pack-detail-tab-bar { position: sticky; top: 0; background: var(--background-color); @@ -6849,6 +6850,81 @@ sidebar-search { padding: 32px 0; } +.starter-pack-detail-header { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 10px; + padding: 14px 12px; +} + +.starter-pack-detail-avatar { + width: 48px; + height: 48px; + border-radius: 8px; + flex-shrink: 0; +} + +.starter-pack-detail-header-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 2px; +} + +.starter-pack-detail-name { + font-size: 22px; + font-weight: 600; + line-height: 1.2; + word-wrap: break-word; +} + +.starter-pack-detail-creator { + color: var(--text-color-muted); + font-size: 14px; + line-height: 1.3; +} + +.starter-pack-detail-creator a { + color: inherit; +} + +.starter-pack-detail-action { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.starter-pack-detail-action .loading-spinner { + width: 16px; + height: 16px; +} + +.starter-pack-detail-description { + padding: 0 12px 14px; + font-size: 15px; + line-height: 1.4; + word-wrap: break-word; +} + +.starter-pack-detail-sign-in { + padding: 0 12px 14px; +} + +.starter-pack-detail-sign-in .rounded-button { + display: inline-block; + text-decoration: none; +} + +.starter-pack-detail-loading { + display: flex; + justify-content: center; + padding: 32px 0; +} + .list-empty-add-people-button { display: flex; align-items: center; diff --git a/src/img/starter-pack-avatar-fallback.svg b/src/img/starter-pack-avatar-fallback.svg new file mode 100644 index 00000000..920b06be --- /dev/null +++ b/src/img/starter-pack-avatar-fallback.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/js/api.js b/src/js/api.js index 775dd66a..be67cb35 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -429,6 +429,19 @@ export class Api { return res.data; } + async getAllListItems(listUri) { + const MAX_PAGES = 6; + const items = []; + let cursor = ""; + for (let i = 0; i < MAX_PAGES; i++) { + const data = await this.getList(listUri, { limit: 50, cursor }); + items.push(...(data.items ?? [])); + cursor = data.cursor; + if (!cursor) break; + } + return items; + } + async getListFeed(listURI, { limit = 31, cursor = "" } = {}) { const query = { list: listURI, limit }; if (cursor) { diff --git a/src/js/app.js b/src/js/app.js index e5bcf4ba..58c6dca8 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -34,6 +34,7 @@ import communityPluginsView from "/js/views/communityPlugins.view.js"; import communityPluginListingView from "/js/views/communityPluginListing.view.js"; import feedDetailView from "/js/views/feedDetail.view.js"; import listDetailView from "/js/views/listDetail.view.js"; +import starterPackDetailView from "/js/views/starterPackDetail.view.js"; import bookmarksView from "/js/views/bookmarks.view.js"; import { DataLayer } from "/js/dataLayer/dataLayer.js"; import { DraftMediaStore } from "/js/drafts.js"; @@ -308,6 +309,14 @@ export async function main() { router.addRoute("/hashtag/:tag", () => hashtagView); router.addRoute("/profile/:handleOrDid/feed/:rkey", () => feedDetailView); router.addRoute("/profile/:handleOrDid/lists/:rkey", () => listDetailView); + router.addRoute( + [ + "/profile/:handleOrDid/starter-pack/:rkey", + "/starter-pack/:handleOrDid/:rkey", + "/start/:handleOrDid/:rkey", + ], + () => starterPackDetailView, + ); router.addRoute( "/profile/:handleOrDid/post/:rkey/likes", () => postLikesView, diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index e2296c5e..4af34dbd 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -373,6 +373,9 @@ export class Derived extends ReactiveStore { members: data.items.map((item) => this.$hydratedProfiles.get(item.subject.did), ), + optedOutDids: data.items + .filter((item) => item.subjectOptedOut) + .map((item) => item.subject.did), cursor: data.cursor, }; }); diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index ceb8e1fc..2785f878 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -16,6 +16,7 @@ import { batch, getCurrentTimestamp } from "/js/utils.js"; import { fetchAndCompressLinkCardImage } from "/js/embedHelpers.js"; import { PostCreator } from "/js/postCreator.js"; import { untrack } from "/js/signals.js"; +import { generateTid } from "/js/atproto.js"; // Handles mutations to the data, making optimistic updates if needed. export class Mutations { @@ -296,6 +297,57 @@ export class Mutations { } } + async followAllStarterPackMembers(starterPack) { + const currentUserDid = this.api.session.did; + const listItems = await this.api.getAllListItems(starterPack.list.uri); + const dids = listItems + .map((item) => item.subject) + .filter( + (profile) => + profile.did !== currentUserDid && + !profile.viewer?.blocking && + !profile.viewer?.blockedBy && + !profile.viewer?.muted && + !profile.viewer?.mutedByList && + !profile.viewer?.following, + ) + .map((profile) => profile.did); + const writes = dids.map((did) => ({ + $type: "com.atproto.repo.applyWrites#create", + collection: "app.bsky.graph.follow", + rkey: generateTid(), + value: { + $type: "app.bsky.graph.follow", + subject: did, + createdAt: getCurrentTimestamp(), + via: { uri: starterPack.uri, cid: starterPack.cid }, + }, + })); + for (const chunk of batch(writes, 50)) { + await this.api.applyWrites(chunk); + } + for (const write of writes) { + const did = write.value.subject; + const followUri = `at://${currentUserDid}/app.bsky.graph.follow/${write.rkey}`; + const profile = this.dataStore.$profiles.get(did); + if (profile && !profile.viewer?.following) { + this.dataStore.$profiles.set(did, { + ...profile, + viewer: { ...profile.viewer, following: followUri }, + }); + } + const detailed = this.dataStore.$detailedProfiles.get(did); + if (detailed && !detailed.viewer?.following) { + this.dataStore.$detailedProfiles.set(did, { + ...detailed, + followersCount: detailed.followersCount + 1, + viewer: { ...detailed.viewer, following: followUri }, + }); + } + } + return dids.length; + } + async addProfileToList(profile, list) { const result = await this.api.createListItemRecord(list.uri, profile.did); this._patchListMembershipForActor(profile.did, list.uri, { diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index d3d3a1db..6b3930a4 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -263,6 +263,14 @@ export class Requests { ); this.enableStatus(this.loadGifs, (query) => "loadGifs-" + query); this.enableStatus(this.loadDrafts, "loadDrafts"); + this.enableStatus( + this.loadStarterPack, + (starterPackUri) => "loadStarterPack-" + starterPackUri, + ); + this.enableStatus( + this.loadAllListMembers, + (listUri) => "loadAllListMembers-" + listUri, + ); this.enableStatus(this.loadBlockedProfiles, "loadBlockedProfiles"); this.enableStatus(this.loadMutedProfiles, "loadMutedProfiles"); } @@ -1219,6 +1227,17 @@ export class Requests { }); } + async loadAllListMembers(listUri) { + const items = await this.api.getAllListItems(listUri); + this.dataStore.setProfiles(items.map((item) => item.subject)); + writePageToCollection( + this.dataStore.$listMembers, + "items", + { items, cursor: null }, + { key: listUri, overwrite: true }, + ); + } + async loadTrends({ limit = 5 } = {}) { const data = await this.api.getTrends({ limit }); this.dataStore.$trends.set(unique(data.trends ?? [], { by: "link" })); diff --git a/src/js/navigation.js b/src/js/navigation.js index 3758b0e8..0f7045fe 100644 --- a/src/js/navigation.js +++ b/src/js/navigation.js @@ -70,6 +70,12 @@ export function linkToList(list) { )}`; } +export function linkToStarterPack(starterPack) { + const creator = profileIdentifier(starterPack.creator); + const rkey = getRKey(starterPack); + return `/profile/${encodePathSegment(creator)}/starter-pack/${encodePathSegment(rkey)}`; +} + export function linkToSearchPostsByProfile(profile) { const searchString = `from:@${profile.handle} `; const query = new URLSearchParams(); @@ -90,6 +96,12 @@ export function getPermalinkForProfile(profile) { return getPermalinkOrigin() + linkToProfile(profile); } +export function getPermalinkForStarterPack(starterPack) { + const creator = profileIdentifier(starterPack.creator); + const rkey = getRKey(starterPack); + return `${getPermalinkOrigin()}/starter-pack/${encodePathSegment(creator)}/${encodePathSegment(rkey)}`; +} + export function getPermalinkForConvo(convoId) { return `${getPermalinkOrigin()}/messages/${encodePathSegment(convoId)}`; } diff --git a/src/js/templates/postEmbed.template.js b/src/js/templates/postEmbed.template.js index 1c72a48f..33bf75c2 100644 --- a/src/js/templates/postEmbed.template.js +++ b/src/js/templates/postEmbed.template.js @@ -14,7 +14,7 @@ import "/js/components/plugin-rich-text.js"; import "/js/components/app-icon.js"; import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; import { authorBadgesTemplate } from "/js/templates/labelBadges.template.js"; -import { linkToPost, linkToFeed } from "/js/navigation.js"; +import { linkToPost, linkToFeed, linkToStarterPack } from "/js/navigation.js"; import { moderationWarningTemplate } from "/js/templates/moderationWarning.template.js"; import "/js/components/container-link.js"; import { @@ -295,7 +295,9 @@ function imageContainerTemplate({ image, lazyLoad, doCalculateAspectRatio }) { src="${cdnImageUrl(image.thumb)}" data-lightbox-src="${cdnImageUrl(image.fullsize ?? image.thumb)}" alt=${image.alt} - style=${doCalculateAspectRatio ? `aspect-ratio: ${aspectRatio ?? 1};` : ""} + style=${doCalculateAspectRatio + ? `aspect-ratio: ${aspectRatio ?? 1};` + : ""} loading=${lazyLoad ? "lazy" : "eager"} /> ${image.alt ? html`
ALT
` : ""} @@ -570,12 +572,7 @@ function getStarterPackThumbnail(starterPack) { function starterPackTemplate({ starterPack }) { return html`
- e.stopPropagation()} - > +
`; } + if (!isAuthenticated) { + profiles = profiles.filter( + (profile) => !doHideAuthorOnUnauthenticated(profile), + ); + } if (profiles.length === 0) { return html`
${emptyMessage ?? "No profiles to show."} diff --git a/src/js/views/feedDetail.view.js b/src/js/views/feedDetail.view.js index 81b155ad..2720a24f 100644 --- a/src/js/views/feedDetail.view.js +++ b/src/js/views/feedDetail.view.js @@ -16,7 +16,6 @@ export default async function feedDetailView({ root, params, context: { - auth, dataLayer, identityResolver, isAuthenticated, @@ -24,8 +23,6 @@ export default async function feedDetailView({ interactionHandlers, }, }) { - await auth.requireAuth(); - const { handleOrDid, rkey } = params; const profileDid = await resolveDidFromHandleOrDid( @@ -96,15 +93,17 @@ export default async function feedDetailView({ - `; + ${isAuthenticated + ? html`` + : ""}`; }, })}
diff --git a/src/js/views/listDetail.view.js b/src/js/views/listDetail.view.js index 31dfea80..2e5dc492 100644 --- a/src/js/views/listDetail.view.js +++ b/src/js/views/listDetail.view.js @@ -28,7 +28,6 @@ export default async function listDetailView({ root, params, context: { - auth, dataLayer, identityResolver, isAuthenticated, @@ -36,8 +35,6 @@ export default async function listDetailView({ interactionHandlers, }, }) { - await auth.requireAuth(); - const { handleOrDid, rkey } = params; const profileDid = await resolveDidFromHandleOrDid( @@ -135,25 +132,30 @@ export default async function listDetailView({ ${headerTemplate({ rightItemTemplate: list ? () => html` - ${isCurateList - ? html`` + : listSubscriptionButtonTemplate({ + list, + listInteractionHandler, })} - data-testid="pin-list-button" - data-teststate=${isPinned ? "pinned" : "not-pinned"} - @click=${() => - listInteractionHandler.handlePinList( - listUri, - !isPinned, - )} - > - ${fillableIconTemplate({ icon: "pin", filled: isPinned })} - ` - : listSubscriptionButtonTemplate({ - list, - listInteractionHandler, - })} + + + { + window.open(permalink, "_blank"); + }} + > + Open in bsky.app + + copyPermalink(permalink)} + > + Copy link to starter pack + + + + `; +} + +function loadingTemplate() { + return html`
+
+
+
+
`; +} + +function notFoundTemplate({ onRetry }) { + return html`
+
+

Not Found

+
That starter pack could not be found.
+ ${tryAgainButtonTemplate({ onClick: onRetry })} +
+
`; +} + +function headerActionTemplate({ + isOwner, + isFollowingAll, + permalink, + onFollowAll, +}) { + if (isOwner) { + return html``; + } + return html``; +} + +function starterPackHeaderTemplate({ + starterPack, + isOwner, + isAuthenticated, + isFollowingAll, + permalink, + onFollowAll, +}) { + const { record, creator } = starterPack; + return html` +
+ ${record.name} + + ${isAuthenticated + ? headerActionTemplate({ + isOwner, + isFollowingAll, + permalink, + onFollowAll, + }) + : ""} +
+ ${record.description + ? html`
+ ${richTextTemplate({ + text: record.description, + facets: record.descriptionFacets ?? [], + })} +
` + : ""} + ${isAuthenticated + ? "" + : html``} + `; +} + +function sortMembers({ members, optedOutDids, currentUserDid, isOwner }) { + const optedOut = new Set(optedOutDids); + const visible = members.filter( + (profile) => + !profile.viewer?.blocking && + !profile.viewer?.blockedBy && + !profile.associated?.labeler, + ); + visible.reverse(); + return visible.sort((a, b) => { + const aOptedOut = optedOut.has(a.did); + const bOptedOut = optedOut.has(b.did); + if (aOptedOut !== bOptedOut) return aOptedOut ? -1 : 1; + if (isOwner) { + if (a.did === currentUserDid) return -1; + if (b.did === currentUserDid) return 1; + } + return 0; + }); +} + +export default async function starterPackDetailView({ + root, + params, + context: { + dataLayer, + identityResolver, + isAuthenticated, + pluginService, + interactionHandlers, + }, +}) { + const { handleOrDid, rkey } = params; + + const creatorDid = await resolveDidFromHandleOrDid( + handleOrDid, + identityResolver, + ); + const starterPackUri = `at://${creatorDid}/app.bsky.graph.starterpack/${rkey}`; + + const { postInteractionHandler, profileInteractionHandler } = + interactionHandlers; + + const state = new ReactiveStore("starterPackDetailView"); + state.$activeTab = new Signal.State("people"); + state.$isFollowingAll = new Signal.State(false); + + const $starterPackError = new Signal.Computed( + () => + dataLayer.requests.statusStore.$errors.get( + "loadStarterPack-" + starterPackUri, + ) ?? null, + ); + + bindPageTitle(root, () => { + return ( + dataLayer.derived.$starterPacks.get(starterPackUri)?.record?.name ?? null + ); + }); + + pageEffect(root, () => { + const currentUser = dataLayer.derived.$currentUser.get(); + const starterPack = dataLayer.derived.$starterPacks.get(starterPackUri); + const error = $starterPackError.get(); + const list = starterPack?.list ?? null; + const listUri = list?.uri ?? null; + const feeds = starterPack?.feeds ?? []; + const membersEntry = listUri + ? dataLayer.derived.$listMembers.get(listUri) + : null; + const feed = listUri ? dataLayer.derived.$hydratedFeeds.get(listUri) : null; + const showLessInteractions = listUri + ? dataLayer.derived.$showLessInteractions.get(listUri) + : []; + const hiddenPostUris = showLessInteractions.map( + (interaction) => interaction.item, + ); + const isFollowingAll = state.$isFollowingAll.get(); + const activeTab = state.$activeTab.get(); + + const isLoaded = !!list; + const isOwner = isLoaded && starterPack.creator.did === currentUser?.did; + const permalink = isLoaded ? getPermalinkForStarterPack(starterPack) : null; + const tabs = isLoaded + ? [ + { value: "people", label: "People" }, + ...(feeds.length > 0 ? [{ value: "feeds", label: "Feeds" }] : []), + { value: "posts", label: "Posts" }, + ] + : []; + const members = membersEntry + ? sortMembers({ + members: membersEntry.members, + optedOutDids: membersEntry.optedOutDids, + currentUserDid: currentUser?.did ?? null, + isOwner, + }) + : null; + + render( + html`
+ ${headerTemplate({ + title: "Starter pack", + rightItemTemplate: isLoaded + ? () => headerMenuTemplate({ permalink }) + : null, + })} + ${!starterPack && !error + ? loadingTemplate() + : !isLoaded + ? notFoundTemplate({ onRetry: () => loadPageData() }) + : html`
+ ${starterPackHeaderTemplate({ + starterPack, + isOwner, + isAuthenticated, + isFollowingAll, + permalink, + onFollowAll: () => handleFollowAll(starterPack), + })} +
+ state.$activeTab.set(event.detail)} + > +
+
+ ${activeTab === "people" + ? html`
+ ${profileFeedTemplate({ + profiles: members, + hasMore: false, + emptyMessage: "This starter pack has no members.", + showEndMessage: true, + isAuthenticated, + currentUserDid: currentUser?.did ?? null, + profileInteractionHandler, + pluginService, + })} +
` + : activeTab === "feeds" + ? feedsFeedTemplate({ + items: feeds, + renderItem: (feedGenerator) => + feedGeneratorListItemTemplate({ + feedGenerator, + currentUserDid: currentUser?.did ?? null, + }), + }) + : html`
+ ${postFeedTemplate({ + feed, + currentUser, + isAuthenticated, + hiddenPostUris, + onLoadMore: () => loadFeed(listUri), + postInteractionHandler, + pluginService, + showEndMessage: true, + })} +
`} +
+
`} +
`, + root, + ); + }); + + pageEffect(root, () => { + if (state.$activeTab.get() !== "feeds") return; + // Reset tab if feeds are no longer available + const feeds = + dataLayer.derived.$starterPacks.get(starterPackUri)?.feeds ?? []; + if (feeds.length === 0) state.$activeTab.set("posts"); + }); + + async function handleFollowAll(starterPack) { + state.$isFollowingAll.set(true); + try { + await dataLayer.mutations.followAllStarterPackMembers(starterPack); + showToast("All accounts have been followed!", { style: "success" }); + } catch (error) { + console.error(error); + showToast("An error occurred while trying to follow all", { + style: "error", + }); + } finally { + state.$isFollowingAll.set(false); + } + } + + async function loadFeed(listUri, { reload = false } = {}) { + await dataLayer.requests.loadNextFeedPage( + { type: "list", uri: listUri }, + { reload, limit: FEED_PAGE_SIZE + 1 }, + ); + } + + async function loadPageData() { + await dataLayer.requests.loadStarterPack(starterPackUri); + const listUri = + dataLayer.derived.$starterPacks.get(starterPackUri)?.list?.uri ?? null; + if (!listUri) return; + await Promise.all([ + dataLayer.requests.loadAllListMembers(listUri), + loadFeed(listUri, { reload: true }), + ]); + } + + onPageShow(root, ({ action }) => { + if (action === "restore") return; + loadPageData(); + }); +} diff --git a/tests/e2e/specs/views/feedDetail.view.test.js b/tests/e2e/specs/views/feedDetail.view.test.js index 411e8872..0acad476 100644 --- a/tests/e2e/specs/views/feedDetail.view.test.js +++ b/tests/e2e/specs/views/feedDetail.view.test.js @@ -288,15 +288,37 @@ test.describe("Feed Detail view", () => { }); test.describe("Logged-out behavior", () => { - test("should redirect to /login when not authenticated", async ({ + test("should render the feed without the pin button when not authenticated", async ({ page, }) => { - const loggedOutMockServer = new MockServer(); - await loggedOutMockServer.setup(page); + const mockServer = new MockServer(); + const feed = createFeedGenerator({ + uri: "at://did:plc:creator1/app.bsky.feed.generator/trending", + displayName: "Trending", + creatorHandle: "creator1.bsky.social", + }); + const post = createPost({ + uri: "at://did:plc:author1/app.bsky.feed.post/post1", + text: "First trending post", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + loggedOut: true, + }); + mockServer.addFeedGenerators([feed]); + mockServer.addFeedItems(feed.uri, [post]); + await mockServer.setup(page); await page.goto("/profile/creator1.bsky.social/feed/trending"); - await expect(page).toHaveURL(/\/login(\?|$)/, { timeout: 10000 }); + const view = page.locator("#feed-detail-view"); + await expect(view.locator('[data-testid="header-title"]')).toContainText( + "Trending", + { timeout: 10000 }, + ); + await expect(view.locator(".post-text").first()).toContainText( + "First trending post", + ); + await expect(view.locator(".pin-feed-button")).toHaveCount(0); }); }); }); diff --git a/tests/e2e/specs/views/listDetail.view.test.js b/tests/e2e/specs/views/listDetail.view.test.js index d3fe9927..f8ad72fe 100644 --- a/tests/e2e/specs/views/listDetail.view.test.js +++ b/tests/e2e/specs/views/listDetail.view.test.js @@ -1025,15 +1025,37 @@ test.describe("List Detail view", () => { }); test.describe("Logged-out behavior", () => { - test("should redirect to /login when not authenticated", async ({ + test("should render the list without pin or follow buttons when not authenticated", async ({ page, }) => { - const loggedOutMockServer = new MockServer(); - await loggedOutMockServer.setup(page); + const mockServer = new MockServer(); + setupList(mockServer); + const member = createProfile({ + did: "did:plc:member1", + handle: "member1.bsky.social", + displayName: "Member One", + }); + mockServer.addListMembers(LIST_URI, [member]); + await mockServer.setup(page); await page.goto("/profile/creator1.bsky.social/lists/mylist"); - await expect(page).toHaveURL(/\/login(\?|$)/, { timeout: 10000 }); + const view = page.locator("#list-detail-view"); + await expect( + view.locator('[data-testid="list-detail-name"]'), + ).toContainText("My Curated List", { timeout: 10000 }); + await expect(view.locator('[data-testid="pin-list-button"]')).toHaveCount( + 0, + ); + + await view.locator('[data-testid="tab-people"]').click(); + const memberRow = view + .locator(".profile-list-item") + .filter({ hasText: "member1.bsky.social" }); + await expect(memberRow).toBeVisible(); + await expect( + memberRow.locator('[data-testid="follow-button"]'), + ).toHaveCount(0); }); }); }); diff --git a/tests/e2e/specs/views/starterPackDetail.view.test.js b/tests/e2e/specs/views/starterPackDetail.view.test.js new file mode 100644 index 00000000..5ca45bc5 --- /dev/null +++ b/tests/e2e/specs/views/starterPackDetail.view.test.js @@ -0,0 +1,503 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { userProfile } from "../../testData.js"; +import { + createFeedGenerator, + createPost, + createProfile, + createStarterPack, +} from "../../../shared/factories.js"; + +const PACK_URI = "at://did:plc:creator1/app.bsky.graph.starterpack/coolpack"; +const LIST_URI = "at://did:plc:creator1/app.bsky.graph.list/coolpack"; +const PACK_PATH = "/profile/creator1.bsky.social/starter-pack/coolpack"; +const OWN_PACK_URI = `at://${userProfile.did}/app.bsky.graph.starterpack/mine`; +const OWN_LIST_URI = `at://${userProfile.did}/app.bsky.graph.list/mine`; + +function setupPack(mockServer, { description, feeds } = {}) { + const starterPack = createStarterPack({ + uri: PACK_URI, + name: "Cool Pack", + creatorHandle: "creator1.bsky.social", + description, + list: true, + feeds, + }); + mockServer.addStarterPacks([starterPack]); + return starterPack; +} + +function setupMembers(mockServer, listUri = LIST_URI) { + const memberOne = createProfile({ + did: "did:plc:member1", + handle: "member1.bsky.social", + displayName: "Member One", + }); + const memberTwo = createProfile({ + did: "did:plc:member2", + handle: "member2.bsky.social", + displayName: "Member Two", + }); + mockServer.addListMembers(listUri, [memberOne, memberTwo]); + return [memberOne, memberTwo]; +} + +async function openPack(page, path = PACK_PATH) { + await login(page); + await page.goto(path); + const view = page.locator("#starter-pack-detail-view"); + await expect(view.locator('[data-testid="starter-pack-name"]')).toBeVisible({ + timeout: 10000, + }); + return view; +} + +test.describe("Starter Pack Detail view", () => { + test("should display name, creator link, and description", async ({ + page, + }) => { + const mockServer = new MockServer(); + const starterPack = setupPack(mockServer, { + description: "Great people to follow. See https://example.com", + }); + starterPack.record.descriptionFacets = [ + { + index: { byteStart: 28, byteEnd: 47 }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: "https://example.com", + }, + ], + }, + ]; + setupMembers(mockServer); + await mockServer.setup(page); + + const view = await openPack(page); + + await expect(view.locator('[data-testid="starter-pack-name"]')).toHaveText( + "Cool Pack", + ); + await expect( + view.locator('[data-testid="starter-pack-creator"]'), + ).toContainText("@creator1.bsky.social"); + await expect( + view.locator('[data-testid="starter-pack-description"]'), + ).toContainText("Great people to follow."); + await expect( + view.locator('[data-testid="starter-pack-description"] a'), + ).toHaveAttribute("href", "https://example.com/"); + + await view.locator('[data-testid="starter-pack-creator"] a').click(); + await expect(page).toHaveURL(/\/profile\/creator1\.bsky\.social$/, { + timeout: 10000, + }); + }); + + test("should resolve the bsky.app style routes to the same page", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupPack(mockServer); + setupMembers(mockServer); + await mockServer.setup(page); + + await openPack(page, "/starter-pack/creator1.bsky.social/coolpack"); + await page.goto("/start/creator1.bsky.social/coolpack"); + await expect( + page.locator( + '#starter-pack-detail-view [data-testid="starter-pack-name"]', + ), + ).toHaveText("Cool Pack", { timeout: 10000 }); + }); + + test("should list members on the People tab in reversed order and hide blocked ones", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupPack(mockServer); + const [memberOne, memberTwo] = setupMembers(mockServer); + const blocked = createProfile({ + did: "did:plc:blocked", + handle: "blocked.bsky.social", + displayName: "Blocked Person", + viewer: { blocking: "at://did:plc:testuser123/app.bsky.graph.block/1" }, + }); + mockServer.addListMembers(LIST_URI, [memberOne, memberTwo, blocked]); + await mockServer.setup(page); + + const view = await openPack(page); + + await expect( + view.locator('[data-testid="starter-pack-tab-content"]'), + ).toHaveAttribute("data-teststate", "people"); + const handles = view.locator('[data-testid="profile-list-item-handle"]'); + await expect(handles).toHaveCount(2, { timeout: 10000 }); + await expect(handles.nth(0)).toContainText("member2.bsky.social"); + await expect(handles.nth(1)).toContainText("member1.bsky.social"); + }); + + test("should show the Feeds tab only when the pack has feeds", async ({ + page, + }) => { + const mockServer = new MockServer(); + const feed = createFeedGenerator({ + uri: "at://did:plc:creator1/app.bsky.feed.generator/cool-feed", + displayName: "Cool Feed", + creatorHandle: "creator1.bsky.social", + }); + mockServer.addFeedGenerators([feed]); + setupPack(mockServer, { feeds: [feed] }); + setupMembers(mockServer); + await mockServer.setup(page); + + const view = await openPack(page); + + await view.locator('[data-testid="tab-feeds"]').click(); + await expect( + view.locator('[data-testid="starter-pack-tab-content"]'), + ).toHaveAttribute("data-teststate", "feeds"); + await expect(view.locator(".feeds-list-item")).toHaveCount(1); + await expect(view.locator(".feeds-list-item")).toContainText("Cool Feed"); + }); + + test("should omit the Feeds tab when the pack has no feeds", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupPack(mockServer); + setupMembers(mockServer); + await mockServer.setup(page); + + const view = await openPack(page); + + await expect(view.locator('[data-testid="tab-people"]')).toBeVisible(); + await expect(view.locator('[data-testid="tab-posts"]')).toBeVisible(); + await expect(view.locator('[data-testid="tab-feeds"]')).toHaveCount(0); + }); + + test("should show the list feed on the Posts tab", async ({ page }) => { + const mockServer = new MockServer(); + setupPack(mockServer); + setupMembers(mockServer); + const post = createPost({ + uri: "at://did:plc:member1/app.bsky.feed.post/p1", + text: "A post from a pack member", + authorHandle: "member1.bsky.social", + authorDisplayName: "Member One", + }); + mockServer.addListFeedItems(LIST_URI, [post]); + await mockServer.setup(page); + + const view = await openPack(page); + + await view.locator('[data-testid="tab-posts"]').click(); + await expect( + view.locator('[data-testid="starter-pack-tab-content"]'), + ).toHaveAttribute("data-teststate", "posts"); + await expect(view.locator('[data-testid="feed-item"]')).toHaveCount(1, { + timeout: 10000, + }); + await expect(view).toContainText("A post from a pack member"); + }); + + test("should show not found when the pack does not exist", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.addStarterPacks([ + createStarterPack({ + uri: "at://did:plc:creator1/app.bsky.graph.starterpack/other", + name: "Other", + creatorHandle: "creator1.bsky.social", + }), + ]); + await mockServer.setup(page); + + await login(page); + await page.goto(PACK_PATH); + + await expect( + page.locator( + '#starter-pack-detail-view [data-testid="starter-pack-not-found"]', + ), + ).toBeVisible({ timeout: 10000 }); + }); + + test("should show not found when the backing list is gone", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.addStarterPacks([ + createStarterPack({ + uri: PACK_URI, + name: "Cool Pack", + creatorHandle: "creator1.bsky.social", + }), + ]); + await mockServer.setup(page); + + await login(page); + await page.goto(PACK_PATH); + + await expect( + page.locator( + '#starter-pack-detail-view [data-testid="starter-pack-not-found"]', + ), + ).toBeVisible({ timeout: 10000 }); + }); + + test.describe("Follow all", () => { + test("should follow eligible members with the pack as via", async ({ + page, + }) => { + const mockServer = new MockServer(); + const starterPack = setupPack(mockServer); + const [memberOne] = setupMembers(mockServer); + const alreadyFollowed = createProfile({ + did: "did:plc:followed", + handle: "followed.bsky.social", + displayName: "Already Followed", + viewer: { following: "at://did:plc:testuser123/follow/1" }, + }); + const muted = createProfile({ + did: "did:plc:muted", + handle: "muted.bsky.social", + displayName: "Muted Person", + viewer: { muted: true }, + }); + mockServer.addListMembers(LIST_URI, [ + memberOne, + alreadyFollowed, + muted, + userProfile, + ]); + await mockServer.setup(page); + + const view = await openPack(page); + + const button = view.locator('[data-testid="starter-pack-follow-all"]'); + await expect(button).toHaveAttribute("data-teststate", "idle"); + await expect( + view.locator('[data-testid="starter-pack-share"]'), + ).toHaveCount(0); + await button.click(); + + await expect(page.locator('[data-testid="toast"]')).toBeVisible({ + timeout: 10000, + }); + await expect(button).toHaveAttribute("data-teststate", "idle"); + + const writes = mockServer.applyWritesCalls.flat(); + expect(writes.length).toBe(1); + expect(writes[0].$type).toBe("com.atproto.repo.applyWrites#create"); + expect(writes[0].collection).toBe("app.bsky.graph.follow"); + expect(writes[0].value.subject).toBe("did:plc:member1"); + expect(writes[0].value.via).toEqual({ + uri: starterPack.uri, + cid: starterPack.cid, + }); + + const memberRow = view + .locator(".profile-list-item") + .filter({ hasText: "member1.bsky.social" }); + await expect( + memberRow.locator('[data-testid="follow-button"]'), + ).toHaveAttribute("data-teststate", "following"); + }); + + test("should toast an error and re-enable the button when the write fails", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupPack(mockServer); + setupMembers(mockServer); + await mockServer.setup(page); + await page.route("**/xrpc/com.atproto.repo.applyWrites*", (route) => + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "InternalServerError" }), + }), + ); + + const view = await openPack(page); + + const button = view.locator('[data-testid="starter-pack-follow-all"]'); + await button.click(); + + await expect(page.locator('[data-testid="toast"].error')).toBeVisible({ + timeout: 10000, + }); + await expect(button).toHaveAttribute("data-teststate", "idle"); + await expect(button).toBeEnabled(); + }); + }); + + test.describe("Share", () => { + test("should show Share instead of Follow all on the owner's pack and copy the link", async ({ + page, + browserName, + }) => { + const mockServer = new MockServer(); + mockServer.addStarterPacks([ + createStarterPack({ + uri: OWN_PACK_URI, + name: "My Pack", + creatorHandle: userProfile.handle, + list: true, + }), + ]); + setupMembers(mockServer, OWN_LIST_URI); + await mockServer.setup(page); + if (browserName === "chromium") { + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"]); + } + + const view = await openPack( + page, + `/profile/${userProfile.handle}/starter-pack/mine`, + ); + + await expect( + view.locator('[data-testid="starter-pack-creator"]'), + ).toContainText("by you"); + await expect( + view.locator('[data-testid="starter-pack-follow-all"]'), + ).toHaveCount(0); + await view.locator('[data-testid="starter-pack-share"]').click(); + + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); + if (browserName === "chromium") { + const clipboardText = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(clipboardText).toBe( + `https://bsky.app/starter-pack/${userProfile.handle}/mine`, + ); + } + }); + + test("should copy the link from the header menu", async ({ + page, + browserName, + }) => { + const mockServer = new MockServer(); + setupPack(mockServer); + setupMembers(mockServer); + await mockServer.setup(page); + if (browserName === "chromium") { + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"]); + } + + const view = await openPack(page); + + await view.locator(".context-menu-button").click(); + await view + .locator('[data-testid="menu-action-starter-pack-copy-link"]') + .click(); + + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); + if (browserName === "chromium") { + const clipboardText = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(clipboardText).toBe( + "https://bsky.app/starter-pack/creator1.bsky.social/coolpack", + ); + } + }); + }); + + test.describe("Logged-out behavior", () => { + test("should render the pack with a sign-in prompt and no follow buttons", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupPack(mockServer); + const [memberOne] = setupMembers(mockServer); + const hidden = createProfile({ + did: "did:plc:hidden", + handle: "hidden.bsky.social", + displayName: "Hidden Person", + }); + hidden.labels = [ + { + val: "!no-unauthenticated", + src: hidden.did, + uri: `at://${hidden.did}/app.bsky.actor.profile/self`, + cts: "2025-01-01T00:00:00.000Z", + }, + ]; + mockServer.addListMembers(LIST_URI, [memberOne, hidden]); + await mockServer.setup(page); + + await page.goto(PACK_PATH); + + const view = page.locator("#starter-pack-detail-view"); + await expect( + view.locator('[data-testid="starter-pack-name"]'), + ).toBeVisible({ timeout: 10000 }); + await expect( + view.locator('[data-testid="starter-pack-follow-all"]'), + ).toHaveCount(0); + await expect( + view.locator('[data-testid="starter-pack-share"]'), + ).toHaveCount(0); + await expect( + view.locator('[data-testid="starter-pack-sign-in"]'), + ).toHaveAttribute("href", /^\/login/); + + const rows = view.locator(".profile-list-item"); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText("member1.bsky.social"); + await expect(rows.locator('[data-testid="follow-button"]')).toHaveCount( + 0, + ); + }); + }); + + test("should open the pack from a post embed in-app", async ({ page }) => { + const mockServer = new MockServer(); + const starterPack = setupPack(mockServer); + setupMembers(mockServer); + const post = createPost({ + uri: "at://did:plc:author1/app.bsky.feed.post/embedpost", + text: "Check out this pack", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + }); + post.embed = { + $type: "app.bsky.embed.record#view", + record: { + $type: "app.bsky.graph.defs#starterPackViewBasic", + uri: starterPack.uri, + cid: starterPack.cid, + record: starterPack.record, + creator: starterPack.creator, + }, + }; + mockServer.addPosts([post]); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/embedpost"); + + await page.locator(".starter-pack-embed a").first().click(); + + await expect(page).toHaveURL(new RegExp(PACK_PATH.replace(/\//g, "\\/")), { + timeout: 10000, + }); + await expect( + page.locator( + '#starter-pack-detail-view [data-testid="starter-pack-name"]', + ), + ).toHaveText("Cool Pack", { timeout: 10000 }); + }); +}); diff --git a/tests/shared/factories.js b/tests/shared/factories.js index 64bc21d6..8474fe9b 100644 --- a/tests/shared/factories.js +++ b/tests/shared/factories.js @@ -317,9 +317,32 @@ export function createList({ }; } -export function createStarterPack({ uri, name, creatorHandle, description }) { +export function createStarterPack({ + uri, + name, + creatorHandle, + description, + list, + feeds, +}) { const creatorDid = uri.split("/")[2]; const rkey = uri.split("/").pop(); + const listUri = `at://${creatorDid}/app.bsky.graph.list/${rkey}`; + const fullView = {}; + if (list) { + fullView.list = + list === true + ? createList({ + uri: listUri, + name, + creatorHandle, + purpose: "app.bsky.graph.defs#referencelist", + }) + : list; + } + if (feeds) { + fullView.feeds = feeds; + } return { uri, cid: "bafyreitest" + rkey, @@ -327,7 +350,7 @@ export function createStarterPack({ uri, name, creatorHandle, description }) { $type: "app.bsky.graph.starterpack", name, description: description || "", - list: `at://${creatorDid}/app.bsky.graph.list/${rkey}`, + list: listUri, createdAt: "2025-01-01T00:00:00.000Z", }, creator: { @@ -344,6 +367,7 @@ export function createStarterPack({ uri, name, creatorHandle, description }) { joinedAllTimeCount: 0, indexedAt: "2025-01-01T00:00:00.000Z", labels: [], + ...fullView, }; } diff --git a/tests/unit/specs/api.test.js b/tests/unit/specs/api.test.js index 2ccbb375..50a70daa 100644 --- a/tests/unit/specs/api.test.js +++ b/tests/unit/specs/api.test.js @@ -2072,3 +2072,48 @@ describe("uploadVideoBlob", () => { assert.deepEqual(thrownError.status, 500); }); }); + +describe("getAllListItems", () => { + function createPagingApi(pages) { + const calls = []; + const api = new Api(createMockSession({})); + api.getList = async (listUri, { limit, cursor }) => { + calls.push({ listUri, limit, cursor }); + return pages[calls.length - 1]; + }; + return { api, calls }; + } + + it("should page until the response has no cursor", async () => { + const { api, calls } = createPagingApi([ + { items: [{ uri: "a" }], cursor: "c1" }, + { items: [{ uri: "b" }], cursor: "c2" }, + { items: [{ uri: "c" }] }, + ]); + + const items = await api.getAllListItems("at://did/app.bsky.graph.list/1"); + + assert.deepEqual( + items.map((item) => item.uri), + ["a", "b", "c"], + ); + assert.deepEqual( + calls.map((call) => call.cursor), + ["", "c1", "c2"], + ); + assert.deepEqual(calls[0].limit, 50); + }); + + it("should stop after six pages", async () => { + const pages = Array.from({ length: 10 }, (_, index) => ({ + items: [{ uri: String(index) }], + cursor: "c" + index, + })); + const { api, calls } = createPagingApi(pages); + + const items = await api.getAllListItems("at://did/app.bsky.graph.list/1"); + + assert.deepEqual(items.length, 6); + assert.deepEqual(calls.length, 6); + }); +}); diff --git a/tests/unit/specs/dataLayer/derived.test.js b/tests/unit/specs/dataLayer/derived.test.js index 99ebf5e9..24eb4b45 100644 --- a/tests/unit/specs/dataLayer/derived.test.js +++ b/tests/unit/specs/dataLayer/derived.test.js @@ -2293,6 +2293,25 @@ describe("$listMembers", () => { assert.deepEqual(result.members.length, 1); assert.deepEqual(result.members[0].handle, "member.test"); assert.deepEqual(result.cursor, "lc"); + assert.deepEqual(result.optedOutDids, []); + }); + + it("should list the dids of members who opted out", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + dataStore.setProfiles([ + createProfile({ did: "did:plc:in", handle: "in.test" }), + createProfile({ did: "did:plc:out", handle: "out.test" }), + ]); + dataStore.$listMembers.set(listUri, { + items: [ + { subject: { did: "did:plc:in" } }, + { subject: { did: "did:plc:out" }, subjectOptedOut: true }, + ], + cursor: null, + }); + const result = derived.$listMembers.get(listUri); + assert.deepEqual(result.optedOutDids, ["did:plc:out"]); }); }); diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 348c008f..f338a88c 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -367,6 +367,137 @@ describe("followProfile", () => { }); }); +describe("followAllStarterPackMembers", () => { + const starterPack = { + uri: "at://did:plc:creator/app.bsky.graph.starterpack/pack", + cid: "bafypack", + list: { uri: "at://did:plc:creator/app.bsky.graph.list/pack" }, + }; + + function makeItem(did, viewer = {}) { + return { + uri: `li-${did}`, + subject: { did, handle: did + ".test", viewer }, + }; + } + + function setup({ listItems, applyWrites }) { + const dataStore = new DataStore(createSessionState(null)); + const patchStore = new PatchStore(); + const applyWritesCalls = []; + const api = { + session: { did: "did:plc:me" }, + getAllListItems: async () => listItems, + applyWrites: + applyWrites ?? + (async (writes) => { + applyWritesCalls.push(writes); + return {}; + }), + }; + const mutations = makeMutations(api, dataStore, patchStore, { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }); + return { dataStore, mutations, applyWritesCalls }; + } + + it("should follow only eligible members with the pack as via", async () => { + const listItems = [ + makeItem("did:plc:a"), + makeItem("did:plc:me"), + makeItem("did:plc:blocking", { blocking: "at://block" }), + makeItem("did:plc:blockedby", { blockedBy: true }), + makeItem("did:plc:muted", { muted: true }), + makeItem("did:plc:mutedbylist", { mutedByList: { uri: "l" } }), + makeItem("did:plc:following", { following: "at://follow" }), + makeItem("did:plc:b"), + ]; + const { mutations, applyWritesCalls } = setup({ listItems }); + + const count = await mutations.followAllStarterPackMembers(starterPack); + + assert.deepEqual(count, 2); + assert.deepEqual(applyWritesCalls.length, 1); + const writes = applyWritesCalls[0]; + assert.deepEqual( + writes.map((write) => write.value.subject), + ["did:plc:a", "did:plc:b"], + ); + for (const write of writes) { + assert.deepEqual(write.$type, "com.atproto.repo.applyWrites#create"); + assert.deepEqual(write.collection, "app.bsky.graph.follow"); + assert.deepEqual(typeof write.rkey, "string"); + assert.deepEqual(write.value.$type, "app.bsky.graph.follow"); + assert.deepEqual(write.value.via, { + uri: starterPack.uri, + cid: starterPack.cid, + }); + } + }); + + it("should chunk writes at fifty", async () => { + const listItems = Array.from({ length: 120 }, (_, index) => + makeItem(`did:plc:m${index}`), + ); + const { mutations, applyWritesCalls } = setup({ listItems }); + + await mutations.followAllStarterPackMembers(starterPack); + + assert.deepEqual( + applyWritesCalls.map((writes) => writes.length), + [50, 50, 20], + ); + }); + + it("should skip the api call when nobody is eligible", async () => { + const listItems = [makeItem("did:plc:following", { following: "at://f" })]; + const { mutations, applyWritesCalls } = setup({ listItems }); + + const count = await mutations.followAllStarterPackMembers(starterPack); + + assert.deepEqual(count, 0); + assert.deepEqual(applyWritesCalls.length, 0); + }); + + it("should mark cached profiles as followed", async () => { + const listItems = [makeItem("did:plc:a"), makeItem("did:plc:b")]; + const { dataStore, mutations } = setup({ listItems }); + dataStore.setProfiles([{ did: "did:plc:a", handle: "a.test", viewer: {} }]); + dataStore.$detailedProfiles.set("did:plc:a", { + did: "did:plc:a", + handle: "a.test", + followersCount: 4, + viewer: {}, + }); + + await mutations.followAllStarterPackMembers(starterPack); + + const profile = dataStore.$profiles.get("did:plc:a"); + assert.match( + profile.viewer.following, + /^at:\/\/did:plc:me\/app\.bsky\.graph\.follow\/[a-z2-7]{13}$/, + ); + const detailed = dataStore.$detailedProfiles.get("did:plc:a"); + assert.deepEqual(detailed.followersCount, 5); + assert.deepEqual(detailed.viewer.following, profile.viewer.following); + assert.deepEqual(dataStore.$profiles.get("did:plc:b"), null); + }); + + it("should rethrow api failures", async () => { + const { mutations } = setup({ + listItems: [makeItem("did:plc:a")], + applyWrites: async () => { + throw new Error("boom"); + }, + }); + + await assert.rejects( + () => mutations.followAllStarterPackMembers(starterPack), + /boom/, + ); + }); +}); + describe("unfollowProfile", () => { const testProfile = { uri: "did:test:profile", diff --git a/tests/unit/specs/dataLayer/requests.test.js b/tests/unit/specs/dataLayer/requests.test.js index eb7f60d5..022e5b50 100644 --- a/tests/unit/specs/dataLayer/requests.test.js +++ b/tests/unit/specs/dataLayer/requests.test.js @@ -4173,6 +4173,26 @@ describe("loadFeedGenerator / loadList / loadStarterPack", () => { assert.deepEqual(dataStore.$starterPacks.get(starterPack.uri), starterPack); }); + + it("should record a starter pack load failure in the status store", async () => { + const dataStore = new DataStore(createSessionState(null)); + const error = new ApiError({ status: 400, data: { error: "NotFound" } }); + const mockApi = { + getStarterPack: async () => { + throw error; + }, + }; + const requests = makeRequests(mockApi, dataStore); + const uri = "at://did/app.bsky.graph.starterpack/sp"; + + await requests.loadStarterPack(uri); + + assert.deepEqual( + requests.statusStore.getError("loadStarterPack-" + uri), + error, + ); + assert.deepEqual(dataStore.$starterPacks.get(uri), null); + }); }); describe("loadListMembers", () => { @@ -4241,6 +4261,54 @@ describe("loadListMembers", () => { }); }); +describe("loadAllListMembers", () => { + const listUri = "at://did/app.bsky.graph.list/1"; + + it("should store every item with no cursor and hydrate profiles", async () => { + const dataStore = new DataStore(createSessionState(null)); + dataStore.$listMembers.set(listUri, { + items: [{ uri: "stale", subject: { did: "did:plc:stale" } }], + cursor: "next", + }); + const mockApi = { + getAllListItems: async () => [ + { uri: "li1", subject: { did: "did:plc:a", handle: "a" } }, + { uri: "li2", subject: { did: "did:plc:b", handle: "b" } }, + ], + }; + const requests = makeRequests(mockApi, dataStore); + + await requests.loadAllListMembers(listUri); + + const stored = dataStore.$listMembers.get(listUri); + assert.deepEqual( + stored.items.map((item) => item.uri), + ["li1", "li2"], + ); + assert.deepEqual(stored.cursor, null); + assert.deepEqual(dataStore.$profiles.get("did:plc:b").handle, "b"); + }); + + it("should record an api failure in the status store", async () => { + const dataStore = new DataStore(createSessionState(null)); + const error = new ApiError({ status: 500, data: { error: "Boom" } }); + const mockApi = { + getAllListItems: async () => { + throw error; + }, + }; + const requests = makeRequests(mockApi, dataStore); + + await requests.loadAllListMembers(listUri); + + assert.deepEqual( + requests.statusStore.getError("loadAllListMembers-" + listUri), + error, + ); + assert.deepEqual(dataStore.$listMembers.get(listUri), null); + }); +}); + describe("loadActorLists", () => { const did = "did:plc:author"; diff --git a/tests/unit/specs/navigation.test.js b/tests/unit/specs/navigation.test.js index 71e5ae63..135708bf 100644 --- a/tests/unit/specs/navigation.test.js +++ b/tests/unit/specs/navigation.test.js @@ -13,6 +13,8 @@ import { linkToProfileFollowers, linkToProfileFollowing, linkToFeed, + linkToStarterPack, + getPermalinkForStarterPack, linkToCommunityPlugin, linkToPluginSettings, getPermalinkForPost, @@ -215,6 +217,37 @@ describe("linkToProfileFollowing", () => { }); }); +describe("linkToStarterPack", () => { + const starterPack = { + uri: "at://did:plc:alice/app.bsky.graph.starterpack/3kpack", + creator: { did: "did:plc:alice", handle: "alice.bsky.social" }, + }; + + it("should link by creator handle and rkey", () => { + assert.deepEqual( + linkToStarterPack(starterPack), + "/profile/alice.bsky.social/starter-pack/3kpack", + ); + }); + + it("should fall back to the creator did when the handle is invalid", () => { + assert.deepEqual( + linkToStarterPack({ + ...starterPack, + creator: { did: "did:plc:alice", handle: "handle.invalid" }, + }), + "/profile/did:plc:alice/starter-pack/3kpack", + ); + }); + + it("should build a bsky.app permalink", () => { + assert.deepEqual( + getPermalinkForStarterPack(starterPack), + "https://bsky.app/starter-pack/alice.bsky.social/3kpack", + ); + }); +}); + describe("linkToFeed", () => { it("should return correct feed link", () => { const feedGenerator = { diff --git a/tests/unit/specs/templates/postEmbed.template.test.js b/tests/unit/specs/templates/postEmbed.template.test.js index c8391646..cdca8216 100644 --- a/tests/unit/specs/templates/postEmbed.template.test.js +++ b/tests/unit/specs/templates/postEmbed.template.test.js @@ -390,7 +390,11 @@ describe("postEmbedTemplate - external links", () => { }, labeler: null, }; - const result = postEmbedTemplate({ embed, mediaLabel, isAuthenticated: true }); + const result = postEmbedTemplate({ + embed, + mediaLabel, + isAuthenticated: true, + }); const container = document.createElement("div"); render(result, container); const warning = container.querySelector("moderation-warning"); @@ -1208,6 +1212,11 @@ describe("postEmbedTemplate - record embeds", () => { }); const card = container.querySelector(".starter-pack-embed"); assert(card !== null); + assert.deepEqual( + card.querySelector("a").getAttribute("href"), + "/profile/creator.bsky.social/starter-pack/cool-pack", + ); + assert.deepEqual(card.querySelector("a").getAttribute("target"), null); assert(card.textContent.includes("Cool Pack")); assert(card.textContent.includes("People to follow")); assert(card.textContent.includes("@creator.bsky.social")); diff --git a/tests/unit/specs/templates/profileFeed.template.test.js b/tests/unit/specs/templates/profileFeed.template.test.js index 3def41e0..4ce5b54a 100644 --- a/tests/unit/specs/templates/profileFeed.template.test.js +++ b/tests/unit/specs/templates/profileFeed.template.test.js @@ -406,6 +406,48 @@ describe("profileFeedTemplate", () => { ); }); + it("should hide no-unauthenticated profiles when not authenticated", () => { + const hiddenActor = { + ...mockActor, + did: "did:plc:hidden", + handle: "hidden.bsky.social", + labels: [{ val: "!no-unauthenticated", src: "did:plc:hidden" }], + }; + render( + profileFeedTemplate({ + profiles: [mockActor, hiddenActor], + hasMore: false, + isAuthenticated: false, + }), + container, + ); + const items = container.querySelectorAll(".profile-list-item"); + assert.deepEqual(items.length, 1); + assert(container.textContent.includes("testuser.bsky.social")); + assert(!container.textContent.includes("hidden.bsky.social")); + }); + + it("should show no-unauthenticated profiles when authenticated", () => { + const hiddenActor = { + ...mockActor, + did: "did:plc:hidden", + handle: "hidden.bsky.social", + labels: [{ val: "!no-unauthenticated", src: "did:plc:hidden" }], + }; + render( + profileFeedTemplate({ + profiles: [mockActor, hiddenActor], + hasMore: false, + isAuthenticated: true, + }), + container, + ); + assert.deepEqual( + container.querySelectorAll(".profile-list-item").length, + 2, + ); + }); + it("should render 10 skeletons by default when loading", () => { const result = profileFeedTemplate({ profiles: null, hasMore: false }); render(result, container); -- 2.51.2