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();