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`