From 7fcc01c2a3fb209dcfd198d38452dcbf2dfa1caf Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Wed, 23 Sep 2026 18:36:47 -0500 Subject: [PATCH] Create and edit starter packs --- src/css/style.css | 178 ++- src/js/api.js | 63 + src/js/components/add-to-lists-dialog.js | 27 +- src/js/components/drafts-dialog.js | 28 +- .../components/manage-list-members-dialog.js | 16 +- src/js/components/new-chat-dialog.js | 26 +- src/js/components/plugin-profiles-list.js | 4 +- .../components/starter-pack-wizard-dialog.js | 1070 +++++++++++++++++ src/js/dataLayer/dataStore.js | 2 + src/js/dataLayer/derived.js | 10 + src/js/dataLayer/mutations.js | 577 +++++++-- src/js/dataLayer/requests.js | 41 + src/js/dialogHelpers.js | 4 +- src/js/dragHelpers.js | 20 +- src/js/interactionHandlers.js | 4 + src/js/listInteractionHandler.js | 47 - src/js/modals/profileList.modal.js | 4 +- src/js/starterPackInteractionHandler.js | 78 ++ .../templates/feedGeneratorList.template.js | 26 + .../feedGeneratorListItem.template.js | 11 + src/js/templates/feedsFeed.template.js | 57 - src/js/templates/listFeed.template.js | 77 -- src/js/templates/listList.template.js | 42 + src/js/templates/paginatedList.template.js | 86 ++ src/js/templates/postFeed.template.js | 118 +- ...ed.template.js => profileList.template.js} | 77 +- src/js/templates/starterPackList.template.js | 129 ++ src/js/utils.js | 14 +- src/js/views/chat.view.js | 27 +- src/js/views/chatRequests.view.js | 30 +- src/js/views/feeds.view.js | 5 +- src/js/views/groupChatDetails.view.js | 4 +- src/js/views/listDetail.view.js | 67 +- src/js/views/lists.view.js | 4 +- src/js/views/notifications.view.js | 88 +- src/js/views/postLikes.view.js | 4 +- src/js/views/postReposts.view.js | 4 +- src/js/views/profile.view.js | 118 +- src/js/views/profileFollowers.view.js | 4 +- src/js/views/profileFollowing.view.js | 4 +- src/js/views/profileKnownFollowers.view.js | 4 +- src/js/views/search.view.js | 272 +---- src/js/views/settings/blockedAccounts.view.js | 4 +- src/js/views/settings/mutedAccounts.view.js | 4 +- src/js/views/starterPackDetail.view.js | 149 ++- tests/e2e/mockServer.js | 273 ++++- .../e2e/specs/flows/addProfileToList.test.js | 4 +- tests/e2e/specs/flows/blockUser.test.js | 24 +- tests/e2e/specs/flows/bookmarkPost.test.js | 2 +- .../e2e/specs/flows/createStarterPack.test.js | 127 ++ tests/e2e/specs/flows/deletePost.test.js | 8 +- .../e2e/specs/flows/deleteStarterPack.test.js | 85 ++ tests/e2e/specs/flows/editStarterPack.test.js | 169 +++ tests/e2e/specs/flows/followUser.test.js | 2 +- tests/e2e/specs/flows/likePost.test.js | 2 +- tests/e2e/specs/flows/muteUser.test.js | 18 +- .../flows/openStarterPackFromProfile.test.js | 58 + tests/e2e/specs/flows/repostPost.test.js | 2 +- tests/e2e/specs/views/bookmarks.view.test.js | 4 +- tests/e2e/specs/views/chat.view.test.js | 6 +- .../e2e/specs/views/chatRequests.view.test.js | 19 +- tests/e2e/specs/views/feedDetail.view.test.js | 2 +- tests/e2e/specs/views/hashtag.view.test.js | 2 +- tests/e2e/specs/views/home.view.test.js | 6 +- tests/e2e/specs/views/listDetail.view.test.js | 6 +- .../specs/views/notifications.view.test.js | 27 +- tests/e2e/specs/views/postLikes.view.test.js | 2 +- .../e2e/specs/views/postReposts.view.test.js | 2 +- tests/e2e/specs/views/profile.view.test.js | 11 +- .../specs/views/profileFollowers.view.test.js | 2 +- .../specs/views/profileFollowing.view.test.js | 2 +- .../views/profileKnownFollowers.view.test.js | 2 +- .../settings/blockedAccounts.view.test.js | 2 +- .../views/settings/mutedAccounts.view.test.js | 2 +- .../views/starterPackDetail.view.test.js | 76 +- .../specs/components/drafts-dialog.test.js | 4 +- .../manage-list-members-dialog.test.js | 4 +- .../specs/components/new-chat-dialog.test.js | 12 +- .../components/plugin-posts-feed.test.js | 12 +- .../components/plugin-profiles-list.test.js | 6 +- .../starter-pack-wizard-dialog.test.js | 385 ++++++ tests/unit/specs/dataLayer/mutations.test.js | 422 +++++++ tests/unit/specs/dialogHelpers.test.js | 17 + tests/unit/specs/dragHelpers.test.js | 18 + ... => starterPackInteractionHandler.test.js} | 14 +- ....js => feedGeneratorList.template.test.js} | 6 +- ...late.test.js => listList.template.test.js} | 6 +- .../templates/paginatedList.template.test.js | 83 ++ .../specs/templates/postFeed.template.test.js | 7 +- ...e.test.js => profileList.template.test.js} | 43 +- .../starterPackList.template.test.js | 94 ++ tests/unit/specs/utils.test.js | 38 + 92 files changed, 4647 insertions(+), 1099 deletions(-) create mode 100644 src/js/components/starter-pack-wizard-dialog.js create mode 100644 src/js/starterPackInteractionHandler.js create mode 100644 src/js/templates/feedGeneratorList.template.js delete mode 100644 src/js/templates/feedsFeed.template.js delete mode 100644 src/js/templates/listFeed.template.js create mode 100644 src/js/templates/listList.template.js create mode 100644 src/js/templates/paginatedList.template.js rename src/js/templates/{profileFeed.template.js => profileList.template.js} (82%) create mode 100644 src/js/templates/starterPackList.template.js create mode 100644 tests/e2e/specs/flows/createStarterPack.test.js create mode 100644 tests/e2e/specs/flows/deleteStarterPack.test.js create mode 100644 tests/e2e/specs/flows/editStarterPack.test.js create mode 100644 tests/e2e/specs/flows/openStarterPackFromProfile.test.js create mode 100644 tests/unit/specs/components/starter-pack-wizard-dialog.test.js rename tests/unit/specs/{listInteractionHandler.test.js => starterPackInteractionHandler.test.js} (82%) rename tests/unit/specs/templates/{feedsFeed.template.test.js => feedGeneratorList.template.test.js} (81%) rename tests/unit/specs/templates/{listFeed.template.test.js => listList.template.test.js} (94%) create mode 100644 tests/unit/specs/templates/paginatedList.template.test.js rename tests/unit/specs/templates/{profileFeed.template.test.js => profileList.template.test.js} (94%) create mode 100644 tests/unit/specs/templates/starterPackList.template.test.js diff --git a/src/css/style.css b/src/css/style.css index abc354e9..404eb405 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -6606,12 +6606,6 @@ sidebar-search { border-bottom: var(--hair) solid var(--post-border-color); } -.feeds-list .loading-spinner { - margin-left: auto; - margin-right: auto; - margin-top: 16px; -} - .feeds-list-item { display: flex; align-items: center; @@ -6694,6 +6688,22 @@ sidebar-search { /* border: 2px solid var(--background-color); */ } +.starter-pack-list-item-skeleton-text { + display: inline-block; + width: 45%; + border-radius: var(--skeleton-border-radius); +} + +.feeds-list-item-creator .starter-pack-list-item-skeleton-text { + width: 30%; +} + +.starter-pack-list-item-member-skeleton { + width: 100%; + height: 100%; + border-radius: 50%; +} + .starter-pack-list-item-member-count { margin-left: 12px; font-size: 13px; @@ -11173,6 +11183,162 @@ label:has(toggle-switch[disabled]) { border-radius: 4px; } +.starter-pack-wizard-dialog .search-dialog-content { + height: 100%; +} + +.starter-pack-wizard-header-button { + display: flex; + align-items: center; + background: none; + border: none; + cursor: pointer; + color: var(--text-color); + padding: 4px 8px; + margin-left: -8px; + opacity: 0.8; +} + +.starter-pack-wizard-header-button app-icon { + width: 24px; + height: 24px; +} + +.starter-pack-wizard-header-spacer { + width: 32px; +} + +.starter-pack-wizard-dialog .search-dialog-title { + flex: 1; + text-align: center; +} + +.starter-pack-wizard-details { + gap: 16px; +} + +.starter-pack-wizard-hero { + text-align: center; + padding: 8px 0 12px; +} + +.starter-pack-wizard-hero h3 { + margin: 0 0 4px; + font-size: 1.4rem; +} + +.starter-pack-wizard-hero p { + margin: 0; + color: var(--text-color-secondary); +} + +.starter-pack-wizard-footer { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 12px; + border-top: var(--hair) solid var(--post-border-color); + flex-shrink: 0; +} + +.starter-pack-wizard-count { + font-size: 13px; + color: var(--text-color-muted); + text-align: right; +} + +.starter-pack-wizard-footer-row { + display: flex; + align-items: center; + gap: 12px; +} + +.starter-pack-wizard-avatars { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.starter-pack-wizard-avatar { + width: 32px; + height: 32px; +} + +.starter-pack-wizard-avatar .avatar, +.starter-pack-wizard-avatar .avatar-image-frame, +.starter-pack-wizard-avatar .feed-avatar { + display: block; + width: 100%; + height: 100%; +} + +.starter-pack-wizard-avatar .feed-avatar { + border-radius: 8px; + object-fit: cover; +} + +.starter-pack-wizard-avatars.is-overlapping { + gap: 0; +} + +.starter-pack-wizard-avatars.is-overlapping .starter-pack-wizard-avatar { + margin-right: -8px; +} + +.starter-pack-wizard-avatars.is-overlapping .avatar-image-frame { + border: 2px solid var(--background-color); +} + +.starter-pack-wizard-summary { + flex: 1; + min-width: 0; + font-size: 14px; + color: var(--text-color-secondary); +} + +.starter-pack-wizard-hint { + margin-top: 4px; + font-size: 13px; + color: var(--text-color-muted); +} + +.starter-pack-wizard-next { + width: 100%; + padding: 12px; + font-size: 16px; + font-weight: 600; +} + +.starter-pack-wizard-next .loading-spinner { + width: 16px; + height: 16px; +} + +.starter-pack-wizard-toggle, +.starter-pack-review-remove { + align-self: center; + flex-shrink: 0; + min-width: 80px; +} + +.starter-pack-wizard-feed-row { + cursor: default; +} + +.starter-pack-empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.starter-pack-create-footer { + display: flex; + justify-content: center; + padding: 16px; +} + .manage-list-members-toggle { align-self: center; flex-shrink: 0; diff --git a/src/js/api.js b/src/js/api.js index 4fa1efd2..48f58125 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -509,6 +509,26 @@ export class Api { return res.data; } + async getActorStarterPacks(did, { limit = 50, cursor = "" } = {}) { + const query = { actor: did, limit }; + if (cursor) { + query.cursor = cursor; + } + const res = await this.appViewRequest( + `app.bsky.graph.getActorStarterPacks`, + { query }, + ); + return res.data; + } + + async getPopularFeedGenerators({ limit = 30 } = {}) { + const res = await this.appViewRequest( + `app.bsky.unspecced.getPopularFeedGenerators`, + { query: { limit } }, + ); + return res.data; + } + async getActorLists(did, { limit = 50, cursor = "" } = {}) { const query = { actor: did, limit }; if (cursor) { @@ -1473,6 +1493,49 @@ export class Api { return res.data; } + async createStarterPackRecord(record) { + const res = await this.request("com.atproto.repo.createRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.graph.starterpack", + record: { + $type: "app.bsky.graph.starterpack", + ...record, + }, + }, + }); + return res.data; + } + + async getStarterPackRecord(rkey) { + const res = await this.request("com.atproto.repo.getRecord", { + query: { + repo: this.session.did, + collection: "app.bsky.graph.starterpack", + rkey, + }, + }); + return res.data; + } + + async putStarterPackRecord(rkey, record, swapRecord) { + const res = await this.request("com.atproto.repo.putRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.graph.starterpack", + rkey, + record: { + $type: "app.bsky.graph.starterpack", + ...record, + }, + swapRecord: swapRecord ?? null, + }, + }); + return res.data; + } + async getThreadgateRecord(rkey) { const res = await this.request("com.atproto.repo.getRecord", { query: { diff --git a/src/js/components/add-to-lists-dialog.js b/src/js/components/add-to-lists-dialog.js index 76cf6896..14437238 100644 --- a/src/js/components/add-to-lists-dialog.js +++ b/src/js/components/add-to-lists-dialog.js @@ -1,4 +1,5 @@ import { html, render } from "/js/lib/lit-html.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; import { closeWithAnimation } from "/js/dialogHelpers.js"; @@ -152,25 +153,13 @@ class AddToListsDialog extends Component { ` : html`
- { - if (!hasMore) return; - await this._loadMore(); - event.detail.resume(); - }} - > - ${entries.map((entry) => this._renderRow(entry))} - ${hasMore - ? html`
-
-
` - : null} -
+ ${paginatedListTemplate({ + items: entries, + renderItem: (entry) => this._renderRow(entry), + hasMore, + onLoadMore: () => this._loadMore(), + lookahead: "400px", + })}
`} diff --git a/src/js/components/drafts-dialog.js b/src/js/components/drafts-dialog.js index 74964280..c7f77622 100644 --- a/src/js/components/drafts-dialog.js +++ b/src/js/components/drafts-dialog.js @@ -1,4 +1,5 @@ import { html, render } from "/js/lib/lit-html.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; import { formatRelativeTime } from "/js/utils.js"; @@ -169,11 +170,8 @@ class DraftsDialog extends Component { } } - async _loadMore(resume) { - if (untrack(() => this.state.$isLoadingMore.get())) { - resume(); - return; - } + async _loadMore() { + if (untrack(() => this.state.$isLoadingMore.get())) return; this.state.$isLoadingMore.set(true); try { await this.dataLayer.requests.loadDrafts(); @@ -181,7 +179,6 @@ class DraftsDialog extends Component { console.error("Failed to load more drafts", error); } finally { this.state.$isLoadingMore.set(false); - resume(); } } @@ -224,7 +221,6 @@ class DraftsDialog extends Component { const draftViews = data?.drafts ?? null; const cursor = data?.cursor ?? null; const loadError = this.state.$loadError.get(); - const isLoadingMore = this.state.$isLoadingMore.get(); render( html` No drafts yet ` - : html` this._loadMore(e.detail.resume)} - > - ${draftViews.map((draftView) => + : paginatedListTemplate({ + items: draftViews, + renderItem: (draftView) => draftItemTemplate({ draftView, onSelect: (selected) => this._onSelect(selected), onDelete: (selected) => this._onDelete(selected), }), - )} - ${isLoadingMore - ? html`
-
-
` - : ""} -
`} + hasMore: cursor !== null, + onLoadMore: () => this._loadMore(), + })}
diff --git a/src/js/components/manage-list-members-dialog.js b/src/js/components/manage-list-members-dialog.js index 4e59dbe7..7ba5d035 100644 --- a/src/js/components/manage-list-members-dialog.js +++ b/src/js/components/manage-list-members-dialog.js @@ -5,7 +5,7 @@ import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; import { enableDragToDismiss } from "/js/dragHelpers.js"; import { Signal, SignalSet, ReactiveStore, effect } from "/js/signals.js"; import "/js/components/app-icon.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import { classnames } from "/js/utils.js"; const MAX_MEMBER_PAGES = 6; @@ -30,8 +30,12 @@ function toggleButtonTemplate({ profile, isMember, isPending, onToggle }) { `; } -function profileListTemplate({ profiles, emptyMessage, rightItemTemplate }) { - return profileFeedTemplate({ +function compactProfileListTemplate({ + profiles, + emptyMessage, + rightItemTemplate, +}) { + return profileListTemplate({ profiles, hasMore: false, clickAction: "none", @@ -242,7 +246,7 @@ class ManageListMembersDialog extends Component { We're having network issues, try again `; } - return profileListTemplate({ + return compactProfileListTemplate({ profiles: searchResults, emptyMessage: "No results", rightItemTemplate, @@ -255,7 +259,7 @@ class ManageListMembersDialog extends Component { suggestedProfiles = profileFollows; } if (!suggestedProfiles?.length) { - return profileListTemplate({ + return compactProfileListTemplate({ profiles: suggestedProfiles, emptyMessage: "Search for someone to add", }); @@ -267,7 +271,7 @@ class ManageListMembersDialog extends Component { > Suggested - ${profileListTemplate({ + ${compactProfileListTemplate({ profiles: suggestedProfiles, rightItemTemplate, })} diff --git a/src/js/components/new-chat-dialog.js b/src/js/components/new-chat-dialog.js index a4c077cf..ea7d9fec 100644 --- a/src/js/components/new-chat-dialog.js +++ b/src/js/components/new-chat-dialog.js @@ -5,7 +5,7 @@ import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; import { enableDragToDismiss } from "/js/dragHelpers.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; import "/js/components/app-icon.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { getDisplayName } from "/js/dataHelpers.js"; import { classnames, graphemeCount } from "/js/utils.js"; @@ -131,8 +131,12 @@ function memberToggleTemplate({ isSelected }) { `; } -function profileListTemplate({ profiles, onSelect, emptyMessage = null }) { - return profileFeedTemplate({ +function compactProfileListTemplate({ + profiles, + onSelect, + emptyMessage = null, +}) { + return profileListTemplate({ profiles, hasMore: false, skeletonCount: 6, @@ -160,7 +164,7 @@ function groupMemberListTemplate({ (atCap && !selectedDids.includes(profile.did)), ) .map((profile) => profile.did); - return profileFeedTemplate({ + return profileListTemplate({ profiles, hasMore: false, skeletonCount: 6, @@ -285,7 +289,7 @@ function chatStepResultsTemplate({ if (searchStatus?.error) { return searchErrorTemplate(); } - return profileListTemplate({ + return compactProfileListTemplate({ profiles: results ? partitionRows(results, currentUserDid) : null, onSelect, emptyMessage: "No results", @@ -308,7 +312,7 @@ function chatStepResultsTemplate({ } if (!suggestedProfiles?.length) { return html`${groupEntry} - ${profileListTemplate({ + ${compactProfileListTemplate({ profiles: suggestedProfiles, emptyMessage: "Search for someone to message", })}`; @@ -321,7 +325,7 @@ function chatStepResultsTemplate({ > Suggested - ${profileListTemplate({ + ${compactProfileListTemplate({ profiles: suggestedProfiles, onSelect, })} @@ -552,7 +556,7 @@ function groupNameStepTemplate({ > New group chat with: - ${profileFeedTemplate({ + ${profileListTemplate({ profiles: selectedProfiles, hasMore: false, compact: true, @@ -872,10 +876,12 @@ class NewChatDialog extends Component { }); enableDragToDismiss(dialog, { onDismiss: () => this.close(), - scrollContainer: this.querySelector(".search-dialog-results"), + scrollContainer: () => this.querySelector(".search-dialog-results"), ignoreTouchTarget: (element) => element.closest("button, input") !== null, }); - resetScrollOnBlur(dialog, this.querySelector(".search-dialog-results")); + resetScrollOnBlur(dialog, () => + this.querySelector(".search-dialog-results"), + ); } close() { diff --git a/src/js/components/plugin-profiles-list.js b/src/js/components/plugin-profiles-list.js index 82f3ef33..f5d24b0d 100644 --- a/src/js/components/plugin-profiles-list.js +++ b/src/js/components/plugin-profiles-list.js @@ -1,6 +1,6 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; class PluginProfilesList extends Component { @@ -42,7 +42,7 @@ class PluginProfilesList extends Component { return; } render( - profileFeedTemplate({ + profileListTemplate({ profiles, hasMore: false, skeletonCount: dids.length, diff --git a/src/js/components/starter-pack-wizard-dialog.js b/src/js/components/starter-pack-wizard-dialog.js new file mode 100644 index 00000000..f53dd62e --- /dev/null +++ b/src/js/components/starter-pack-wizard-dialog.js @@ -0,0 +1,1070 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { scrollLocks } from "/js/scrollLocks.js"; +import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; +import { Signal, SignalMap, ReactiveStore, effect } from "/js/signals.js"; +import { classnames, graphemeCount, truncateGraphemes } from "/js/utils.js"; +import { cdnImageUrl, getDisplayName } from "/js/dataHelpers.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; +import { avatarTemplate } from "/js/templates/avatar.template.js"; +import { showToast } from "/js/toasts.js"; +import { confirmModal } from "/js/modals/confirm.modal.js"; +import { LOGGED_OUT_FEED_URI } from "/js/config.js"; +import "/js/components/app-icon.js"; + +const MAX_NAME_LENGTH = 50; +const MAX_DESCRIPTION_LENGTH = 300; +const MAX_PROFILES = 150; +const MAX_FEEDS = 3; +const MIN_PROFILES = 8; +const MAX_SUMMARY_NAME_LENGTH = 28; +const DISCOVER_FEED_URI = LOGGED_OUT_FEED_URI; + +function truncateName(name) { + return truncateGraphemes(name, MAX_SUMMARY_NAME_LENGTH, { suffix: "…" }); +} + +function joinNames(names, remaining) { + if (names.length === 1) return `${names[0]} is`; + if (remaining === 0) return `${names[0]} and ${names[1]} are`; + return `${names[0]}, ${names[1]}, and ${remaining} ${remaining === 1 ? "other" : "others"} are`; +} + +function profilesSummaryText({ profiles, currentUserDid }) { + if (profiles.length <= 1) { + return "It's just you right now! Add more people to your starter pack by searching above."; + } + const others = profiles.filter((profile) => profile.did !== currentUserDid); + const names = others + .slice(0, 2) + .map((profile) => truncateName(getDisplayName(profile))); + if (profiles.length === 2) { + return `You and ${names[0]} are included in your starter pack`; + } + return `${joinNames(names, profiles.length - 3)} included in your starter pack`; +} + +function feedsSummaryText({ feeds }) { + const names = feeds.slice(0, 2).map((feed) => truncateName(feed.displayName)); + return `${joinNames(names, Math.max(0, feeds.length - 2))} included in your starter pack`; +} + +function canBeAdded(profile) { + return ( + !profile.associated?.labeler && + !profile.viewer?.blocking && + !profile.viewer?.blockedBy && + !profile.viewer?.blockingByList + ); +} + +function wizardHeaderTemplate({ title, onBack, onClose, action }) { + return html`
+ ${onBack + ? html`` + : onClose + ? html`` + : html``} +

${title}

+ ${action ?? html``} +
`; +} + +function searchInputTemplate({ + rawQuery, + placeholder, + testId, + onInput, + onClear, +}) { + return html`
+ + onInput(event.target.value)} + /> + ${rawQuery.length > 0 + ? html`` + : ""} +
`; +} + +function detailsStepTemplate({ + mode, + name, + description, + namePlaceholder, + nameTooLong, + descriptionTooLong, + onNameInput, + onDescriptionInput, + onNext, + onClose, +}) { + const nameCount = graphemeCount(name); + const descriptionCount = graphemeCount(description); + return { + header: wizardHeaderTemplate({ + title: mode === "edit" ? "Edit Starter Pack" : "Starter Pack", + onClose, + }), + search: null, + resultsClass: "starter-pack-wizard-details", + results: html` + ${mode === "create" + ? html`
+

Invites, but personal

+

Invite your friends to follow your favorite feeds and people

+
` + : ""} +
+ + onNameInput(event.target.value)} + data-testid="starter-pack-name-input" + /> +
+ ${nameCount}/${MAX_NAME_LENGTH} +
+
+
+ + +
+ ${descriptionCount}/${MAX_DESCRIPTION_LENGTH} +
+
+ `, + footer: html``, + }; +} + +function toggleButtonTemplate({ testId, toggleState, onToggle }) { + const isIncluded = toggleState === "included" || toggleState === "locked"; + const isDisabled = + toggleState !== "included" && toggleState !== "not-included"; + let label; + if (toggleState === "opted-out") label = "Opted out"; + else if (isIncluded) label = "Added"; + else label = "Add"; + return html``; +} + +function profileToggleTemplate({ profile, toggleState, onToggle }) { + return toggleButtonTemplate({ + testId: "starter-pack-profile-toggle", + toggleState, + onToggle: () => onToggle(profile), + }); +} + +function profileRowsTemplate({ + profiles, + emptyMessage, + rightItemTemplate, + disabledProfiles = null, +}) { + return profileListTemplate({ + profiles, + hasMore: false, + clickAction: "none", + compact: true, + rightItemTemplate, + emptyMessage, + disabledProfiles, + }); +} + +function wizardFooterTemplate({ + count, + max, + showCount, + avatars, + overlapAvatars, + summary, + hint, + buttonLabel, + buttonDisabled, + saving, + onNext, +}) { + return html``; +} + +function profilesStepTemplate({ + rawQuery, + query, + currentUserDid, + searchResults, + searchError, + suggestedProfiles, + profiles, + optedOutDids, + atCap, + saving, + onSearchInput, + onClearSearch, + onToggle, + onBack, + onOpenReview, + onNext, +}) { + const includedDids = new Set(profiles.map((profile) => profile.did)); + const rightItemTemplate = (profile) => { + let toggleState; + if (profile.did === currentUserDid) toggleState = "locked"; + else if (optedOutDids.has(profile.did)) toggleState = "opted-out"; + else if (includedDids.has(profile.did)) toggleState = "included"; + else toggleState = "not-included"; + return profileToggleTemplate({ profile, toggleState, onToggle }); + }; + const disabledProfiles = atCap + ? (query ? searchResults : suggestedProfiles) + ?.filter((profile) => !includedDids.has(profile.did)) + .map((profile) => profile.did) + : null; + let results; + if (query) { + results = searchError + ? html`
+ We're having network issues, try again +
` + : profileRowsTemplate({ + profiles: searchResults, + emptyMessage: "Nobody was found. Try searching for someone else.", + rightItemTemplate, + disabledProfiles, + }); + } else if (suggestedProfiles?.length) { + results = html`
Suggested
+ ${profileRowsTemplate({ + profiles: suggestedProfiles, + rightItemTemplate, + disabledProfiles, + })}`; + } else { + results = profileRowsTemplate({ + profiles: suggestedProfiles, + emptyMessage: "Search for people to add", + rightItemTemplate, + }); + } + const missing = MIN_PROFILES - profiles.length; + return { + header: wizardHeaderTemplate({ + title: "Choose People", + onBack, + action: + profiles.length > 1 + ? html`` + : null, + }), + search: searchInputTemplate({ + rawQuery, + placeholder: "Search for people", + testId: "starter-pack-profile-search", + onInput: onSearchInput, + onClear: onClearSearch, + }), + resultsClass: null, + results, + footer: wizardFooterTemplate({ + count: profiles.length, + max: MAX_PROFILES, + showCount: profiles.length > MIN_PROFILES, + avatars: profiles.slice(0, 6).map( + (profile) => + html`
+ ${avatarTemplate({ + author: profile, + clickAction: "none", + showLiveBadge: false, + })} +
`, + ), + overlapAvatars: true, + summary: profilesSummaryText({ profiles, currentUserDid }), + hint: + missing > 0 + ? `Add ${missing} more ${missing === 1 ? "person" : "people"} to continue` + : null, + buttonLabel: "Next", + buttonDisabled: missing > 0 || saving, + saving: false, + onNext, + }), + }; +} + +function feedRowTemplate({ feed, currentUserDid, toggleState, onToggle }) { + return html`
+
+ ${feed.displayName} +
+
+
${feed.displayName}
+ ${feed.creator + ? html`
+ Feed by + ${feed.creator.did === currentUserDid + ? "you" + : `@${feed.creator.handle}`} +
` + : ""} +
+ ${toggleButtonTemplate({ + testId: "starter-pack-feed-toggle", + toggleState, + onToggle: () => onToggle(feed), + })} +
`; +} + +function feedsStepTemplate({ + rawQuery, + query, + currentUserDid, + searchResults, + searchError, + suggestedFeeds, + feeds, + saving, + onSearchInput, + onClearSearch, + onToggle, + onBack, + onOpenReview, + onNext, +}) { + const includedUris = new Set(feeds.map((feed) => feed.uri)); + const atCap = feeds.length >= MAX_FEEDS; + const rowsTemplate = (list) => + list.map((feed) => { + let toggleState; + if (feed.uri === DISCOVER_FEED_URI) toggleState = "locked"; + else if (includedUris.has(feed.uri)) toggleState = "included"; + else if (atCap) toggleState = "capped"; + else toggleState = "not-included"; + return feedRowTemplate({ feed, currentUserDid, toggleState, onToggle }); + }); + let results; + if (query) { + if (searchError) { + results = html`
+ We're having network issues, try again +
`; + } else if (!searchResults) { + results = html`
+
+
`; + } else if (searchResults.length === 0) { + results = html`
+ No feeds found. Try searching for something else. +
`; + } else { + results = html`
+ ${rowsTemplate(searchResults)} +
`; + } + } else if (!suggestedFeeds) { + results = html`
+
+
`; + } else { + results = html`
+ ${rowsTemplate(suggestedFeeds)} +
`; + } + const summary = + feeds.length === 0 + ? html`Add some feeds to your Starter Pack! +
Search for feeds that you want to suggest to others.
` + : feedsSummaryText({ feeds }); + return { + header: wizardHeaderTemplate({ + title: "Choose Feeds", + onBack, + action: + feeds.length > 0 + ? html`` + : null, + }), + search: searchInputTemplate({ + rawQuery, + placeholder: "Search for feeds", + testId: "starter-pack-feed-search", + onInput: onSearchInput, + onClear: onClearSearch, + }), + resultsClass: null, + results, + footer: wizardFooterTemplate({ + count: feeds.length, + max: MAX_FEEDS, + showCount: feeds.length > 0, + avatars: feeds.map( + (feed) => + html`
+ ${feed.displayName} +
`, + ), + overlapAvatars: false, + summary, + hint: null, + buttonLabel: feeds.length === 0 ? "Skip" : "Finish", + buttonDisabled: saving, + saving, + onNext, + }), + }; +} + +function reviewTemplate({ + step, + currentUserDid, + profiles, + feeds, + onRemoveProfile, + onRemoveFeed, + onClose, +}) { + const isProfiles = step === "profiles"; + const removeButton = ({ onClick, label }) => + html``; + return { + header: wizardHeaderTemplate({ + title: isProfiles ? "Edit People" : "Edit Feeds", + action: html``, + }), + search: null, + resultsClass: null, + results: html`
+ ${isProfiles + ? profileRowsTemplate({ + profiles, + rightItemTemplate: (profile) => + profile.did === currentUserDid + ? null + : removeButton({ + onClick: () => onRemoveProfile(profile), + label: `Remove ${getDisplayName(profile)}`, + }), + }) + : html`
+ ${feeds.map( + (feed) => + html`
+
+ ${feed.displayName} +
+
+
${feed.displayName}
+
+ ${removeButton({ + onClick: () => onRemoveFeed(feed), + label: `Remove ${feed.displayName}`, + })} +
`, + )} +
`} +
`, + footer: null, + }; +} + +class StarterPackWizardDialog extends Component { + connectedCallback() { + if (this.initialized) { + return; + } + this.dataLayer = this.dataLayer ?? null; + this.starterPack = this.starterPack ?? null; + this.members = this.members ?? []; + this.optedOutDids = this.optedOutDids ?? []; + this.setAttribute("data-dialog-wrapper", ""); + this.scrollLock = null; + this._mode = this.starterPack ? "edit" : "create"; + this._isOpen = false; + this._feedSuggestionsRequested = false; + this.state = new ReactiveStore("starter-pack-wizard-dialog"); + this.state.$step = new Signal.State("details"); + this.state.$name = new Signal.State(""); + this.state.$description = new Signal.State(""); + this.state.$profiles = new SignalMap(); + this.state.$feeds = new SignalMap(); + this.state.$profileQuery = new Signal.State(""); + this.state.$feedQuery = new Signal.State(""); + this.state.$saving = new Signal.State(false); + this.state.$reviewOpen = new Signal.State(false); + this._seedState(); + this._originalSnapshot = this._snapshot(); + this.innerHTML = ""; + this._disposeEffect = effect(() => { + this.render(); + }); + this._loadProfileSuggestions(); + this.initialized = true; + } + + disconnectedCallback() { + this._disposeEffect?.(); + this._disposeEffect = null; + this.scrollLock?.release(); + this.scrollLock = null; + } + + _seedState() { + const currentUser = this.dataLayer.derived.$currentUser.get(); + if (currentUser) { + this.state.$profiles.set(currentUser.did, currentUser); + } + if (this._mode !== "edit") return; + const { record } = this.starterPack; + this.state.$name.set(record.name ?? ""); + this.state.$description.set(record.description ?? ""); + const optedOut = new Set(this.optedOutDids); + for (const profile of this.members) { + if (optedOut.has(profile.did)) continue; + if (this.state.$profiles.has(profile.did)) continue; + this.state.$profiles.set(profile.did, profile); + } + for (const feed of this.starterPack.feeds ?? []) { + if (feed.uri === DISCOVER_FEED_URI) continue; + if (this.state.$feeds.size >= MAX_FEEDS) break; + this.state.$feeds.set(feed.uri, feed); + } + } + + _snapshot() { + return JSON.stringify({ + name: this.state.$name.get(), + description: this.state.$description.get(), + profiles: [...this.state.$profiles.keys()], + feeds: [...this.state.$feeds.keys()], + }); + } + + get _isDirty() { + return this._snapshot() !== this._originalSnapshot; + } + + async _loadProfileSuggestions() { + try { + const currentUser = await this.dataLayer.declarative.ensureCurrentUser(); + await this.dataLayer.declarative.ensureProfileFollows(currentUser.did); + } catch (error) { + console.warn("Failed to load suggested profiles", error); + } + } + + _loadFeedSuggestions() { + if (this._feedSuggestionsRequested) return; + this._feedSuggestionsRequested = true; + this.dataLayer.declarative.ensurePinnedItems().catch((error) => { + console.warn("Failed to load pinned feeds", error); + }); + this.dataLayer.requests.loadPopularFeeds().catch((error) => { + console.warn("Failed to load popular feeds", error); + }); + } + + _onProfileSearchInput(value) { + this.state.$profileQuery.set(value); + const query = value.trim(); + if (!query) { + this.dataLayer.requests.loadChatRecipientSearch(""); + } else { + this.dataLayer.requests.loadChatRecipientSearch(query, { limit: 12 }); + } + this._resetResultsScroll(); + } + + _onFeedSearchInput(value) { + this.state.$feedQuery.set(value); + this.dataLayer.requests.loadFeedSearch(value.trim()).catch((error) => { + console.warn("Failed to search feeds", error); + }); + this._resetResultsScroll(); + } + + _resetResultsScroll() { + const results = this.querySelector(".search-dialog-results"); + if (results) results.scrollTop = 0; + } + + _toggleProfile(profile) { + const currentUser = this.dataLayer.derived.$currentUser.get(); + if (profile.did === currentUser?.did) return; + if (this.state.$profiles.has(profile.did)) { + this.state.$profiles.delete(profile.did); + return; + } + if (this.state.$profiles.size >= MAX_PROFILES) { + showToast(`You may only add up to ${MAX_PROFILES} profiles`, { + style: "error", + }); + return; + } + this.state.$profiles.set(profile.did, profile); + } + + _toggleFeed(feed) { + if (feed.uri === DISCOVER_FEED_URI) return; + if (this.state.$feeds.has(feed.uri)) { + this.state.$feeds.delete(feed.uri); + return; + } + if (this.state.$feeds.size >= MAX_FEEDS) { + showToast(`You may only add up to ${MAX_FEEDS} feeds`, { + style: "error", + }); + return; + } + this.state.$feeds.set(feed.uri, feed); + } + + _setStep(step) { + if (this.state.$profileQuery.get()) this._onProfileSearchInput(""); + if (this.state.$feedQuery.get()) this._onFeedSearchInput(""); + this.state.$reviewOpen.set(false); + this.state.$step.set(step); + if (step === "feeds") this._loadFeedSuggestions(); + } + + _next() { + const step = this.state.$step.get(); + if (step === "details") { + if ( + graphemeCount(this.state.$name.get()) > MAX_NAME_LENGTH || + graphemeCount(this.state.$description.get()) > MAX_DESCRIPTION_LENGTH + ) { + return; + } + this._setStep("profiles"); + } else if (step === "profiles") { + if (this.state.$profiles.size < MIN_PROFILES) return; + this._setStep("feeds"); + } else { + this._submit(); + } + } + + _back() { + const step = this.state.$step.get(); + if (step === "feeds") this._setStep("profiles"); + else if (step === "profiles") this._setStep("details"); + } + + _submit() { + if (this.state.$saving.get()) return; + this.state.$saving.set(true); + const data = { + name: this.state.$name.get(), + description: this.state.$description.get(), + profiles: [...this.state.$profiles.values()], + feeds: [...this.state.$feeds.values()], + }; + const successCallback = () => { + this._originalSnapshot = this._snapshot(); + this.state.$saving.set(false); + this.close(); + }; + const errorCallback = (error) => { + console.error("Failed to save starter pack:", error); + showToast( + this._mode === "edit" + ? "Failed to save Starter Pack" + : "Failed to create Starter Pack", + { style: "error" }, + ); + this.state.$saving.set(false); + }; + this.dispatchEvent( + new CustomEvent( + this._mode === "edit" ? "starter-pack-update" : "starter-pack-create", + { detail: { data, successCallback, errorCallback } }, + ), + ); + } + + render() { + const step = this.state.$step.get(); + const rawProfileQuery = this.state.$profileQuery.get(); + const rawFeedQuery = this.state.$feedQuery.get(); + const name = this.state.$name.get(); + const description = this.state.$description.get(); + const profiles = [...this.state.$profiles.values()]; + const feeds = [...this.state.$feeds.values()]; + const saving = this.state.$saving.get(); + const reviewOpen = this.state.$reviewOpen.get(); + const currentUser = this.dataLayer.derived.$currentUser.get(); + const currentUserDid = currentUser?.did ?? null; + const optedOutDids = new Set(this.optedOutDids); + + const profileSearchResults = + this.dataLayer.derived.$chatRecipientSearchResults.get(); + const profileSearchStatus = + this.dataLayer.requests.statusStore.$statuses.get( + "loadChatRecipientSearch", + ); + const profileFollows = currentUserDid + ? this.dataLayer.derived.$profileFollows.get(currentUserDid)?.follows + : null; + const profileFollowsStatus = currentUserDid + ? this.dataLayer.requests.statusStore.$statuses.get( + `loadProfileFollows-${currentUserDid}`, + ) + : null; + const feedSearchResults = this.dataLayer.derived.$feedSearchResults.get(); + const feedSearchStatus = + this.dataLayer.requests.statusStore.$statuses.get("loadFeedSearch"); + const pinnedItems = this.dataLayer.derived.$hydratedPinnedItems.get(); + const popularFeeds = this.dataLayer.derived.$popularFeeds.get(); + const popularFeedsStatus = + this.dataLayer.requests.statusStore.$statuses.get("loadPopularFeeds"); + + let suggestedProfiles = null; + if (profileFollowsStatus?.error) { + suggestedProfiles = []; + } else if (profileFollows) { + suggestedProfiles = profileFollows.filter(canBeAdded); + } + + let suggestedFeeds = null; + if (pinnedItems || popularFeeds || popularFeedsStatus?.error) { + const seen = new Set([DISCOVER_FEED_URI]); + suggestedFeeds = []; + for (const item of pinnedItems ?? []) { + if (item.type !== "feed" || seen.has(item.uri)) continue; + seen.add(item.uri); + suggestedFeeds.push(item.data); + } + for (const feed of popularFeeds ?? []) { + if (seen.has(feed.uri)) continue; + seen.add(feed.uri); + suggestedFeeds.push(feed); + } + } + + const namePlaceholder = `${currentUser ? getDisplayName(currentUser) : "My"}'s Starter Pack`; + + let content; + if (reviewOpen) { + content = reviewTemplate({ + step, + currentUserDid, + profiles, + feeds, + onRemoveProfile: (profile) => this._toggleProfile(profile), + onRemoveFeed: (feed) => this._toggleFeed(feed), + onClose: () => this.state.$reviewOpen.set(false), + }); + } else if (step === "details") { + content = detailsStepTemplate({ + mode: this._mode, + name, + description, + namePlaceholder, + nameTooLong: graphemeCount(name) > MAX_NAME_LENGTH, + descriptionTooLong: graphemeCount(description) > MAX_DESCRIPTION_LENGTH, + onNameInput: (value) => this.state.$name.set(value), + onDescriptionInput: (value) => this.state.$description.set(value), + onNext: () => this._next(), + onClose: async () => { + if (await this.confirmClose()) this.close(); + }, + }); + } else if (step === "profiles") { + content = profilesStepTemplate({ + rawQuery: rawProfileQuery, + query: rawProfileQuery.trim(), + currentUserDid, + searchResults: profileSearchResults + ? profileSearchResults.filter(canBeAdded) + : null, + searchError: !!profileSearchStatus?.error, + suggestedProfiles, + profiles, + optedOutDids, + atCap: profiles.length >= MAX_PROFILES, + saving, + onSearchInput: (value) => this._onProfileSearchInput(value), + onClearSearch: () => this._onProfileSearchInput(""), + onToggle: (profile) => this._toggleProfile(profile), + onBack: () => this._back(), + onOpenReview: () => this.state.$reviewOpen.set(true), + onNext: () => this._next(), + }); + } else { + content = feedsStepTemplate({ + rawQuery: rawFeedQuery, + query: rawFeedQuery.trim(), + currentUserDid, + searchResults: feedSearchResults, + searchError: !!feedSearchStatus?.error, + suggestedFeeds, + feeds, + saving, + onSearchInput: (value) => this._onFeedSearchInput(value), + onClearSearch: () => this._onFeedSearchInput(""), + onToggle: (feed) => this._toggleFeed(feed), + onBack: () => this._back(), + onOpenReview: () => this.state.$reviewOpen.set(true), + onNext: () => this._next(), + }); + } + + render( + html` { + if (event.target !== event.currentTarget) return; + if (await this.confirmClose()) this.close(); + }} + @cancel=${async (event) => { + event.preventDefault(); + if (await this.confirmClose()) this.close(); + }} + @close=${() => { + this.scrollLock?.release(); + this.scrollLock = null; + this.dispatchEvent(new CustomEvent("dialog-closed")); + }} + > +
+ ${content.header} ${content.search} +
+ ${content.results} +
+ ${content.footer} +
+
`, + this, + ); + + if (this._isOpen) { + const dialog = this.querySelector(".starter-pack-wizard-dialog"); + if (dialog && !dialog.open) { + dialog.showModal(); + } + } + } + + open() { + this._isOpen = true; + this.scrollLock ??= scrollLocks.acquire({ target: this }); + const dialog = this.querySelector(".starter-pack-wizard-dialog"); + if (!dialog || dialog.open) return; + dialog.showModal(); + this.querySelector("#starter-pack-name")?.focus({ preventScroll: true }); + enableDragToDismiss(dialog, { + confirmDismiss: () => this.confirmClose(), + onDismiss: () => this.close(), + scrollContainer: () => this.querySelector(".search-dialog-results"), + dragHandle: () => this.querySelector(".search-dialog-header"), + ignoreTouchTarget: (element) => + element.closest("button, input, textarea") !== null, + disableWhenKeyboardOpen: true, + }); + resetScrollOnBlur(dialog, () => + this.querySelector(".search-dialog-results"), + ); + } + + async confirmClose() { + if (this.state.$saving.get()) return false; + if (!this._isDirty) return true; + return confirmModal( + this._mode === "edit" + ? "Are you sure you want to discard your changes?" + : "Are you sure you want to discard this starter pack?", + { + title: + this._mode === "edit" ? "Discard changes?" : "Discard starter pack?", + confirmButtonStyle: "danger", + confirmButtonText: "Discard", + }, + ); + } + + close() { + this._isOpen = false; + return closeWithAnimation( + this.querySelector(".starter-pack-wizard-dialog"), + ); + } +} + +StarterPackWizardDialog.register(); diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index fe701115..783f47d6 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -75,6 +75,8 @@ export class DataStore extends ReactiveStore { this.$listMembers = new SignalMap(); this.$actorFeeds = new SignalMap(); this.$actorLists = new SignalMap(); + this.$actorStarterPacks = new SignalMap(); + this.$popularFeeds = new Signal.State(null); this.$listsWithMembershipByActor = new SignalMap(); this.$hashtagFeeds = new SignalMap(); this.$profileFollowers = new SignalMap(); diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index fc9df88c..cf952965 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -565,6 +565,16 @@ export class Derived extends ReactiveStore { this.$actorLists = new ComputedMap((did) => this.dataStore.$actorLists.get(did), ); + this.$actorStarterPacks = new ComputedMap((did) => + this.dataStore.$actorStarterPacks.get(did), + ); + this.$popularFeeds = new Signal.Computed(() => { + const feeds = this.dataStore.$popularFeeds.get(); + if (!feeds) return null; + return feeds.map( + (feed) => this.dataStore.$feedGenerators.get(feed.uri) ?? feed, + ); + }); this.$listsWithMembershipByActor = new ComputedMap((did) => this.dataStore.$listsWithMembershipByActor.get(did), ); diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 47005acb..31ae3509 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -12,8 +12,14 @@ import { valueForPinnedItem, buildCdnUrl, } from "/js/dataHelpers.js"; -import { batch, getCurrentTimestamp, wait } from "/js/utils.js"; +import { + batch, + getCurrentTimestamp, + truncateGraphemes, + wait, +} from "/js/utils.js"; import { fetchAndCompressLinkCardImage } from "/js/embedHelpers.js"; +import { getFacetsFromText } from "/js/facetHelpers.js"; import { PostCreator } from "/js/postCreator.js"; import { untrack } from "/js/signals.js"; import { generateTid } from "/js/atproto.js"; @@ -35,6 +41,7 @@ export class Mutations { this.patchStore = patchStore; this.preferencesProvider = preferencesProvider; this.draftMediaStore = draftMediaStore; + this.identityResolver = identityResolver; this.postCreator = new PostCreator(api, identityResolver); } @@ -747,19 +754,10 @@ export class Mutations { }); try { await this.api.muteActor(profile.did); - const latestProfile = - this.dataStore.$profiles.get(profile.did) ?? profile; - this.dataStore.$profiles.set(profile.did, { - ...latestProfile, - viewer: { ...latestProfile.viewer, muted: true }, - }); - const detailed = this.dataStore.$detailedProfiles.get(profile.did); - if (detailed) { - this.dataStore.$detailedProfiles.set(profile.did, { - ...detailed, - viewer: { ...detailed.viewer, muted: true }, - }); - } + this._updateStoredProfile(profile, (stored) => ({ + ...stored, + viewer: { ...stored.viewer, muted: true }, + })); this._updatePostsByAuthor(profile.did, (post) => { return { ...post, @@ -795,19 +793,10 @@ export class Mutations { }); try { await this.api.unmuteActor(profile.did); - const latestProfile = - this.dataStore.$profiles.get(profile.did) ?? profile; - this.dataStore.$profiles.set(profile.did, { - ...latestProfile, - viewer: { ...latestProfile.viewer, muted: false }, - }); - const detailed = this.dataStore.$detailedProfiles.get(profile.did); - if (detailed) { - this.dataStore.$detailedProfiles.set(profile.did, { - ...detailed, - viewer: { ...detailed.viewer, muted: false }, - }); - } + this._updateStoredProfile(profile, (stored) => ({ + ...stored, + viewer: { ...stored.viewer, muted: false }, + })); this._updatePostsByAuthor(profile.did, (post) => { return { ...post, @@ -840,19 +829,10 @@ export class Mutations { }); try { const block = await this.api.blockActor(profile); - const latestProfile = - this.dataStore.$profiles.get(profile.did) ?? profile; - this.dataStore.$profiles.set(profile.did, { - ...latestProfile, - viewer: { ...latestProfile.viewer, blocking: block.uri }, - }); - const detailed = this.dataStore.$detailedProfiles.get(profile.did); - if (detailed) { - this.dataStore.$detailedProfiles.set(profile.did, { - ...detailed, - viewer: { ...detailed.viewer, blocking: block.uri }, - }); - } + this._updateStoredProfile(profile, (stored) => ({ + ...stored, + viewer: { ...stored.viewer, blocking: block.uri }, + })); this._updatePostsByAuthor(profile.did, (post) => { return { ...post, @@ -889,19 +869,10 @@ export class Mutations { }); try { await this.api.putActivitySubscription(profile.did, activitySubscription); - const latestProfile = - this.dataStore.$profiles.get(profile.did) ?? profile; - this.dataStore.$profiles.set(profile.did, { - ...latestProfile, - viewer: { ...latestProfile.viewer, activitySubscription }, - }); - const detailed = this.dataStore.$detailedProfiles.get(profile.did); - if (detailed) { - this.dataStore.$detailedProfiles.set(profile.did, { - ...detailed, - viewer: { ...detailed.viewer, activitySubscription }, - }); - } + this._updateStoredProfile(profile, (stored) => ({ + ...stored, + viewer: { ...stored.viewer, activitySubscription }, + })); } catch (error) { console.error(error); throw error; @@ -916,19 +887,10 @@ export class Mutations { }); try { await this.api.unblockActor(profile); - const latestProfile = - this.dataStore.$profiles.get(profile.did) ?? profile; - this.dataStore.$profiles.set(profile.did, { - ...latestProfile, - viewer: { ...latestProfile.viewer, blocking: null }, - }); - const detailed = this.dataStore.$detailedProfiles.get(profile.did); - if (detailed) { - this.dataStore.$detailedProfiles.set(profile.did, { - ...detailed, - viewer: { ...detailed.viewer, blocking: null }, - }); - } + this._updateStoredProfile(profile, (stored) => ({ + ...stored, + viewer: { ...stored.viewer, blocking: null }, + })); this._updatePostsByAuthor(profile.did, (post) => { return { ...post, @@ -1119,24 +1081,7 @@ export class Mutations { patch.banner = ""; } - const existingProfile = this.dataStore.$profiles.get(profile.did); - if (existingProfile) { - this.dataStore.$profiles.set(profile.did, { - ...existingProfile, - ...patch, - }); - } - const existingDetailed = this.dataStore.$detailedProfiles.get(profile.did); - if (existingDetailed) { - this.dataStore.$detailedProfiles.set(profile.did, { - ...existingDetailed, - ...patch, - }); - } - const currentUser = this.dataStore.$currentUser.get(); - if (currentUser && currentUser.did === profile.did) { - this.dataStore.$currentUser.set({ ...currentUser, ...patch }); - } + this._updateCurrentUserProfile((existing) => ({ ...existing, ...patch })); } async setLiveStatus({ @@ -1319,17 +1264,21 @@ export class Mutations { this.dataStore.$lists.set(list.uri, patched); } - async deleteList(list) { - const { rkey } = parseUri(list.uri); - const listItemUris = []; + // Scans the current user's listitem records on the PDS for the ones + // belonging to the given list. Returns [{ rkey, subjectDid }]. + async _findListItemRecords(listUri) { + const records = []; let cursor = ""; const MAX_PAGES = 100; let hitCap = true; for (let i = 0; i < MAX_PAGES; i++) { const res = await this.api.getListItems({ cursor, limit: 100 }); for (const record of res.records) { - if (record.value?.list === list.uri) { - listItemUris.push(record.uri); + if (record.value?.list === listUri) { + records.push({ + rkey: parseUri(record.uri).rkey, + subjectDid: record.value.subject, + }); } } cursor = res.cursor; @@ -1340,14 +1289,20 @@ export class Mutations { } if (hitCap) { console.warn( - `deleteList: stopped scanning listitems after ${MAX_PAGES} pages`, + `_findListItemRecords: stopped scanning listitems after ${MAX_PAGES} pages`, ); } + return records; + } + + async deleteList(list) { + const { rkey } = parseUri(list.uri); + const listItemRecords = await this._findListItemRecords(list.uri); const writes = [ - ...listItemUris.map((uri) => ({ + ...listItemRecords.map((item) => ({ $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.listitem", - rkey: parseUri(uri).rkey, + rkey: item.rkey, })), { $type: "com.atproto.repo.applyWrites#delete", @@ -1401,6 +1356,427 @@ export class Mutations { } } + async _buildStarterPackDescription(description) { + const trimmed = (description ?? "").trim(); + if (!trimmed) return { description: null, descriptionFacets: null }; + const facets = await getFacetsFromText(trimmed, this.identityResolver); + return { + description: trimmed, + descriptionFacets: facets.length > 0 ? facets : null, + }; + } + + _buildStarterPackName(name, currentUser) { + const trimmed = (name ?? "").trim(); + if (trimmed) return trimmed; + const fallback = `${currentUser.displayName || currentUser.handle}'s Starter Pack`; + return truncateGraphemes(fallback, 50); + } + + _listItemCreateWrite({ listUri, did }) { + return { + $type: "com.atproto.repo.applyWrites#create", + collection: "app.bsky.graph.listitem", + rkey: generateTid(), + value: { + $type: "app.bsky.graph.listitem", + subject: did, + list: listUri, + createdAt: getCurrentTimestamp(), + }, + }; + } + + _listItemUri(rkey) { + return `at://${this.api.session.did}/app.bsky.graph.listitem/${rkey}`; + } + + async createStarterPack({ name, description, profiles, feeds }) { + const currentUser = untrack(() => this.dataStore.$currentUser.get()); + if (!currentUser) throw new Error("No current user"); + const resolvedName = this._buildStarterPackName(name, currentUser); + const resolvedDescription = + await this._buildStarterPackDescription(description); + const createdAt = getCurrentTimestamp(); + const did = currentUser.did; + + const listRkey = generateTid(); + const listUri = `at://${did}/app.bsky.graph.list/${listRkey}`; + const listRecord = { + $type: "app.bsky.graph.list", + purpose: "app.bsky.graph.defs#referencelist", + name: resolvedName, + createdAt, + }; + if (resolvedDescription.description) { + listRecord.description = resolvedDescription.description; + if (resolvedDescription.descriptionFacets) { + listRecord.descriptionFacets = resolvedDescription.descriptionFacets; + } + } + + const listItemWrites = profiles.map((profile) => + this._listItemCreateWrite({ listUri, did: profile.did }), + ); + + const packRkey = generateTid(); + const packUri = `at://${did}/app.bsky.graph.starterpack/${packRkey}`; + const packRecord = { + $type: "app.bsky.graph.starterpack", + name: resolvedName, + list: listUri, + createdAt, + }; + if (resolvedDescription.description) { + packRecord.description = resolvedDescription.description; + if (resolvedDescription.descriptionFacets) { + packRecord.descriptionFacets = resolvedDescription.descriptionFacets; + } + } + if (feeds.length > 0) { + packRecord.feeds = feeds.map((feed) => ({ uri: feed.uri })); + } + + const writes = [ + { + $type: "com.atproto.repo.applyWrites#create", + collection: "app.bsky.graph.list", + rkey: listRkey, + value: listRecord, + }, + ...listItemWrites, + { + $type: "com.atproto.repo.applyWrites#create", + collection: "app.bsky.graph.starterpack", + rkey: packRkey, + value: packRecord, + }, + ]; + const res = await this.api.applyWrites(writes); + const results = res?.results ?? []; + const listCid = results[0]?.cid ?? null; + const packCid = results[writes.length - 1]?.cid ?? null; + + const creator = { + did: currentUser.did, + handle: currentUser.handle, + displayName: currentUser.displayName, + avatar: currentUser.avatar, + }; + const listView = { + $type: "app.bsky.graph.defs#listViewBasic", + uri: listUri, + cid: listCid, + name: resolvedName, + purpose: "app.bsky.graph.defs#referencelist", + listItemCount: profiles.length, + indexedAt: createdAt, + viewer: {}, + }; + const items = listItemWrites.map((write, i) => ({ + uri: this._listItemUri(write.rkey), + subject: profiles[i], + })); + const starterPackView = { + $type: "app.bsky.graph.defs#starterPackView", + uri: packUri, + cid: packCid, + record: packRecord, + creator, + list: listView, + listItemsSample: items.slice(0, 12), + feeds, + joinedWeekCount: 0, + joinedAllTimeCount: 0, + indexedAt: createdAt, + }; + + this.dataStore.$starterPacks.set(packUri, starterPackView); + this.dataStore.$starterPackUrisByList.set(listUri, packUri); + this.dataStore.$listMembers.set(listUri, { items, cursor: null }); + const actorStarterPacks = untrack(() => + this.dataStore.$actorStarterPacks.get(did), + ); + if (actorStarterPacks) { + this.dataStore.$actorStarterPacks.set(did, { + ...actorStarterPacks, + starterPacks: [starterPackView, ...actorStarterPacks.starterPacks], + }); + } + this._updateCurrentUserProfile((profile) => + profile.associated + ? { + ...profile, + associated: { + ...profile.associated, + starterPacks: (profile.associated.starterPacks ?? 0) + 1, + lists: (profile.associated.lists ?? 0) + 1, + }, + } + : profile, + ); + return starterPackView; + } + + async updateStarterPack(starterPack, { name, description, profiles, feeds }) { + const currentUser = untrack(() => this.dataStore.$currentUser.get()); + if (!currentUser) throw new Error("No current user"); + if (starterPack.creator.did !== currentUser.did) { + throw new Error("Cannot edit a starter pack owned by another account"); + } + const listUri = starterPack.list?.uri; + if (!listUri) throw new Error("Starter pack has no list"); + const existingMembers = untrack(() => + this.dataStore.$listMembers.get(listUri), + ); + const optedOutItems = (existingMembers?.items ?? []).filter( + (item) => item.subjectOptedOut, + ); + const optedOutDids = new Set(optedOutItems.map((item) => item.subject.did)); + + const resolvedName = this._buildStarterPackName(name, currentUser); + const resolvedDescription = + await this._buildStarterPackDescription(description); + + const selectedDids = new Set(profiles.map((profile) => profile.did)); + const existingRecords = await this._findListItemRecords(listUri); + const rkeysByDid = new Map(); + for (const record of existingRecords) { + const rkeys = rkeysByDid.get(record.subjectDid) ?? []; + rkeys.push(record.rkey); + rkeysByDid.set(record.subjectDid, rkeys); + } + + const deleteWrites = []; + for (const [did, rkeys] of rkeysByDid) { + const keep = + selectedDids.has(did) || + did === currentUser.did || + optedOutDids.has(did); + const surplus = keep ? rkeys.slice(1) : rkeys; + for (const rkey of surplus) { + deleteWrites.push({ + $type: "com.atproto.repo.applyWrites#delete", + collection: "app.bsky.graph.listitem", + rkey, + }); + } + } + const createWrites = profiles + .filter((profile) => !rkeysByDid.has(profile.did)) + .map((profile) => + this._listItemCreateWrite({ listUri, did: profile.did }), + ); + + for (const chunk of batch(deleteWrites, 50)) { + await this.api.applyWrites(chunk); + } + for (const chunk of batch(createWrites, 50)) { + await this.api.applyWrites(chunk); + } + + const listRkey = parseUri(listUri).rkey; + const listRecordData = await this.api.getListRecord(listRkey); + const listRecord = { ...listRecordData.value, name: resolvedName }; + delete listRecord.description; + delete listRecord.descriptionFacets; + if (resolvedDescription.description) { + listRecord.description = resolvedDescription.description; + if (resolvedDescription.descriptionFacets) { + listRecord.descriptionFacets = resolvedDescription.descriptionFacets; + } + } + await this.api.putListRecord(listRkey, listRecord, listRecordData.cid); + + const packRkey = parseUri(starterPack.uri).rkey; + const packRecordData = await this.api.getStarterPackRecord(packRkey); + const packRecord = { + ...packRecordData.value, + name: resolvedName, + updatedAt: getCurrentTimestamp(), + }; + delete packRecord.description; + delete packRecord.descriptionFacets; + delete packRecord.feeds; + if (resolvedDescription.description) { + packRecord.description = resolvedDescription.description; + if (resolvedDescription.descriptionFacets) { + packRecord.descriptionFacets = resolvedDescription.descriptionFacets; + } + } + if (feeds.length > 0) { + packRecord.feeds = feeds.map((feed) => ({ uri: feed.uri })); + } + const putRes = await this.api.putStarterPackRecord( + packRkey, + packRecord, + packRecordData.cid, + ); + + const createdRkeyByDid = new Map( + createWrites.map((write) => [write.value.subject, write.rkey]), + ); + const items = [ + ...profiles.map((profile) => ({ + uri: this._listItemUri( + rkeysByDid.get(profile.did)?.[0] ?? createdRkeyByDid.get(profile.did), + ), + subject: profile, + })), + ...optedOutItems, + ]; + const current = + untrack(() => this.dataStore.$starterPacks.get(starterPack.uri)) ?? + starterPack; + const patchedList = { + ...current.list, + name: resolvedName, + listItemCount: items.length, + }; + this.dataStore.$starterPacks.set(starterPack.uri, { + ...current, + cid: putRes?.cid ?? current.cid, + record: packRecord, + list: patchedList, + listItemsSample: items.slice(0, 12), + feeds, + }); + this.dataStore.$listMembers.set(listUri, { items, cursor: null }); + const list = untrack(() => this.dataStore.$lists.get(listUri)); + if (list) { + this.dataStore.$lists.set(listUri, { + ...list, + name: resolvedName, + description: resolvedDescription.description ?? "", + descriptionFacets: resolvedDescription.descriptionFacets ?? [], + listItemCount: items.length, + }); + } + const actorStarterPacks = untrack(() => + this.dataStore.$actorStarterPacks.get(currentUser.did), + ); + if (actorStarterPacks) { + this.dataStore.$actorStarterPacks.set(currentUser.did, { + ...actorStarterPacks, + starterPacks: actorStarterPacks.starterPacks.map((entry) => + entry.uri === starterPack.uri + ? { ...entry, record: packRecord, listItemCount: items.length } + : entry, + ), + }); + } + } + + async deleteStarterPack(starterPack) { + const { rkey } = parseUri(starterPack.uri); + const listUri = starterPack.list?.uri ?? null; + const writes = []; + if (listUri) { + const listItemRecords = await this._findListItemRecords(listUri); + writes.push( + ...listItemRecords.map((item) => ({ + $type: "com.atproto.repo.applyWrites#delete", + collection: "app.bsky.graph.listitem", + rkey: item.rkey, + })), + { + $type: "com.atproto.repo.applyWrites#delete", + collection: "app.bsky.graph.list", + rkey: parseUri(listUri).rkey, + }, + ); + } + writes.push({ + $type: "com.atproto.repo.applyWrites#delete", + collection: "app.bsky.graph.starterpack", + rkey, + }); + for (const chunk of batch(writes, 50)) { + await this.api.applyWrites(chunk); + } + + this.dataStore.$starterPacks.set(starterPack.uri, null); + if (listUri) { + this.dataStore.$starterPackUrisByList.set(listUri, null); + this.dataStore.$lists.set(listUri, null); + this.dataStore.$listMembers.set(listUri, null); + } + const creatorDid = starterPack.creator?.did ?? null; + if (creatorDid) { + const actorStarterPacks = untrack(() => + this.dataStore.$actorStarterPacks.get(creatorDid), + ); + if (actorStarterPacks) { + this.dataStore.$actorStarterPacks.set(creatorDid, { + ...actorStarterPacks, + starterPacks: actorStarterPacks.starterPacks.filter( + (entry) => entry.uri !== starterPack.uri, + ), + }); + } + if (listUri) { + const actorLists = untrack(() => + this.dataStore.$actorLists.get(creatorDid), + ); + if (actorLists) { + this.dataStore.$actorLists.set(creatorDid, { + ...actorLists, + lists: actorLists.lists.filter((entry) => entry.uri !== listUri), + }); + } + } + } + const searchResults = untrack(() => + this.dataStore.$starterPackSearchResults.get(), + ); + if (searchResults) { + this.dataStore.$starterPackSearchResults.set({ + ...searchResults, + starterPacks: searchResults.starterPacks.filter( + (entry) => entry.uri !== starterPack.uri, + ), + }); + } + const listDelta = listUri ? 1 : 0; + this._updateCurrentUserProfile((profile) => + profile.associated + ? { + ...profile, + associated: { + ...profile.associated, + starterPacks: Math.max( + 0, + (profile.associated.starterPacks ?? 0) - 1, + ), + lists: Math.max(0, (profile.associated.lists ?? 0) - listDelta), + }, + } + : profile, + ); + } + + // A profile lives in $profiles and, once opened, $detailedProfiles; the + // current user's is also mirrored in $currentUser. Apply a change to each + _updateStoredProfile(profile, update) { + const { did } = profile; + const stored = untrack(() => this.dataStore.$profiles.get(did)) ?? profile; + this.dataStore.$profiles.set(did, update(stored)); + const detailed = untrack(() => this.dataStore.$detailedProfiles.get(did)); + if (detailed) { + this.dataStore.$detailedProfiles.set(did, update(detailed)); + } + const currentUser = untrack(() => this.dataStore.$currentUser.get()); + if (currentUser?.did === did) { + this.dataStore.$currentUser.set(update(currentUser)); + } + } + + _updateCurrentUserProfile(update) { + const currentUser = untrack(() => this.dataStore.$currentUser.get()); + if (!currentUser) return; + this._updateStoredProfile(currentUser, update); + } + async pinPost(post) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); @@ -1426,13 +1802,10 @@ export class Mutations { swapCid, ); // Commit to dataStore - const latestUser = this.dataStore.$currentUser.get(); - if (latestUser) { - this.dataStore.$currentUser.set({ - ...latestUser, - pinnedPost: pinnedRef, - }); - } + this._updateCurrentUserProfile((user) => ({ + ...user, + pinnedPost: pinnedRef, + })); const existingFeed = this.dataStore.$authorFeeds.get(authorFeedURI); if (existingFeed) { this.dataStore.$authorFeeds.set(authorFeedURI, { @@ -1470,11 +1843,7 @@ export class Mutations { const { pinnedPost: _, ...updatedRecord } = existingRecord; await this.api.putProfileRecord(updatedRecord, swapCid); // Commit to dataStore - const latestUser = this.dataStore.$currentUser.get(); - if (latestUser) { - const { pinnedPost: _, ...rest } = latestUser; - this.dataStore.$currentUser.set(rest); - } + this._updateCurrentUserProfile(({ pinnedPost: _, ...rest }) => rest); const existingFeed = this.dataStore.$authorFeeds.get(authorFeedURI); if (existingFeed) { this.dataStore.$authorFeeds.set(authorFeedURI, { diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index f283919c..461967c1 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -275,6 +275,11 @@ export class Requests { this.loadStarterPackUriForList, (list) => "loadStarterPackUriForList-" + list.uri, ); + this.enableStatus( + this.loadActorStarterPacks, + (did) => "loadActorStarterPacks-" + did, + ); + this.enableStatus(this.loadPopularFeeds, "loadPopularFeeds"); this.enableStatus( this.loadAllListMembers, (listUri) => "loadAllListMembers-" + listUri, @@ -1405,6 +1410,42 @@ export class Requests { }); } + async loadActorStarterPacks(did, { reload = false, limit = 50 } = {}) { + const existing = this.dataStore.$actorStarterPacks.get(did); + if (existing && !existing.cursor && !reload) { + return; + } + const cursor = reload + ? "" + : readCollectionCursor(this.dataStore.$actorStarterPacks, { key: did }); + const data = await this.api.getActorStarterPacks(did, { limit, cursor }); + for (const starterPack of data.starterPacks) { + const existingPack = this.dataStore.$starterPacks.get(starterPack.uri); + if (!existingPack?.list) { + this.dataStore.$starterPacks.set(starterPack.uri, starterPack); + } + } + writePageToCollection( + this.dataStore.$actorStarterPacks, + "starterPacks", + data, + { + key: did, + requestCursor: cursor, + overwrite: reload, + }, + ); + } + + async loadPopularFeeds({ limit = 30 } = {}) { + const data = await this.api.getPopularFeedGenerators({ limit }); + const feeds = data.feeds ?? []; + for (const feed of feeds) { + this.dataStore.$feedGenerators.set(feed.uri, feed); + } + this.dataStore.$popularFeeds.set(feeds); + } + async loadCurrentUserLists({ reload = false } = {}) { if (!this.api.isAuthenticated) return; await this.loadActorLists(this.api.session.did, { reload }); diff --git a/src/js/dialogHelpers.js b/src/js/dialogHelpers.js index fc755d1e..e7469c6a 100644 --- a/src/js/dialogHelpers.js +++ b/src/js/dialogHelpers.js @@ -72,7 +72,9 @@ export function resetScrollOnBlur(dialog, scrollArea) { ) { return; } - if (scrollArea) scrollArea.scrollTop = 0; + const scrollElement = + typeof scrollArea === "function" ? scrollArea() : scrollArea; + if (scrollElement) scrollElement.scrollTop = 0; window.scrollTo(0, 0); }, true, diff --git a/src/js/dragHelpers.js b/src/js/dragHelpers.js index f3abd55c..89f999a0 100644 --- a/src/js/dragHelpers.js +++ b/src/js/dragHelpers.js @@ -18,6 +18,14 @@ function hasTextSelection() { return selection !== null && !selection.isCollapsed; } +// Element options accept a node or a getter that's re-evaluated on every +// touch, for dialogs whose scroll container is re-rendered over time. +function resolveElement(elementOrGetter) { + return typeof elementOrGetter === "function" + ? elementOrGetter() + : elementOrGetter; +} + // Low-level touch drag tracker. Reports raw signed delta along the given axis. function trackDrag( target, @@ -68,15 +76,17 @@ function trackDrag( }; const handleTouchStart = (e) => { - const onHandle = !!dragHandle && dragHandle.contains(e.target); + const handleElement = resolveElement(dragHandle); + const onHandle = !!handleElement && handleElement.contains(e.target); if (!onHandle && disableWhenKeyboardOpen && isKeyboardOpen()) return; if (ignoreTouchTarget(e.target)) return; if (hasTextSelection()) return; + const scrollElement = resolveElement(scrollContainer); if ( !onHandle && - scrollContainer && - scrollContainer.contains(e.target) && - scrollContainer.scrollTop > 0 + scrollElement && + scrollElement.contains(e.target) && + scrollElement.scrollTop > 0 ) { return; } @@ -127,7 +137,7 @@ function trackDrag( // If the caller can't consume this move (e.g. dragging up on a // dismiss-down sheet with no stretch) and there's a scroll container, // release the gesture back to native scroll instead of preventing default. - if (consumed === false && scrollContainer) { + if (consumed === false && resolveElement(scrollContainer)) { state.tracking = false; state.locked = false; onEnd({ deltaPx: 0, velocity: 0, cancelled: true }); diff --git a/src/js/interactionHandlers.js b/src/js/interactionHandlers.js index 400f0239..b1851f50 100644 --- a/src/js/interactionHandlers.js +++ b/src/js/interactionHandlers.js @@ -2,6 +2,7 @@ import { PostInteractionHandler } from "/js/postInteractionHandler.js"; import { ProfileInteractionHandler } from "/js/profileInteractionHandler.js"; import { FeedInteractionHandler } from "/js/feedInteractionHandler.js"; import { ListInteractionHandler } from "/js/listInteractionHandler.js"; +import { StarterPackInteractionHandler } from "/js/starterPackInteractionHandler.js"; function loggedOutHandler(name) { return new Proxy( @@ -32,5 +33,8 @@ export class InteractionHandlers { this.listInteractionHandler = session ? new ListInteractionHandler(dataLayer) : loggedOutHandler("listInteractionHandler"); + this.starterPackInteractionHandler = session + ? new StarterPackInteractionHandler(dataLayer) + : loggedOutHandler("starterPackInteractionHandler"); } } diff --git a/src/js/listInteractionHandler.js b/src/js/listInteractionHandler.js index 97915d01..fff33e21 100644 --- a/src/js/listInteractionHandler.js +++ b/src/js/listInteractionHandler.js @@ -87,53 +87,6 @@ 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/modals/profileList.modal.js b/src/js/modals/profileList.modal.js index 9405b4ce..95dc8bc9 100644 --- a/src/js/modals/profileList.modal.js +++ b/src/js/modals/profileList.modal.js @@ -1,6 +1,6 @@ import { html } from "/js/lib/lit-html.js"; import { Modal } from "/js/modals/modal.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; class ProfileListModal extends Modal { get className() { @@ -33,7 +33,7 @@ class ProfileListModal extends Modal { ` : null}
- ${profileFeedTemplate({ + ${profileListTemplate({ profiles, hasMore: false, skeletonCount: 0, diff --git a/src/js/starterPackInteractionHandler.js b/src/js/starterPackInteractionHandler.js new file mode 100644 index 00000000..56b40653 --- /dev/null +++ b/src/js/starterPackInteractionHandler.js @@ -0,0 +1,78 @@ +import { hapticsImpactMedium } from "/js/haptics.js"; +import { showToast } from "/js/toasts.js"; +import { confirmModal } from "/js/modals/confirm.modal.js"; + +export class StarterPackInteractionHandler { + constructor(dataLayer) { + this.dataLayer = dataLayer; + } + + async handleOptOut(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 handleUndoOptOut(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 handleDelete(starterPack) { + return await confirmModal( + "This starter pack will be permanently deleted. This action cannot be undone.", + { + title: "Delete this starter pack?", + confirmButtonText: "Delete", + pendingText: "Deleting…", + confirmButtonStyle: "danger", + onConfirm: async () => { + try { + await this.dataLayer.mutations.deleteStarterPack(starterPack); + showToast("Starter pack deleted"); + } catch (error) { + console.error(error); + showToast("Failed to delete starter pack", { style: "error" }); + throw error; + } + }, + }, + ); + } +} diff --git a/src/js/templates/feedGeneratorList.template.js b/src/js/templates/feedGeneratorList.template.js new file mode 100644 index 00000000..04122eef --- /dev/null +++ b/src/js/templates/feedGeneratorList.template.js @@ -0,0 +1,26 @@ +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; +import { feedGeneratorListItemTemplate } from "/js/templates/feedGeneratorListItem.template.js"; + +export function feedGeneratorListTemplate({ + feedGenerators, + cursor = null, + currentUserDid, + onLoadMore = null, + emptyMessage = "No feeds.", + showDescription = false, + rightItemTemplate = null, +}) { + return paginatedListTemplate({ + items: feedGenerators, + renderItem: (feedGenerator) => + feedGeneratorListItemTemplate({ + feedGenerator, + currentUserDid, + showDescription, + rightItemTemplate, + }), + hasMore: !!cursor, + onLoadMore, + emptyMessage, + }); +} diff --git a/src/js/templates/feedGeneratorListItem.template.js b/src/js/templates/feedGeneratorListItem.template.js index 461582d7..113ed714 100644 --- a/src/js/templates/feedGeneratorListItem.template.js +++ b/src/js/templates/feedGeneratorListItem.template.js @@ -6,6 +6,8 @@ import "/js/components/container-link.js"; export function feedGeneratorListItemTemplate({ feedGenerator, currentUserDid, + showDescription = false, + rightItemTemplate = null, }) { return html` ` : ""} + ${showDescription && feedGenerator.description + ? // prettier-ignore + html`
${feedGenerator.description}
` + : ""}
+ ${rightItemTemplate + ? html`
+ ${rightItemTemplate(feedGenerator)} +
` + : ""} `; } diff --git a/src/js/templates/feedsFeed.template.js b/src/js/templates/feedsFeed.template.js deleted file mode 100644 index c535e967..00000000 --- a/src/js/templates/feedsFeed.template.js +++ /dev/null @@ -1,57 +0,0 @@ -import { html } from "/js/lib/lit-html.js"; - -export function feedsListItemSkeletonTemplate() { - return html` -
-
-
-
-
-
-
-
-
- `; -} - -export function feedsFeedTemplate({ - items, - renderItem, - emptyMessage, - hasMore = false, - onLoadMore, - isEditing = false, -}) { - if (!items) { - return html`
- ${Array.from({ length: 10 }).map(() => feedsListItemSkeletonTemplate())} -
`; - } - const list = html`
- ${items.length === 0 && emptyMessage - ? html`
${emptyMessage}
` - : items.map((item) => renderItem(item))} - ${hasMore ? html`
` : ""} -
`; - if (!onLoadMore) return list; - return html` - { - if (hasMore) { - await onLoadMore(); - event.detail.resume(); - } - }} - > - ${list} - - `; -} diff --git a/src/js/templates/listFeed.template.js b/src/js/templates/listFeed.template.js deleted file mode 100644 index e0cf4ac9..00000000 --- a/src/js/templates/listFeed.template.js +++ /dev/null @@ -1,77 +0,0 @@ -import { html } from "/js/lib/lit-html.js"; -import { cdnImageUrl, isModerationList } from "/js/dataHelpers.js"; -import { linkToList } from "/js/navigation.js"; -import "/js/components/container-link.js"; - -function listItemSkeletonTemplate() { - return html` -
-
-
-
-
-
-
-
-
- `; -} - -function listItemTemplate({ list }) { - return html` - -
- ${list.name} -
-
-
${list.name}
- ${list.creator - ? html`
- ${isModerationList(list) ? "Moderation list" : "List"} by - @${list.creator.handle} -
` - : ""} -
-
- `; -} - -export function listFeedTemplate({ lists, cursor, onLoadMore }) { - if (!lists) { - return html`
- ${Array.from({ length: 10 }).map(() => listItemSkeletonTemplate())} -
`; - } - const hasMore = !!cursor; - const list = html`
- ${lists.length === 0 - ? html`
No lists.
` - : lists.map((item) => listItemTemplate({ list: item }))} - ${hasMore ? html`
` : ""} -
`; - if (!onLoadMore) return list; - return html` - { - if (hasMore) { - await onLoadMore(); - event.detail.resume(); - } - }} - > - ${list} - - `; -} diff --git a/src/js/templates/listList.template.js b/src/js/templates/listList.template.js new file mode 100644 index 00000000..cb5dc114 --- /dev/null +++ b/src/js/templates/listList.template.js @@ -0,0 +1,42 @@ +import { html } from "/js/lib/lit-html.js"; +import { cdnImageUrl, isModerationList } from "/js/dataHelpers.js"; +import { linkToList } from "/js/navigation.js"; +import "/js/components/container-link.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; + +function listItemTemplate({ list }) { + return html` + +
+ ${list.name} +
+
+
${list.name}
+ ${list.creator + ? html`
+ ${isModerationList(list) ? "Moderation list" : "List"} by + @${list.creator.handle} +
` + : ""} +
+
+ `; +} + +export function listListTemplate({ lists, cursor, onLoadMore }) { + return paginatedListTemplate({ + items: lists, + renderItem: (list) => listItemTemplate({ list }), + hasMore: !!cursor, + onLoadMore, + emptyMessage: "No lists.", + }); +} diff --git a/src/js/templates/paginatedList.template.js b/src/js/templates/paginatedList.template.js new file mode 100644 index 00000000..997b308b --- /dev/null +++ b/src/js/templates/paginatedList.template.js @@ -0,0 +1,86 @@ +import { html } from "/js/lib/lit-html.js"; +import "/js/components/infinite-scroll-container.js"; + +function paginatedListItemSkeletonTemplate() { + return html` +
+
+
+
+
+
+
+
+
+ `; +} + +function emptyListMessageTemplate(message) { + return html`
+ ${message} +
`; +} + +function endListMessageTemplate(message) { + return html`
+ ${message} +
`; +} + +export function paginatedListTemplate({ + items, + renderItem, + renderSkeletonItem = paginatedListItemSkeletonTemplate, + skeletonCount = 10, + hasMore = false, + onLoadMore = null, + emptyMessage = null, + emptyTemplate = null, + endMessage = null, + footerTemplate = null, + errorTemplate = null, + lookahead = "2500px", + containerClass = "feeds-list", + containerTestId = "feeds-list", +}) { + if (!items) { + return html`
+ ${Array.from({ length: skeletonCount }).map(() => renderSkeletonItem())} +
`; + } + const trailing = errorTemplate + ? errorTemplate + : hasMore + ? html`
+
+
` + : (footerTemplate ?? + (endMessage ? endListMessageTemplate(endMessage) : "")); + const list = html`
+ ${items.length === 0 + ? (emptyTemplate ?? + (emptyMessage ? emptyListMessageTemplate(emptyMessage) : "")) + : html`${items.map(renderItem)}${trailing}`} +
`; + if (!onLoadMore) return list; + return html` + { + if (hasMore) { + await onLoadMore(); + event.detail.resume(); + } + }} + > + ${list} + + `; +} diff --git a/src/js/templates/postFeed.template.js b/src/js/templates/postFeed.template.js index 576e5742..d766ed51 100644 --- a/src/js/templates/postFeed.template.js +++ b/src/js/templates/postFeed.template.js @@ -5,6 +5,7 @@ import { postSkeletonTemplate } from "/js/templates/postSkeleton.template.js"; import { linkToPost } from "/js/navigation.js"; import { isEmptyPost, isBlockedPost } from "/js/dataHelpers.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; // Posts likely to be above the fold on load; lazy load the rest const EAGER_IMAGE_ITEM_COUNT = 5; @@ -209,14 +210,6 @@ function feedItemTemplate({ `; } -function feedSkeletonTemplate() { - return html`
- ${Array.from({ length: 10 }).map((_, index) => { - return postSkeletonTemplate(); - })} -
`; -} - function feedLoadMoreErrorTemplate({ onRetry }) { return html`
-
- ${emptyMessage ?? "This feed is empty."} -
+ const renderItem = (feedItem, index) => + html`
+ ${keyed( + feedItem.post.uri, + feedItemTemplate({ + feedItem, + currentUser, + isAuthenticated, + feedGenerator, + hiddenPostUris, + postInteractionHandler, + onClickShowLess, + onClickShowMore, + enableFeedFeedback, + pluginService, + lazyLoadImages: index >= EAGER_IMAGE_ITEM_COUNT, + }), + )}
`; - } - const hasMore = !!feed.cursor; try { - return html` - { - if (hasMore && onLoadMore) { - await onLoadMore(); - e.detail.resume(); - } - }} - > -
- ${feed.feed.map((feedItem, i) => { - // data attributes are used by post seen observer - const content = html`
- ${keyed( - feedItem.post.uri, - feedItemTemplate({ - feedItem, - currentUser, - isAuthenticated, - feedGenerator, - hiddenPostUris, - postInteractionHandler, - onClickShowLess, - onClickShowMore, - enableFeedFeedback, - pluginService, - lazyLoadImages: i >= EAGER_IMAGE_ITEM_COUNT, - }), - )} -
`; - if (i < feed.feed.length - 1) { - return content; - } - const endingElement = loadMoreError - ? feedLoadMoreErrorTemplate({ onRetry: onLoadMore }) - : hasMore - ? html`
-
-
` - : showEndMessage - ? html`
- End of feed -
` - : null; - return html`
${content}${endingElement}
`; - })} -
-
- `; + return paginatedListTemplate({ + items: feed?.feed ?? null, + renderItem, + renderSkeletonItem: postSkeletonTemplate, + hasMore: !!feed?.cursor, + onLoadMore, + emptyMessage: emptyMessage ?? "This feed is empty.", + endMessage: showEndMessage ? "End of feed" : null, + errorTemplate: loadMoreError + ? feedLoadMoreErrorTemplate({ onRetry: onLoadMore }) + : null, + containerClass: "feed", + containerTestId: "feed", + }); } catch (error) { console.error(error); return html`
diff --git a/src/js/templates/profileFeed.template.js b/src/js/templates/profileList.template.js similarity index 82% rename from src/js/templates/profileFeed.template.js rename to src/js/templates/profileList.template.js index dde24249..49b631ce 100644 --- a/src/js/templates/profileFeed.template.js +++ b/src/js/templates/profileList.template.js @@ -1,4 +1,5 @@ import { html } from "/js/lib/lit-html.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { linkToProfile } from "/js/navigation.js"; import "/js/components/app-icon.js"; @@ -203,7 +204,7 @@ export function profileListItemSkeletonTemplate({ compact = false } = {}) {
`; } -export function profileFeedTemplate({ +export function profileListTemplate({ profiles, hasMore, onLoadMore, @@ -221,62 +222,34 @@ export function profileFeedTemplate({ }) { if (!compact && !pluginService) { console.warn( - "profileFeedTemplate: non-compact feed rendered without a pluginService β€” plugin author badges won't render", + "profileListTemplate: non-compact feed rendered without a pluginService β€” plugin author badges won't render", ); } - if (!profiles) { - return html`
- ${Array.from({ length: skeletonCount }).map(() => - profileListItemSkeletonTemplate({ compact }), - )} -
`; - } - if (!isAuthenticated) { + if (profiles && !isAuthenticated) { profiles = profiles.filter( (profile) => !doHideAuthorOnUnauthenticated(profile), ); } - if (profiles.length === 0) { - return html`
- ${emptyMessage ?? "No profiles to show."} -
`; - } const disabledSet = disabledProfiles ? new Set(disabledProfiles) : null; - return html` { - if (hasMore && onLoadMore) { - await onLoadMore(); - event.detail.resume(); - } - }} - > -
- ${profiles.map((profile) => - profileListItemTemplate({ - actor: profile, - isAuthenticated, - currentUserDid, - profileInteractionHandler, - pluginService, - rightItemTemplate, - clickAction, - compact, - isDisabled: disabledSet ? disabledSet.has(profile.did) : false, - }), - )} -
- ${hasMore - ? html`
-
-
` - : showEndMessage - ? html`
- End of feed -
` - : null} -
`; + return paginatedListTemplate({ + items: profiles, + renderItem: (profile) => + profileListItemTemplate({ + actor: profile, + isAuthenticated, + currentUserDid, + profileInteractionHandler, + pluginService, + rightItemTemplate, + clickAction, + compact, + isDisabled: disabledSet ? disabledSet.has(profile.did) : false, + }), + renderSkeletonItem: () => profileListItemSkeletonTemplate({ compact }), + skeletonCount, + hasMore, + onLoadMore, + emptyMessage: emptyMessage ?? "No profiles to show.", + endMessage: showEndMessage ? "End of feed" : null, + }); } diff --git a/src/js/templates/starterPackList.template.js b/src/js/templates/starterPackList.template.js new file mode 100644 index 00000000..7aea84b6 --- /dev/null +++ b/src/js/templates/starterPackList.template.js @@ -0,0 +1,129 @@ +import { html, keyed } from "/js/lib/lit-html.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; +import { avatarTemplate } from "/js/templates/avatar.template.js"; +import { linkToStarterPack } from "/js/navigation.js"; +import "/js/components/container-link.js"; + +function starterPackListItemTemplate({ + starterPack, + currentUser, + testId = "starter-pack-list-item", +}) { + const { record, creator } = starterPack; + const isOwner = currentUser?.did === creator.did; + const sampleProfiles = (starterPack.listItemsSample ?? []) + .slice(0, 8) + .map((item) => item.subject); + const remainingCount = + (starterPack.listItemCount ?? 0) - sampleProfiles.length; + return html` +
+ ${record.name} +
+
+
${record.name}
+
+ Starter pack by ${isOwner ? "you" : html`@${creator.handle}`} +
+ ${record.description + ? // prettier-ignore + html`
${record.description}
` + : ""} + ${sampleProfiles.length > 0 + ? html`
+ ${sampleProfiles.map((profile) => + keyed( + profile.did, + html`
+ ${avatarTemplate({ + author: profile, + clickAction: "none", + showLiveBadge: false, + })} +
`, + ), + )} + ${remainingCount > 0 + ? html`
+ +${remainingCount} +
` + : ""} +
` + : ""} +
+
`; +} + +function starterPackListItemSkeletonTemplate() { + return html`
+
+
+
+
+
+ ​ +
+
+ ​ +
+
+ ${Array.from({ length: 6 }).map( + () => + html`
+
+
`, + )} +
+
+
`; +} + +export function starterPackListTemplate({ + starterPacks, + cursor, + currentUser, + onLoadMore, + emptyMessage = "No starter packs yet.", + emptyTemplate = null, + footerTemplate = null, + itemTestId = "starter-pack-list-item", +}) { + return paginatedListTemplate({ + items: starterPacks, + renderItem: (starterPack) => + keyed( + starterPack.uri, + starterPackListItemTemplate({ + starterPack, + currentUser, + testId: itemTestId, + }), + ), + renderSkeletonItem: starterPackListItemSkeletonTemplate, + hasMore: !!cursor, + onLoadMore, + emptyMessage, + emptyTemplate, + footerTemplate, + }); +} diff --git a/src/js/utils.js b/src/js/utils.js index c8fecac8..3c35d30a 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -210,6 +210,14 @@ export function graphemeCount(str) { return [...str].length; } +export function truncateGraphemes(str, maxGraphemes, { suffix = "" } = {}) { + const graphemes = graphemeSegmenter + ? [...graphemeSegmenter.segment(str)].map((segment) => segment.segment) + : [...str]; + if (graphemes.length <= maxGraphemes) return str; + return graphemes.slice(0, maxGraphemes).join("") + suffix; +} + const EMOJI_ONLY_RE = /^[\p{Emoji_Presentation}\p{Extended_Pictographic}\uFE0F\u200D]+$/u; @@ -273,7 +281,9 @@ export function formatFullTimestamp(timestamp) { export function classnames(...defs) { let classname = ""; for (const def of defs) { - if (typeof def === "string") { + if (isNil(def)) { + continue; + } else if (typeof def === "string") { if (def.length > 0) { classname += def + " "; } @@ -283,8 +293,6 @@ export function classnames(...defs) { .filter(([_, value]) => value) .map(([key]) => key) .join(" ") + " "; - } else if (isNil(def)) { - continue; } else { throw new Error("Invalid classname definition"); } diff --git a/src/js/views/chat.view.js b/src/js/views/chat.view.js index 7ab7cc4a..36f6b295 100644 --- a/src/js/views/chat.view.js +++ b/src/js/views/chat.view.js @@ -18,6 +18,7 @@ import { import { avatarTemplate } from "/js/templates/avatar.template.js"; import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js"; import "/js/components/infinite-scroll-container.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; import "/js/components/container-link.js"; import "/js/components/app-icon.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; @@ -155,8 +156,12 @@ export default async function chatView({ } function convosTemplate({ convos, hasMore, currentUser }) { - if (convos.length === 0) { - return html`
+ return paginatedListTemplate({ + items: convos, + renderItem: (convo) => convoItemTemplate({ convo, currentUser }), + hasMore, + onLoadMore: loadConvoList, + emptyTemplate: html`
No conversations yet!
-
`; - } - - return html` - { - if (hasMore) { - await loadConvoList(); - e.detail.resume(); - } - }} - > - ${convos.map((convo) => convoItemTemplate({ convo, currentUser }))} - ${hasMore ? convoSkeletonTemplate() : ""} - - `; +
`, + }); } function convosErrorTemplate({ error }) { diff --git a/src/js/views/chatRequests.view.js b/src/js/views/chatRequests.view.js index 69290e7e..653ac5d1 100644 --- a/src/js/views/chatRequests.view.js +++ b/src/js/views/chatRequests.view.js @@ -1,4 +1,5 @@ import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; import { html, render } from "/js/lib/lit-html.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { formatRelativeTime } from "/js/utils.js"; @@ -177,27 +178,14 @@ export default async function chatRequestsView({ } function requestsTemplate({ requests, hasMore }) { - if (requests.length === 0) { - return html`
-
No chat requests
-
`; - } - - return html` - { - if (hasMore) { - await dataLayer.requests.loadConvoRequestList(); - e.detail.resume(); - } - }} - > -
- ${requests.map((convo) => requestItemTemplate({ convo }))} - ${hasMore ? requestSkeletonTemplate() : ""} -
-
- `; + return paginatedListTemplate({ + items: requests, + renderItem: (convo) => requestItemTemplate({ convo }), + hasMore, + onLoadMore: () => dataLayer.requests.loadConvoRequestList(), + emptyMessage: "No chat requests", + containerClass: "chat-requests-list", + }); } function requestsErrorTemplate({ error }) { diff --git a/src/js/views/feeds.view.js b/src/js/views/feeds.view.js index 57aac5e4..44824bf3 100644 --- a/src/js/views/feeds.view.js +++ b/src/js/views/feeds.view.js @@ -7,7 +7,7 @@ import { } from "/js/router.js"; import { html, render } from "/js/lib/lit-html.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { feedsFeedTemplate } from "/js/templates/feedsFeed.template.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; import { fillableIconTemplate } from "/js/templates/fillableIcon.template.js"; import "/js/components/app-icon.js"; import { linkToList, linkToFeed } from "/js/navigation.js"; @@ -252,7 +252,7 @@ export default async function feedsView({ })}
Pinned Feeds
- ${feedsFeedTemplate({ + ${paginatedListTemplate({ items: orderedItems, renderItem: (item) => { const value = valueForPinnedItem(item); @@ -265,7 +265,6 @@ export default async function feedsView({
`; return rowTemplate({ item, currentUser, rightItem }); }, - isEditing, })} `, diff --git a/src/js/views/groupChatDetails.view.js b/src/js/views/groupChatDetails.view.js index 702ab2e1..669b1311 100644 --- a/src/js/views/groupChatDetails.view.js +++ b/src/js/views/groupChatDetails.view.js @@ -2,7 +2,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import { getDisplayName, getGroupConvoDetails, @@ -294,7 +294,7 @@ export default async function groupChatDetailsView({ }) : groupHeaderCardSkeletonTemplate()} ${membersHeadingTemplate({ groupDetails })} - ${profileFeedTemplate({ + ${profileListTemplate({ profiles: members, hasMore, onLoadMore: loadMoreMembers, diff --git a/src/js/views/listDetail.view.js b/src/js/views/listDetail.view.js index 2573ef72..236cdb3b 100644 --- a/src/js/views/listDetail.view.js +++ b/src/js/views/listDetail.view.js @@ -9,7 +9,7 @@ import { isReferenceList, } from "/js/dataHelpers.js"; import { postFeedTemplate } from "/js/templates/postFeed.template.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import { headerTemplate } from "/js/templates/header.template.js"; import "/js/components/tab-bar.js"; import { fillableIconTemplate } from "/js/templates/fillableIcon.template.js"; @@ -57,6 +57,7 @@ export default async function listDetailView({ postInteractionHandler, listInteractionHandler, profileInteractionHandler, + starterPackInteractionHandler, } = interactionHandlers; const state = new ReactiveStore("listDetailView"); @@ -227,31 +228,49 @@ export default async function listDetailView({ ` : ""} - ${isCurrentUserList - ? html` handleAddPeople(list)} - > - Add people to list - - + ${isCurrentUserList && isReference + ? starterPackUri + ? html` handleEditList(list)} + @click=${() => + window.router.go( + linkToStarterPack({ + uri: starterPackUri, + creator: listCreator, + }), + )} > - Edit list details + Edit starter pack - handleDeleteList(list)} + ` + : "" + : isCurrentUserList + ? html` handleAddPeople(list)} > - Delete list + Add people to list - ` - : ""} + + handleEditList(list)} + > + Edit list details + + handleDeleteList(list)} + > + Delete list + + ` + : ""} ` : null, @@ -361,7 +380,7 @@ export default async function listDetailView({ : ""} ` : html`
- ${profileFeedTemplate({ + ${profileListTemplate({ profiles: members, hasMore: hasMoreMembers, onLoadMore: () => loadMembers(), @@ -392,14 +411,12 @@ export default async function listDetailView({ }); async function handleOptOut(list) { - const optedOut = - await listInteractionHandler.handleOptOutOfReferenceList(list); + const optedOut = await starterPackInteractionHandler.handleOptOut(list); if (optedOut) loadMembers({ reload: true }).catch(console.warn); } async function handleUndoOptOut(list) { - const undone = - await listInteractionHandler.handleUndoReferenceListOptOut(list); + const undone = await starterPackInteractionHandler.handleUndoOptOut(list); if (undone) loadMembers({ reload: true }).catch(console.warn); } diff --git a/src/js/views/lists.view.js b/src/js/views/lists.view.js index d9106a15..76314c60 100644 --- a/src/js/views/lists.view.js +++ b/src/js/views/lists.view.js @@ -1,6 +1,6 @@ import { html, render } from "/js/lib/lit-html.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { listFeedTemplate } from "/js/templates/listFeed.template.js"; +import { listListTemplate } from "/js/templates/listList.template.js"; import { bindToPage, pageEffect, @@ -50,7 +50,7 @@ export default async function listsView({ `, })}
- ${listFeedTemplate({ + ${listListTemplate({ lists: actorLists?.lists, cursor: actorLists?.cursor, onLoadMore: () => loadLists(), diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js index 4c86f39f..a0eed285 100644 --- a/src/js/views/notifications.view.js +++ b/src/js/views/notifications.view.js @@ -1,4 +1,5 @@ import { html, render } from "/js/lib/lit-html.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; import "/js/components/app-icon.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButton.template.js"; @@ -684,12 +685,6 @@ export default async function notificationsView({
`; } - function notificationsSkeletonTemplate() { - return html` - ${Array.from({ length: 10 }).map(() => postSkeletonTemplate())} - `; - } - function notificationsErrorTemplate({ error }) { console.error(error); return html`
@@ -715,38 +710,20 @@ export default async function notificationsView({ loadMore, loadMoreError, }) { - if (groupedNotifications.length === 0) { - return html`
-
No notifications yet!
-
`; - } try { - return html` - { - if (hasMore) { - await loadMore(); - e.detail.resume(); - } - }} - > - ${groupedNotifications.map((notificationGroup) => - notificationGroupTemplate({ notificationGroup, currentUser }), - )} - ${(() => { - if (loadMoreError) { - return loadMoreErrorTemplate({ onRetry: loadMore }); - } - if (!hasMore) { - return html`
- No more notifications -
`; - } - return Array.from({ length: 10 }).map(() => postSkeletonTemplate()); - })()} -
- `; + return paginatedListTemplate({ + items: groupedNotifications, + renderItem: (notificationGroup) => + notificationGroupTemplate({ notificationGroup, currentUser }), + renderSkeletonItem: postSkeletonTemplate, + hasMore, + onLoadMore: loadMore, + emptyMessage: "No notifications yet!", + endMessage: "No more notifications", + errorTemplate: loadMoreError + ? loadMoreErrorTemplate({ onRetry: loadMore }) + : null, + }); } catch (error) { console.error(error); return notificationsErrorTemplate({ error }); @@ -846,17 +823,14 @@ export default async function notificationsView({ return notificationsErrorTemplate({ error: notificationsRequestStatus.error, }); - } else if (groupedNotifications) { - return notificationsTemplate({ - groupedNotifications, - currentUser, - hasMore, - loadMore: loadMoreNotifications, - loadMoreError: state.$loadMoreError.get(), - }); - } else { - return notificationsSkeletonTemplate(); } + return notificationsTemplate({ + groupedNotifications, + currentUser, + hasMore, + loadMore: loadMoreNotifications, + loadMoreError: state.$loadMoreError.get(), + }); })()}
@@ -868,19 +842,17 @@ export default async function notificationsView({ return notificationsErrorTemplate({ error: mentionNotificationsRequestStatus.error, }); - } else if (groupedMentionNotifications) { - return notificationsTemplate({ - groupedNotifications: groupedMentionNotifications, - currentUser, - hasMore: mentionHasMore, - loadMore: loadMoreMentionNotifications, - loadMoreError: state.$mentionLoadMoreError.get(), - }); - } else if (activeTab === "mentions") { - return notificationsSkeletonTemplate(); - } else { + } + if (!groupedMentionNotifications && activeTab !== "mentions") { return ""; } + return notificationsTemplate({ + groupedNotifications: groupedMentionNotifications, + currentUser, + hasMore: mentionHasMore, + loadMore: loadMoreMentionNotifications, + loadMoreError: state.$mentionLoadMoreError.get(), + }); })()}
diff --git a/src/js/views/postLikes.view.js b/src/js/views/postLikes.view.js index c0a8cc93..1a04981d 100644 --- a/src/js/views/postLikes.view.js +++ b/src/js/views/postLikes.view.js @@ -2,7 +2,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { resolveDidFromHandleOrDid } from "/js/atproto.js"; import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import { formatLargeNumber } from "/js/utils.js"; import "/js/components/infinite-scroll-container.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; @@ -62,7 +62,7 @@ export default async function postLikesView({ error: postLikesRequestStatus.error, }); } - return profileFeedTemplate({ + return profileListTemplate({ profiles: postLikes?.likes?.map((like) => like.actor) ?? null, hasMore, onLoadMore: loadLikes, diff --git a/src/js/views/postReposts.view.js b/src/js/views/postReposts.view.js index bc96f936..2055209e 100644 --- a/src/js/views/postReposts.view.js +++ b/src/js/views/postReposts.view.js @@ -2,7 +2,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { resolveDidFromHandleOrDid } from "/js/atproto.js"; import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import { formatLargeNumber } from "/js/utils.js"; import "/js/components/infinite-scroll-container.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; @@ -62,7 +62,7 @@ export default async function postRepostsView({ error: postRepostsRequestStatus.error, }); } - return profileFeedTemplate({ + return profileListTemplate({ profiles: postReposts?.repostedBy ?? null, hasMore, onLoadMore: loadReposts, diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js index 2b2a145e..62c4e26e 100644 --- a/src/js/views/profile.view.js +++ b/src/js/views/profile.view.js @@ -16,9 +16,11 @@ import { AUTHOR_FEED_PAGE_SIZE, BSKY_LABELER_DID } from "/js/config.js"; import { showToast } from "/js/toasts.js"; import "/js/components/tab-bar.js"; import "/js/components/app-icon.js"; -import { feedGeneratorListItemTemplate } from "/js/templates/feedGeneratorListItem.template.js"; -import { feedsFeedTemplate } from "/js/templates/feedsFeed.template.js"; -import { listFeedTemplate } from "/js/templates/listFeed.template.js"; +import { feedGeneratorListTemplate } from "/js/templates/feedGeneratorList.template.js"; +import { listListTemplate } from "/js/templates/listList.template.js"; +import { starterPackListTemplate } from "/js/templates/starterPackList.template.js"; +import { linkToStarterPack } from "/js/navigation.js"; +import "/js/components/starter-pack-wizard-dialog.js"; import "/js/components/edit-profile-dialog.js"; import "/js/components/add-to-lists-dialog.js"; import "/js/components/go-live-dialog.js"; @@ -142,6 +144,8 @@ export default async function profileView({ scrollAndReloadActorFeeds(); } else if (tab === "lists") { scrollAndReloadActorLists(); + } else if (tab === "starter-packs") { + scrollAndReloadActorStarterPacks(); } else { scrollAndReloadFeed(); } @@ -158,6 +162,10 @@ export default async function profileView({ if (!dataLayer.derived.$actorLists.get(profileDid)) { await loadActorLists(); } + } else if (tab === "starter-packs") { + if (!dataLayer.derived.$actorStarterPacks.get(profileDid)) { + await loadActorStarterPacks(); + } } else { const isFeedTab = tab !== "labeler-settings"; if (isFeedTab && !dataLayer.hasCachedAuthorFeed(profileDid, tab)) { @@ -180,12 +188,11 @@ export default async function profileView({ } function actorFeedsTemplate({ actorFeeds, onLoadMore, currentUserDid }) { - return feedsFeedTemplate({ - items: actorFeeds?.feeds, - renderItem: (feedGenerator) => - feedGeneratorListItemTemplate({ feedGenerator, currentUserDid }), + return feedGeneratorListTemplate({ + feedGenerators: actorFeeds?.feeds, + cursor: actorFeeds?.cursor, + currentUserDid, emptyMessage: "No custom feeds.", - hasMore: !!actorFeeds?.cursor, onLoadMore, }); } @@ -266,6 +273,13 @@ export default async function profileView({ { feedType: "feeds", name: "Feeds" }, ]; } + const starterPacksCount = profile.associated?.starterPacks || 0; + if (isCurrentUser || starterPacksCount > 0) { + authorFeedsToShow = [ + ...authorFeedsToShow, + { feedType: "starter-packs", name: "Starter Packs" }, + ]; + } const listsCount = profile.associated?.lists || 0; if (listsCount > 0) { authorFeedsToShow = [ @@ -401,6 +415,45 @@ export default async function profileView({ })} `; } + if (feedInfo.feedType === "starter-packs") { + const actorStarterPacks = + dataLayer.derived.$actorStarterPacks.get(profileDid); + return html`
+ ${starterPackListTemplate({ + starterPacks: actorStarterPacks?.starterPacks ?? null, + cursor: actorStarterPacks?.cursor ?? null, + currentUser, + onLoadMore: () => loadActorStarterPacks(), + emptyTemplate: isCurrentUser + ? html`
+
You haven't created a starter pack yet!
+ ${createStarterPackButtonTemplate({ + label: "Create a starter pack", + testId: "starter-pack-create-button", + onClick: () => + handleCreateStarterPack({ currentUser }), + })} +
` + : null, + footerTemplate: isCurrentUser + ? html`` + : null, + })} +
`; + } if (feedInfo.feedType === "lists") { const actorLists = dataLayer.derived.$actorLists.get(profileDid); @@ -408,7 +461,7 @@ export default async function profileView({ class="feed-container" ?hidden=${activeTab !== "lists"} > - ${listFeedTemplate({ + ${listListTemplate({ lists: actorLists?.lists, cursor: actorLists?.cursor, onLoadMore: () => loadActorLists(), @@ -515,7 +568,8 @@ export default async function profileView({ if ( activeTab === "labeler-settings" || activeTab === "feeds" || - activeTab === "lists" + activeTab === "lists" || + activeTab === "starter-packs" ) { return; } @@ -540,6 +594,50 @@ export default async function profileView({ await dataLayer.requests.loadActorLists(profileDid, { reload }); } + async function loadActorStarterPacks({ reload = false } = {}) { + await dataLayer.requests.loadActorStarterPacks(profileDid, { reload }); + } + + async function scrollAndReloadActorStarterPacks() { + if (window.scrollY > 0) { + window.scrollTo({ top: -1, behavior: "smooth" }); + } + await loadActorStarterPacks({ reload: true }); + } + + function createStarterPackButtonTemplate({ label, testId, onClick }) { + return html``; + } + + function handleCreateStarterPack({ currentUser }) { + const dialog = document.createElement("starter-pack-wizard-dialog"); + dialog.dataLayer = dataLayer; + dialog.addEventListener("starter-pack-create", async (event) => { + const { data, successCallback, errorCallback } = event.detail; + try { + const starterPack = await dataLayer.mutations.createStarterPack(data); + successCallback(); + showToast("Starter pack created", { style: "success" }); + window.router.go( + linkToStarterPack({ ...starterPack, creator: currentUser }), + ); + } catch (error) { + errorCallback(error); + } + }); + dialog.addEventListener("dialog-closed", () => { + dialog.remove(); + }); + root.querySelector("main").appendChild(dialog); + dialog.open(); + } + async function scrollAndReloadActorLists() { if (window.scrollY > 0) { window.scrollTo({ top: -1, behavior: "smooth" }); diff --git a/src/js/views/profileFollowers.view.js b/src/js/views/profileFollowers.view.js index 6a304a80..7213bf08 100644 --- a/src/js/views/profileFollowers.view.js +++ b/src/js/views/profileFollowers.view.js @@ -2,7 +2,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { resolveDidFromHandleOrDid } from "/js/atproto.js"; import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import { getDisplayName } from "/js/dataHelpers.js"; import "/js/components/infinite-scroll-container.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; @@ -72,7 +72,7 @@ export default async function profileFollowersView({ error: profileFollowersRequestStatus.error, }); } - return profileFeedTemplate({ + return profileListTemplate({ profiles: profileFollowers?.followers ?? null, hasMore, onLoadMore: loadFollowers, diff --git a/src/js/views/profileFollowing.view.js b/src/js/views/profileFollowing.view.js index 6e3bd7f0..532ab8fb 100644 --- a/src/js/views/profileFollowing.view.js +++ b/src/js/views/profileFollowing.view.js @@ -2,7 +2,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { resolveDidFromHandleOrDid } from "/js/atproto.js"; import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import { getDisplayName } from "/js/dataHelpers.js"; import "/js/components/infinite-scroll-container.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; @@ -69,7 +69,7 @@ export default async function profileFollowingView({ error: profileFollowingRequestStatus.error, }); } - return profileFeedTemplate({ + return profileListTemplate({ profiles: profileFollowing?.follows ?? null, hasMore, onLoadMore: loadFollowing, diff --git a/src/js/views/profileKnownFollowers.view.js b/src/js/views/profileKnownFollowers.view.js index 9e38dd7c..dbad51aa 100644 --- a/src/js/views/profileKnownFollowers.view.js +++ b/src/js/views/profileKnownFollowers.view.js @@ -2,7 +2,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { resolveDidFromHandleOrDid } from "/js/atproto.js"; import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import { getDisplayName } from "/js/dataHelpers.js"; import "/js/components/infinite-scroll-container.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; @@ -59,7 +59,7 @@ export default async function profileKnownFollowersView({ if (requestStatus.error) { return errorTemplate({ error: requestStatus.error }); } - return profileFeedTemplate({ + return profileListTemplate({ profiles: knownFollowers?.followers ?? null, hasMore, onLoadMore: loadKnownFollowers, diff --git a/src/js/views/search.view.js b/src/js/views/search.view.js index 7722d1e7..87ebf412 100644 --- a/src/js/views/search.view.js +++ b/src/js/views/search.view.js @@ -3,14 +3,9 @@ import "/js/components/app-icon.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { classnames } from "/js/utils.js"; -import { - cdnImageUrl, - getDisplayName, - MISSING_HANDLE, -} from "/js/dataHelpers.js"; +import { getDisplayName, MISSING_HANDLE } from "/js/dataHelpers.js"; import { Signal, ReactiveStore } from "/js/signals.js"; import { - linkToFeed, linkToProfile, linkToProfileByDid, linkToStarterPack, @@ -20,7 +15,10 @@ import { bindToPage, pageEffect, bindPageTitle } from "/js/router.js"; import { fillableIconTemplate } from "/js/templates/fillableIcon.template.js"; import "/js/components/container-link.js"; import "/js/components/tab-bar.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; +import { starterPackListTemplate } from "/js/templates/starterPackList.template.js"; +import { feedGeneratorListTemplate } from "/js/templates/feedGeneratorList.template.js"; +import { paginatedListTemplate } from "/js/templates/paginatedList.template.js"; export default async function searchView({ root, @@ -455,39 +453,24 @@ export default async function searchView({ No posts found. `; } - return html` { - if (postSearchHasMore) { - await onLoadMore(); - event.detail.resume(); - } - }} - ?disabled=${!postSearchHasMore} - > -
- ${postSearchResults.map( - (post) => - html`
- ${smallPostTemplate({ - post, - currentUser, - isAuthenticated, - showReplyToLabel: !!post.record?.reply, - replyToAuthor: post.record?.reply?.parentAuthor ?? null, - isUserPost: currentUser?.did === post.author?.did, - postInteractionHandler, - pluginService, - })} -
`, - )} - ${postSearchHasMore - ? html`
-
-
` - : ""} -
-
`; + return paginatedListTemplate({ + items: postSearchResults, + renderItem: (post) => + html`
+ ${smallPostTemplate({ + post, + currentUser, + isAuthenticated, + showReplyToLabel: !!post.record?.reply, + replyToAuthor: post.record?.reply?.parentAuthor ?? null, + isUserPost: currentUser?.did === post.author?.did, + postInteractionHandler, + pluginService, + })} +
`, + hasMore: postSearchHasMore, + onLoadMore, + }); } function profileSearchResultsTemplate({ @@ -510,7 +493,7 @@ export default async function searchView({ No profiles found. `; } - return profileFeedTemplate({ + return profileListTemplate({ profiles: profileSearchResults, hasMore: profileSearchHasMore, onLoadMore: loadMoreProfiles, @@ -524,8 +507,9 @@ export default async function searchView({ function feedSearchResultsTemplate({ status, feedSearchResults, - feedSearchHasMore, + feedSearchCursor, preferences, + currentUser, }) { if (!feedSearchResults && status.loading) { return html`
Searching feeds…
`; @@ -536,147 +520,41 @@ export default async function searchView({ ${status.error.message ? html`(${status.error.message})` : ""}. `; } - if (!feedSearchResults || feedSearchResults.length === 0) { - return html`
- No feeds found. -
`; - } - return html` { - if (feedSearchHasMore) { - await loadMoreFeeds(); - event.detail.resume(); - } - }} - ?disabled=${!feedSearchHasMore} - > -
- ${feedSearchResults.map((feedGenerator) => { - const isPinned = preferences.isFeedPinned(feedGenerator.uri); - return html` - -
- ${feedGenerator.avatar - ? html`${feedGenerator.displayName}` - : html`${feedGenerator.displayName}`} -
-
-
- ${feedGenerator.displayName} -
- ${feedGenerator.creator - ? html`
- by @${feedGenerator.creator.handle} -
` - : ""} - ${feedGenerator.description - ? // prettier-ignore - html`
${feedGenerator.description}
` - : ""} -
-
- -
-
- `; - })} - ${feedSearchHasMore - ? html`
-
-
` - : ""} -
-
`; + return feedGeneratorListTemplate({ + feedGenerators: feedSearchResults ?? [], + cursor: feedSearchCursor, + currentUserDid: currentUser?.did ?? null, + onLoadMore: () => loadMoreFeeds(), + emptyMessage: "No feeds found.", + showDescription: true, + rightItemTemplate: (feedGenerator) => + pinFeedButtonTemplate({ + feedGenerator, + isPinned: preferences.isFeedPinned(feedGenerator.uri), + }), + }); } - function starterPackListItemTemplate({ starterPack, currentUser }) { - const { record, creator } = starterPack; - const isOwner = currentUser?.did === creator.did; - const sampleProfiles = (starterPack.listItemsSample ?? []) - .slice(0, 8) - .map((item) => item.subject); - const remainingCount = - (starterPack.listItemCount ?? 0) - sampleProfiles.length; - return html` { + e.stopPropagation(); + feedInteractionHandler.handlePinFeed(feedGenerator.uri, !isPinned); + }} > -
- ${record.name} -
-
-
${record.name}
-
- Starter pack by ${isOwner ? "you" : html`@${creator.handle}`} -
- ${record.description - ? // prettier-ignore - html`
${record.description}
` - : ""} - ${sampleProfiles.length > 0 - ? html`
- ${sampleProfiles.map((profile) => - keyed( - profile.did, - html`
- ${avatarTemplate({ - author: profile, - clickAction: "none", - showLiveBadge: false, - })} -
`, - ), - )} - ${remainingCount > 0 - ? html`
- +${remainingCount} -
` - : ""} -
` - : ""} -
-
`; + ${isPinned ? "" : fillableIconTemplate({ icon: "pin" })} + ${isPinned ? "Unpin feed" : "Pin feed"} + `; } function starterPackSearchResultsTemplate({ status, starterPackSearchResults, - starterPackSearchHasMore, + starterPackSearchCursor, currentUser, }) { if (!starterPackSearchResults && status.loading) { @@ -690,35 +568,14 @@ export default async function searchView({ ${status.error.message ? html`(${status.error.message})` : ""}. `; } - if (!starterPackSearchResults || starterPackSearchResults.length === 0) { - return html`
- No starter packs found. -
`; - } - return html` { - if (starterPackSearchHasMore) { - await loadMoreStarterPacks(); - event.detail.resume(); - } - }} - ?disabled=${!starterPackSearchHasMore} - > -
- ${starterPackSearchResults.map((starterPack) => - keyed( - starterPack.uri, - starterPackListItemTemplate({ starterPack, currentUser }), - ), - )} - ${starterPackSearchHasMore - ? html`
-
-
` - : ""} -
-
`; + return starterPackListTemplate({ + starterPacks: starterPackSearchResults ?? [], + cursor: starterPackSearchCursor, + currentUser, + onLoadMore: () => loadMoreStarterPacks(), + emptyMessage: "No starter packs found.", + itemTestId: "starter-pack-search-result", + }); } function getActivePanelTemplate(activeTab, committedQuery, currentUser) { @@ -756,8 +613,9 @@ export default async function searchView({ ${feedSearchResultsTemplate({ status, feedSearchResults: dataLayer.derived.$feedSearchResults.get(), - feedSearchHasMore: !!dataLayer.derived.$feedSearchCursor.get(), + feedSearchCursor: dataLayer.derived.$feedSearchCursor.get(), preferences: dataLayer.derived.$preferences.get(), + currentUser, })} `; case "starterPacks": @@ -766,8 +624,8 @@ export default async function searchView({ status, starterPackSearchResults: dataLayer.derived.$starterPackSearchResults.get(), - starterPackSearchHasMore: - !!dataLayer.derived.$starterPackSearchCursor.get(), + starterPackSearchCursor: + dataLayer.derived.$starterPackSearchCursor.get(), currentUser, })} `; diff --git a/src/js/views/settings/blockedAccounts.view.js b/src/js/views/settings/blockedAccounts.view.js index 7ad49206..bf1172ff 100644 --- a/src/js/views/settings/blockedAccounts.view.js +++ b/src/js/views/settings/blockedAccounts.view.js @@ -1,7 +1,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import "/js/components/infinite-scroll-container.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; @@ -56,7 +56,7 @@ export default async function settingsBlockedAccountsView({ if (status.error) { return errorTemplate({ error: status.error }); } - return profileFeedTemplate({ + return profileListTemplate({ profiles: blockedProfiles?.blocks ?? null, hasMore, onLoadMore: loadBlockedAccounts, diff --git a/src/js/views/settings/mutedAccounts.view.js b/src/js/views/settings/mutedAccounts.view.js index f7535b18..637bbad0 100644 --- a/src/js/views/settings/mutedAccounts.view.js +++ b/src/js/views/settings/mutedAccounts.view.js @@ -1,7 +1,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; import "/js/components/infinite-scroll-container.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; @@ -55,7 +55,7 @@ export default async function settingsMutedAccountsView({ if (status.error) { return errorTemplate({ error: status.error }); } - return profileFeedTemplate({ + return profileListTemplate({ profiles: mutedProfiles?.mutes ?? null, hasMore, onLoadMore: loadMutedAccounts, diff --git a/src/js/views/starterPackDetail.view.js b/src/js/views/starterPackDetail.view.js index 154ba47f..d574ab0b 100644 --- a/src/js/views/starterPackDetail.view.js +++ b/src/js/views/starterPackDetail.view.js @@ -2,9 +2,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { resolveDidFromHandleOrDid } from "/js/atproto.js"; import { Signal, ReactiveStore } from "/js/signals.js"; import { postFeedTemplate } from "/js/templates/postFeed.template.js"; -import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; -import { feedsFeedTemplate } from "/js/templates/feedsFeed.template.js"; -import { feedGeneratorListItemTemplate } from "/js/templates/feedGeneratorListItem.template.js"; +import { profileListTemplate } from "/js/templates/profileList.template.js"; +import { feedGeneratorListTemplate } from "/js/templates/feedGeneratorList.template.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { richTextTemplate } from "/js/templates/richText.template.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; @@ -20,6 +19,7 @@ import "/js/components/tab-bar.js"; import "/js/components/context-menu.js"; import "/js/components/context-menu-item.js"; import "/js/components/context-menu-item-group.js"; +import "/js/components/starter-pack-wizard-dialog.js"; function copyPermalink(permalink) { navigator.clipboard.writeText(permalink); @@ -41,10 +41,13 @@ function optOutMenuItemTemplate({ isOptedOut, onOptOut, onUndoOptOut }) { function headerMenuTemplate({ permalink, + isOwner, showOptOut, isOptedOut, onOptOut, onUndoOptOut, + onEdit, + onDelete, }) { return html` `; - } +function followAllButtonTemplate({ isFollowingAll, onFollowAll }) { return html``; + const footerTemplate = html``; + const empty = renderList({ + starterPacks: [], + emptyTemplate, + footerTemplate, + }); + assert(empty.querySelector("[data-testid='starter-pack-create-button']")); + assert.equal( + empty.querySelector("[data-testid='starter-pack-create-another']"), + null, + ); + const filled = renderList({ + starterPacks: [makeStarterPack("a")], + emptyTemplate, + footerTemplate, + }); + assert(filled.querySelector("[data-testid='starter-pack-create-another']")); + assert.equal( + filled.querySelector("[data-testid='starter-pack-create-button']"), + null, + ); + }); + + it("shows the plain empty message without an empty template", () => { + const container = renderList({ starterPacks: [] }); + const empty = container.querySelector("[data-testid='empty-state']"); + assert(empty.textContent.includes("No starter packs yet.")); + assert.equal(empty.querySelector("button"), null); + }); +}); diff --git a/tests/unit/specs/utils.test.js b/tests/unit/specs/utils.test.js index 945a1c52..a8d719d5 100644 --- a/tests/unit/specs/utils.test.js +++ b/tests/unit/specs/utils.test.js @@ -35,6 +35,8 @@ import { throttleByKey, WindowedCounter, Poller, + graphemeCount, + truncateGraphemes, } from "/js/utils.js"; import { flushMicrotasks, installFakeIndexedDB } from "../testHelpers.js"; @@ -397,6 +399,11 @@ describe("classnames", () => { assert.deepEqual(result, ""); }); + it("should skip null and undefined entries", () => { + const result = classnames("base", null, undefined, "extra"); + assert.deepEqual(result, "base extra"); + }); + it("should throw error for invalid input", () => { let errorThrown = false; try { @@ -2086,3 +2093,34 @@ describe("Poller", () => { assert.deepEqual(counter.calls, 2); }); }); + +describe("truncateGraphemes", () => { + it("returns the string unchanged when within the limit", () => { + assert.equal(truncateGraphemes("hello", 5), "hello"); + assert.equal(truncateGraphemes("", 3), ""); + }); + + it("cuts plain text to the grapheme limit", () => { + assert.equal(truncateGraphemes("hello world", 5), "hello"); + }); + + it("keeps multi-code-point grapheme clusters intact", () => { + const family = "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦"; + const flag = "πŸ‡ΊπŸ‡Έ"; + const truncated = truncateGraphemes(`ab${family}${flag}cd`, 4); + assert.equal(truncated, `ab${family}${flag}`); + assert.equal(graphemeCount(truncated), 4); + }); + + it("does not split a combining mark from its base", () => { + assert.equal(truncateGraphemes("e\u0301x", 1), "e\u0301"); + }); + + it("appends the suffix only when truncating", () => { + assert.equal( + truncateGraphemes("hello world", 5, { suffix: "…" }), + "hello…", + ); + assert.equal(truncateGraphemes("hello", 5, { suffix: "…" }), "hello"); + }); +}); -- 2.51.2