diff --git a/package.json b/package.json index 8f442d80..40fe7465 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.63", + "version": "0.18.64", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 949f57b7..6e415d35 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -5300,17 +5300,38 @@ emoji-picker-dialog emoji-picker, gap: 8px; padding: 12px 16px; border-bottom: var(--hair) solid var(--post-border-color); +} + +container-link.profile-list-item, +button.profile-list-item-button { cursor: pointer; } +button.profile-list-item-button { + display: flex; + background: none; + border: none; + color: inherit; + font: inherit; + text-align: left; + width: 100%; +} + +button.profile-list-item-button.is-disabled { + cursor: not-allowed; + opacity: 0.55; +} + .profile-list-item-row { display: flex; align-items: flex-start; gap: 12px; + width: 100%; } @media (hover: hover) { - .profile-list-item:hover { + container-link.profile-list-item:hover, + button.profile-list-item-button:not(:disabled):hover { background: var(--post-hover-color); } } @@ -9224,6 +9245,13 @@ toggle-switch { padding: 20px 0; } +.new-chat-not-messageable-hint { + align-self: center; + color: var(--text-color-muted); + font-size: 0.85rem; + flex-shrink: 0; +} + .new-chat-section-header { color: var(--text-color-secondary); font-size: 0.85rem; @@ -9293,6 +9321,29 @@ toggle-switch { border-radius: 4px; } +.manage-list-members-toggle { + align-self: center; + flex-shrink: 0; + min-width: 80px; +} + +.manage-list-members-toggle .loading-spinner { + width: 14px; + height: 14px; +} + +.manage-list-members-message { + color: var(--text-color-secondary); + text-align: center; + padding: 20px 0; +} + +.manage-list-members-section-header { + color: var(--text-color-secondary); + font-size: 0.85rem; + padding: 4px 16px 8px; +} + tab-bar[full-width] { width: 100%; } diff --git a/src/js/components/manage-list-members-dialog.js b/src/js/components/manage-list-members-dialog.js new file mode 100644 index 00000000..2e926eea --- /dev/null +++ b/src/js/components/manage-list-members-dialog.js @@ -0,0 +1,310 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { scrollLocks } from "/js/scrollLocks.js"; +import { + closeWithAnimation, + enableDragToDismiss, + resetScrollOnBlur, +} from "/js/dialogHelpers.js"; +import { Signal, SignalSet, ReactiveStore, effect } from "/js/signals.js"; +import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js"; +import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; +import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { classnames } from "/js/utils.js"; + +const MAX_MEMBER_PAGES = 6; + +function toggleButtonTemplate({ profile, isMember, isPending, onToggle }) { + return html``; +} + +function profileListTemplate({ profiles, emptyMessage, rightItemTemplate }) { + return profileFeedTemplate({ + profiles, + hasMore: false, + clickAction: "none", + compact: true, + rightItemTemplate, + emptyMessage, + }); +} + +class ManageListMembersDialog extends Component { + connectedCallback() { + if (this.initialized) { + return; + } + this.dataLayer = this.dataLayer ?? null; + this.list = this.list ?? null; + this.setAttribute("data-dialog-wrapper", ""); + this.scrollLock = null; + this.state = new ReactiveStore("manage-list-members-dialog"); + this.state.$query = new Signal.State(""); + this.state.$pendingDids = new SignalSet(); + this.state.$membersLoaded = new Signal.State(false); + this.innerHTML = ""; + this._disposeEffect = effect(() => { + this.render(); + }); + this._loadSuggestions(); + this._loadAllMembers(); + this.initialized = true; + } + + disconnectedCallback() { + this._disposeEffect?.(); + this._disposeEffect = null; + } + + async _loadSuggestions() { + 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); + } + } + + async _loadAllMembers() { + const listUri = this.list.uri; + try { + await this.dataLayer.requests.loadListMembers(listUri, { reload: true }); + for (let i = 1; i < MAX_MEMBER_PAGES; i++) { + const data = this.dataLayer.dataStore.$listMembers.get(listUri); + if (!data?.cursor) break; + await this.dataLayer.requests.loadListMembers(listUri); + } + } catch (error) { + console.warn("Failed to load list members", error); + } finally { + this.state.$membersLoaded.set(true); + } + } + + _onSearchInput(value) { + this.state.$query.set(value); + const query = value.trim(); + if (!query) { + this.dataLayer.requests.loadChatRecipientSearch(""); + } else { + this.dataLayer.requests.loadChatRecipientSearch(query, { limit: 12 }); + } + const results = this.querySelector(".new-chat-results"); + if (results) results.scrollTop = 0; + } + + _onClearSearch() { + this._onSearchInput(""); + } + + async _onToggle(profile) { + if (this.state.$pendingDids.has(profile.did)) return; + const memberDidToUri = this._memberDidToUri(); + const membershipUri = memberDidToUri.get(profile.did); + this.state.$pendingDids.add(profile.did); + try { + if (membershipUri) { + await this.dataLayer.mutations.removeProfileFromList( + profile, + this.list, + membershipUri, + ); + } else { + await this.dataLayer.mutations.addProfileToList(profile, this.list); + } + } catch (error) { + console.error(error); + } finally { + this.state.$pendingDids.delete(profile.did); + } + } + + _memberDidToUri() { + const data = this.dataLayer.dataStore.$listMembers.get(this.list.uri); + const map = new Map(); + if (!data) return map; + for (const item of data.items) { + map.set(item.subject.did, item.uri); + } + return map; + } + + render() { + const query = this.state.$query.get().trim(); + const currentUserDid = this.dataLayer.derived.$currentUser.get()?.did; + const searchResults = + this.dataLayer.derived.$chatRecipientSearchResults.get(); + const searchStatus = 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 memberDidToUri = this._memberDidToUri(); + const membersLoaded = this.state.$membersLoaded.get(); + const pendingDids = this.state.$pendingDids; + const rightItemTemplate = (profile) => { + if (!membersLoaded) return ""; + return toggleButtonTemplate({ + profile, + isMember: memberDidToUri.has(profile.did), + isPending: pendingDids.has(profile.did), + onToggle: (p) => this._onToggle(p), + }); + }; + render( + html` + { + if (event.target.tagName === "DIALOG") { + this.close(); + } + }} + @cancel=${(event) => { + event.preventDefault(); + this.close(); + }} + @close=${() => { + this.scrollLock?.release(); + this.scrollLock = null; + this.dispatchEvent(new CustomEvent("dialog-closed")); + }} + > +
+
+

Add people to list

+ +
+
+ ${searchIconTemplate()} + this._onSearchInput(event.target.value)} + /> + ${this.state.$query.get().length > 0 + ? html` + + ` + : ""} +
+
+ ${(() => { + if (query) { + if (searchStatus?.error) { + return html`
+ We're having network issues, try again +
`; + } + return profileListTemplate({ + profiles: searchResults, + emptyMessage: "No results", + rightItemTemplate, + }); + } + let suggestedProfiles = null; + if (profileFollowsStatus?.error) { + suggestedProfiles = []; + } else if (profileFollows) { + suggestedProfiles = profileFollows; + } + if (!suggestedProfiles?.length) { + return profileListTemplate({ + profiles: suggestedProfiles, + emptyMessage: "Search for someone to add", + }); + } + return html` +
+ Suggested +
+ ${profileListTemplate({ + profiles: suggestedProfiles, + rightItemTemplate, + })} + `; + })()} +
+
+
+ `, + this, + ); + } + + open() { + this.scrollLock ??= scrollLocks.acquire({ target: this }); + const dialog = this.querySelector(".manage-list-members-dialog"); + if (dialog?.open) return; + dialog.showModal(); + this.querySelector(".new-chat-search-input")?.focus({ + preventScroll: true, + }); + enableDragToDismiss(dialog, { + onClose: () => this.close(), + scrollContainer: this.querySelector(".new-chat-results"), + ignoreTouchTarget: (element) => element.closest("button, input") !== null, + }); + resetScrollOnBlur(dialog, this.querySelector(".new-chat-results")); + } + + close() { + return closeWithAnimation( + this.querySelector(".manage-list-members-dialog"), + ); + } +} + +ManageListMembersDialog.register(); diff --git a/src/js/components/new-chat-dialog.js b/src/js/components/new-chat-dialog.js index 3c99419b..70caae36 100644 --- a/src/js/components/new-chat-dialog.js +++ b/src/js/components/new-chat-dialog.js @@ -7,10 +7,9 @@ import { resetScrollOnBlur, } from "/js/dialogHelpers.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; -import { getDisplayName, MISSING_HANDLE } from "/js/dataHelpers.js"; -import { avatarTemplate } from "/js/templates/avatar.template.js"; import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; +import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; import { showToast } from "/js/toasts.js"; const CREATE_CHAT_ERROR_TOASTS = { @@ -61,84 +60,29 @@ function partitionRows(profiles, currentUserDid) { ]; } -function skeletonTemplate() { - return html`${Array.from({ length: 6 }).map( - () => html` -
-
-
-
-
-
-
- `, - )}`; +function notMessageableRightItem(profile) { + if (canBeMessaged(profile)) return null; + return html`
+ Can't be messaged +
`; } -function resultRowTemplate({ profile, onSelect }) { - const isMessageable = canBeMessaged(profile); - const hasHandle = profile.handle && profile.handle !== MISSING_HANDLE; - return html` - - `; -} - -function searchResultsTemplate({ status, rows, onSelect }) { - if (status.error) { - return html`
- We're having network issues, try again -
`; - } - if (!rows || (rows.length === 0 && status.loading)) { - return skeletonTemplate(); - } - if (rows.length === 0) { - return html`
- No results -
`; - } - return rows.map((profile) => resultRowTemplate({ profile, onSelect })); -} - -function suggestionsTemplate({ status, rows, onSelect }) { - if (!rows && !status?.error) { - return skeletonTemplate(); - } - if (!rows || rows.length === 0) { - return html`
- Search for someone to message -
`; - } - return html` -
- Suggested -
- ${rows.map((profile) => resultRowTemplate({ profile, onSelect }))} - `; +function profileListTemplate({ profiles, onSelect, emptyMessage = null }) { + return profileFeedTemplate({ + profiles, + hasMore: false, + skeletonCount: 6, + compact: true, + clickAction: onSelect, + rightItemTemplate: notMessageableRightItem, + disabledProfiles: (profiles ?? []) + .filter((profile) => !canBeMessaged(profile)) + .map((profile) => profile.did), + emptyMessage, + }); } class NewChatDialog extends Component { @@ -181,6 +125,8 @@ class NewChatDialog extends Component { } else { this.dataLayer.requests.loadChatRecipientSearch(query, { limit: 12 }); } + const results = this.querySelector(".new-chat-results"); + if (results) results.scrollTop = 0; } _onClearSearch() { @@ -202,16 +148,15 @@ class NewChatDialog extends Component { render() { const query = this.state.$query.get().trim(); - const onSelect = (profile) => this._onSelect(profile); const currentUserDid = this.dataLayer.derived.$currentUser.get()?.did; const results = this.dataLayer.derived.$chatRecipientSearchResults.get(); const searchStatus = this.dataLayer.requests.statusStore.$statuses.get( "loadChatRecipientSearch", ); - const follows = currentUserDid + const profileFollows = currentUserDid ? this.dataLayer.derived.$profileFollows.get(currentUserDid)?.follows : null; - const followsStatus = currentUserDid + const profileFollowsStatus = currentUserDid ? this.dataLayer.requests.statusStore.$statuses.get( `loadProfileFollows-${currentUserDid}`, ) @@ -278,24 +223,52 @@ class NewChatDialog extends Component { : ""}
- ${query - ? searchResultsTemplate({ - status: searchStatus, - rows: results + ${(() => { + if (query) { + if (searchStatus?.error) { + return html`
+ We're having network issues, try again +
`; + } + return profileListTemplate({ + profiles: results ? partitionRows(results, currentUserDid) : null, - onSelect, - }) - : suggestionsTemplate({ - status: followsStatus, - rows: follows - ? partitionRows( - follows.filter((profile) => canBeMessaged(profile)), - currentUserDid, - ) - : null, - onSelect, + onSelect: (profile) => this._onSelect(profile), + emptyMessage: "No results", + }); + } + let suggestedProfiles = null; + if (profileFollowsStatus?.error) { + suggestedProfiles = []; + } else if (profileFollows) { + suggestedProfiles = partitionRows( + profileFollows.filter((profile) => canBeMessaged(profile)), + currentUserDid, + ); + } + if (!suggestedProfiles?.length) { + return profileListTemplate({ + profiles: suggestedProfiles, + emptyMessage: "Search for someone to message", + }); + } + return html` +
+ Suggested +
+ ${profileListTemplate({ + profiles: suggestedProfiles, + onSelect: (profile) => this._onSelect(profile), })} + `; + })()}
diff --git a/src/js/components/plugin-profiles-list.js b/src/js/components/plugin-profiles-list.js index 9a05428a..acfdd47c 100644 --- a/src/js/components/plugin-profiles-list.js +++ b/src/js/components/plugin-profiles-list.js @@ -41,7 +41,7 @@ class PluginProfilesList extends Component { hasMore: false, skeletonCount: dids.length, emptyMessage, - showFollowButton: false, + rightItemTemplate: null, }), this, ); diff --git a/src/js/templates/profileFeed.template.js b/src/js/templates/profileFeed.template.js index 0cd09bc6..28f5307c 100644 --- a/src/js/templates/profileFeed.template.js +++ b/src/js/templates/profileFeed.template.js @@ -9,102 +9,164 @@ import { getDisplayName } from "/js/dataHelpers.js"; import { classnames } from "/js/utils.js"; import "/js/components/container-link.js"; +// clickAction: "link" | "none" | callback +function itemWrapperTemplate({ actor, clickAction, isDisabled, children }) { + if (typeof clickAction === "function") { + return html``; + } + if (clickAction === "none") { + return html`
${children}
`; + } + return html`${children}`; +} + +function nameTemplate({ actor, doLink }) { + const content = html` + ${getDisplayName(actor)}${verificationBadgeTemplate({ + profile: actor, + })}${automatedAccountBadgeTemplate({ profile: actor })} + `; + if (doLink) { + return html`${content}`; + } + return html`${content}`; +} + +function followButtonRightItem({ + actor, + isAuthenticated, + currentUserDid, + profileInteractionHandler, +}) { + const isCurrentUser = currentUserDid && actor.did === currentUserDid; + const isBlocking = !!actor.viewer?.blocking; + const isBlockedBy = !!actor.viewer?.blockedBy; + if (isCurrentUser || !isAuthenticated || isBlocking || isBlockedBy) { + return null; + } + const isFollowing = !!actor.viewer?.following; + const isFollowedBy = !!actor.viewer?.followedBy; + return html``; +} + export function profileListItemTemplate({ actor, isAuthenticated = false, currentUserDid = null, profileInteractionHandler = null, - showFollowButton = true, - badgeTemplate = null, + rightItemTemplate, + clickAction = "link", + compact = false, + isDisabled = false, }) { - const displayName = getDisplayName(actor); - const isCurrentUser = currentUserDid && actor.did === currentUserDid; - const isFollowing = !!actor.viewer?.following; const isFollowedBy = !!actor.viewer?.followedBy; const isBlocking = !!actor.viewer?.blocking; const isBlockedBy = !!actor.viewer?.blockedBy; const showsFollowsYou = isFollowedBy && !isBlocking && !isBlockedBy; - const showsFollowControl = - showFollowButton && - !isCurrentUser && - isAuthenticated && - !isBlocking && - !isBlockedBy; const description = actor.description?.trim(); - return html` -
- ${avatarTemplate({ author: actor })} -
- - - ${displayName}${verificationBadgeTemplate({ - profile: actor, - })}${automatedAccountBadgeTemplate({ profile: actor })} - - + // Render follow button by default + const rightItem = + rightItemTemplate === undefined + ? (followButtonRightItem({ + actor, + isAuthenticated, + currentUserDid, + profileInteractionHandler, + }) ?? "") + : rightItemTemplate === null + ? "" + : (rightItemTemplate(actor) ?? ""); + return itemWrapperTemplate({ + actor, + clickAction, + isDisabled, + children: html` +
+ ${avatarTemplate({ + author: actor, + clickAction: clickAction === "link" ? "link" : "none", + })}
- @${actor.handle} + ${nameTemplate({ actor, doLink: clickAction === "link" })} +
+ @${actor.handle} +
+ ${rightItem}
- ${badgeTemplate ? (badgeTemplate(actor) ?? "") : ""} - ${showsFollowControl - ? html`` + ${richTextTemplate({ text: description })} +
` : ""} -
- ${showsFollowsYou - ? html`
- Follows you -
` - : ""} - ${description - ? html`
- ${richTextTemplate({ text: description })} -
` - : ""} -
`; + `, + }); } -export function profileListItemSkeletonTemplate() { +export function profileListItemSkeletonTemplate({ compact = false } = {}) { return html`
-
-
-
-
+ ${compact + ? "" + : html`
+
+
+
`} `; } @@ -133,13 +197,15 @@ export function profileFeedTemplate({ isAuthenticated = false, currentUserDid = null, profileInteractionHandler = null, - showFollowButton = true, - badgeTemplate = null, + rightItemTemplate, + clickAction = "link", + compact = false, + disabledProfiles = null, }) { if (!profiles) { return html`
${Array.from({ length: skeletonCount }).map(() => - profileListItemSkeletonTemplate(), + profileListItemSkeletonTemplate({ compact }), )}
`; } @@ -148,6 +214,7 @@ export function profileFeedTemplate({ ${emptyMessage ?? "No profiles to show."} `; } + const disabledSet = disabledProfiles ? new Set(disabledProfiles) : null; return html` { @@ -164,8 +231,10 @@ export function profileFeedTemplate({ isAuthenticated, currentUserDid, profileInteractionHandler, - showFollowButton, - badgeTemplate, + rightItemTemplate, + clickAction, + compact, + isDisabled: disabledSet ? disabledSet.has(profile.did) : false, }), )} diff --git a/src/js/views/groupChatDetails.view.js b/src/js/views/groupChatDetails.view.js index af781ca8..bdeced4d 100644 --- a/src/js/views/groupChatDetails.view.js +++ b/src/js/views/groupChatDetails.view.js @@ -195,9 +195,8 @@ class GroupChatDetailsView extends View { hasMore, onLoadMore: loadMoreMembers, isAuthenticated, - currentUserDid: currentUser?.did ?? null, - showFollowButton: false, - badgeTemplate: (actor) => + compact: true, + rightItemTemplate: (actor) => memberTrailingTemplate({ member: actor, ownerDid }), })}`; })()} diff --git a/src/js/views/listDetail.view.js b/src/js/views/listDetail.view.js index f1640cec..fd321473 100644 --- a/src/js/views/listDetail.view.js +++ b/src/js/views/listDetail.view.js @@ -17,6 +17,7 @@ import "/js/components/infinite-scroll-container.js"; import "/js/components/context-menu.js"; import "/js/components/context-menu-item.js"; import "/js/components/edit-list-details-dialog.js"; +import "/js/components/manage-list-members-dialog.js"; class ListDetailView extends View { async render({ @@ -176,6 +177,12 @@ class ListDetailView extends View { currentUser?.did && listCreator.did === currentUser.did ? html` handleAddPeople(list)} + > + Add people to list + + handleEditList(list)} > @@ -294,7 +301,7 @@ class ListDetailView extends View { isAuthenticated, currentUserDid: currentUser?.did ?? null, profileInteractionHandler, - showFollowButton: isCurateList, + ...(isCurateList ? {} : { rightItemTemplate: null }), })} `} @@ -313,6 +320,17 @@ class ListDetailView extends View { window.router.back({ fallbackRoute }); } + function handleAddPeople(list) { + const dialog = document.createElement("manage-list-members-dialog"); + dialog.dataLayer = dataLayer; + dialog.list = list; + dialog.addEventListener("dialog-closed", () => { + dialog.remove(); + }); + root.querySelector("main").appendChild(dialog); + dialog.open(); + } + async function handleEditList(list) { const dialog = document.createElement("edit-list-details-dialog"); dialog.addEventListener("list-save", async (event) => { diff --git a/src/js/views/settings/blockedAccounts.view.js b/src/js/views/settings/blockedAccounts.view.js index 26cbf2f1..7da9d7e4 100644 --- a/src/js/views/settings/blockedAccounts.view.js +++ b/src/js/views/settings/blockedAccounts.view.js @@ -7,7 +7,12 @@ import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; import "/js/components/infinite-scroll-container.js"; class SettingsBlockedAccountsView extends View { - async render({ root, router, layout, context: { dataLayer } }) { + async render({ + root, + router, + layout, + context: { dataLayer, isAuthenticated }, + }) { await auth.requireAuth(); async function loadMore() { @@ -61,7 +66,7 @@ class SettingsBlockedAccountsView extends View { hasMore, onLoadMore: loadMore, emptyMessage: "You haven't blocked any accounts.", - showFollowButton: false, + isAuthenticated, }); })()} diff --git a/src/js/views/settings/mutedAccounts.view.js b/src/js/views/settings/mutedAccounts.view.js index 15b0f742..d6553358 100644 --- a/src/js/views/settings/mutedAccounts.view.js +++ b/src/js/views/settings/mutedAccounts.view.js @@ -7,7 +7,12 @@ import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; import "/js/components/infinite-scroll-container.js"; class SettingsMutedAccountsView extends View { - async render({ root, router, layout, context: { dataLayer } }) { + async render({ + root, + router, + layout, + context: { dataLayer, isAuthenticated }, + }) { await auth.requireAuth(); async function loadMore() { @@ -57,7 +62,8 @@ class SettingsMutedAccountsView extends View { hasMore, onLoadMore: loadMore, emptyMessage: "You have not muted any accounts yet.", - showFollowButton: false, + isAuthenticated, + rightItemTemplate: null, }); })()} diff --git a/tests/e2e/specs/views/settings/blockedAccounts.view.test.js b/tests/e2e/specs/views/settings/blockedAccounts.view.test.js index abacb3b2..11e94c6e 100644 --- a/tests/e2e/specs/views/settings/blockedAccounts.view.test.js +++ b/tests/e2e/specs/views/settings/blockedAccounts.view.test.js @@ -66,4 +66,23 @@ test.describe("Settings Blocked Accounts view", () => { await expect(view).toContainText("Alice"); await expect(view).toContainText("Bob"); }); + + test("should not render follow buttons on blocked accounts", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.blockedProfiles = [alice, bob]; + mockServer.addProfile(alice); + mockServer.addProfile(bob); + await mockServer.setup(page); + + await login(page); + await page.goto("/settings/blocked-accounts"); + + const view = page.locator("#settings-blocked-accounts-view"); + await expect(view.locator(".profile-list-item")).toHaveCount(2, { + timeout: 10000, + }); + await expect(view.locator('[data-testid="follow-button"]')).toHaveCount(0); + }); }); diff --git a/tests/e2e/specs/views/settings/mutedAccounts.view.test.js b/tests/e2e/specs/views/settings/mutedAccounts.view.test.js index 0fd21e65..5574f13a 100644 --- a/tests/e2e/specs/views/settings/mutedAccounts.view.test.js +++ b/tests/e2e/specs/views/settings/mutedAccounts.view.test.js @@ -66,4 +66,23 @@ test.describe("Settings Muted Accounts view", () => { await expect(view).toContainText("Alice"); await expect(view).toContainText("Bob"); }); + + test("should not render follow buttons on muted accounts", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.mutedProfiles = [alice, bob]; + mockServer.addProfile(alice); + mockServer.addProfile(bob); + await mockServer.setup(page); + + await login(page); + await page.goto("/settings/muted-accounts"); + + const view = page.locator("#settings-muted-accounts-view"); + await expect(view.locator(".profile-list-item")).toHaveCount(2, { + timeout: 10000, + }); + await expect(view.locator('[data-testid="follow-button"]')).toHaveCount(0); + }); }); diff --git a/tests/unit/specs/components/manage-list-members-dialog.test.js b/tests/unit/specs/components/manage-list-members-dialog.test.js new file mode 100644 index 00000000..a53a31c2 --- /dev/null +++ b/tests/unit/specs/components/manage-list-members-dialog.test.js @@ -0,0 +1,379 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { Signal, SignalMap, ComputedMap } from "/js/signals.js"; +import "/js/components/manage-list-members-dialog.js"; + +describe("manage-list-members-dialog", () => { + const originalSetTimeout = globalThis.setTimeout; + + beforeEach(() => { + document.body.innerHTML = ""; + globalThis.setTimeout = (fn) => originalSetTimeout(fn, 0); + }); + + afterEach(() => { + globalThis.setTimeout = originalSetTimeout; + }); + + async function nextFrame() { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + async function flushMicrotasks() { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + } + + const LIST = { + uri: "at://did:plc:me/app.bsky.graph.list/rk1", + cid: "cid1", + name: "My List", + purpose: "app.bsky.graph.defs#curatelist", + creator: { did: "did:plc:me", handle: "me.test" }, + }; + + function createProfile({ did, handle, displayName } = {}) { + return { + did, + handle, + displayName: displayName ?? handle, + avatar: "", + labels: [], + viewer: {}, + }; + } + + function createFakeDataLayer({ + members = [], + searchResults = null, + follows = null, + addFailure = null, + removeFailure = null, + membersPages = null, + } = {}) { + const $listMembers = new SignalMap(); + const $currentUser = new Signal.State({ + did: "did:plc:me", + handle: "me.test", + }); + const $profileFollows = new SignalMap(); + const $chatRecipientSearchResults = new Signal.State(null); + const $loading = new SignalMap(); + const $errors = new SignalMap(); + const addCalls = []; + const removeCalls = []; + const loadMembersCalls = []; + + if (members.length && !membersPages) { + $listMembers.set(LIST.uri, { + items: members.map((p, i) => ({ + uri: `at://did:plc:me/app.bsky.graph.listitem/li${i}`, + subject: p, + })), + cursor: null, + }); + } + if (follows) { + $profileFollows.set("did:plc:me", { follows }); + } + if (searchResults) { + $chatRecipientSearchResults.set(searchResults); + } + + const dataLayer = { + dataStore: { $listMembers }, + derived: { + $currentUser, + $profileFollows, + $chatRecipientSearchResults, + }, + requests: { + statusStore: { + $statuses: new ComputedMap((requestId) => ({ + loading: $loading.get(requestId) ?? false, + error: $errors.get(requestId) ?? null, + })), + }, + loadListMembers: async (uri, { reload = false } = {}) => { + loadMembersCalls.push({ uri, reload }); + if (membersPages) { + const idx = loadMembersCalls.length - 1; + const page = membersPages[idx]; + if (!page) return; + const existing = $listMembers.get(uri); + const items = [ + ...(existing && !reload ? existing.items : []), + ...page.items, + ]; + $listMembers.set(uri, { items, cursor: page.cursor }); + } + }, + loadChatRecipientSearch: (query) => { + if (!query) { + $chatRecipientSearchResults.set(null); + } + return Promise.resolve(); + }, + }, + declarative: { + ensureCurrentUser: async () => $currentUser.get(), + ensureProfileFollows: async () => $profileFollows.get("did:plc:me"), + }, + mutations: { + addProfileToList: async (profile, list) => { + addCalls.push({ did: profile.did, listUri: list.uri }); + if (addFailure) throw addFailure; + const existing = $listMembers.get(list.uri) ?? { + items: [], + cursor: null, + }; + $listMembers.set(list.uri, { + ...existing, + items: [ + { + uri: `at://did:plc:me/app.bsky.graph.listitem/new-${profile.did}`, + subject: profile, + }, + ...existing.items, + ], + }); + }, + removeProfileFromList: async (profile, list, membershipUri) => { + removeCalls.push({ did: profile.did, membershipUri }); + if (removeFailure) throw removeFailure; + const existing = $listMembers.get(list.uri); + if (existing) { + $listMembers.set(list.uri, { + ...existing, + items: existing.items.filter( + (item) => item.subject.did !== profile.did, + ), + }); + } + }, + }, + }; + return { + dataLayer, + $listMembers, + $profileFollows, + $chatRecipientSearchResults, + addCalls, + removeCalls, + loadMembersCalls, + }; + } + + function createDialog(dataLayer, list = LIST) { + const element = document.createElement("manage-list-members-dialog"); + element.dataLayer = dataLayer; + element.list = list; + document.body.appendChild(element); + return element; + } + + async function typeQuery(element, value) { + const input = element.querySelector( + '[data-testid="manage-list-members-search-input"]', + ); + input.value = value; + input.dispatchEvent(new window.InputEvent("input", { bubbles: true })); + await nextFrame(); + await nextFrame(); + } + + it("renders the dialog title and search input", () => { + const { dataLayer } = createFakeDataLayer(); + const element = createDialog(dataLayer); + const dialog = element.querySelector("dialog.manage-list-members-dialog"); + assert(dialog !== null); + assert(dialog.classList.contains("bottom-sheet")); + assert(element.textContent.includes("Add people to list")); + assert( + element.querySelector( + '[data-testid="manage-list-members-search-input"]', + ) !== null, + ); + }); + + it("loads all list members on connect (paginating up to the cap)", async () => { + const alice = createProfile({ did: "did:plc:alice", handle: "alice.test" }); + const bob = createProfile({ did: "did:plc:bob", handle: "bob.test" }); + const { dataLayer, loadMembersCalls } = createFakeDataLayer({ + membersPages: [ + { items: [{ uri: "at://li1", subject: alice }], cursor: "next" }, + { items: [{ uri: "at://li2", subject: bob }], cursor: null }, + ], + }); + createDialog(dataLayer); + await flushMicrotasks(); + assert.deepEqual(loadMembersCalls.length, 2); + assert.deepEqual(loadMembersCalls[0].reload, true); + assert.deepEqual(loadMembersCalls[1].reload, false); + }); + + it("shows Add for a suggested profile that is not a member", async () => { + const alice = createProfile({ did: "did:plc:alice", handle: "alice.test" }); + const { dataLayer } = createFakeDataLayer({ + follows: [alice], + }); + const element = createDialog(dataLayer); + await flushMicrotasks(); + await nextFrame(); + const buttons = element.querySelectorAll( + '[data-testid="manage-list-members-toggle"]', + ); + assert.deepEqual(buttons.length, 1); + assert.deepEqual(buttons[0].dataset.teststate, "not-member"); + assert.equal(buttons[0].textContent.trim(), "Add"); + }); + + it("shows Remove for a search result that is already a member", async () => { + const alice = createProfile({ did: "did:plc:alice", handle: "alice.test" }); + const { dataLayer } = createFakeDataLayer({ + members: [alice], + searchResults: [alice], + }); + const element = createDialog(dataLayer); + await flushMicrotasks(); + await typeQuery(element, "alice"); + const buttons = element.querySelectorAll( + '[data-testid="manage-list-members-toggle"]', + ); + assert.deepEqual(buttons.length, 1); + assert.deepEqual(buttons[0].dataset.teststate, "member"); + assert.equal(buttons[0].textContent.trim(), "Remove"); + }); + + it("adds a profile when Add is clicked and flips the button to Remove", async () => { + const alice = createProfile({ did: "did:plc:alice", handle: "alice.test" }); + const { dataLayer, addCalls } = createFakeDataLayer({ + follows: [alice], + }); + const element = createDialog(dataLayer); + await flushMicrotasks(); + await nextFrame(); + element.querySelector('[data-testid="manage-list-members-toggle"]').click(); + await flushMicrotasks(); + await nextFrame(); + assert.deepEqual(addCalls.length, 1); + assert.deepEqual(addCalls[0].did, "did:plc:alice"); + const button = element.querySelector( + '[data-testid="manage-list-members-toggle"]', + ); + assert.deepEqual(button.dataset.teststate, "member"); + assert.equal(button.textContent.trim(), "Remove"); + }); + + it("removes a profile when Remove is clicked and flips the button to Add", async () => { + const alice = createProfile({ did: "did:plc:alice", handle: "alice.test" }); + const { dataLayer, removeCalls } = createFakeDataLayer({ + members: [alice], + follows: [alice], + }); + const element = createDialog(dataLayer); + await flushMicrotasks(); + await nextFrame(); + element.querySelector('[data-testid="manage-list-members-toggle"]').click(); + await flushMicrotasks(); + await nextFrame(); + assert.deepEqual(removeCalls.length, 1); + assert.deepEqual(removeCalls[0].did, "did:plc:alice"); + assert.match(removeCalls[0].membershipUri, /listitem\/li0$/); + const button = element.querySelector( + '[data-testid="manage-list-members-toggle"]', + ); + assert.deepEqual(button.dataset.teststate, "not-member"); + assert.equal(button.textContent.trim(), "Add"); + }); + + it("shows a spinner and disables the button while a toggle is pending", async () => { + const alice = createProfile({ did: "did:plc:alice", handle: "alice.test" }); + let releaseAdd; + const gate = new Promise((resolve) => (releaseAdd = resolve)); + const { dataLayer } = createFakeDataLayer({ + follows: [alice], + }); + dataLayer.mutations.addProfileToList = async () => { + await gate; + }; + const element = createDialog(dataLayer); + await flushMicrotasks(); + await nextFrame(); + element.querySelector('[data-testid="manage-list-members-toggle"]').click(); + await nextFrame(); + const pendingButton = element.querySelector( + '[data-testid="manage-list-members-toggle"]', + ); + assert(pendingButton.disabled); + assert( + pendingButton.querySelector('[data-testid="loading-spinner"]') !== null, + ); + releaseAdd(); + await flushMicrotasks(); + await nextFrame(); + const finalButton = element.querySelector( + '[data-testid="manage-list-members-toggle"]', + ); + assert.equal(finalButton.disabled, false); + assert.equal( + finalButton.querySelector('[data-testid="loading-spinner"]'), + null, + ); + }); + + it("switches from suggestions to search results when a query is typed", async () => { + const alice = createProfile({ did: "did:plc:alice", handle: "alice.test" }); + const dan = createProfile({ did: "did:plc:dan", handle: "dan.test" }); + const { dataLayer, $chatRecipientSearchResults } = createFakeDataLayer({ + follows: [alice], + }); + const element = createDialog(dataLayer); + await flushMicrotasks(); + await nextFrame(); + assert( + element.querySelector( + '[data-testid="manage-list-members-suggested-header"]', + ) !== null, + ); + $chatRecipientSearchResults.set([dan]); + await typeQuery(element, "dan"); + assert.equal( + element.querySelector( + '[data-testid="manage-list-members-suggested-header"]', + ), + null, + ); + const rows = element.querySelectorAll( + '[data-testid="profile-list-item-body"]', + ); + assert.deepEqual(rows.length, 1); + assert(rows[0].textContent.includes("@dan.test")); + }); + + it("shows the empty state when the search returns nothing", async () => { + const { dataLayer, $chatRecipientSearchResults } = createFakeDataLayer(); + const element = createDialog(dataLayer); + $chatRecipientSearchResults.set([]); + await typeQuery(element, "zzz"); + const emptyMessage = element.querySelector( + '[data-testid="feed-end-message"]', + ); + assert(emptyMessage !== null); + assert.equal(emptyMessage.textContent.trim(), "No results"); + }); + + it("closes on the close button", async () => { + const { dataLayer } = createFakeDataLayer(); + const element = createDialog(dataLayer); + let closed = false; + element.addEventListener("dialog-closed", () => { + closed = true; + }); + element.open(); + element.querySelector('[data-testid="manage-list-members-close"]').click(); + await flushMicrotasks(); + assert(closed); + }); +}); diff --git a/tests/unit/specs/components/new-chat-dialog.test.js b/tests/unit/specs/components/new-chat-dialog.test.js index 9daf68eb..4cf37d4c 100644 --- a/tests/unit/specs/components/new-chat-dialog.test.js +++ b/tests/unit/specs/components/new-chat-dialog.test.js @@ -202,10 +202,12 @@ describe("new-chat-dialog", () => { $profileFollows.set("did:plc:me", { follows: [] }); const element = createDialog(dataLayer); assert( - element.querySelector('[data-testid="new-chat-empty-prompt"]') !== null, + [...element.querySelectorAll('[data-testid="feed-end-message"]')].some( + (el) => el.textContent.includes("Search for someone to message"), + ), ); assert.deepEqual( - element.querySelector('[data-testid="new-chat-result"]'), + element.querySelector('[data-testid="profile-list-item-button"]'), null, ); }); @@ -232,10 +234,7 @@ describe("new-chat-dialog", () => { it("should show skeletons while follows load", () => { const { dataLayer } = createFakeDataLayer(); const element = createDialog(dataLayer); - assert( - element.querySelectorAll('[data-testid="new-chat-skeleton"]').length > - 0, - ); + assert(element.querySelectorAll(".profile-skeleton").length > 0); }); it("should show only messageable follows under a header", async () => { @@ -259,9 +258,11 @@ describe("new-chat-dialog", () => { element.querySelector('[data-testid="new-chat-suggested-header"]') !== null, ); - const rows = element.querySelectorAll('[data-testid="new-chat-result"]'); + const rows = element.querySelectorAll( + '[data-testid="profile-list-item-button"]', + ); assert.deepEqual(rows.length, 1); - assert.deepEqual(rows[0].dataset.teststate, "messageable"); + assert.deepEqual(rows[0].dataset.teststate, "enabled"); assert(rows[0].textContent.includes("@alice.test")); }); @@ -278,10 +279,12 @@ describe("new-chat-dialog", () => { }); const element = createDialog(dataLayer); assert( - element.querySelector('[data-testid="new-chat-empty-prompt"]') !== null, + [...element.querySelectorAll('[data-testid="feed-end-message"]')].some( + (el) => el.textContent.includes("Search for someone to message"), + ), ); assert.deepEqual( - element.querySelector('[data-testid="new-chat-result"]'), + element.querySelector('[data-testid="profile-list-item-button"]'), null, ); }); @@ -294,7 +297,9 @@ describe("new-chat-dialog", () => { await flushMicrotasks(); await nextFrame(); assert( - element.querySelector('[data-testid="new-chat-empty-prompt"]') !== null, + [...element.querySelectorAll('[data-testid="feed-end-message"]')].some( + (el) => el.textContent.includes("Search for someone to message"), + ), ); }); @@ -306,12 +311,11 @@ describe("new-chat-dialog", () => { await flushMicrotasks(); await nextFrame(); assert( - element.querySelector('[data-testid="new-chat-empty-prompt"]') !== null, - ); - assert.deepEqual( - element.querySelectorAll('[data-testid="new-chat-skeleton"]').length, - 0, + [...element.querySelectorAll('[data-testid="feed-end-message"]')].some( + (el) => el.textContent.includes("Search for someone to message"), + ), ); + assert.deepEqual(element.querySelectorAll(".profile-skeleton").length, 0); }); it("should hide the suggestions once a query is typed", async () => { @@ -338,7 +342,9 @@ describe("new-chat-dialog", () => { element.querySelector('[data-testid="new-chat-suggested-header"]'), null, ); - const rows = element.querySelectorAll('[data-testid="new-chat-result"]'); + const rows = element.querySelectorAll( + '[data-testid="profile-list-item-button"]', + ); assert.deepEqual(rows.length, 1); assert(rows[0].textContent.includes("@dan.test")); }); @@ -380,7 +386,9 @@ describe("new-chat-dialog", () => { assert.deepEqual(input.value, ""); assert.deepEqual(searchCalls[searchCalls.length - 1].query, ""); assert( - element.querySelector('[data-testid="new-chat-empty-prompt"]') !== null, + [...element.querySelectorAll('[data-testid="feed-end-message"]')].some( + (el) => el.textContent.includes("Search for someone to message"), + ), ); assert.deepEqual( element.querySelector('[data-testid="new-chat-search-clear"]'), @@ -412,10 +420,7 @@ describe("new-chat-dialog", () => { const element = createDialog(dataLayer); $loading.set("loadChatRecipientSearch", true); await typeQuery(element, "alice"); - assert( - element.querySelectorAll('[data-testid="new-chat-skeleton"]').length > - 0, - ); + assert(element.querySelectorAll(".profile-skeleton").length > 0); }); it("should show the empty state when a settled search has no results", async () => { @@ -423,7 +428,11 @@ describe("new-chat-dialog", () => { const element = createDialog(dataLayer); $results.set([]); await typeQuery(element, "alice"); - assert(element.querySelector('[data-testid="empty-state"]') !== null); + assert( + [...element.querySelectorAll('[data-testid="feed-end-message"]')].some( + (el) => el.textContent.includes("No results"), + ), + ); }); it("should show an error row when the search fails with an ApiError", async () => { @@ -495,7 +504,9 @@ describe("new-chat-dialog", () => { createProfile({ did: "did:plc:me", handle: "me.test" }), ]); await typeQuery(element, "test"); - const rows = element.querySelectorAll('[data-testid="new-chat-result"]'); + const rows = element.querySelectorAll( + '[data-testid="profile-list-item-button"]', + ); assert.deepEqual(rows.length, 1); assert(rows[0].textContent.includes("@alice.test")); }); @@ -516,13 +527,16 @@ describe("new-chat-dialog", () => { }), ]); await typeQuery(element, "test"); - const rows = element.querySelectorAll('[data-testid="new-chat-result"]'); + const rows = element.querySelectorAll( + '[data-testid="profile-list-item-button"]', + ); assert.deepEqual(rows.length, 2); - assert.deepEqual(rows[0].dataset.teststate, "messageable"); + assert.deepEqual(rows[0].dataset.teststate, "enabled"); assert.deepEqual(rows[0].disabled, false); - assert.deepEqual(rows[1].dataset.teststate, "not-messageable"); + assert.deepEqual(rows[1].dataset.teststate, "disabled"); assert.deepEqual(rows[1].disabled, true); - assert(rows[1].textContent.includes("@carol.test can't be messaged")); + assert(rows[1].textContent.includes("@carol.test")); + assert(rows[1].textContent.includes("Can't be messaged")); }); it("should treat a missing allowIncoming declaration as following-only", async () => { @@ -537,10 +551,12 @@ describe("new-chat-dialog", () => { createProfile({ did: "did:plc:stranger", handle: "stranger.test" }), ]); await typeQuery(element, "test"); - const rows = element.querySelectorAll('[data-testid="new-chat-result"]'); - assert.deepEqual(rows[0].dataset.teststate, "messageable"); + const rows = element.querySelectorAll( + '[data-testid="profile-list-item-button"]', + ); + assert.deepEqual(rows[0].dataset.teststate, "enabled"); assert(rows[0].textContent.includes("@follower.test")); - assert.deepEqual(rows[1].dataset.teststate, "not-messageable"); + assert.deepEqual(rows[1].dataset.teststate, "disabled"); }); }); @@ -561,7 +577,7 @@ describe("new-chat-dialog", () => { }), ]); await typeQuery(element, "alice"); - element.querySelector('[data-testid="new-chat-result"]').click(); + element.querySelector('[data-testid="profile-list-item-button"]').click(); await flushMicrotasks(); assert(closed, "the dialog closes when a user is selected"); assert.deepEqual(ensureCalls, ["did:plc:alice"]); @@ -587,7 +603,7 @@ describe("new-chat-dialog", () => { }), ]); await typeQuery(element, "alice"); - element.querySelector('[data-testid="new-chat-result"]').click(); + element.querySelector('[data-testid="profile-list-item-button"]').click(); await flushMicrotasks(); const toast = document.body.querySelector('[data-testid="toast"]'); assert(toast !== null, "toast should be shown"); @@ -614,7 +630,7 @@ describe("new-chat-dialog", () => { }), ]); await typeQuery(element, "alice"); - element.querySelector('[data-testid="new-chat-result"]').click(); + element.querySelector('[data-testid="profile-list-item-button"]').click(); await flushMicrotasks(); const toast = document.body.querySelector('[data-testid="toast"]'); assert(toast !== null, "toast should be shown"); @@ -636,7 +652,7 @@ describe("new-chat-dialog", () => { }), ]); await typeQuery(element, "alice"); - element.querySelector('[data-testid="new-chat-result"]').click(); + element.querySelector('[data-testid="profile-list-item-button"]').click(); await flushMicrotasks(); const toast = document.body.querySelector('[data-testid="toast"]'); assert(toast !== null, "toast should be shown"); diff --git a/tests/unit/specs/components/plugin-profiles-list.test.js b/tests/unit/specs/components/plugin-profiles-list.test.js index 67f240ac..363f6674 100644 --- a/tests/unit/specs/components/plugin-profiles-list.test.js +++ b/tests/unit/specs/components/plugin-profiles-list.test.js @@ -89,6 +89,21 @@ describe("plugin-profiles-list", () => { ); }); + it("does not render follow buttons on any row", async () => { + const dataLayer = makeDataLayer(); + dataLayer.__setProfile("did:test:a", makeProfile("did:test:a", "a.test")); + dataLayer.__setProfile("did:test:b", makeProfile("did:test:b", "b.test")); + const element = document.createElement("plugin-profiles-list"); + element.dataLayer = dataLayer; + element.setAttribute("dids", "did:test:a,did:test:b"); + document.body.appendChild(element); + await flushMicrotasks(); + assert.deepEqual( + element.querySelectorAll("[data-testid='follow-button']").length, + 0, + ); + }); + it("does not render the end-of-feed message", async () => { const dataLayer = makeDataLayer(); dataLayer.__setProfile("did:test:a", makeProfile("did:test:a", "a.test")); diff --git a/tests/unit/specs/templates/profileFeed.template.test.js b/tests/unit/specs/templates/profileFeed.template.test.js index a51309c3..e89fed08 100644 --- a/tests/unit/specs/templates/profileFeed.template.test.js +++ b/tests/unit/specs/templates/profileFeed.template.test.js @@ -189,10 +189,10 @@ describe("profileListItemTemplate - displayName sanitization", () => { }); describe("profileListItemTemplate - custom badge", () => { - it("should render the badge returned by badgeTemplate", () => { + it("should render the badge returned by rightItemTemplate", () => { const result = profileListItemTemplate({ actor: mockActor, - badgeTemplate: (actor) => + rightItemTemplate: (actor) => html`
${actor.handle}
`, }); const container = document.createElement("div"); @@ -202,10 +202,10 @@ describe("profileListItemTemplate - custom badge", () => { assert(badge.textContent.includes(mockActor.handle)); }); - it("should render nothing when badgeTemplate returns null", () => { + it("should render nothing when rightItemTemplate returns null", () => { const result = profileListItemTemplate({ actor: mockActor, - badgeTemplate: () => null, + rightItemTemplate: () => null, }); const container = document.createElement("div"); render(result, container); @@ -309,11 +309,12 @@ describe("profileFeedTemplate", () => { assert(msg.textContent.includes("End of feed")); }); - it("should pass badgeTemplate through to list items", () => { + it("should pass rightItemTemplate through to list items", () => { const result = profileFeedTemplate({ profiles: [mockActor], hasMore: false, - badgeTemplate: () => html`
Admin
`, + rightItemTemplate: () => + html`
Admin
`, }); render(result, container); assert(container.querySelector("[data-testid='custom-badge']") !== null);