diff --git a/package.json b/package.json index 86fe2f00..d4c50331 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.99", + "version": "0.18.100", "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 5a6b1734..38854876 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -393,6 +393,10 @@ plugin-rich-text { display: contents; } +plugin-slot:empty { + display: none; +} + .rich-text a { color: var(--text-link-color); } @@ -5461,6 +5465,22 @@ button.profile-list-item-button.is-disabled { min-width: 0; } +.profile-list-item-badges { + display: contents; +} + +.profile-list-item .label-badge { + font-size: 12px; + padding-right: 5px; + margin-top: 5px; + margin-right: 2px; +} + +.profile-list-item .label-badge-image { + width: 12px; + height: 12px; +} + .profile-list-item-follow { margin-left: auto; align-self: center; diff --git a/src/js/components/plugin-profiles-list.js b/src/js/components/plugin-profiles-list.js index d408389c..dfb6513b 100644 --- a/src/js/components/plugin-profiles-list.js +++ b/src/js/components/plugin-profiles-list.js @@ -14,7 +14,9 @@ class PluginProfilesList extends Component { if (!this.renderContext) { throw new Error("plugin-profiles-list requires a renderContext property"); } - this.dataLayer = this.renderContext.dataLayer; + const { dataLayer, pluginService } = this.renderContext; + this.dataLayer = dataLayer; + this.pluginService = pluginService; this.state = new ReactiveStore("plugin-profiles-list"); this.state.$dids = new Signal.State(this.parseDids()); this.state.$emptyMessage = new Signal.State( @@ -45,6 +47,7 @@ class PluginProfilesList extends Component { hasMore: false, skeletonCount: dids.length, emptyMessage, + pluginService: this.pluginService, rightItemTemplate: null, }), this, diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index 43779948..5a2c98a2 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -353,25 +353,14 @@ export class Derived extends ReactiveStore { if (!profile) return profile; const preferences = this.$preferences.get(); if (!preferences) return profile; - const blurLabel = preferences.getProfileBlurLabel(profile); - if (!blurLabel) return profile; - return { ...profile, blurLabel }; + return this.hydrateProfileLabels(profile, preferences); }); this.$hydratedDetailedProfiles = new ComputedMap((did) => { const profile = this.patchStore.$patchedDetailedProfiles.get(did); if (!profile) return null; const preferences = this.$preferences.get(); if (!preferences) return profile; - let result = profile; - const blurLabel = preferences.getProfileBlurLabel(profile); - if (blurLabel) { - result = { ...result, blurLabel }; - } - const badgeLabels = preferences.getBadgeLabelsForProfile(profile); - if (badgeLabels.length > 0) { - result = { ...result, badgeLabels }; - } - return result; + return this.hydrateProfileLabels(profile, preferences); }); this.$hydratedAuthorFeeds = new ComputedMap((feedURI) => { const rawFeed = this.dataStore.$authorFeeds.get(feedURI); @@ -527,12 +516,11 @@ export class Derived extends ReactiveStore { .map((did) => this.$hydratedProfiles.get(did)) .filter(Boolean); const preferences = this.$preferences.get(); - const members = convo.members.map((member) => { - if (!preferences) return member; - const badgeLabels = preferences.getBadgeLabelsForProfile(member); - if (badgeLabels.length === 0) return member; - return { ...member, badgeLabels }; - }); + const members = preferences + ? convo.members.map((member) => + this.hydrateProfileLabels(member, preferences), + ) + : convo.members; return [...members, ...referencedProfiles]; }); this.$convoForProfile = new ComputedMap((profileDid) => { @@ -627,12 +615,26 @@ export class Derived extends ReactiveStore { ), }; }); - this.$mutedProfiles = new Signal.Computed(() => - this.dataStore.$mutedProfiles.get(), - ); - this.$blockedProfiles = new Signal.Computed(() => - this.dataStore.$blockedProfiles.get(), - ); + this.$mutedProfiles = new Signal.Computed(() => { + const data = this.dataStore.$mutedProfiles.get(); + if (!data) return data; + return { + ...data, + mutes: data.mutes.map((profile) => + this.$hydratedProfiles.get(profile.did), + ), + }; + }); + this.$blockedProfiles = new Signal.Computed(() => { + const data = this.dataStore.$blockedProfiles.get(); + if (!data) return data; + return { + ...data, + blocks: data.blocks.map((profile) => + this.$hydratedProfiles.get(profile.did), + ), + }; + }); this.$notificationCursor = new Signal.Computed( () => this.dataStore.$notifications.get()?.cursor ?? null, ); @@ -692,6 +694,19 @@ export class Derived extends ReactiveStore { return { ...item, embed: updated }; } + hydrateProfileLabels(profile, preferences) { + let result = profile; + const blurLabel = preferences.getProfileBlurLabel(profile); + if (blurLabel) { + result = { ...result, blurLabel }; + } + const badgeLabels = preferences.getBadgeLabelsForProfile(profile); + if (badgeLabels.length > 0) { + result = { ...result, badgeLabels }; + } + return result; + } + hydratePost(post, preferences) { if (!post || !preferences) { return null; @@ -754,7 +769,11 @@ export class Derived extends ReactiveStore { return hydratedSearchResults; } - hydrateNotification(notification) { + hydrateNotification(rawNotification) { + const notification = { + ...rawNotification, + author: this.$hydratedProfiles.get(rawNotification.author.did), + }; if (notification.reason === "like" || notification.reason === "repost") { const subject = this.$hydratedPosts.get(notification.reasonSubject) ?? diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index eb85226d..086236c3 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -601,13 +601,7 @@ export class Mutations { if (!alreadyListed) { this.dataStore.$mutedProfiles.set({ ...mutedProfiles, - mutes: [ - { - ...profile, - viewer: { ...profile.viewer, muted: true }, - }, - ...mutedProfiles.mutes, - ], + mutes: [profile, ...mutedProfiles.mutes], }); } } @@ -696,13 +690,7 @@ export class Mutations { if (!alreadyListed) { this.dataStore.$blockedProfiles.set({ ...blockedProfiles, - blocks: [ - { - ...profile, - viewer: { ...profile.viewer, blocking: block.uri }, - }, - ...blockedProfiles.blocks, - ], + blocks: [profile, ...blockedProfiles.blocks], }); } } diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index 0ef6787e..c3d0e6ab 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -835,6 +835,9 @@ export class Requests { : readCollectionCursor(this.dataStore.$notifications); const labelers = this.requireLabelers(); const res = await this.api.getNotifications({ cursor, limit, labelers }); + this.dataStore.setProfiles( + res.notifications.map((notification) => notification.author), + ); // Get associated posts const postUris = getPostUrisFromNotifications(res.notifications); if (postUris.length > 0) { @@ -860,6 +863,9 @@ export class Requests { reasons: MENTION_REASONS, labelers, }); + this.dataStore.setProfiles( + res.notifications.map((notification) => notification.author), + ); const postUris = getPostUrisFromNotifications(res.notifications); if (postUris.length > 0) { const fetchedPosts = await this.api.getPosts(postUris, { labelers }); @@ -1441,6 +1447,7 @@ export class Requests { async loadBlockedProfiles({ cursor } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getBlocks({ cursor, labelers }); + this.dataStore.setProfiles(res.blocks); writePageToCollection(this.dataStore.$blockedProfiles, "blocks", res, { requestCursor: cursor ?? "", @@ -1451,6 +1458,7 @@ export class Requests { async loadMutedProfiles({ cursor } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getMutes({ cursor, labelers }); + this.dataStore.setProfiles(res.mutes); writePageToCollection(this.dataStore.$mutedProfiles, "mutes", res, { requestCursor: cursor ?? "", diff --git a/src/js/modals/profileList.modal.js b/src/js/modals/profileList.modal.js index eb37ede1..9405b4ce 100644 --- a/src/js/modals/profileList.modal.js +++ b/src/js/modals/profileList.modal.js @@ -22,6 +22,7 @@ class ProfileListModal extends Modal { isAuthenticated, currentUserDid, profileInteractionHandler, + pluginService, }, }) { return html` @@ -40,6 +41,7 @@ class ProfileListModal extends Modal { isAuthenticated, currentUserDid, profileInteractionHandler, + pluginService, })} diff --git a/src/js/templates/profileFeed.template.js b/src/js/templates/profileFeed.template.js index f775c3d4..a0a6202c 100644 --- a/src/js/templates/profileFeed.template.js +++ b/src/js/templates/profileFeed.template.js @@ -4,10 +4,12 @@ import { plusIconTemplate } from "/js/templates/icons/plusIcon.template.js"; import { linkToProfile } from "/js/navigation.js"; import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; +import { labelBadgesTemplate } from "/js/templates/labelBadges.template.js"; import { richTextTemplate } from "/js/templates/richText.template.js"; import { getDisplayName } from "/js/dataHelpers.js"; import { classnames } from "/js/utils.js"; import "/js/components/container-link.js"; +import "/js/components/plugin-slot.js"; // clickAction: "link" | "none" | callback function itemWrapperTemplate({ actor, clickAction, isDisabled, children }) { @@ -103,6 +105,7 @@ export function profileListItemTemplate({ isAuthenticated = false, currentUserDid = null, profileInteractionHandler = null, + pluginService = null, rightItemTemplate, clickAction = "link", compact = false, @@ -112,6 +115,8 @@ export function profileListItemTemplate({ const isBlocking = !!actor.viewer?.blocking; const isBlockedBy = !!actor.viewer?.blockedBy; const showsFollowsYou = isFollowedBy && !isBlocking && !isBlockedBy; + const showsLabelBadges = + actor.did !== currentUserDid && !!actor.badgeLabels?.length; const description = actor.description?.trim(); // Render follow button by default const rightItem = @@ -154,6 +159,20 @@ export function profileListItemTemplate({ Follows you ` : ""} + ${!compact + ? html`
+ ${showsLabelBadges + ? labelBadgesTemplate({ badgeLabels: actor.badgeLabels }) + : ""} + ${pluginService + ? html`` + : ""} +
` + : ""} ${!compact && description ? html`
${Array.from({ length: skeletonCount }).map(() => @@ -231,6 +256,7 @@ export function profileFeedTemplate({ isAuthenticated, currentUserDid, profileInteractionHandler, + pluginService, rightItemTemplate, clickAction, compact, diff --git a/src/js/views/groupChatDetails.view.js b/src/js/views/groupChatDetails.view.js index cf8c83d7..3b60b696 100644 --- a/src/js/views/groupChatDetails.view.js +++ b/src/js/views/groupChatDetails.view.js @@ -183,7 +183,7 @@ class GroupChatDetailsView extends View { root, params, router, - context: { dataLayer, isAuthenticated }, + context: { dataLayer, isAuthenticated, pluginService }, }) { await auth.requireAuth(); @@ -304,6 +304,7 @@ class GroupChatDetailsView extends View { hasMore, onLoadMore: loadMoreMembers, isAuthenticated, + pluginService, 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 a986f574..474d0dfd 100644 --- a/src/js/views/listDetail.view.js +++ b/src/js/views/listDetail.view.js @@ -326,6 +326,7 @@ class ListDetailView extends View { isAuthenticated, currentUserDid: currentUser?.did ?? null, profileInteractionHandler, + pluginService, ...(isCurateList ? {} : { rightItemTemplate: null }), })} ${members?.length === 0 && isCurrentUserList diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js index c2b0ab08..ea657d15 100644 --- a/src/js/views/notifications.view.js +++ b/src/js/views/notifications.view.js @@ -270,6 +270,7 @@ class NotificationsView extends View { currentUserDid: dataLayer.derived.$currentUser.get()?.did, profileInteractionHandler: interactionHandlers.profileInteractionHandler, + pluginService, }, ); }} diff --git a/src/js/views/postLikes.view.js b/src/js/views/postLikes.view.js index a0212845..07b5ce07 100644 --- a/src/js/views/postLikes.view.js +++ b/src/js/views/postLikes.view.js @@ -15,6 +15,7 @@ class PostLikesView extends View { identityResolver, isAuthenticated, interactionHandlers, + pluginService, }, }) { const { handleOrDid, rkey } = params; @@ -75,6 +76,7 @@ class PostLikesView extends View { currentUserDid: currentUser?.did ?? null, profileInteractionHandler: interactionHandlers.profileInteractionHandler, + pluginService, }); })()} diff --git a/src/js/views/postReposts.view.js b/src/js/views/postReposts.view.js index 596083c6..2320b86b 100644 --- a/src/js/views/postReposts.view.js +++ b/src/js/views/postReposts.view.js @@ -15,6 +15,7 @@ class PostRepostsView extends View { identityResolver, isAuthenticated, interactionHandlers, + pluginService, }, }) { const { handleOrDid, rkey } = params; @@ -74,6 +75,7 @@ class PostRepostsView extends View { currentUserDid: currentUser?.did ?? null, profileInteractionHandler: interactionHandlers.profileInteractionHandler, + pluginService, }); })()} diff --git a/src/js/views/profileFollowers.view.js b/src/js/views/profileFollowers.view.js index 9d644006..89cfaaad 100644 --- a/src/js/views/profileFollowers.view.js +++ b/src/js/views/profileFollowers.view.js @@ -16,6 +16,7 @@ class ProfileFollowersView extends View { identityResolver, interactionHandlers, isAuthenticated, + pluginService, }, }) { await auth.requireAuth(); @@ -86,6 +87,7 @@ class ProfileFollowersView extends View { currentUserDid: currentUser?.did ?? null, profileInteractionHandler: interactionHandlers.profileInteractionHandler, + pluginService, }); })()} diff --git a/src/js/views/profileFollowing.view.js b/src/js/views/profileFollowing.view.js index b6d9bc6c..ad0bb90b 100644 --- a/src/js/views/profileFollowing.view.js +++ b/src/js/views/profileFollowing.view.js @@ -16,6 +16,7 @@ class ProfileFollowingView extends View { identityResolver, interactionHandlers, isAuthenticated, + pluginService, }, }) { await auth.requireAuth(); @@ -84,6 +85,7 @@ class ProfileFollowingView extends View { currentUserDid: currentUser?.did ?? null, profileInteractionHandler: interactionHandlers.profileInteractionHandler, + pluginService, }); })()} diff --git a/src/js/views/profileKnownFollowers.view.js b/src/js/views/profileKnownFollowers.view.js index 050a8558..52848b11 100644 --- a/src/js/views/profileKnownFollowers.view.js +++ b/src/js/views/profileKnownFollowers.view.js @@ -16,6 +16,7 @@ class ProfileKnownFollowersView extends View { identityResolver, interactionHandlers, isAuthenticated, + pluginService, }, }) { await auth.requireAuth(); @@ -74,6 +75,7 @@ class ProfileKnownFollowersView extends View { currentUserDid: currentUser?.did ?? null, profileInteractionHandler: interactionHandlers.profileInteractionHandler, + pluginService, }); })()} diff --git a/src/js/views/search.view.js b/src/js/views/search.view.js index b3ad550c..2ada75a2 100644 --- a/src/js/views/search.view.js +++ b/src/js/views/search.view.js @@ -312,6 +312,7 @@ class SearchView extends View { isAuthenticated, currentUserDid: currentUser?.did ?? null, profileInteractionHandler, + pluginService, }); } diff --git a/src/js/views/settings/blockedAccounts.view.js b/src/js/views/settings/blockedAccounts.view.js index d2a42ac3..01737c2d 100644 --- a/src/js/views/settings/blockedAccounts.view.js +++ b/src/js/views/settings/blockedAccounts.view.js @@ -11,7 +11,7 @@ class SettingsBlockedAccountsView extends View { root, router, layout, - context: { dataLayer, isAuthenticated }, + context: { dataLayer, isAuthenticated, pluginService }, }) { await auth.requireAuth(); @@ -69,6 +69,7 @@ class SettingsBlockedAccountsView extends View { onLoadMore: loadMore, emptyMessage: "You haven't blocked any accounts.", isAuthenticated, + pluginService, }); })()} diff --git a/src/js/views/settings/mutedAccounts.view.js b/src/js/views/settings/mutedAccounts.view.js index 62a3e02d..708f8dce 100644 --- a/src/js/views/settings/mutedAccounts.view.js +++ b/src/js/views/settings/mutedAccounts.view.js @@ -11,7 +11,7 @@ class SettingsMutedAccountsView extends View { root, router, layout, - context: { dataLayer, isAuthenticated }, + context: { dataLayer, isAuthenticated, pluginService }, }) { await auth.requireAuth(); @@ -65,6 +65,7 @@ class SettingsMutedAccountsView extends View { onLoadMore: loadMore, emptyMessage: "You have not muted any accounts yet.", isAuthenticated, + pluginService, rightItemTemplate: null, }); })()} diff --git a/tests/unit/specs/components/plugin-profiles-list.test.js b/tests/unit/specs/components/plugin-profiles-list.test.js index a9bda6fc..54abdb36 100644 --- a/tests/unit/specs/components/plugin-profiles-list.test.js +++ b/tests/unit/specs/components/plugin-profiles-list.test.js @@ -3,8 +3,15 @@ import assert from "node:assert/strict"; import "/js/components/plugin-profiles-list.js"; import { makeTestDataLayer } from "../../testHelpers.js"; +function makeStubPluginService() { + return { + $slots: { get: () => null }, + getSlotEntries: () => [], + }; +} + function mount(element, dataLayer) { - element.renderContext = { dataLayer }; + element.renderContext = { dataLayer, pluginService: makeStubPluginService() }; document.body.appendChild(element); return element; } diff --git a/tests/unit/specs/dataLayer/derived.test.js b/tests/unit/specs/dataLayer/derived.test.js index 90e7fc22..9e6f43f3 100644 --- a/tests/unit/specs/dataLayer/derived.test.js +++ b/tests/unit/specs/dataLayer/derived.test.js @@ -1462,6 +1462,9 @@ describe("$notifications", () => { const author = createProfile({ did: "did:plc:actor", handle: "actor.test" }); function seedNotifications(dataStore, notifications, cursor = null) { + dataStore.setProfiles( + notifications.map((notification) => notification.author), + ); dataStore.$notifications.set({ notifications, cursor }); } @@ -1560,6 +1563,22 @@ describe("$notifications", () => { const result = derived.$notifications.get(); assert.deepEqual(result[0], notification); }); + + it("should hydrate the author from the profile store", () => { + const dataStore = new DataStore(); + const { derived, patchStore } = makeDerived(dataStore, { + preferences: fakePreferences({ + getBadgeLabelsForProfile: () => ["verified"], + }), + }); + seedNotifications(dataStore, [ + createNotification({ reason: "follow", author }), + ]); + patchStore.addProfilePatch(author.did, { type: "followProfile" }); + const result = derived.$notifications.get(); + assert.deepEqual(result[0].author.badgeLabels, ["verified"]); + assert.deepEqual(result[0].author.viewer.following, "fake following"); + }); }); describe("$mentionNotifications", () => { @@ -1573,6 +1592,7 @@ describe("$mentionNotifications", () => { const dataStore = new DataStore(); const { derived } = makeDerived(dataStore); const author = createProfile({ did: "did:plc:a", handle: "a.test" }); + dataStore.setProfiles([author]); dataStore.$posts.set("m1", { uri: "m1", record: { text: "m" } }); dataStore.$mentionNotifications.set({ notifications: [ @@ -1858,17 +1878,100 @@ describe("$listMembers", () => { }); }); -describe("$hydratedProfiles (blur labels)", () => { +describe("$hydratedProfiles (labels)", () => { + const did = "did:plc:user"; + it("should attach a blur label from preferences", () => { const dataStore = new DataStore(); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ getProfileBlurLabel: () => "adult" }), }); - const did = "did:plc:user"; dataStore.setProfiles([createProfile({ did, handle: "user.test" })]); const result = derived.$hydratedProfiles.get(did); assert.deepEqual(result.blurLabel, "adult"); }); + + it("should attach badge labels from preferences", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences({ + getBadgeLabelsForProfile: () => ["verified"], + }), + }); + dataStore.setProfiles([createProfile({ did, handle: "user.test" })]); + const result = derived.$hydratedProfiles.get(did); + assert.deepEqual(result.badgeLabels, ["verified"]); + }); + + it("should return the profile unchanged when no labels apply", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + dataStore.setProfiles([createProfile({ did, handle: "user.test" })]); + const result = derived.$hydratedProfiles.get(did); + assert.deepEqual(result.blurLabel, undefined); + assert.deepEqual(result.badgeLabels, undefined); + }); +}); + +describe("$mutedProfiles and $blockedProfiles", () => { + const did = "did:plc:user"; + + function seedMuted(dataStore) { + const profile = createProfile({ did, handle: "user.test" }); + dataStore.setProfiles([profile]); + dataStore.$mutedProfiles.set({ mutes: [profile], cursor: null }); + } + + it("should attach badge labels to muted profiles", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences({ + getBadgeLabelsForProfile: () => ["verified"], + }), + }); + seedMuted(dataStore); + const result = derived.$mutedProfiles.get(); + assert.deepEqual(result.mutes[0].badgeLabels, ["verified"]); + assert.deepEqual(result.cursor, null); + }); + + it("should attach badge labels to blocked profiles", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences({ + getBadgeLabelsForProfile: () => ["verified"], + }), + }); + const profile = createProfile({ did, handle: "user.test" }); + dataStore.setProfiles([profile]); + dataStore.$blockedProfiles.set({ blocks: [profile], cursor: null }); + const result = derived.$blockedProfiles.get(); + assert.deepEqual(result.blocks[0].badgeLabels, ["verified"]); + }); + + it("should return the lists unchanged when no labels apply", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + seedMuted(dataStore); + const result = derived.$mutedProfiles.get(); + assert.deepEqual(result.mutes[0].badgeLabels, undefined); + assert.deepEqual(result.mutes[0].blurLabel, undefined); + }); + + it("should reflect profile patches in the list", () => { + const dataStore = new DataStore(); + const { derived, patchStore } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + seedMuted(dataStore); + patchStore.addProfilePatch(did, { type: "followProfile" }); + const result = derived.$mutedProfiles.get(); + assert.deepEqual(result.mutes[0].viewer.following, "fake following"); + }); }); describe("$hydratedDetailedProfiles", () => { @@ -1996,13 +2099,17 @@ describe("$convoList and $convoRequestList", () => { }); }); -describe("$convoProfiles (badge labels)", () => { - it("should attach badge labels to convo members", () => { - const dataStore = new DataStore(); +describe("$convoProfiles (labels)", () => { + function seedConvo(dataStore) { dataStore.$convos.set("convo1", { id: "convo1", members: [{ did: "did:plc:member", handle: "member.test" }], }); + } + + it("should attach badge labels to convo members", () => { + const dataStore = new DataStore(); + seedConvo(dataStore); const { derived } = makeDerived(dataStore, { preferences: fakePreferences({ getBadgeLabelsForProfile: () => ["verified"], @@ -2011,6 +2118,27 @@ describe("$convoProfiles (badge labels)", () => { const profiles = derived.$convoProfiles.get("convo1"); assert.deepEqual(profiles[0].badgeLabels, ["verified"]); }); + + it("should attach a blur label to convo members", () => { + const dataStore = new DataStore(); + seedConvo(dataStore); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences({ getProfileBlurLabel: () => "adult" }), + }); + const profiles = derived.$convoProfiles.get("convo1"); + assert.deepEqual(profiles[0].blurLabel, "adult"); + }); + + it("should return members unchanged when no labels apply", () => { + const dataStore = new DataStore(); + seedConvo(dataStore); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + const profiles = derived.$convoProfiles.get("convo1"); + assert.deepEqual(profiles[0].badgeLabels, undefined); + assert.deepEqual(profiles[0].blurLabel, undefined); + }); }); describe("interaction and graph list hydration", () => { diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 6d66c749..ac6acffc 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -1325,7 +1325,6 @@ describe("muteProfile", () => { const stored = dataStore.$mutedProfiles.get(); assert.deepEqual(stored.mutes.length, 2); assert.deepEqual(stored.mutes[0].did, profile.did); - assert.deepEqual(stored.mutes[0].viewer.muted, true); assert.deepEqual(stored.mutes[1].did, existing.did); assert.deepEqual(stored.cursor, "abc"); }); @@ -1449,7 +1448,6 @@ describe("blockProfile", () => { const stored = dataStore.$blockedProfiles.get(); assert.deepEqual(stored.blocks.length, 2); assert.deepEqual(stored.blocks[0].did, profile.did); - assert.deepEqual(stored.blocks[0].viewer.blocking, blockUri); assert.deepEqual(stored.blocks[1].did, existing.did); assert.deepEqual(stored.cursor, "abc"); }); diff --git a/tests/unit/specs/dataLayer/requests.test.js b/tests/unit/specs/dataLayer/requests.test.js index b24db13d..d1777963 100644 --- a/tests/unit/specs/dataLayer/requests.test.js +++ b/tests/unit/specs/dataLayer/requests.test.js @@ -560,6 +560,12 @@ describe("loadMutedProfiles", () => { await requests.loadMutedProfiles(); assert.deepEqual(dataStore.$mutedProfiles.get(), res); + assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { + did: "did:plc:a", + }); + assert.deepEqual(dataStore.$profiles.get("did:plc:b"), { + did: "did:plc:b", + }); }); it("should append paginated muted profiles when cursor is provided", async () => { @@ -665,6 +671,12 @@ describe("loadBlockedProfiles", () => { await requests.loadBlockedProfiles(); assert.deepEqual(dataStore.$blockedProfiles.get(), res); + assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { + did: "did:plc:a", + }); + assert.deepEqual(dataStore.$profiles.get("did:plc:b"), { + did: "did:plc:b", + }); }); it("should append paginated blocked profiles when cursor is provided", async () => { @@ -1344,7 +1356,9 @@ describe("loadNotifications", () => { const dataStore = new DataStore(); const mockApi = { getNotifications: async () => ({ - notifications: [{ reason: "like", uri: "n1" }], + notifications: [ + { reason: "like", uri: "n1", author: { did: "did:plc:liker" } }, + ], cursor: "next", }), getPosts: async () => [], @@ -1355,6 +1369,9 @@ describe("loadNotifications", () => { assert.deepEqual(dataStore.$notifications.get().notifications.length, 1); assert.deepEqual(dataStore.$notifications.get().cursor, "next"); + assert.deepEqual(dataStore.$profiles.get("did:plc:liker"), { + did: "did:plc:liker", + }); }); it("should append when cursor matches previous", async () => { @@ -1369,7 +1386,9 @@ describe("loadNotifications", () => { getNotifications: async ({ cursor }) => { capturedCursor = cursor; return { - notifications: [{ reason: "follow", uri: "n2" }], + notifications: [ + { reason: "follow", uri: "n2", author: { did: "did:plc:f" } }, + ], cursor: "page3", }; }, @@ -1396,7 +1415,9 @@ describe("loadNotifications", () => { getNotifications: async ({ cursor }) => { capturedCursor = cursor; return { - notifications: [{ reason: "follow", uri: "n2" }], + notifications: [ + { reason: "follow", uri: "n2", author: { did: "did:plc:f" } }, + ], cursor: "fresh", }; }, @@ -1429,7 +1450,9 @@ describe("loadNotifications", () => { // Simulate a reload finishing while this page request is in flight dataStore.$notifications.set(reloadedNotifications); return { - notifications: [{ uri: "n2", reason: "follow" }], + notifications: [ + { uri: "n2", reason: "follow", author: { did: "did:plc:f" } }, + ], cursor: "c2", }; }, @@ -1460,7 +1483,9 @@ describe("loadNotifications", () => { // Simulate a duplicate page request landing first and exhausting the list dataStore.$notifications.set(fullyLoadedNotifications); return { - notifications: [{ uri: "n2", reason: "follow" }], + notifications: [ + { uri: "n2", reason: "follow", author: { did: "did:plc:f" } }, + ], cursor: null, }; }, @@ -1481,7 +1506,9 @@ describe("loadMentionNotifications", () => { getNotifications: async ({ reasons }) => { capturedReasons = reasons; return { - notifications: [{ reason: "mention", uri: "n1" }], + notifications: [ + { reason: "mention", uri: "n1", author: { did: "did:plc:m" } }, + ], cursor: "next", }; }, @@ -1508,7 +1535,9 @@ describe("loadMentionNotifications", () => { const mockApi = { getNotifications: async () => ({ - notifications: [{ reason: "reply", uri: "n2" }], + notifications: [ + { reason: "reply", uri: "n2", author: { did: "did:plc:r" } }, + ], cursor: "page3", }), getPosts: async () => [], @@ -1533,7 +1562,9 @@ describe("loadMentionNotifications", () => { const mockApi = { getNotifications: async () => ({ - notifications: [{ reason: "quote", uri: "n2" }], + notifications: [ + { reason: "quote", uri: "n2", author: { did: "did:plc:q" } }, + ], cursor: "fresh", }), getPosts: async () => [], diff --git a/tests/unit/specs/dragHelpers.test.js b/tests/unit/specs/dragHelpers.test.js index 113e88d8..2b52d4df 100644 --- a/tests/unit/specs/dragHelpers.test.js +++ b/tests/unit/specs/dragHelpers.test.js @@ -228,11 +228,14 @@ describe("enableDragToDismiss", () => { el.dispatchEvent(touchEvent("touchmove", { clientY: 150 })); assert.deepEqual(el.style.caretColor, "transparent"); }); - it("restores the caret only after the snap-back transition lands", async () => { + it("restores the caret only after the snap-back transition lands", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); - await drag(50); + el.dispatchEvent(touchEvent("touchstart", { clientY: 100 })); + el.dispatchEvent(touchEvent("touchmove", { clientY: 150 })); + el.dispatchEvent(touchEvent("touchend")); assert.deepEqual(el.style.caretColor, "transparent"); - await wait(300); + t.mock.timers.tick(300); assert.deepEqual(el.style.caretColor, ""); }); it("keeps the caret hidden through a dismiss", async () => { diff --git a/tests/unit/specs/templates/profileFeed.template.test.js b/tests/unit/specs/templates/profileFeed.template.test.js index 7cb6bd45..3def41e0 100644 --- a/tests/unit/specs/templates/profileFeed.template.test.js +++ b/tests/unit/specs/templates/profileFeed.template.test.js @@ -1,4 +1,4 @@ -import { describe, it, beforeEach } from "node:test"; +import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert/strict"; import { profileListItemTemplate, @@ -244,6 +244,115 @@ describe("profileListItemTemplate - custom badge", () => { }); }); +describe("profileListItemTemplate - label badges", () => { + const badgeLabels = [ + { + labelDefinition: { + identifier: "cool-badge", + locales: [{ lang: "en", name: "Cool Badge", description: "A badge" }], + }, + labeler: { + creator: { handle: "labeler.test", avatar: null }, + }, + }, + ]; + + it("should render label badges when the actor has badgeLabels", () => { + const result = profileListItemTemplate({ + actor: { ...mockActor, badgeLabels }, + }); + const container = document.createElement("div"); + render(result, container); + const badges = container.querySelector("[data-testid='label-badges']"); + assert(badges !== null); + assert(badges.textContent.includes("Cool Badge")); + }); + + it("should not render label badges when the actor has none", () => { + const result = profileListItemTemplate({ actor: mockActor }); + const container = document.createElement("div"); + render(result, container); + assert.deepEqual( + container.querySelector("[data-testid='label-badges']"), + null, + ); + }); + + it("should not render label badges when compact", () => { + const result = profileListItemTemplate({ + actor: { ...mockActor, badgeLabels }, + compact: true, + }); + const container = document.createElement("div"); + render(result, container); + assert.deepEqual( + container.querySelector("[data-testid='label-badges']"), + null, + ); + }); + + it("should not render label badges on the current user's own row", () => { + const result = profileListItemTemplate({ + actor: { ...mockActor, badgeLabels }, + currentUserDid: mockActor.did, + }); + const container = document.createElement("div"); + render(result, container); + assert.deepEqual( + container.querySelector("[data-testid='label-badges']"), + null, + ); + }); +}); + +describe("profileListItemTemplate - badges wrapper", () => { + const badgeLabels = [ + { + labelDefinition: { + identifier: "cool-badge", + locales: [{ lang: "en", name: "Cool Badge", description: "A badge" }], + }, + labeler: { + creator: { handle: "labeler.test", avatar: null }, + }, + }, + ]; + + it("should render label badges and the plugin slot inside a shared wrapper", () => { + const result = profileListItemTemplate({ + actor: { ...mockActor, badgeLabels }, + pluginService: {}, + }); + const container = document.createElement("div"); + render(result, container); + const wrapper = container.querySelector(".profile-list-item-badges"); + assert(wrapper !== null); + assert(wrapper.querySelector("[data-testid='label-badges']") !== null); + assert(wrapper.querySelector("plugin-slot[name='author-badges']") !== null); + }); +}); + +describe("profileListItemTemplate - plugin author badges", () => { + it("should render an author-badges plugin slot when pluginService is provided", () => { + const result = profileListItemTemplate({ + actor: mockActor, + pluginService: {}, + }); + const container = document.createElement("div"); + render(result, container); + const slot = container.querySelector("plugin-slot[name='author-badges']"); + assert(slot !== null); + assert.deepEqual(slot.getAttribute("context-did"), mockActor.did); + }); + + it("should not render a plugin slot without pluginService", () => { + const result = profileListItemTemplate({ actor: mockActor }); + const container = document.createElement("div"); + render(result, container); + assert.deepEqual(container.querySelector("plugin-slot"), null); + }); +}); + describe("profileListItemSkeletonTemplate", () => { it("should render skeleton avatar", () => { const result = profileListItemSkeletonTemplate(); @@ -258,6 +367,12 @@ describe("profileFeedTemplate", () => { beforeEach(() => { container = document.createElement("div"); + // These tests omit pluginService; silence the missing-pluginService warning. + mock.method(console, "warn", () => {}); + }); + + afterEach(() => { + mock.restoreAll(); }); it("should render skeleton when profiles is null", () => { @@ -364,3 +479,50 @@ describe("profileFeedTemplate", () => { ); }); }); + +describe("profileFeedTemplate - missing pluginService warning", () => { + let container; + let warn; + + beforeEach(() => { + container = document.createElement("div"); + warn = mock.method(console, "warn", () => {}); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it("should warn when a non-compact feed has no pluginService", () => { + render( + profileFeedTemplate({ profiles: [mockActor], hasMore: false }), + container, + ); + assert.deepEqual(warn.mock.callCount(), 1); + assert(warn.mock.calls[0].arguments[0].includes("pluginService")); + }); + + it("should not warn when pluginService is provided", () => { + render( + profileFeedTemplate({ + profiles: [mockActor], + hasMore: false, + pluginService: {}, + }), + container, + ); + assert.deepEqual(warn.mock.callCount(), 0); + }); + + it("should not warn for compact feeds", () => { + render( + profileFeedTemplate({ + profiles: [mockActor], + hasMore: false, + compact: true, + }), + container, + ); + assert.deepEqual(warn.mock.callCount(), 0); + }); +});