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);
+ });
+});