[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Add follow button to profile list items

Grace Kind (Jun 7, 2026, 2:44 AM -0500) 2e612f55 a4c490e7

+1769 -158
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.15.34", 3 + "version": "0.15.35", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+30 -4
src/css/style.css
··· 3961 3961 padding: 3px 8px; 3962 3962 border-radius: var(--tag-border-radius); 3963 3963 display: inline-block; 3964 - min-width: fit-content; 3964 + width: fit-content; 3965 3965 } 3966 3966 3967 3967 .profile-blocked-badge { ··· 4115 4115 4116 4116 .profile-list-item { 4117 4117 display: flex; 4118 - align-items: flex-start; 4119 - gap: 12px; 4118 + flex-direction: column; 4119 + gap: 8px; 4120 4120 padding: 16px; 4121 4121 border-bottom: var(--hair) solid var(--post-border-color); 4122 4122 cursor: pointer; 4123 + } 4124 + 4125 + .profile-list-item-row { 4126 + display: flex; 4127 + align-items: flex-start; 4128 + gap: 12px; 4123 4129 } 4124 4130 4125 4131 @media (min-width: 800px) { ··· 4163 4169 } 4164 4170 4165 4171 .profile-list-item-handle { 4166 - font-size: 13px; 4172 + font-size: 14px; 4167 4173 color: var(--text-color-muted); 4174 + overflow: hidden; 4175 + text-overflow: ellipsis; 4176 + white-space: nowrap; 4177 + min-width: 0; 4178 + } 4179 + 4180 + .profile-list-item-follow { 4181 + margin-left: auto; 4182 + align-self: center; 4183 + white-space: nowrap; 4184 + flex-shrink: 0; 4185 + } 4186 + 4187 + .profile-list-item-skeleton-bio { 4188 + display: flex; 4189 + flex-direction: column; 4190 + gap: 6px; 4168 4191 } 4169 4192 4170 4193 .profile-list-item-description { 4171 4194 font-size: 13px; 4172 4195 color: var(--text-color-muted); 4196 + } 4197 + 4198 + .profile-list-item-description .rich-text { 4173 4199 overflow: hidden; 4174 4200 text-overflow: ellipsis; 4175 4201 display: -webkit-box;
+77
src/js/components/detected-rich-text.js
··· 1 + import { render } from "/js/lib/lit-html.js"; 2 + import { Component } from "/js/components/component.js"; 3 + import { Signal, effect } from "/js/signals.js"; 4 + import { richTextTemplate } from "/js/templates/richText.template.js"; 5 + import { 6 + getUnresolvedFacetsFromText, 7 + resolveFacets, 8 + } from "/js/facetHelpers.js"; 9 + 10 + class DetectedRichText extends Component { 11 + static get observedAttributes() { 12 + return ["text", "truncate-urls"]; 13 + } 14 + 15 + connectedCallback() { 16 + if (this.initialized) return; 17 + this.initialized = true; 18 + 19 + this.$text = new Signal.State(this.getAttribute("text") ?? ""); 20 + this.$truncateUrls = new Signal.State(this.hasAttribute("truncate-urls")); 21 + this.$unresolvedFacets = new Signal.Computed(() => 22 + getUnresolvedFacetsFromText(this.$text.get()), 23 + ); 24 + this.$resolvedFacets = new Signal.State(null); 25 + 26 + // Resolve facets whenever unresolved facets change 27 + this.disposeResolve = effect(() => { 28 + if (!this.identityResolver) return; 29 + const unresolvedFacets = this.$unresolvedFacets.get(); 30 + resolveFacets(unresolvedFacets, this.identityResolver).then( 31 + (resolvedFacets) => { 32 + if (unresolvedFacets !== this.$unresolvedFacets.get()) { 33 + // If unresolved facets have changed since we started resolving, don't update 34 + return; 35 + } 36 + this.$resolvedFacets.set(resolvedFacets); 37 + }, 38 + ); 39 + }); 40 + 41 + this.disposeRender = effect(() => { 42 + const text = this.$text.get(); 43 + const unresolvedFacets = this.$unresolvedFacets.get(); 44 + const resolvedFacets = this.$resolvedFacets.get(); 45 + const truncateUrls = this.$truncateUrls.get(); 46 + let facets = resolvedFacets ?? unresolvedFacets; 47 + if (!this.identityResolver) { 48 + facets = facets.filter( 49 + (facet) => 50 + facet.features[0].$type !== "app.bsky.richtext.facet#mention", 51 + ); 52 + } 53 + render(richTextTemplate({ text, facets, truncateUrls }), this); 54 + }); 55 + } 56 + 57 + attributeChangedCallback(name, oldValue, newValue) { 58 + if (!this.initialized || oldValue === newValue) return; 59 + if (name === "text") { 60 + const text = newValue ?? ""; 61 + this.$resolvedFacets.set(null); 62 + this.$text.set(text); 63 + } else if (name === "truncate-urls") { 64 + this.$truncateUrls.set(newValue !== null); 65 + } 66 + } 67 + 68 + disconnectedCallback() { 69 + this.disposeRender?.(); 70 + this.disposeRender = null; 71 + this.disposeResolve?.(); 72 + this.disposeResolve = null; 73 + this.initialized = false; 74 + } 75 + } 76 + 77 + DetectedRichText.register();
+2 -1
src/js/components/plugin-profiles-list.js
··· 42 42 hasMore: false, 43 43 skeletonCount: dids.length, 44 44 emptyMessage, 45 + showFollowButton: false, 45 46 }), 46 47 this, 47 48 ); ··· 85 86 } 86 87 this.state.loaded.set(false); 87 88 try { 88 - await this.dataLayer.declarative.ensureProfiles(dids); 89 + await this.dataLayer.declarative.ensureDetailedProfiles(dids); 89 90 if (this._requestToken !== requestToken) return; 90 91 this.state.loaded.set(true); 91 92 } catch (error) {
+7
src/js/dataLayer/dataStore.js
··· 32 32 this.$postThreads = new SignalMap(); 33 33 this.$postThreadOthers = new SignalMap(); 34 34 this.$profiles = new SignalMap(); 35 + this.$detailedProfiles = new SignalMap(); 35 36 this.$authorFeeds = new SignalMap(); 36 37 this.$unavailablePosts = new SignalMap(); 37 38 this.$reposts = new SignalMap(); ··· 64 65 ) { 65 66 this.$posts.set(quotedPost.uri, embedViewRecordToPostView(quotedPost)); 66 67 } 68 + } 69 + } 70 + 71 + setProfiles(profiles) { 72 + for (const profile of profiles) { 73 + this.$profiles.set(profile.did, profile); 67 74 } 68 75 } 69 76 }
+6 -6
src/js/dataLayer/declarative.js
··· 15 15 return currentUser; 16 16 } 17 17 18 - async ensureProfile(profileDid) { 19 - const getProfile = (did) => this.derived.$hydratedProfiles.get(did); 18 + async ensureDetailedProfile(profileDid) { 19 + const getProfile = (did) => this.derived.$hydratedDetailedProfiles.get(did); 20 20 let profile = getProfile(profileDid); 21 21 if (!profile) { 22 - await this.requests.loadProfile(profileDid); 22 + await this.requests.loadDetailedProfile(profileDid); 23 23 profile = getProfile(profileDid); 24 24 } 25 25 if (!profile) { ··· 28 28 return profile; 29 29 } 30 30 31 - async ensureProfiles(profileDids) { 32 - const getProfile = (did) => this.derived.$hydratedProfiles.get(did); 31 + async ensureDetailedProfiles(profileDids) { 32 + const getProfile = (did) => this.derived.$hydratedDetailedProfiles.get(did); 33 33 const missing = profileDids.filter((did) => !getProfile(did)); 34 34 if (missing.length > 0) { 35 - await this.requests.loadProfiles(missing); 35 + await this.requests.loadDetailedProfiles(missing); 36 36 } 37 37 return profileDids.map((did) => getProfile(did) ?? null); 38 38 }
+71 -19
src/js/dataLayer/derived.js
··· 235 235 this.$lists = new ComputedMap((listUri) => 236 236 this.dataStore.$lists.get(listUri), 237 237 ); 238 - this.$listMembers = new ComputedMap((listUri) => 239 - this.dataStore.$listMembers.get(listUri), 240 - ); 238 + this.$listMembers = new ComputedMap((listUri) => { 239 + const data = this.dataStore.$listMembers.get(listUri); 240 + if (!data) return data; 241 + return { 242 + ...data, 243 + members: data.members.map((actor) => 244 + this.$hydratedProfiles.get(actor.did), 245 + ), 246 + }; 247 + }); 241 248 this.$profileSearchResults = new Signal.Computed(() => { 242 249 const data = this.dataStore.$profileSearchResults.get(); 243 250 if (!data) return null; 244 - return data.actors; 251 + return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did)); 245 252 }); 246 253 this.$profileSearchCursor = new Signal.Computed( 247 254 () => this.dataStore.$profileSearchResults.get()?.cursor ?? null, ··· 316 323 this.$hydratedProfiles = new ComputedMap((did) => { 317 324 const profile = this.patchStore.$patchedProfiles.get(did); 318 325 if (!profile) return profile; 326 + const preferences = this.$preferences.get(); 327 + if (!preferences) return profile; 328 + const blurLabel = preferences.getProfileBlurLabel(profile); 329 + if (!blurLabel) return profile; 330 + return { ...profile, blurLabel }; 331 + }); 332 + this.$hydratedDetailedProfiles = new ComputedMap((did) => { 333 + const profile = this.patchStore.$patchedDetailedProfiles.get(did); 334 + if (!profile) return null; 319 335 const preferences = this.$preferences.get(); 320 336 if (!preferences) return profile; 321 337 const blurLabel = preferences.getProfileBlurLabel(profile); ··· 444 460 cursor: messages.cursor, 445 461 }; 446 462 }); 447 - this.$postLikes = new ComputedMap((postUri) => 448 - this.dataStore.$postLikes.get(postUri), 449 - ); 450 - this.$postReposts = new ComputedMap((postUri) => 451 - this.dataStore.$postReposts.get(postUri), 452 - ); 453 - this.$profileFollows = new ComputedMap((did) => 454 - this.dataStore.$profileFollows.get(did), 455 - ); 456 - this.$profileFollowers = new ComputedMap((did) => 457 - this.dataStore.$profileFollowers.get(did), 458 - ); 459 - this.$knownFollowers = new ComputedMap((did) => 460 - this.dataStore.$knownFollowers.get(did), 461 - ); 463 + this.$postLikes = new ComputedMap((postUri) => { 464 + const data = this.dataStore.$postLikes.get(postUri); 465 + if (!data) return data; 466 + return { 467 + ...data, 468 + likes: data.likes.map((like) => ({ 469 + ...like, 470 + actor: this.$hydratedProfiles.get(like.actor.did), 471 + })), 472 + }; 473 + }); 474 + this.$postReposts = new ComputedMap((postUri) => { 475 + const data = this.dataStore.$postReposts.get(postUri); 476 + if (!data) return data; 477 + return { 478 + ...data, 479 + reposts: data.reposts.map((actor) => 480 + this.$hydratedProfiles.get(actor.did), 481 + ), 482 + }; 483 + }); 484 + this.$profileFollows = new ComputedMap((did) => { 485 + const data = this.dataStore.$profileFollows.get(did); 486 + if (!data) return data; 487 + return { 488 + ...data, 489 + follows: data.follows.map((actor) => 490 + this.$hydratedProfiles.get(actor.did), 491 + ), 492 + }; 493 + }); 494 + this.$profileFollowers = new ComputedMap((did) => { 495 + const data = this.dataStore.$profileFollowers.get(did); 496 + if (!data) return data; 497 + return { 498 + ...data, 499 + followers: data.followers.map((actor) => 500 + this.$hydratedProfiles.get(actor.did), 501 + ), 502 + }; 503 + }); 504 + this.$knownFollowers = new ComputedMap((did) => { 505 + const data = this.dataStore.$knownFollowers.get(did); 506 + if (!data) return data; 507 + return { 508 + ...data, 509 + followers: data.followers.map((actor) => 510 + this.$hydratedProfiles.get(actor.did), 511 + ), 512 + }; 513 + }); 462 514 this.$mutedProfiles = new Signal.Computed(() => 463 515 this.dataStore.$mutedProfiles.get(), 464 516 );
+52 -3
src/js/dataLayer/mutations.js
··· 228 228 }); 229 229 try { 230 230 const follow = await this.api.createFollowRecord(profile); 231 - // todo update followers count 232 231 this.dataStore.$profiles.set(profile.did, { 233 232 ...profile, 234 - followersCount: profile.followersCount + 1, 235 233 viewer: { ...profile.viewer, following: follow.uri }, 236 234 }); 235 + const detailed = this.dataStore.$detailedProfiles.get(profile.did); 236 + if (detailed) { 237 + this.dataStore.$detailedProfiles.set(profile.did, { 238 + ...detailed, 239 + followersCount: detailed.followersCount + 1, 240 + viewer: { ...detailed.viewer, following: follow.uri }, 241 + }); 242 + } 237 243 } catch (error) { 238 244 console.error(error); 239 245 throw error; ··· 289 295 await this.api.deleteFollowRecord(profile); 290 296 this.dataStore.$profiles.set(profile.did, { 291 297 ...profile, 292 - followersCount: profile.followersCount - 1, 293 298 viewer: { ...profile.viewer, following: null }, 294 299 }); 300 + const detailed = this.dataStore.$detailedProfiles.get(profile.did); 301 + if (detailed) { 302 + this.dataStore.$detailedProfiles.set(profile.did, { 303 + ...detailed, 304 + followersCount: detailed.followersCount - 1, 305 + viewer: { ...detailed.viewer, following: null }, 306 + }); 307 + } 295 308 } catch (error) { 296 309 console.error(error); 297 310 throw error; ··· 521 534 ...profile, 522 535 viewer: { ...profile.viewer, muted: true }, 523 536 }); 537 + const detailed = this.dataStore.$detailedProfiles.get(profile.did); 538 + if (detailed) { 539 + this.dataStore.$detailedProfiles.set(profile.did, { 540 + ...detailed, 541 + viewer: { ...detailed.viewer, muted: true }, 542 + }); 543 + } 524 544 this._updatePostsByAuthor(profile.did, (post) => { 525 545 return { 526 546 ...post, ··· 566 586 ...profile, 567 587 viewer: { ...profile.viewer, muted: false }, 568 588 }); 589 + const detailed = this.dataStore.$detailedProfiles.get(profile.did); 590 + if (detailed) { 591 + this.dataStore.$detailedProfiles.set(profile.did, { 592 + ...detailed, 593 + viewer: { ...detailed.viewer, muted: false }, 594 + }); 595 + } 569 596 this._updatePostsByAuthor(profile.did, (post) => { 570 597 return { 571 598 ...post, ··· 602 629 ...profile, 603 630 viewer: { ...profile.viewer, blocking: block.uri }, 604 631 }); 632 + const detailed = this.dataStore.$detailedProfiles.get(profile.did); 633 + if (detailed) { 634 + this.dataStore.$detailedProfiles.set(profile.did, { 635 + ...detailed, 636 + viewer: { ...detailed.viewer, blocking: block.uri }, 637 + }); 638 + } 605 639 this._updatePostsByAuthor(profile.did, (post) => { 606 640 return { 607 641 ...post, ··· 648 682 ...profile, 649 683 viewer: { ...profile.viewer, activitySubscription }, 650 684 }); 685 + const detailed = this.dataStore.$detailedProfiles.get(profile.did); 686 + if (detailed) { 687 + this.dataStore.$detailedProfiles.set(profile.did, { 688 + ...detailed, 689 + viewer: { ...detailed.viewer, activitySubscription }, 690 + }); 691 + } 651 692 } catch (error) { 652 693 console.error(error); 653 694 throw error; ··· 666 707 ...profile, 667 708 viewer: { ...profile.viewer, blocking: null }, 668 709 }); 710 + const detailed = this.dataStore.$detailedProfiles.get(profile.did); 711 + if (detailed) { 712 + this.dataStore.$detailedProfiles.set(profile.did, { 713 + ...detailed, 714 + viewer: { ...detailed.viewer, blocking: null }, 715 + }); 716 + } 669 717 this._updatePostsByAuthor(profile.did, (post) => { 670 718 return { 671 719 ...post, ··· 801 849 // Fetch full profile to get updated image urls 802 850 const updatedProfile = await this.api.getProfile(profile.did, { labelers }); 803 851 this.dataStore.$profiles.set(updatedProfile.did, updatedProfile); 852 + this.dataStore.$detailedProfiles.set(updatedProfile.did, updatedProfile); 804 853 const currentUser = this.dataStore.$currentUser.get(); 805 854 if (currentUser && currentUser.did === updatedProfile.did) { 806 855 this.dataStore.$currentUser.set({
+6
src/js/dataLayer/patchStore.js
··· 23 23 const patches = this.$profilePatches.get(did) || []; 24 24 return this.applyProfilePatches(profile, patches); 25 25 }); 26 + this.$patchedDetailedProfiles = new ComputedMap((did) => { 27 + const profile = this.dataStore.$detailedProfiles.get(did); 28 + if (!profile) return null; 29 + const patches = this.$profilePatches.get(did) || []; 30 + return this.applyProfilePatches(profile, patches); 31 + }); 26 32 27 33 this.$messagePatches = new SignalMap(); 28 34 this.$patchedMessages = new ComputedMap((messageId) => {
+15 -3
src/js/dataLayer/requests.js
··· 93 93 this.loadNextFeedPage, 94 94 (feedURI) => "loadNextFeedPage-" + feedURI, 95 95 ); 96 - this.enableStatus(this.loadProfile, (did) => "loadProfile-" + did); 96 + this.enableStatus( 97 + this.loadDetailedProfile, 98 + (did) => "loadDetailedProfile-" + did, 99 + ); 97 100 this.enableStatus( 98 101 this.loadProfileSearch, 99 102 (query) => "loadProfileSearch-" + query, ··· 452 455 } 453 456 } 454 457 455 - async loadProfile(did) { 458 + async loadDetailedProfile(did) { 456 459 const labelers = this.requireLabelers(); 457 460 const profile = await this.api.getProfile(did, { labelers }); 458 461 this.dataStore.$profiles.set(did, profile); 462 + this.dataStore.$detailedProfiles.set(did, profile); 459 463 } 460 464 461 - async loadProfiles(dids) { 465 + async loadDetailedProfiles(dids) { 462 466 if (dids.length === 0) return; 463 467 const labelers = this.requireLabelers(); 464 468 const profiles = await this.api.getProfiles(dids, { labelers }); 465 469 for (const profile of profiles) { 466 470 this.dataStore.$profiles.set(profile.did, profile); 471 + this.dataStore.$detailedProfiles.set(profile.did, profile); 467 472 } 468 473 } 469 474 ··· 486 491 if (requestTime !== this.dataStore.$latestProfileSearchRequestTime.get()) { 487 492 return; 488 493 } 494 + this.dataStore.setProfiles(searchData.actors); 489 495 const existingResults = this.dataStore.$profileSearchResults.get(); 490 496 if (existingResults && cursor) { 491 497 this.dataStore.$profileSearchResults.set({ ··· 818 824 const labelers = this.requireLabelers(); 819 825 const existingLikes = this.dataStore.$postLikes.get(postUri); 820 826 const res = await this.api.getLikes(postUri, { cursor, labelers }); 827 + this.dataStore.setProfiles(res.likes.map((like) => like.actor)); 821 828 822 829 if (existingLikes && cursor) { 823 830 // Append to existing likes ··· 863 870 const labelers = this.requireLabelers(); 864 871 const existingReposts = this.dataStore.$postReposts.get(postUri); 865 872 const res = await this.api.getRepostedBy(postUri, { cursor, labelers }); 873 + this.dataStore.setProfiles(res.repostedBy); 866 874 867 875 if (existingReposts && cursor) { 868 876 // Append to existing reposts ··· 933 941 } 934 942 const data = await this.api.getList(listUri, { limit, cursor }); 935 943 const newMembers = (data.items ?? []).map((item) => item.subject); 944 + this.dataStore.setProfiles(newMembers); 936 945 if (reload || !existing) { 937 946 this.dataStore.$listMembers.set(listUri, { 938 947 members: newMembers, ··· 1189 1198 const labelers = this.requireLabelers(); 1190 1199 const existingFollowers = this.dataStore.$profileFollowers.get(profileDid); 1191 1200 const res = await this.api.getFollowers(profileDid, { cursor, labelers }); 1201 + this.dataStore.setProfiles(res.followers); 1192 1202 1193 1203 if (existingFollowers && cursor) { 1194 1204 // Append to existing followers ··· 1209 1219 cursor, 1210 1220 labelers, 1211 1221 }); 1222 + this.dataStore.setProfiles(res.followers); 1212 1223 1213 1224 if (existing && cursor) { 1214 1225 this.dataStore.$knownFollowers.set(profileDid, { ··· 1224 1235 const labelers = this.requireLabelers(); 1225 1236 const existingFollows = this.dataStore.$profileFollows.get(profileDid); 1226 1237 const res = await this.api.getFollows(profileDid, { cursor, labelers }); 1238 + this.dataStore.setProfiles(res.follows); 1227 1239 1228 1240 if (existingFollows && cursor) { 1229 1241 // Append to existing follows
+11 -10
src/js/templates/profileCard.template.js
··· 19 19 sortBy, 20 20 } from "/js/utils.js"; 21 21 import { showSignInModal } from "/js/modals.js"; 22 - import { richTextTemplate } from "/js/templates/richText.template.js"; 22 + import "/js/components/detected-rich-text.js"; 23 23 import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; 24 24 import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; 25 25 import "/js/components/context-menu.js"; ··· 93 93 isBlockedBy, 94 94 isCurrentUser, 95 95 profile, 96 - richTextProfileDescription, 96 + identityResolver, 97 97 labelerInfo, 98 98 }) { 99 99 if (isBlocking) { ··· 110 110 </div> 111 111 </div>`; 112 112 } 113 + const description = profile.description?.trim(); 113 114 return html` 114 115 ${!isLabeler ? profileStatsTemplate({ profile }) : null} 115 - ${richTextProfileDescription 116 + ${description 116 117 ? html`<div class="profile-description"> 117 - ${richTextTemplate({ 118 - text: richTextProfileDescription.text, 119 - facets: richTextProfileDescription.facets, 120 - truncateUrls: true, 121 - })} 118 + <detected-rich-text 119 + text=${description} 120 + .identityResolver=${identityResolver} 121 + ?truncate-urls=${true} 122 + ></detected-rich-text> 122 123 </div>` 123 124 : ""} 124 125 ${!isLabeler && !isCurrentUser ··· 265 266 266 267 export function profileCardTemplate({ 267 268 profile, 268 - richTextProfileDescription, 269 + identityResolver, 269 270 isAuthenticated, 270 271 isCurrentUser, 271 272 profileChatStatus = null, ··· 461 462 isLabeler, 462 463 labelerInfo, 463 464 profile, 464 - richTextProfileDescription, 465 + identityResolver, 465 466 })} 466 467 </div>`; 467 468 }
+113 -28
src/js/templates/profileFeed.template.js
··· 3 3 import { linkToProfile } from "/js/navigation.js"; 4 4 import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; 5 5 import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; 6 + import { richTextTemplate } from "/js/templates/richText.template.js"; 6 7 import { getDisplayName } from "/js/dataHelpers.js"; 8 + import { classnames } from "/js/utils.js"; 7 9 8 - export function profileListItemTemplate({ actor }) { 10 + export function profileListItemTemplate({ 11 + actor, 12 + isAuthenticated = false, 13 + currentUserDid = null, 14 + profileInteractionHandler = null, 15 + showFollowButton = true, 16 + }) { 9 17 const displayName = getDisplayName(actor); 18 + const isCurrentUser = currentUserDid && actor.did === currentUserDid; 19 + const isFollowing = !!actor.viewer?.following; 20 + const isFollowedBy = !!actor.viewer?.followedBy; 21 + const isBlocking = !!actor.viewer?.blocking; 22 + const isBlockedBy = !!actor.viewer?.blockedBy; 23 + const showsFollowsYou = isFollowedBy && !isBlocking && !isBlockedBy; 24 + const showsFollowControl = 25 + showFollowButton && 26 + !isCurrentUser && 27 + isAuthenticated && 28 + !isBlocking && 29 + !isBlockedBy; 30 + const description = actor.description?.trim(); 10 31 return html`<div 11 - @click=${(e) => { 12 - if (e.target.closest("a")) return; 32 + @click=${(event) => { 33 + if (event.target.closest("a,button")) return; 13 34 window.router.go(linkToProfile(actor)); 14 35 }} 15 36 class="profile-list-item" 16 37 > 17 - ${avatarTemplate({ author: actor })} 18 - <div class="profile-list-item-body" data-testid="profile-list-item-body"> 19 - <a class="profile-list-item-name" href="${linkToProfile(actor)}"> 20 - <span 21 - class="profile-list-item-display-name" 22 - data-testid="profile-list-item-display-name" 38 + <div class="profile-list-item-row"> 39 + ${avatarTemplate({ author: actor })} 40 + <div class="profile-list-item-body" data-testid="profile-list-item-body"> 41 + <a class="profile-list-item-name" href="${linkToProfile(actor)}"> 42 + <span 43 + class="profile-list-item-display-name" 44 + data-testid="profile-list-item-display-name" 45 + > 46 + ${displayName}${verificationBadgeTemplate({ 47 + profile: actor, 48 + })}${automatedAccountBadgeTemplate({ profile: actor })} 49 + </span> 50 + </a> 51 + <div 52 + class="profile-list-item-handle" 53 + data-testid="profile-list-item-handle" 23 54 > 24 - ${displayName}${verificationBadgeTemplate({ 25 - profile: actor, 26 - })}${automatedAccountBadgeTemplate({ profile: actor })} 27 - </span> 28 - </a> 29 - <div 30 - class="profile-list-item-handle" 31 - data-testid="profile-list-item-handle" 32 - > 33 - @${actor.handle} 55 + @${actor.handle} 56 + </div> 34 57 </div> 58 + ${showsFollowControl 59 + ? html`<button 60 + @click=${() => { 61 + if (!profileInteractionHandler) { 62 + console.warn( 63 + "No profileInteractionHandler provided for follow button", 64 + ); 65 + return; 66 + } 67 + profileInteractionHandler.handleFollow(actor, !isFollowing); 68 + }} 69 + class=${classnames( 70 + "rounded-button profile-following-button profile-list-item-follow", 71 + { 72 + "rounded-button-primary": !isFollowing, 73 + }, 74 + )} 75 + data-testid="follow-button" 76 + data-teststate=${isFollowing 77 + ? "following" 78 + : isFollowedBy 79 + ? "follow-back" 80 + : "follow"} 81 + > 82 + ${isFollowing 83 + ? "Following" 84 + : isFollowedBy 85 + ? "+ Follow back" 86 + : "+ Follow"} 87 + </button>` 88 + : ""} 35 89 </div> 90 + ${showsFollowsYou 91 + ? html`<div class="profile-follows-you" data-testid="follows-you-badge"> 92 + Follows you 93 + </div>` 94 + : ""} 95 + ${description 96 + ? html`<div 97 + class="profile-list-item-description" 98 + data-testid="profile-list-item-description" 99 + > 100 + ${richTextTemplate({ text: description })} 101 + </div>` 102 + : ""} 36 103 </div>`; 37 104 } 38 105 39 106 export function profileListItemSkeletonTemplate() { 40 107 return html`<div class="profile-list-item profile-skeleton"> 41 - <div 42 - class="skeleton-avatar skeleton-animate" 43 - data-testid="skeleton-avatar" 44 - ></div> 45 - <div class="profile-list-item-body"> 108 + <div class="profile-list-item-row"> 109 + <div 110 + class="skeleton-avatar skeleton-animate" 111 + data-testid="skeleton-avatar" 112 + ></div> 113 + <div class="profile-list-item-body"> 114 + <div class="skeleton-line-short skeleton-animate"></div> 115 + <div class="skeleton-line-shorter skeleton-animate"></div> 116 + </div> 117 + </div> 118 + <div class="profile-list-item-skeleton-bio"> 46 119 <div class="skeleton-line-short skeleton-animate"></div> 47 - <div class="skeleton-line-shorter skeleton-animate"></div> 120 + <div class="skeleton-line-short skeleton-animate"></div> 48 121 </div> 49 122 </div>`; 50 123 } ··· 56 129 emptyMessage = null, 57 130 skeletonCount = 10, 58 131 showEndMessage = false, 132 + isAuthenticated = false, 133 + currentUserDid = null, 134 + profileInteractionHandler = null, 135 + showFollowButton = true, 59 136 }) { 60 137 if (!profiles) { 61 138 return html`<div class="profile-list"> ··· 71 148 } 72 149 return html`<infinite-scroll-container 73 150 lookahead="2500px" 74 - @load-more=${async (e) => { 151 + @load-more=${async (event) => { 75 152 if (hasMore && onLoadMore) { 76 153 await onLoadMore(); 77 - e.detail.resume(); 154 + event.detail.resume(); 78 155 } 79 156 }} 80 157 > 81 158 <div class="profile-list" data-testid="profile-feed"> 82 - ${profiles.map((profile) => profileListItemTemplate({ actor: profile }))} 159 + ${profiles.map((profile) => 160 + profileListItemTemplate({ 161 + actor: profile, 162 + isAuthenticated, 163 + currentUserDid, 164 + profileInteractionHandler, 165 + showFollowButton, 166 + }), 167 + )} 83 168 </div> 84 169 ${hasMore 85 170 ? html`<div
+9 -2
src/js/views/listDetail.view.js
··· 45 45 } 46 46 const listUri = `at://${profileDid}/app.bsky.graph.list/${rkey}`; 47 47 48 - const { postInteractionHandler, listInteractionHandler } = 49 - interactionHandlers; 48 + const { 49 + postInteractionHandler, 50 + listInteractionHandler, 51 + profileInteractionHandler, 52 + } = interactionHandlers; 50 53 51 54 const state = new ReactiveStore("listDetailView"); 52 55 state.$activeTab = new Signal.State("posts"); ··· 289 292 onLoadMore: () => loadMembers(), 290 293 emptyMessage: "This list has no members.", 291 294 showEndMessage: true, 295 + isAuthenticated, 296 + currentUserDid: currentUser?.did ?? null, 297 + profileInteractionHandler, 298 + showFollowButton: isCurateList, 292 299 })} 293 300 </div>`} 294 301 </div>
+5
src/js/views/postLikes.view.js
··· 19 19 postComposerService, 20 20 isAuthenticated, 21 21 pluginService, 22 + interactionHandlers, 22 23 }, 23 24 }) { 24 25 const { handleOrDid, rkey } = params; ··· 86 87 hasMore, 87 88 onLoadMore: loadLikes, 88 89 emptyMessage: "No likes yet.", 90 + isAuthenticated, 91 + currentUserDid: currentUser?.did ?? null, 92 + profileInteractionHandler: 93 + interactionHandlers.profileInteractionHandler, 89 94 }); 90 95 })()} 91 96 </main>`,
+5
src/js/views/postReposts.view.js
··· 19 19 postComposerService, 20 20 isAuthenticated, 21 21 pluginService, 22 + interactionHandlers, 22 23 }, 23 24 }) { 24 25 const { handleOrDid, rkey } = params; ··· 84 85 hasMore, 85 86 onLoadMore: loadReposts, 86 87 emptyMessage: "No reposts yet.", 88 + isAuthenticated, 89 + currentUserDid: currentUser?.did ?? null, 90 + profileInteractionHandler: 91 + interactionHandlers.profileInteractionHandler, 87 92 }); 88 93 })()} 89 94 </main>`,
+5 -28
src/js/views/profile.view.js
··· 12 12 import { labelerSettingsTemplate } from "/js/templates/labelerSettings.template.js"; 13 13 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 14 14 import { ApiError } from "/js/api.js"; 15 - import { getFacetsFromText } from "/js/facetHelpers.js"; 16 15 import { pageEffect } from "/js/router.js"; 17 16 import { AUTHOR_FEED_PAGE_SIZE, BSKY_LABELER_DID } from "/js/config.js"; 18 17 import { showToast } from "/js/toasts.js"; ··· 66 65 67 66 const state = new ReactiveStore("profileView"); 68 67 state.$activeTab = new Signal.State("posts"); 69 - state.$richTextProfileDescription = new Signal.State(null); 70 68 71 69 const { handleOrDid } = params; 72 70 let profileDid = null; ··· 121 119 ) { 122 120 try { 123 121 await dataLayer.mutations.updateProfile(profile, profileUpdates); 124 - await loadProfileDescription(); 125 122 showToast("Profile updated"); 126 123 successCallback(); 127 124 } catch (error) { ··· 325 322 labelerInfo, 326 323 currentUser, 327 324 activeTab, 328 - richTextProfileDescription, 329 325 }) { 330 326 try { 331 327 if (!isAuthenticated && doHideAuthorOnUnauthenticated(profile)) { ··· 374 370 <div class="profile-container"> 375 371 ${profileCardTemplate({ 376 372 profile, 377 - richTextProfileDescription, 373 + identityResolver, 378 374 isAuthenticated, 379 375 isCurrentUser, 380 376 profileChatStatus, ··· 528 524 } 529 525 530 526 pageEffect(root, () => { 531 - const profile = dataLayer.derived.$hydratedProfiles.get(profileDid); 527 + const profile = 528 + dataLayer.derived.$hydratedDetailedProfiles.get(profileDid); 532 529 const currentUser = dataLayer.derived.$currentUser.get(); 533 530 const numNotifications = 534 531 notificationService?.$numNotifications.get() ?? null; 535 532 const numChatNotifications = 536 533 chatNotificationService?.$numNotifications.get() ?? null; 537 534 const profileRequestStatus = dataLayer.requests.statusStore.$statuses.get( 538 - "loadProfile-" + profileDid, 535 + "loadDetailedProfile-" + profileDid, 539 536 ); 540 537 const isLabeler = profile && isLabelerProfile(profile); 541 538 const labelerInfo = isLabeler ··· 544 541 // If labeler, require labeler info to be loaded 545 542 const isLoaded = profile && (isLabeler ? !!labelerInfo : true); 546 543 const activeTab = state.$activeTab.get(); 547 - const richTextProfileDescription = 548 - state.$richTextProfileDescription.get(); 549 544 render( 550 545 html`<div id="profile-view"> 551 546 ${mainLayoutTemplate({ ··· 583 578 labelerInfo, 584 579 currentUser, 585 580 activeTab, 586 - richTextProfileDescription, 587 581 }); 588 582 } else { 589 583 return profileSkeletonTemplate(); ··· 650 644 } 651 645 } 652 646 653 - // This is async because it needs to resolve mentions 654 - async function loadProfileDescription() { 655 - const profile = dataLayer.derived.$hydratedProfiles.get(profileDid); 656 - if (!profile?.description) { 657 - return; 658 - } 659 - const facets = await getFacetsFromText( 660 - profile.description, 661 - identityResolver, 662 - ); 663 - state.$richTextProfileDescription.set({ 664 - text: profile.description, 665 - facets, 666 - }); 667 - } 668 - 669 647 root.addEventListener("page-enter", async () => { 670 648 if (isAuthenticated) { 671 649 await dataLayer.declarative.ensureCurrentUser(); ··· 673 651 674 652 let profile; 675 653 try { 676 - profile = await dataLayer.declarative.ensureProfile(profileDid); 654 + profile = await dataLayer.declarative.ensureDetailedProfile(profileDid); 677 655 } catch { 678 656 return; 679 657 } ··· 685 663 dataLayer.requests.loadLabelerInfo(profile.did); 686 664 } 687 665 688 - await loadProfileDescription(); 689 666 if (!profile.viewer?.blocking && !profile.viewer?.blockedBy) { 690 667 loadAuthorFeed(); 691 668 preloadHiddenFeeds();
+9 -2
src/js/views/profileFollowers.view.js
··· 19 19 chatNotificationService, 20 20 postComposerService, 21 21 pluginService, 22 + interactionHandlers, 23 + isAuthenticated, 22 24 }, 23 25 }) { 24 26 await auth.requireAuth(); ··· 48 50 chatNotificationService?.$numNotifications.get() ?? null; 49 51 const profileFollowers = 50 52 dataLayer.derived.$profileFollowers.get(profileDid); 51 - const profile = dataLayer.derived.$hydratedProfiles.get(profileDid); 53 + const profile = 54 + dataLayer.derived.$hydratedDetailedProfiles.get(profileDid); 52 55 const profileFollowersRequestStatus = 53 56 dataLayer.requests.statusStore.$statuses.get( 54 57 "loadProfileFollowers-" + profileDid, ··· 86 89 hasMore, 87 90 onLoadMore: loadFollowers, 88 91 emptyMessage: "No followers yet.", 92 + isAuthenticated, 93 + currentUserDid: currentUser?.did ?? null, 94 + profileInteractionHandler: 95 + interactionHandlers.profileInteractionHandler, 89 96 }); 90 97 })()} 91 98 </main>`, ··· 105 112 root.addEventListener("page-enter", async () => { 106 113 dataLayer.declarative.ensureCurrentUser(); 107 114 // Load the profile to get the follower count 108 - dataLayer.declarative.ensureProfile(profileDid); 115 + dataLayer.declarative.ensureDetailedProfile(profileDid); 109 116 await loadFollowers(); 110 117 }); 111 118
+9 -2
src/js/views/profileFollowing.view.js
··· 19 19 chatNotificationService, 20 20 postComposerService, 21 21 pluginService, 22 + interactionHandlers, 23 + isAuthenticated, 22 24 }, 23 25 }) { 24 26 await auth.requireAuth(); ··· 48 50 chatNotificationService?.$numNotifications.get() ?? null; 49 51 const profileFollowing = 50 52 dataLayer.derived.$profileFollows.get(profileDid); 51 - const profile = dataLayer.derived.$hydratedProfiles.get(profileDid); 53 + const profile = 54 + dataLayer.derived.$hydratedDetailedProfiles.get(profileDid); 52 55 const profileFollowingRequestStatus = 53 56 dataLayer.requests.statusStore.$statuses.get( 54 57 "loadProfileFollows-" + profileDid, ··· 84 87 hasMore, 85 88 onLoadMore: loadFollowing, 86 89 emptyMessage: "Not following anyone yet.", 90 + isAuthenticated, 91 + currentUserDid: currentUser?.did ?? null, 92 + profileInteractionHandler: 93 + interactionHandlers.profileInteractionHandler, 87 94 }); 88 95 })()} 89 96 </main>`, ··· 103 110 root.addEventListener("page-enter", async () => { 104 111 dataLayer.declarative.ensureCurrentUser(); 105 112 // Load the profile to get the follows count 106 - dataLayer.declarative.ensureProfile(profileDid); 113 + dataLayer.declarative.ensureDetailedProfile(profileDid); 107 114 await loadFollowing(); 108 115 }); 109 116
+9 -2
src/js/views/profileKnownFollowers.view.js
··· 19 19 chatNotificationService, 20 20 postComposerService, 21 21 pluginService, 22 + interactionHandlers, 23 + isAuthenticated, 22 24 }, 23 25 }) { 24 26 await auth.requireAuth(); ··· 47 49 const numChatNotifications = 48 50 chatNotificationService?.$numNotifications.get() ?? null; 49 51 const knownFollowers = dataLayer.derived.$knownFollowers.get(profileDid); 50 - const profile = dataLayer.derived.$hydratedProfiles.get(profileDid); 52 + const profile = 53 + dataLayer.derived.$hydratedDetailedProfiles.get(profileDid); 51 54 const requestStatus = dataLayer.requests.statusStore.$statuses.get( 52 55 "loadKnownFollowers-" + profileDid, 53 56 ); ··· 79 82 emptyMessage: profile 80 83 ? `You don't follow anyone who follows @${profile.handle}.` 81 84 : "You don't follow anyone who follows this user.", 85 + isAuthenticated, 86 + currentUserDid: currentUser?.did ?? null, 87 + profileInteractionHandler: 88 + interactionHandlers.profileInteractionHandler, 82 89 }); 83 90 })()} 84 91 </main>`, ··· 96 103 97 104 root.addEventListener("page-enter", async () => { 98 105 dataLayer.declarative.ensureCurrentUser(); 99 - dataLayer.declarative.ensureProfile(profileDid); 106 + dataLayer.declarative.ensureDetailedProfile(profileDid); 100 107 await loadKnownFollowers(); 101 108 }); 102 109
+10 -2
src/js/views/search.view.js
··· 103 103 }); 104 104 } 105 105 106 - const { postInteractionHandler, feedInteractionHandler } = 107 - interactionHandlers; 106 + const { 107 + postInteractionHandler, 108 + feedInteractionHandler, 109 + profileInteractionHandler, 110 + } = interactionHandlers; 108 111 109 112 const handleSearchInput = debounce((value) => { 110 113 state.$searchQuery.set(value); ··· 188 191 status, 189 192 profileSearchResults, 190 193 profileSearchHasMore, 194 + currentUser, 191 195 }) { 192 196 if (!profileSearchResults && status.loading) { 193 197 return html`<div class="search-status-message"> ··· 212 216 profiles: profileSearchResults, 213 217 hasMore: profileSearchHasMore, 214 218 onLoadMore: loadMoreProfiles, 219 + isAuthenticated, 220 + currentUserDid: currentUser?.did ?? null, 221 + profileInteractionHandler, 215 222 }); 216 223 } 217 224 ··· 432 439 status: profileStatus, 433 440 profileSearchResults, 434 441 profileSearchHasMore, 442 + currentUser, 435 443 })} 436 444 </div> 437 445 </div>
+1 -1
src/js/views/settings.view.js
··· 358 358 const otherAccounts = $otherAccounts.get(); 359 359 if (otherAccounts === null) return; 360 360 dataLayer.declarative 361 - .ensureProfiles(otherAccounts.map((account) => account.did)) 361 + .ensureDetailedProfiles(otherAccounts.map((account) => account.did)) 362 362 .then((profiles) => { 363 363 const profilesByDid = {}; 364 364 for (const profile of profiles) {
+1
src/js/views/settings/blockedAccounts.view.js
··· 78 78 hasMore, 79 79 onLoadMore: loadMore, 80 80 emptyMessage: "You haven't blocked any accounts.", 81 + showFollowButton: false, 81 82 }); 82 83 })()} 83 84 </main>`,
+1
src/js/views/settings/mutedAccounts.view.js
··· 77 77 hasMore, 78 78 onLoadMore: loadMore, 79 79 emptyMessage: "You have not muted any accounts yet.", 80 + showFollowButton: false, 80 81 }); 81 82 })()} 82 83 </main>`,
+134
tests/e2e/specs/views/listDetail.view.test.js
··· 232 232 ); 233 233 }); 234 234 235 + test("should render bio, follows-you, and follow-state per list member", async ({ 236 + page, 237 + }) => { 238 + const mockServer = new MockServer(); 239 + setupList(mockServer); 240 + const followsBack = createProfile({ 241 + did: "did:plc:followsback1", 242 + handle: "followsback.bsky.social", 243 + displayName: "Follows Back", 244 + description: "I follow you and have a bio.", 245 + viewer: { followedBy: "at://did:plc:followsback1/follow/1" }, 246 + }); 247 + const alreadyFollowing = createProfile({ 248 + did: "did:plc:already1", 249 + handle: "already.bsky.social", 250 + displayName: "Already Following", 251 + description: "", 252 + viewer: { following: "at://did:plc:viewer/follow/abc" }, 253 + }); 254 + const stranger = createProfile({ 255 + did: "did:plc:stranger1", 256 + handle: "stranger.bsky.social", 257 + displayName: "Stranger", 258 + description: "A stranger with a description.", 259 + }); 260 + mockServer.addListMembers(LIST_URI, [ 261 + followsBack, 262 + alreadyFollowing, 263 + stranger, 264 + ]); 265 + await mockServer.setup(page); 266 + 267 + await login(page); 268 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 269 + 270 + const view = page.locator("#list-detail-view"); 271 + await view.locator('[data-testid="tab-people"]').click(); 272 + 273 + const followsBackRow = view 274 + .locator(".profile-list-item") 275 + .filter({ hasText: "Follows Back" }); 276 + const alreadyRow = view 277 + .locator(".profile-list-item") 278 + .filter({ hasText: "Already Following" }); 279 + const strangerRow = view 280 + .locator(".profile-list-item") 281 + .filter({ hasText: "Stranger" }); 282 + 283 + await expect( 284 + followsBackRow.locator('[data-testid="follows-you-badge"]'), 285 + ).toBeVisible({ timeout: 10000 }); 286 + await expect( 287 + alreadyRow.locator('[data-testid="follows-you-badge"]'), 288 + ).toHaveCount(0); 289 + await expect( 290 + strangerRow.locator('[data-testid="follows-you-badge"]'), 291 + ).toHaveCount(0); 292 + 293 + await expect( 294 + followsBackRow.locator('[data-testid="profile-list-item-description"]'), 295 + ).toContainText("I follow you and have a bio."); 296 + await expect( 297 + alreadyRow.locator('[data-testid="profile-list-item-description"]'), 298 + ).toHaveCount(0); 299 + 300 + await expect( 301 + followsBackRow.locator('[data-testid="follow-button"]'), 302 + ).toHaveAttribute("data-teststate", "follow-back"); 303 + await expect( 304 + alreadyRow.locator('[data-testid="follow-button"]'), 305 + ).toHaveAttribute("data-teststate", "following"); 306 + await expect( 307 + strangerRow.locator('[data-testid="follow-button"]'), 308 + ).toHaveAttribute("data-teststate", "follow"); 309 + }); 310 + 311 + test("clicking the follow button on a list member toggles to following", async ({ 312 + page, 313 + }) => { 314 + const mockServer = new MockServer(); 315 + setupList(mockServer); 316 + const target = createProfile({ 317 + did: "did:plc:target1", 318 + handle: "target.bsky.social", 319 + displayName: "Target User", 320 + }); 321 + mockServer.addListMembers(LIST_URI, [target]); 322 + await mockServer.setup(page); 323 + 324 + await login(page); 325 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 326 + 327 + const view = page.locator("#list-detail-view"); 328 + await view.locator('[data-testid="tab-people"]').click(); 329 + 330 + const targetRow = view 331 + .locator(".profile-list-item") 332 + .filter({ hasText: "Target User" }); 333 + 334 + const followButton = targetRow.locator('[data-testid="follow-button"]'); 335 + await expect(followButton).toHaveAttribute("data-teststate", "follow", { 336 + timeout: 10000, 337 + }); 338 + await followButton.click(); 339 + await expect(followButton).toHaveAttribute("data-teststate", "following", { 340 + timeout: 10000, 341 + }); 342 + }); 343 + 235 344 test.describe("Moderation list subscription", () => { 236 345 const MOD_LIST_URI = "at://did:plc:creator1/app.bsky.graph.list/modlist"; 237 346 ··· 373 482 await expect(button).toHaveAttribute("data-teststate", "not-subscribed", { 374 483 timeout: 10000, 375 484 }); 485 + }); 486 + 487 + test("should not show follow buttons on moderation list members", async ({ 488 + page, 489 + }) => { 490 + const mockServer = new MockServer(); 491 + setupModList(mockServer); 492 + const member = createProfile({ 493 + did: "did:plc:modmember1", 494 + handle: "modmember.bsky.social", 495 + displayName: "Mod Member", 496 + }); 497 + mockServer.addListMembers(MOD_LIST_URI, [member]); 498 + await mockServer.setup(page); 499 + 500 + await login(page); 501 + await page.goto("/profile/creator1.bsky.social/lists/modlist"); 502 + 503 + const view = page.locator("#list-detail-view"); 504 + await expect(view.locator(".profile-list-item")).toHaveCount(1, { 505 + timeout: 10000, 506 + }); 507 + await expect(view.locator('[data-testid="follow-button"]')).toHaveCount( 508 + 0, 509 + ); 376 510 }); 377 511 378 512 test("should show Subscribe button on the user's own moderation list", async ({
+113
tests/e2e/specs/views/postLikes.view.test.js
··· 144 144 ); 145 145 }); 146 146 147 + test("should render bio, follows-you, and follow-state per liker", async ({ 148 + page, 149 + }) => { 150 + const followsBack = createProfile({ 151 + did: "did:plc:followsback1", 152 + handle: "followsback.bsky.social", 153 + displayName: "Follows Back", 154 + description: "I follow you and have a bio.", 155 + viewer: { followedBy: "at://did:plc:followsback1/follow/1" }, 156 + }); 157 + const alreadyFollowing = createProfile({ 158 + did: "did:plc:already1", 159 + handle: "already.bsky.social", 160 + displayName: "Already Following", 161 + description: "", 162 + viewer: { following: "at://did:plc:viewer/follow/abc" }, 163 + }); 164 + const stranger = createProfile({ 165 + did: "did:plc:stranger1", 166 + handle: "stranger.bsky.social", 167 + displayName: "Stranger", 168 + description: "A stranger with a description.", 169 + }); 170 + 171 + const mockServer = new MockServer(); 172 + mockServer.addPosts([post]); 173 + mockServer.addPostLikes(postUri, [ 174 + { actor: followsBack, createdAt: "2025-01-15T12:00:00.000Z" }, 175 + { actor: alreadyFollowing, createdAt: "2025-01-15T13:00:00.000Z" }, 176 + { actor: stranger, createdAt: "2025-01-15T14:00:00.000Z" }, 177 + ]); 178 + await mockServer.setup(page); 179 + 180 + await login(page); 181 + await page.goto("/profile/author1.bsky.social/post/abc123/likes"); 182 + 183 + const view = page.locator("#post-likes-view"); 184 + const followsBackRow = view 185 + .locator(".profile-list-item") 186 + .filter({ hasText: "Follows Back" }); 187 + const alreadyRow = view 188 + .locator(".profile-list-item") 189 + .filter({ hasText: "Already Following" }); 190 + const strangerRow = view 191 + .locator(".profile-list-item") 192 + .filter({ hasText: "Stranger" }); 193 + 194 + await expect( 195 + followsBackRow.locator('[data-testid="follows-you-badge"]'), 196 + ).toBeVisible({ timeout: 10000 }); 197 + await expect( 198 + alreadyRow.locator('[data-testid="follows-you-badge"]'), 199 + ).toHaveCount(0); 200 + await expect( 201 + strangerRow.locator('[data-testid="follows-you-badge"]'), 202 + ).toHaveCount(0); 203 + 204 + await expect( 205 + followsBackRow.locator('[data-testid="profile-list-item-description"]'), 206 + ).toContainText("I follow you and have a bio."); 207 + await expect( 208 + alreadyRow.locator('[data-testid="profile-list-item-description"]'), 209 + ).toHaveCount(0); 210 + 211 + await expect( 212 + followsBackRow.locator('[data-testid="follow-button"]'), 213 + ).toHaveAttribute("data-teststate", "follow-back"); 214 + await expect( 215 + alreadyRow.locator('[data-testid="follow-button"]'), 216 + ).toHaveAttribute("data-teststate", "following"); 217 + await expect( 218 + strangerRow.locator('[data-testid="follow-button"]'), 219 + ).toHaveAttribute("data-teststate", "follow"); 220 + }); 221 + 222 + test("clicking the follow button on a row toggles to following", async ({ 223 + page, 224 + }) => { 225 + const target = createProfile({ 226 + did: "did:plc:target1", 227 + handle: "target.bsky.social", 228 + displayName: "Target User", 229 + }); 230 + 231 + const mockServer = new MockServer(); 232 + mockServer.addPosts([post]); 233 + mockServer.addPostLikes(postUri, [ 234 + { actor: target, createdAt: "2025-01-15T12:00:00.000Z" }, 235 + ]); 236 + await mockServer.setup(page); 237 + 238 + await login(page); 239 + await page.goto("/profile/author1.bsky.social/post/abc123/likes"); 240 + 241 + const view = page.locator("#post-likes-view"); 242 + const targetRow = view 243 + .locator(".profile-list-item") 244 + .filter({ hasText: "Target User" }); 245 + 246 + const followButton = targetRow.locator('[data-testid="follow-button"]'); 247 + await expect(followButton).toHaveAttribute("data-teststate", "follow", { 248 + timeout: 10000, 249 + }); 250 + await followButton.click(); 251 + await expect(followButton).toHaveAttribute("data-teststate", "following", { 252 + timeout: 10000, 253 + }); 254 + }); 255 + 147 256 test.describe("Logged-out behavior", () => { 148 257 test("should display list of users who liked the post", async ({ 149 258 page, ··· 171 280 await expect(view).toContainText("Alice"); 172 281 await expect(view).toContainText("Bob"); 173 282 await expect(view).toContainText("Charlie"); 283 + 284 + await expect(view.locator('[data-testid="follow-button"]')).toHaveCount( 285 + 0, 286 + ); 174 287 }); 175 288 }); 176 289 });
+137
tests/e2e/specs/views/postReposts.view.test.js
··· 137 137 { timeout: 10000 }, 138 138 ); 139 139 }); 140 + 141 + test("should render bio, follows-you, and follow-state per reposter", async ({ 142 + page, 143 + }) => { 144 + const followsBack = createProfile({ 145 + did: "did:plc:followsback1", 146 + handle: "followsback.bsky.social", 147 + displayName: "Follows Back", 148 + description: "I follow you and have a bio.", 149 + viewer: { followedBy: "at://did:plc:followsback1/follow/1" }, 150 + }); 151 + const alreadyFollowing = createProfile({ 152 + did: "did:plc:already1", 153 + handle: "already.bsky.social", 154 + displayName: "Already Following", 155 + description: "", 156 + viewer: { following: "at://did:plc:viewer/follow/abc" }, 157 + }); 158 + const stranger = createProfile({ 159 + did: "did:plc:stranger1", 160 + handle: "stranger.bsky.social", 161 + displayName: "Stranger", 162 + description: "A stranger with a description.", 163 + }); 164 + 165 + const mockServer = new MockServer(); 166 + mockServer.addPosts([post]); 167 + mockServer.addPostReposts(postUri, [ 168 + followsBack, 169 + alreadyFollowing, 170 + stranger, 171 + ]); 172 + await mockServer.setup(page); 173 + 174 + await login(page); 175 + await page.goto("/profile/author1.bsky.social/post/abc123/reposts"); 176 + 177 + const view = page.locator("#post-reposts-view"); 178 + const followsBackRow = view 179 + .locator(".profile-list-item") 180 + .filter({ hasText: "Follows Back" }); 181 + const alreadyRow = view 182 + .locator(".profile-list-item") 183 + .filter({ hasText: "Already Following" }); 184 + const strangerRow = view 185 + .locator(".profile-list-item") 186 + .filter({ hasText: "Stranger" }); 187 + 188 + await expect( 189 + followsBackRow.locator('[data-testid="follows-you-badge"]'), 190 + ).toBeVisible({ timeout: 10000 }); 191 + await expect( 192 + alreadyRow.locator('[data-testid="follows-you-badge"]'), 193 + ).toHaveCount(0); 194 + await expect( 195 + strangerRow.locator('[data-testid="follows-you-badge"]'), 196 + ).toHaveCount(0); 197 + 198 + await expect( 199 + followsBackRow.locator('[data-testid="profile-list-item-description"]'), 200 + ).toContainText("I follow you and have a bio."); 201 + await expect( 202 + alreadyRow.locator('[data-testid="profile-list-item-description"]'), 203 + ).toHaveCount(0); 204 + 205 + await expect( 206 + followsBackRow.locator('[data-testid="follow-button"]'), 207 + ).toHaveAttribute("data-teststate", "follow-back"); 208 + await expect( 209 + alreadyRow.locator('[data-testid="follow-button"]'), 210 + ).toHaveAttribute("data-teststate", "following"); 211 + await expect( 212 + strangerRow.locator('[data-testid="follow-button"]'), 213 + ).toHaveAttribute("data-teststate", "follow"); 214 + }); 215 + 216 + test("clicking the follow button on a row toggles to following", async ({ 217 + page, 218 + }) => { 219 + const target = createProfile({ 220 + did: "did:plc:target1", 221 + handle: "target.bsky.social", 222 + displayName: "Target User", 223 + }); 224 + 225 + const mockServer = new MockServer(); 226 + mockServer.addPosts([post]); 227 + mockServer.addPostReposts(postUri, [target]); 228 + await mockServer.setup(page); 229 + 230 + await login(page); 231 + await page.goto("/profile/author1.bsky.social/post/abc123/reposts"); 232 + 233 + const view = page.locator("#post-reposts-view"); 234 + const targetRow = view 235 + .locator(".profile-list-item") 236 + .filter({ hasText: "Target User" }); 237 + 238 + const followButton = targetRow.locator('[data-testid="follow-button"]'); 239 + await expect(followButton).toHaveAttribute("data-teststate", "follow", { 240 + timeout: 10000, 241 + }); 242 + await followButton.click(); 243 + await expect(followButton).toHaveAttribute("data-teststate", "following", { 244 + timeout: 10000, 245 + }); 246 + }); 247 + 248 + test.describe("Logged-out behavior", () => { 249 + test("should display list of users who reposted the post without follow buttons", async ({ 250 + page, 251 + }) => { 252 + const mockServer = new MockServer(); 253 + mockServer.addPosts([post]); 254 + mockServer.addPostReposts(postUri, [alice, bob, charlie]); 255 + await mockServer.setup(page); 256 + 257 + await page.goto("/profile/author1.bsky.social/post/abc123/reposts"); 258 + 259 + const view = page.locator("#post-reposts-view"); 260 + await expect(view.locator('[data-testid="header-title"]')).toContainText( 261 + "Reposted by", 262 + { timeout: 10000 }, 263 + ); 264 + 265 + await expect(view.locator(".profile-list-item")).toHaveCount(3, { 266 + timeout: 10000, 267 + }); 268 + await expect(view).toContainText("Alice"); 269 + await expect(view).toContainText("Bob"); 270 + await expect(view).toContainText("Charlie"); 271 + 272 + await expect(view.locator('[data-testid="follow-button"]')).toHaveCount( 273 + 0, 274 + ); 275 + }); 276 + }); 140 277 });
+107
tests/e2e/specs/views/profileFollowers.view.test.js
··· 179 179 ); 180 180 }); 181 181 182 + test("should render bio, follows-you, and follow-state per follower", async ({ 183 + page, 184 + }) => { 185 + const followsBack = createProfile({ 186 + did: "did:plc:followsback1", 187 + handle: "followsback.bsky.social", 188 + displayName: "Follows Back", 189 + description: "I follow you and have a bio.", 190 + viewer: { followedBy: "at://did:plc:followsback1/follow/1" }, 191 + }); 192 + const alreadyFollowing = createProfile({ 193 + did: "did:plc:already1", 194 + handle: "already.bsky.social", 195 + displayName: "Already Following", 196 + description: "", 197 + viewer: { following: "at://did:plc:viewer/follow/abc" }, 198 + }); 199 + const stranger = createProfile({ 200 + did: "did:plc:stranger1", 201 + handle: "stranger.bsky.social", 202 + displayName: "Stranger", 203 + description: "A stranger with a description.", 204 + }); 205 + 206 + const mockServer = new MockServer(); 207 + mockServer.addProfile(profileUser); 208 + mockServer.addProfileFollowers(profileUser.did, [ 209 + followsBack, 210 + alreadyFollowing, 211 + stranger, 212 + ]); 213 + await mockServer.setup(page); 214 + 215 + await login(page); 216 + await page.goto(`/profile/${profileUser.did}/followers`); 217 + 218 + const view = page.locator("#profile-followers-view"); 219 + const followsBackRow = view 220 + .locator(".profile-list-item") 221 + .filter({ hasText: "Follows Back" }); 222 + const alreadyRow = view 223 + .locator(".profile-list-item") 224 + .filter({ hasText: "Already Following" }); 225 + const strangerRow = view 226 + .locator(".profile-list-item") 227 + .filter({ hasText: "Stranger" }); 228 + 229 + await expect( 230 + followsBackRow.locator('[data-testid="follows-you-badge"]'), 231 + ).toBeVisible({ timeout: 10000 }); 232 + await expect( 233 + alreadyRow.locator('[data-testid="follows-you-badge"]'), 234 + ).toHaveCount(0); 235 + await expect( 236 + strangerRow.locator('[data-testid="follows-you-badge"]'), 237 + ).toHaveCount(0); 238 + 239 + await expect( 240 + followsBackRow.locator('[data-testid="profile-list-item-description"]'), 241 + ).toContainText("I follow you and have a bio."); 242 + await expect( 243 + alreadyRow.locator('[data-testid="profile-list-item-description"]'), 244 + ).toHaveCount(0); 245 + 246 + await expect( 247 + followsBackRow.locator('[data-testid="follow-button"]'), 248 + ).toHaveAttribute("data-teststate", "follow-back"); 249 + await expect( 250 + alreadyRow.locator('[data-testid="follow-button"]'), 251 + ).toHaveAttribute("data-teststate", "following"); 252 + await expect( 253 + strangerRow.locator('[data-testid="follow-button"]'), 254 + ).toHaveAttribute("data-teststate", "follow"); 255 + }); 256 + 257 + test("clicking the follow button on a row toggles to following", async ({ 258 + page, 259 + }) => { 260 + const target = createProfile({ 261 + did: "did:plc:target1", 262 + handle: "target.bsky.social", 263 + displayName: "Target User", 264 + }); 265 + 266 + const mockServer = new MockServer(); 267 + mockServer.addProfile(profileUser); 268 + mockServer.addProfileFollowers(profileUser.did, [target]); 269 + await mockServer.setup(page); 270 + 271 + await login(page); 272 + await page.goto(`/profile/${profileUser.did}/followers`); 273 + 274 + const view = page.locator("#profile-followers-view"); 275 + const targetRow = view 276 + .locator(".profile-list-item") 277 + .filter({ hasText: "Target User" }); 278 + 279 + const followButton = targetRow.locator('[data-testid="follow-button"]'); 280 + await expect(followButton).toHaveAttribute("data-teststate", "follow", { 281 + timeout: 10000, 282 + }); 283 + await followButton.click(); 284 + await expect(followButton).toHaveAttribute("data-teststate", "following", { 285 + timeout: 10000, 286 + }); 287 + }); 288 + 182 289 test.describe("Logged-out behavior", () => { 183 290 test("should redirect to /login when not authenticated", async ({ 184 291 page,
+107
tests/e2e/specs/views/profileFollowing.view.test.js
··· 105 105 ); 106 106 }); 107 107 108 + test("should render bio, follows-you, and follow-state per following", async ({ 109 + page, 110 + }) => { 111 + const followsBack = createProfile({ 112 + did: "did:plc:followsback1", 113 + handle: "followsback.bsky.social", 114 + displayName: "Follows Back", 115 + description: "I follow you and have a bio.", 116 + viewer: { followedBy: "at://did:plc:followsback1/follow/1" }, 117 + }); 118 + const alreadyFollowing = createProfile({ 119 + did: "did:plc:already1", 120 + handle: "already.bsky.social", 121 + displayName: "Already Following", 122 + description: "", 123 + viewer: { following: "at://did:plc:viewer/follow/abc" }, 124 + }); 125 + const stranger = createProfile({ 126 + did: "did:plc:stranger1", 127 + handle: "stranger.bsky.social", 128 + displayName: "Stranger", 129 + description: "A stranger with a description.", 130 + }); 131 + 132 + const mockServer = new MockServer(); 133 + mockServer.addProfile(profileUser); 134 + mockServer.addProfileFollows(profileUser.did, [ 135 + followsBack, 136 + alreadyFollowing, 137 + stranger, 138 + ]); 139 + await mockServer.setup(page); 140 + 141 + await login(page); 142 + await page.goto(`/profile/${profileUser.did}/following`); 143 + 144 + const view = page.locator("#profile-following-view"); 145 + const followsBackRow = view 146 + .locator(".profile-list-item") 147 + .filter({ hasText: "Follows Back" }); 148 + const alreadyRow = view 149 + .locator(".profile-list-item") 150 + .filter({ hasText: "Already Following" }); 151 + const strangerRow = view 152 + .locator(".profile-list-item") 153 + .filter({ hasText: "Stranger" }); 154 + 155 + await expect( 156 + followsBackRow.locator('[data-testid="follows-you-badge"]'), 157 + ).toBeVisible({ timeout: 10000 }); 158 + await expect( 159 + alreadyRow.locator('[data-testid="follows-you-badge"]'), 160 + ).toHaveCount(0); 161 + await expect( 162 + strangerRow.locator('[data-testid="follows-you-badge"]'), 163 + ).toHaveCount(0); 164 + 165 + await expect( 166 + followsBackRow.locator('[data-testid="profile-list-item-description"]'), 167 + ).toContainText("I follow you and have a bio."); 168 + await expect( 169 + alreadyRow.locator('[data-testid="profile-list-item-description"]'), 170 + ).toHaveCount(0); 171 + 172 + await expect( 173 + followsBackRow.locator('[data-testid="follow-button"]'), 174 + ).toHaveAttribute("data-teststate", "follow-back"); 175 + await expect( 176 + alreadyRow.locator('[data-testid="follow-button"]'), 177 + ).toHaveAttribute("data-teststate", "following"); 178 + await expect( 179 + strangerRow.locator('[data-testid="follow-button"]'), 180 + ).toHaveAttribute("data-teststate", "follow"); 181 + }); 182 + 183 + test("clicking the follow button on a row toggles to following", async ({ 184 + page, 185 + }) => { 186 + const target = createProfile({ 187 + did: "did:plc:target1", 188 + handle: "target.bsky.social", 189 + displayName: "Target User", 190 + }); 191 + 192 + const mockServer = new MockServer(); 193 + mockServer.addProfile(profileUser); 194 + mockServer.addProfileFollows(profileUser.did, [target]); 195 + await mockServer.setup(page); 196 + 197 + await login(page); 198 + await page.goto(`/profile/${profileUser.did}/following`); 199 + 200 + const view = page.locator("#profile-following-view"); 201 + const targetRow = view 202 + .locator(".profile-list-item") 203 + .filter({ hasText: "Target User" }); 204 + 205 + const followButton = targetRow.locator('[data-testid="follow-button"]'); 206 + await expect(followButton).toHaveAttribute("data-teststate", "follow", { 207 + timeout: 10000, 208 + }); 209 + await followButton.click(); 210 + await expect(followButton).toHaveAttribute("data-teststate", "following", { 211 + timeout: 10000, 212 + }); 213 + }); 214 + 108 215 test.describe("Logged-out behavior", () => { 109 216 test("should redirect to /login when not authenticated", async ({ 110 217 page,
+107
tests/e2e/specs/views/profileKnownFollowers.view.test.js
··· 141 141 ); 142 142 }); 143 143 144 + test("should render bio, follows-you, and follow-state per known follower", async ({ 145 + page, 146 + }) => { 147 + const followsBack = createProfile({ 148 + did: "did:plc:followsback1", 149 + handle: "followsback.bsky.social", 150 + displayName: "Follows Back", 151 + description: "I follow you and have a bio.", 152 + viewer: { followedBy: "at://did:plc:followsback1/follow/1" }, 153 + }); 154 + const alreadyFollowing = createProfile({ 155 + did: "did:plc:already1", 156 + handle: "already.bsky.social", 157 + displayName: "Already Following", 158 + description: "", 159 + viewer: { following: "at://did:plc:viewer/follow/abc" }, 160 + }); 161 + const stranger = createProfile({ 162 + did: "did:plc:stranger1", 163 + handle: "stranger.bsky.social", 164 + displayName: "Stranger", 165 + description: "A stranger with a description.", 166 + }); 167 + 168 + const mockServer = new MockServer(); 169 + mockServer.addProfile(profileUser); 170 + mockServer.addKnownFollowers(profileUser.did, [ 171 + followsBack, 172 + alreadyFollowing, 173 + stranger, 174 + ]); 175 + await mockServer.setup(page); 176 + 177 + await login(page); 178 + await page.goto(`/profile/${profileUser.did}/known-followers`); 179 + 180 + const view = page.locator("#profile-known-followers-view"); 181 + const followsBackRow = view 182 + .locator(".profile-list-item") 183 + .filter({ hasText: "Follows Back" }); 184 + const alreadyRow = view 185 + .locator(".profile-list-item") 186 + .filter({ hasText: "Already Following" }); 187 + const strangerRow = view 188 + .locator(".profile-list-item") 189 + .filter({ hasText: "Stranger" }); 190 + 191 + await expect( 192 + followsBackRow.locator('[data-testid="follows-you-badge"]'), 193 + ).toBeVisible({ timeout: 10000 }); 194 + await expect( 195 + alreadyRow.locator('[data-testid="follows-you-badge"]'), 196 + ).toHaveCount(0); 197 + await expect( 198 + strangerRow.locator('[data-testid="follows-you-badge"]'), 199 + ).toHaveCount(0); 200 + 201 + await expect( 202 + followsBackRow.locator('[data-testid="profile-list-item-description"]'), 203 + ).toContainText("I follow you and have a bio."); 204 + await expect( 205 + alreadyRow.locator('[data-testid="profile-list-item-description"]'), 206 + ).toHaveCount(0); 207 + 208 + await expect( 209 + followsBackRow.locator('[data-testid="follow-button"]'), 210 + ).toHaveAttribute("data-teststate", "follow-back"); 211 + await expect( 212 + alreadyRow.locator('[data-testid="follow-button"]'), 213 + ).toHaveAttribute("data-teststate", "following"); 214 + await expect( 215 + strangerRow.locator('[data-testid="follow-button"]'), 216 + ).toHaveAttribute("data-teststate", "follow"); 217 + }); 218 + 219 + test("clicking the follow button on a row toggles to following", async ({ 220 + page, 221 + }) => { 222 + const target = createProfile({ 223 + did: "did:plc:target1", 224 + handle: "target.bsky.social", 225 + displayName: "Target User", 226 + }); 227 + 228 + const mockServer = new MockServer(); 229 + mockServer.addProfile(profileUser); 230 + mockServer.addKnownFollowers(profileUser.did, [target]); 231 + await mockServer.setup(page); 232 + 233 + await login(page); 234 + await page.goto(`/profile/${profileUser.did}/known-followers`); 235 + 236 + const view = page.locator("#profile-known-followers-view"); 237 + const targetRow = view 238 + .locator(".profile-list-item") 239 + .filter({ hasText: "Target User" }); 240 + 241 + const followButton = targetRow.locator('[data-testid="follow-button"]'); 242 + await expect(followButton).toHaveAttribute("data-teststate", "follow", { 243 + timeout: 10000, 244 + }); 245 + await followButton.click(); 246 + await expect(followButton).toHaveAttribute("data-teststate", "following", { 247 + timeout: 10000, 248 + }); 249 + }); 250 + 144 251 test.describe("Logged-out behavior", () => { 145 252 test("should redirect to /login when not authenticated", async ({ 146 253 page,
+106
tests/e2e/specs/views/search.view.test.js
··· 507 507 await expect(page).toHaveURL(/\/search/); 508 508 }); 509 509 510 + test("should render bio, follows-you, and follow-state per profile result", async ({ 511 + page, 512 + }) => { 513 + const followsBack = createProfile({ 514 + did: "did:plc:followsback1", 515 + handle: "followsback.bsky.social", 516 + displayName: "Follows Back", 517 + description: "I follow you and have a bio.", 518 + viewer: { followedBy: "at://did:plc:followsback1/follow/1" }, 519 + }); 520 + const alreadyFollowing = createProfile({ 521 + did: "did:plc:already1", 522 + handle: "already.bsky.social", 523 + displayName: "Already Following", 524 + description: "", 525 + viewer: { following: "at://did:plc:viewer/follow/abc" }, 526 + }); 527 + const stranger = createProfile({ 528 + did: "did:plc:stranger1", 529 + handle: "stranger.bsky.social", 530 + displayName: "Stranger", 531 + description: "A stranger with a description.", 532 + }); 533 + 534 + const mockServer = new MockServer(); 535 + mockServer.addSearchProfiles([followsBack, alreadyFollowing, stranger]); 536 + await mockServer.setup(page); 537 + 538 + await login(page); 539 + await page.goto("/search?q=test"); 540 + 541 + const view = page.locator("#search-view"); 542 + const followsBackRow = view 543 + .locator(".profile-list-item") 544 + .filter({ hasText: "Follows Back" }); 545 + const alreadyRow = view 546 + .locator(".profile-list-item") 547 + .filter({ hasText: "Already Following" }); 548 + const strangerRow = view 549 + .locator(".profile-list-item") 550 + .filter({ hasText: "Stranger" }); 551 + 552 + await expect( 553 + followsBackRow.locator('[data-testid="follows-you-badge"]'), 554 + ).toBeVisible({ timeout: 10000 }); 555 + await expect( 556 + alreadyRow.locator('[data-testid="follows-you-badge"]'), 557 + ).toHaveCount(0); 558 + await expect( 559 + strangerRow.locator('[data-testid="follows-you-badge"]'), 560 + ).toHaveCount(0); 561 + 562 + await expect( 563 + followsBackRow.locator('[data-testid="profile-list-item-description"]'), 564 + ).toContainText("I follow you and have a bio."); 565 + await expect( 566 + alreadyRow.locator('[data-testid="profile-list-item-description"]'), 567 + ).toHaveCount(0); 568 + 569 + await expect( 570 + followsBackRow.locator('[data-testid="follow-button"]'), 571 + ).toHaveAttribute("data-teststate", "follow-back"); 572 + await expect( 573 + alreadyRow.locator('[data-testid="follow-button"]'), 574 + ).toHaveAttribute("data-teststate", "following"); 575 + await expect( 576 + strangerRow.locator('[data-testid="follow-button"]'), 577 + ).toHaveAttribute("data-teststate", "follow"); 578 + }); 579 + 580 + test("clicking the follow button on a profile result toggles to following", async ({ 581 + page, 582 + }) => { 583 + const target = createProfile({ 584 + did: "did:plc:target1", 585 + handle: "target.bsky.social", 586 + displayName: "Target User", 587 + }); 588 + 589 + const mockServer = new MockServer(); 590 + mockServer.addSearchProfiles([target]); 591 + await mockServer.setup(page); 592 + 593 + await login(page); 594 + await page.goto("/search?q=target"); 595 + 596 + const view = page.locator("#search-view"); 597 + const targetRow = view 598 + .locator(".profile-list-item") 599 + .filter({ hasText: "Target User" }); 600 + 601 + const followButton = targetRow.locator('[data-testid="follow-button"]'); 602 + await expect(followButton).toHaveAttribute("data-teststate", "follow", { 603 + timeout: 10000, 604 + }); 605 + await followButton.click(); 606 + await expect(followButton).toHaveAttribute("data-teststate", "following", { 607 + timeout: 10000, 608 + }); 609 + }); 610 + 510 611 test.describe("Pagination", () => { 511 612 test("should paginate profile results", async ({ page }) => { 512 613 const mockServer = new MockServer(); ··· 649 750 // Posts and Feeds tabs should be hidden for logged-out users 650 751 await expect(view.locator('[data-testid="tab-posts"]')).not.toBeVisible(); 651 752 await expect(view.locator('[data-testid="tab-feeds"]')).not.toBeVisible(); 753 + 754 + // Follow buttons should be hidden for logged-out users 755 + await expect(view.locator('[data-testid="follow-button"]')).toHaveCount( 756 + 0, 757 + ); 652 758 }); 653 759 }); 654 760 });
+236
tests/unit/specs/components/detected-rich-text.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assert, assertEquals, mock } from "../../testHelpers.js"; 3 + import "/js/components/detected-rich-text.js"; 4 + 5 + const t = new TestSuite("DetectedRichText"); 6 + 7 + function makeIdentityResolver(handleToDid = {}) { 8 + return { 9 + resolveHandle: mock(async (handle) => { 10 + if (handle in handleToDid) return handleToDid[handle]; 11 + throw new Error(`Unknown handle: ${handle}`); 12 + }), 13 + }; 14 + } 15 + 16 + async function flushMicrotasks() { 17 + // Two ticks: the first flushes microtasks (e.g. getFacetsFromText), the 18 + // second lets the rAF-scheduled effect re-render before assertions. 19 + await new Promise((resolve) => setTimeout(resolve, 0)); 20 + await new Promise((resolve) => setTimeout(resolve, 0)); 21 + } 22 + 23 + t.beforeEach(() => { 24 + document.body.innerHTML = ""; 25 + }); 26 + 27 + t.describe("DetectedRichText - rendering", (it) => { 28 + it("renders plain text from the text attribute", () => { 29 + const element = document.createElement("detected-rich-text"); 30 + element.setAttribute("text", "Hello world"); 31 + document.body.appendChild(element); 32 + const richText = element.querySelector("[data-testid='rich-text']"); 33 + assert(richText !== null); 34 + assert(richText.textContent.includes("Hello world")); 35 + }); 36 + 37 + it("renders nothing meaningful when text is empty", () => { 38 + const element = document.createElement("detected-rich-text"); 39 + document.body.appendChild(element); 40 + const richText = element.querySelector("[data-testid='rich-text']"); 41 + assert(richText !== null); 42 + assertEquals(richText.textContent.trim(), ""); 43 + }); 44 + 45 + it("updates rendered text when the text attribute changes", async () => { 46 + const element = document.createElement("detected-rich-text"); 47 + element.setAttribute("text", "first"); 48 + document.body.appendChild(element); 49 + assert(element.textContent.includes("first")); 50 + element.setAttribute("text", "second"); 51 + await flushMicrotasks(); 52 + assert(element.textContent.includes("second")); 53 + assert(!element.textContent.includes("first")); 54 + }); 55 + }); 56 + 57 + t.describe("DetectedRichText - facet detection", (it) => { 58 + it("detects link facets and renders them as anchors", async () => { 59 + const element = document.createElement("detected-rich-text"); 60 + element.identityResolver = makeIdentityResolver(); 61 + element.setAttribute("text", "Visit example.com today"); 62 + document.body.appendChild(element); 63 + await flushMicrotasks(); 64 + const link = element.querySelector("a"); 65 + assert(link !== null); 66 + assert(link.getAttribute("href").startsWith("https://example.com")); 67 + }); 68 + 69 + it("detects hashtag facets and renders them as anchors", async () => { 70 + const element = document.createElement("detected-rich-text"); 71 + element.identityResolver = makeIdentityResolver(); 72 + element.setAttribute("text", "Loving #bluesky right now"); 73 + document.body.appendChild(element); 74 + await flushMicrotasks(); 75 + const link = element.querySelector("a"); 76 + assert(link !== null); 77 + assert(link.textContent.includes("#bluesky")); 78 + }); 79 + 80 + it("resolves mention handles via identityResolver", async () => { 81 + const resolver = makeIdentityResolver({ 82 + "alice.com": "did:plc:alice", 83 + }); 84 + const element = document.createElement("detected-rich-text"); 85 + element.identityResolver = resolver; 86 + element.setAttribute("text", "Hi @alice.com welcome"); 87 + document.body.appendChild(element); 88 + await flushMicrotasks(); 89 + assertEquals(resolver.resolveHandle.calls.length, 1); 90 + assertEquals(resolver.resolveHandle.calls[0][0], "alice.com"); 91 + const link = element.querySelector("a"); 92 + assert(link !== null); 93 + assert(link.getAttribute("href").includes("did:plc:alice")); 94 + }); 95 + 96 + it("does not call resolveHandle when there are no mentions", async () => { 97 + const resolver = makeIdentityResolver(); 98 + const element = document.createElement("detected-rich-text"); 99 + element.identityResolver = resolver; 100 + element.setAttribute("text", "Just plain text here"); 101 + document.body.appendChild(element); 102 + await flushMicrotasks(); 103 + assertEquals(resolver.resolveHandle.calls.length, 0); 104 + }); 105 + 106 + it("does not attempt to resolve facets without an identityResolver", async () => { 107 + const element = document.createElement("detected-rich-text"); 108 + element.setAttribute("text", "Hi @alice.com welcome"); 109 + document.body.appendChild(element); 110 + await flushMicrotasks(); 111 + // No identity resolver set, so mention stays as plain text (no anchor). 112 + assert(element.querySelector("a") === null); 113 + assert(element.textContent.includes("@alice.com")); 114 + }); 115 + 116 + it("re-resolves facets when the text attribute changes", async () => { 117 + const resolver = makeIdentityResolver({ 118 + "alice.com": "did:plc:alice", 119 + "bob.com": "did:plc:bob", 120 + }); 121 + const element = document.createElement("detected-rich-text"); 122 + element.identityResolver = resolver; 123 + element.setAttribute("text", "Hi @alice.com"); 124 + document.body.appendChild(element); 125 + await flushMicrotasks(); 126 + assertEquals(resolver.resolveHandle.calls.length, 1); 127 + element.setAttribute("text", "Hi @bob.com"); 128 + await flushMicrotasks(); 129 + assertEquals(resolver.resolveHandle.calls.length, 2); 130 + assertEquals(resolver.resolveHandle.calls[1][0], "bob.com"); 131 + const link = element.querySelector("a"); 132 + assert(link !== null); 133 + assert(link.getAttribute("href").includes("did:plc:bob")); 134 + }); 135 + 136 + it("ignores stale resolution when text changes mid-flight", async () => { 137 + let resolveSlow; 138 + const resolver = { 139 + resolveHandle: mock((handle) => { 140 + if (handle === "slow.com") { 141 + return new Promise((resolve) => { 142 + resolveSlow = () => resolve("did:plc:slow"); 143 + }); 144 + } 145 + return Promise.resolve("did:plc:fast"); 146 + }), 147 + }; 148 + const element = document.createElement("detected-rich-text"); 149 + element.identityResolver = resolver; 150 + element.setAttribute("text", "Hi @slow.com"); 151 + document.body.appendChild(element); 152 + await flushMicrotasks(); 153 + // Swap text before slow resolution completes. 154 + element.setAttribute("text", "Hi @fast.com"); 155 + await flushMicrotasks(); 156 + // Now let the stale promise resolve — the component should ignore it. 157 + resolveSlow(); 158 + await flushMicrotasks(); 159 + const link = element.querySelector("a"); 160 + assert(link !== null); 161 + assert(link.getAttribute("href").includes("did:plc:fast")); 162 + assert(!link.getAttribute("href").includes("did:plc:slow")); 163 + }); 164 + }); 165 + 166 + t.describe("DetectedRichText - truncate-urls attribute", (it) => { 167 + it("does not truncate displayed URL text without truncate-urls", async () => { 168 + const element = document.createElement("detected-rich-text"); 169 + element.identityResolver = makeIdentityResolver(); 170 + element.setAttribute( 171 + "text", 172 + "See https://example.com/very/long/path/to/page", 173 + ); 174 + document.body.appendChild(element); 175 + await flushMicrotasks(); 176 + const link = element.querySelector("a"); 177 + assert(link !== null); 178 + assertEquals( 179 + link.textContent, 180 + "https://example.com/very/long/path/to/page", 181 + ); 182 + }); 183 + 184 + it("truncates displayed URL text when truncate-urls is set", async () => { 185 + const element = document.createElement("detected-rich-text"); 186 + element.identityResolver = makeIdentityResolver(); 187 + element.toggleAttribute("truncate-urls", true); 188 + element.setAttribute( 189 + "text", 190 + "See https://example.com/very/long/path/to/page", 191 + ); 192 + document.body.appendChild(element); 193 + await flushMicrotasks(); 194 + const link = element.querySelector("a"); 195 + assert(link !== null); 196 + assert(link.textContent.endsWith("...")); 197 + assert(link.textContent.length < link.getAttribute("href").length); 198 + }); 199 + 200 + it("reacts to truncate-urls being toggled after mount", async () => { 201 + const element = document.createElement("detected-rich-text"); 202 + element.identityResolver = makeIdentityResolver(); 203 + element.setAttribute( 204 + "text", 205 + "See https://example.com/very/long/path/to/page", 206 + ); 207 + document.body.appendChild(element); 208 + await flushMicrotasks(); 209 + let link = element.querySelector("a"); 210 + assertEquals( 211 + link.textContent, 212 + "https://example.com/very/long/path/to/page", 213 + ); 214 + element.toggleAttribute("truncate-urls", true); 215 + await flushMicrotasks(); 216 + link = element.querySelector("a"); 217 + assert(link.textContent.endsWith("...")); 218 + }); 219 + }); 220 + 221 + t.describe("DetectedRichText - lifecycle", (it) => { 222 + it("cleans up effects on disconnect", async () => { 223 + const element = document.createElement("detected-rich-text"); 224 + element.identityResolver = makeIdentityResolver(); 225 + element.setAttribute("text", "Hello world"); 226 + document.body.appendChild(element); 227 + await flushMicrotasks(); 228 + assert(element.initialized); 229 + element.remove(); 230 + assert(!element.initialized); 231 + assertEquals(element.disposeRender, null); 232 + assertEquals(element.disposeResolve, null); 233 + }); 234 + }); 235 + 236 + await t.run();
+12 -12
tests/unit/specs/components/plugin-profiles-list.test.js
··· 5 5 6 6 const t = new TestSuite("PluginProfilesList"); 7 7 8 - function makeDataLayer({ ensureProfiles } = {}) { 8 + function makeDataLayer({ ensureDetailedProfiles } = {}) { 9 9 // Mirror the real layering: a value SignalMap store, with $hydratedProfiles a 10 10 // ComputedMap (family) over it that returns a stable per-key cell. 11 11 const profileValues = new SignalMap(); 12 12 const $hydratedProfiles = new ComputedMap((did) => profileValues.get(did)); 13 13 const declarative = { 14 - ensureProfiles: 15 - ensureProfiles ?? 14 + ensureDetailedProfiles: 15 + ensureDetailedProfiles ?? 16 16 (async (dids) => dids.map((did) => profileValues.get(did) ?? null)), 17 17 }; 18 18 return { ··· 31 31 } 32 32 33 33 async function flushMicrotasks() { 34 - // Two ticks: the first flushes microtasks (e.g. ensureProfiles), the second 34 + // Two ticks: the first flushes microtasks (e.g. ensureDetailedProfiles), the second 35 35 // lets the rAF-scheduled effect render run before assertions. 36 36 await new Promise((resolve) => setTimeout(resolve, 0)); 37 37 await new Promise((resolve) => setTimeout(resolve, 0)); ··· 45 45 it("renders one skeleton per did before profiles resolve", () => { 46 46 const element = document.createElement("plugin-profiles-list"); 47 47 element.dataLayer = makeDataLayer({ 48 - ensureProfiles: () => new Promise(() => {}), 48 + ensureDetailedProfiles: () => new Promise(() => {}), 49 49 }); 50 50 element.setAttribute("dids", "did:test:a,did:test:b,did:test:c"); 51 51 document.body.appendChild(element); ··· 57 57 }); 58 58 59 59 t.describe("PluginProfilesList - loaded state", (it) => { 60 - it("renders profile list items once ensureProfiles resolves", async () => { 60 + it("renders profile list items once ensureDetailedProfiles resolves", async () => { 61 61 const dataLayer = makeDataLayer(); 62 62 dataLayer.__setProfile("did:test:a", makeProfile("did:test:a", "a.test")); 63 63 dataLayer.__setProfile("did:test:b", makeProfile("did:test:b", "b.test")); ··· 109 109 const element = document.createElement("plugin-profiles-list"); 110 110 let called = false; 111 111 element.dataLayer = makeDataLayer({ 112 - ensureProfiles: async () => { 112 + ensureDetailedProfiles: async () => { 113 113 called = true; 114 114 return []; 115 115 }, ··· 130 130 }); 131 131 132 132 t.describe("PluginProfilesList - error state", (it) => { 133 - it("renders the error message when ensureProfiles rejects", async () => { 133 + it("renders the error message when ensureDetailedProfiles rejects", async () => { 134 134 const element = document.createElement("plugin-profiles-list"); 135 135 element.dataLayer = makeDataLayer({ 136 - ensureProfiles: async () => { 136 + ensureDetailedProfiles: async () => { 137 137 throw new Error("boom"); 138 138 }, 139 139 }); ··· 150 150 it("reloads when the dids attribute changes", async () => { 151 151 const calls = []; 152 152 const dataLayer = makeDataLayer({ 153 - ensureProfiles: async (dids) => { 153 + ensureDetailedProfiles: async (dids) => { 154 154 calls.push(dids); 155 155 dids.forEach((did) => 156 156 dataLayer.__setProfile(did, makeProfile(did, did)), ··· 175 175 ); 176 176 }); 177 177 178 - it("ignores stale ensureProfiles results when dids change mid-flight", async () => { 178 + it("ignores stale ensureDetailedProfiles results when dids change mid-flight", async () => { 179 179 const dataLayer = makeDataLayer(); 180 180 let resolveFirst; 181 181 const firstPromise = new Promise((resolve) => { 182 182 resolveFirst = resolve; 183 183 }); 184 184 let callIndex = 0; 185 - dataLayer.declarative.ensureProfiles = (dids) => { 185 + dataLayer.declarative.ensureDetailedProfiles = (dids) => { 186 186 callIndex++; 187 187 if (callIndex === 1) return firstPromise; 188 188 dids.forEach((did) => dataLayer.__setProfile(did, makeProfile(did, did)));
+2 -1
tests/unit/specs/dataLayer/dataLayer.test.js
··· 195 195 await dataLayer.initializePreferences(); 196 196 197 197 // Verify declarative can access derived 198 - const profile = await dataLayer.declarative.ensureProfile("did:test:user"); 198 + const profile = 199 + await dataLayer.declarative.ensureDetailedProfile("did:test:user"); 199 200 assert(profile !== null); 200 201 }); 201 202 });
+21 -19
tests/unit/specs/dataLayer/declarative.test.js
··· 11 11 function createMockDerived(data = {}) { 12 12 return { 13 13 $currentUser: sig(() => data.currentUser ?? null), 14 - $hydratedProfiles: mapSig((did) => data.profiles?.[did] ?? null), 14 + $hydratedDetailedProfiles: mapSig((did) => data.profiles?.[did] ?? null), 15 15 $hydratedPostThreads: mapSig((uri) => data.postThreads?.[uri] ?? null), 16 16 $hydratedPosts: mapSig((uri) => data.posts?.[uri] ?? null), 17 17 $feedGenerators: mapSig((uri) => data.feedGenerators?.[uri] ?? null), ··· 25 25 function createMockRequests(loadResults = {}) { 26 26 return { 27 27 loadCurrentUser: async () => loadResults.currentUser, 28 - loadProfile: async (did) => loadResults.profiles?.[did], 29 - loadProfiles: async () => {}, 28 + loadDetailedProfile: async (did) => loadResults.profiles?.[did], 29 + loadDetailedProfiles: async () => {}, 30 30 loadPostThread: async (uri) => loadResults.postThreads?.[uri], 31 31 loadPost: async (uri) => loadResults.posts?.[uri], 32 32 loadPosts: async () => {}, ··· 96 96 }); 97 97 }); 98 98 99 - t.describe("ensureProfile", (it) => { 99 + t.describe("ensureDetailedProfile", (it) => { 100 100 it("should return existing profile without loading", async () => { 101 101 const profileDid = "did:test:profile"; 102 102 const profile = { did: profileDid, handle: "test.profile" }; ··· 104 104 105 105 const derived = createMockDerived({ profiles: { [profileDid]: profile } }); 106 106 const requests = { 107 - loadProfile: async () => { 107 + loadDetailedProfile: async () => { 108 108 loadCalled = true; 109 109 }, 110 110 }; 111 111 112 112 const declarative = new Declarative(derived, requests); 113 - const result = await declarative.ensureProfile(profileDid); 113 + const result = await declarative.ensureDetailedProfile(profileDid); 114 114 115 115 assertEquals(result, profile); 116 116 assertEquals(loadCalled, false); ··· 122 122 let callCount = 0; 123 123 124 124 const derived = { 125 - $hydratedProfiles: mapSig(() => { 125 + $hydratedDetailedProfiles: mapSig(() => { 126 126 callCount++; 127 127 return callCount > 1 ? profile : null; 128 128 }), 129 129 }; 130 130 const requests = { 131 - loadProfile: async () => {}, 131 + loadDetailedProfile: async () => {}, 132 132 }; 133 133 134 134 const declarative = new Declarative(derived, requests); 135 - const result = await declarative.ensureProfile(profileDid); 135 + const result = await declarative.ensureDetailedProfile(profileDid); 136 136 137 137 assertEquals(result, profile); 138 138 }); ··· 145 145 146 146 let error = null; 147 147 try { 148 - await declarative.ensureProfile("did:nonexistent"); 148 + await declarative.ensureDetailedProfile("did:nonexistent"); 149 149 } catch (e) { 150 150 error = e; 151 151 } ··· 155 155 }); 156 156 }); 157 157 158 - t.describe("ensureProfiles", (it) => { 158 + t.describe("ensureDetailedProfiles", (it) => { 159 159 it("returns cached profiles in input order without loading", async () => { 160 160 const profileA = { did: "did:test:a", handle: "a.test" }; 161 161 const profileB = { did: "did:test:b", handle: "b.test" }; ··· 165 165 profiles: { [profileA.did]: profileA, [profileB.did]: profileB }, 166 166 }); 167 167 const requests = { 168 - loadProfiles: async () => { 168 + loadDetailedProfiles: async () => { 169 169 loadCalled = true; 170 170 }, 171 171 }; 172 172 173 173 const declarative = new Declarative(derived, requests); 174 - const result = await declarative.ensureProfiles([ 174 + const result = await declarative.ensureDetailedProfiles([ 175 175 profileB.did, 176 176 profileA.did, 177 177 ]); ··· 187 187 let loadedWith = null; 188 188 189 189 const derived = { 190 - $hydratedProfiles: mapSig((did) => store[did] ?? null), 190 + $hydratedDetailedProfiles: mapSig((did) => store[did] ?? null), 191 191 }; 192 192 const requests = { 193 - loadProfiles: async (dids) => { 193 + loadDetailedProfiles: async (dids) => { 194 194 loadedWith = dids; 195 195 store[profileB.did] = profileB; 196 196 }, 197 197 }; 198 198 199 199 const declarative = new Declarative(derived, requests); 200 - const result = await declarative.ensureProfiles([ 200 + const result = await declarative.ensureDetailedProfiles([ 201 201 profileA.did, 202 202 profileB.did, 203 203 ]); ··· 207 207 }); 208 208 209 209 it("returns null entries for profiles still missing after load", async () => { 210 - const derived = { $hydratedProfiles: mapSig(() => null) }; 211 - const requests = { loadProfiles: async () => {} }; 210 + const derived = { $hydratedDetailedProfiles: mapSig(() => null) }; 211 + const requests = { loadDetailedProfiles: async () => {} }; 212 212 213 213 const declarative = new Declarative(derived, requests); 214 - const result = await declarative.ensureProfiles(["did:test:missing"]); 214 + const result = await declarative.ensureDetailedProfiles([ 215 + "did:test:missing", 216 + ]); 215 217 216 218 assertEquals(result, [null]); 217 219 });
+211 -2
tests/unit/specs/dataLayer/mutations.test.js
··· 251 251 createFollowRecord: async () => mockFollow, 252 252 }; 253 253 const dataStore = new DataStore(); 254 + dataStore.$detailedProfiles.set(testProfile.did, testProfile); 254 255 const patchStore = new PatchStore(dataStore); 255 256 const mockPreferencesProvider = { 256 257 requirePreferences: () => Preferences.createLoggedOutPreferences(), ··· 266 267 267 268 const storedProfile = dataStore.$profiles.get(testProfile.did); 268 269 assertEquals(storedProfile.viewer.following, "follow-123"); 269 - assertEquals(storedProfile.followersCount, 11); 270 + 271 + const storedDetailed = dataStore.$detailedProfiles.get(testProfile.did); 272 + assertEquals(storedDetailed.viewer.following, "follow-123"); 273 + assertEquals(storedDetailed.followersCount, 11); 270 274 271 275 const patchedProfile = patchStore.applyProfilePatches(storedProfile); 272 276 assertEquals(patchedProfile, storedProfile); ··· 313 317 deleteFollowRecord: async () => {}, 314 318 }; 315 319 const dataStore = new DataStore(); 320 + dataStore.$detailedProfiles.set(testProfile.did, testProfile); 316 321 const patchStore = new PatchStore(dataStore); 317 322 const mockPreferencesProvider = { 318 323 requirePreferences: () => Preferences.createLoggedOutPreferences(), ··· 328 333 329 334 const storedProfile = dataStore.$profiles.get(testProfile.did); 330 335 assertEquals(storedProfile.viewer.following, null); 331 - assertEquals(storedProfile.followersCount, 9); 336 + 337 + const storedDetailed = dataStore.$detailedProfiles.get(testProfile.did); 338 + assertEquals(storedDetailed.viewer.following, null); 339 + assertEquals(storedDetailed.followersCount, 9); 332 340 333 341 const patchedProfile = patchStore.applyProfilePatches(storedProfile); 334 342 assertEquals(patchedProfile, storedProfile); ··· 3125 3133 const memberships = dataStore.$currentUserListMemberships.get(); 3126 3134 assertEquals(memberships.length, 1); 3127 3135 assertEquals(memberships[0].uri, membershipUri); 3136 + }); 3137 + }); 3138 + 3139 + t.describe("$detailedProfiles mirroring", (it) => { 3140 + const targetDid = "did:plc:target"; 3141 + const baseProfile = { 3142 + did: targetDid, 3143 + handle: "target.bsky.social", 3144 + followersCount: 10, 3145 + viewer: {}, 3146 + }; 3147 + // Detailed shape — superset, with fields that the basic profile won't carry. 3148 + const detailedSeed = { 3149 + ...baseProfile, 3150 + description: "Detailed bio", 3151 + pinnedPost: { uri: "at://pinned" }, 3152 + }; 3153 + 3154 + function setup(mockApi, { seedDetailed = true } = {}) { 3155 + const dataStore = new DataStore(); 3156 + const patchStore = new PatchStore(dataStore); 3157 + const preferencesProvider = { 3158 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 3159 + }; 3160 + dataStore.$profiles.set(targetDid, baseProfile); 3161 + if (seedDetailed) { 3162 + dataStore.$detailedProfiles.set(targetDid, detailedSeed); 3163 + } 3164 + const mutations = makeMutations( 3165 + mockApi, 3166 + dataStore, 3167 + patchStore, 3168 + preferencesProvider, 3169 + ); 3170 + return { mutations, dataStore }; 3171 + } 3172 + 3173 + it("updateProfile writes the fetched detailed profile to both stores", async () => { 3174 + const fetched = { 3175 + did: targetDid, 3176 + displayName: "Updated Name", 3177 + description: "Updated bio", 3178 + pinnedPost: { uri: "at://newpinned" }, 3179 + viewer: {}, 3180 + }; 3181 + const mockApi = { 3182 + getProfileRecord: async () => ({ value: {}, cid: "cid" }), 3183 + putProfileRecord: async () => ({}), 3184 + getProfile: async () => fetched, 3185 + }; 3186 + const { mutations, dataStore } = setup(mockApi); 3187 + await mutations.updateProfile(baseProfile, { 3188 + displayName: "Updated Name", 3189 + description: "Updated bio", 3190 + }); 3191 + assertEquals(dataStore.$profiles.get(targetDid), fetched); 3192 + assertEquals(dataStore.$detailedProfiles.get(targetDid), fetched); 3193 + }); 3194 + 3195 + it("followProfile mirrors viewer.following and count into $detailedProfiles", async () => { 3196 + const mockApi = { 3197 + createFollowRecord: async () => ({ uri: "at://follow" }), 3198 + }; 3199 + const { mutations, dataStore } = setup(mockApi); 3200 + await mutations.followProfile(baseProfile); 3201 + const detailed = dataStore.$detailedProfiles.get(targetDid); 3202 + assertEquals(detailed.viewer.following, "at://follow"); 3203 + assertEquals(detailed.followersCount, 11); 3204 + // Preserves detailed-only fields like description/pinnedPost. 3205 + assertEquals(detailed.description, "Detailed bio"); 3206 + assertEquals(detailed.pinnedPost.uri, "at://pinned"); 3207 + }); 3208 + 3209 + it("followProfile does not seed $detailedProfiles when no entry exists", async () => { 3210 + const mockApi = { 3211 + createFollowRecord: async () => ({ uri: "at://follow" }), 3212 + }; 3213 + const { mutations, dataStore } = setup(mockApi, { seedDetailed: false }); 3214 + await mutations.followProfile(baseProfile); 3215 + assertEquals(dataStore.$detailedProfiles.get(targetDid), null); 3216 + }); 3217 + 3218 + it("unfollowProfile mirrors viewer.following=null and decremented count", async () => { 3219 + const seedFollowed = { ...detailedSeed, viewer: { following: "at://x" } }; 3220 + const mockApi = { deleteFollowRecord: async () => {} }; 3221 + const dataStore = new DataStore(); 3222 + const patchStore = new PatchStore(dataStore); 3223 + const preferencesProvider = { 3224 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 3225 + }; 3226 + dataStore.$profiles.set(targetDid, { 3227 + ...baseProfile, 3228 + viewer: { following: "at://x" }, 3229 + }); 3230 + dataStore.$detailedProfiles.set(targetDid, seedFollowed); 3231 + const mutations = makeMutations( 3232 + mockApi, 3233 + dataStore, 3234 + patchStore, 3235 + preferencesProvider, 3236 + ); 3237 + await mutations.unfollowProfile({ 3238 + ...baseProfile, 3239 + viewer: { following: "at://x" }, 3240 + }); 3241 + const detailed = dataStore.$detailedProfiles.get(targetDid); 3242 + assertEquals(detailed.viewer.following, null); 3243 + assertEquals(detailed.followersCount, 9); 3244 + assertEquals(detailed.pinnedPost.uri, "at://pinned"); 3245 + }); 3246 + 3247 + it("muteProfile mirrors viewer.muted=true into $detailedProfiles", async () => { 3248 + const { mutations, dataStore } = setup({ muteActor: async () => ({}) }); 3249 + await mutations.muteProfile(baseProfile); 3250 + assertEquals(dataStore.$detailedProfiles.get(targetDid).viewer.muted, true); 3251 + }); 3252 + 3253 + it("unmuteProfile mirrors viewer.muted=false into $detailedProfiles", async () => { 3254 + const dataStore = new DataStore(); 3255 + const patchStore = new PatchStore(dataStore); 3256 + const preferencesProvider = { 3257 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 3258 + }; 3259 + dataStore.$profiles.set(targetDid, { 3260 + ...baseProfile, 3261 + viewer: { muted: true }, 3262 + }); 3263 + dataStore.$detailedProfiles.set(targetDid, { 3264 + ...detailedSeed, 3265 + viewer: { muted: true }, 3266 + }); 3267 + const mutations = makeMutations( 3268 + { unmuteActor: async () => ({}) }, 3269 + dataStore, 3270 + patchStore, 3271 + preferencesProvider, 3272 + ); 3273 + await mutations.unmuteProfile({ 3274 + ...baseProfile, 3275 + viewer: { muted: true }, 3276 + }); 3277 + assertEquals( 3278 + dataStore.$detailedProfiles.get(targetDid).viewer.muted, 3279 + false, 3280 + ); 3281 + }); 3282 + 3283 + it("blockProfile mirrors viewer.blocking into $detailedProfiles", async () => { 3284 + const { mutations, dataStore } = setup({ 3285 + blockActor: async () => ({ uri: "at://block" }), 3286 + }); 3287 + await mutations.blockProfile(baseProfile); 3288 + assertEquals( 3289 + dataStore.$detailedProfiles.get(targetDid).viewer.blocking, 3290 + "at://block", 3291 + ); 3292 + }); 3293 + 3294 + it("unblockProfile mirrors viewer.blocking=null into $detailedProfiles", async () => { 3295 + const dataStore = new DataStore(); 3296 + const patchStore = new PatchStore(dataStore); 3297 + const preferencesProvider = { 3298 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 3299 + }; 3300 + dataStore.$profiles.set(targetDid, { 3301 + ...baseProfile, 3302 + viewer: { blocking: "at://block" }, 3303 + }); 3304 + dataStore.$detailedProfiles.set(targetDid, { 3305 + ...detailedSeed, 3306 + viewer: { blocking: "at://block" }, 3307 + }); 3308 + const mutations = makeMutations( 3309 + { unblockActor: async () => {} }, 3310 + dataStore, 3311 + patchStore, 3312 + preferencesProvider, 3313 + ); 3314 + await mutations.unblockProfile({ 3315 + ...baseProfile, 3316 + viewer: { blocking: "at://block" }, 3317 + }); 3318 + assertEquals( 3319 + dataStore.$detailedProfiles.get(targetDid).viewer.blocking, 3320 + null, 3321 + ); 3322 + }); 3323 + 3324 + it("updatePostNotificationSubscription mirrors viewer.activitySubscription", async () => { 3325 + const subscription = { post: true, reply: false }; 3326 + const { mutations, dataStore } = setup({ 3327 + putActivitySubscription: async () => ({}), 3328 + }); 3329 + await mutations.updatePostNotificationSubscription( 3330 + baseProfile, 3331 + subscription, 3332 + ); 3333 + assertEquals( 3334 + dataStore.$detailedProfiles.get(targetDid).viewer.activitySubscription, 3335 + subscription, 3336 + ); 3128 3337 }); 3129 3338 }); 3130 3339
+18 -9
tests/unit/specs/dataLayer/requests.test.js
··· 367 367 }); 368 368 }); 369 369 370 - t.describe("loadProfile", (it) => { 370 + t.describe("loadDetailedProfile", (it) => { 371 371 const profileDID = "did:test:profile"; 372 372 373 373 it("should load and store profile", async () => { ··· 393 393 mockPreferencesProvider, 394 394 ); 395 395 396 - await requests.loadProfile(profileDID); 396 + await requests.loadDetailedProfile(profileDID); 397 397 398 398 // Check profile was stored 399 399 assertEquals(dataStore.$profiles.get(profileDID), mockProfile); ··· 422 422 mockPreferencesProvider, 423 423 ); 424 424 425 - await requests.loadProfile(profileDID); 425 + await requests.loadDetailedProfile(profileDID); 426 426 427 427 assertEquals(dataStore.$profiles.get(profileDID), initialProfile); 428 428 ··· 435 435 436 436 mockApi.getProfile = async () => updatedProfile; 437 437 438 - await requests.loadProfile(profileDID); 438 + await requests.loadDetailedProfile(profileDID); 439 439 440 440 assertEquals(dataStore.$profiles.get(profileDID), updatedProfile); 441 441 }); ··· 1979 1979 }; 1980 1980 const requests = makeRequests(mockApi, dataStore); 1981 1981 1982 - await requests.loadProfile("did:plc:a"); 1983 - await requests.loadProfile("did:plc:b"); 1982 + await requests.loadDetailedProfile("did:plc:a"); 1983 + await requests.loadDetailedProfile("did:plc:b"); 1984 1984 1985 - assertEquals(requests.getStatus("loadProfile-did:plc:a").error, null); 1986 - assertEquals(requests.getStatus("loadProfile-did:plc:a").loading, false); 1987 - assertEquals(requests.getStatus("loadProfile-did:plc:b").loading, false); 1985 + assertEquals( 1986 + requests.getStatus("loadDetailedProfile-did:plc:a").error, 1987 + null, 1988 + ); 1989 + assertEquals( 1990 + requests.getStatus("loadDetailedProfile-did:plc:a").loading, 1991 + false, 1992 + ); 1993 + assertEquals( 1994 + requests.getStatus("loadDetailedProfile-did:plc:b").loading, 1995 + false, 1996 + ); 1988 1997 }); 1989 1998 }); 1990 1999
+3 -1
tests/unit/specs/plugins/pluginRendering.test.js
··· 45 45 46 46 it("renders <plugin-profiles-list> and passes dataLayer", () => { 47 47 const { bridge } = makeBridge(); 48 - const dataLayer = { declarative: { ensureProfiles: async () => [] } }; 48 + const dataLayer = { 49 + declarative: { ensureDetailedProfiles: async () => [] }, 50 + }; 49 51 const renderer = new PluginRenderer(bridge, "demo", { dataLayer }); 50 52 const element = renderer.createRoot().render({ 51 53 tag: "plugin-profiles-list",