Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

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

Add chat info panel and profile labels

Grace Kind (Jul 15, 2026, 4:22 PM -0500) baa47bbc 1ab63e29

+841 -90
+92 -1
src/css/style.css
··· 132 132 --avatar-size-sidebar-compact: 48px; 133 133 --avatar-size-convo: 50px; 134 134 --avatar-size-sidebar: 52px; 135 + --avatar-size-chat-info-panel: 88px; 135 136 --avatar-size-profile: 100px; 136 137 137 138 /* border radius */ ··· 1856 1857 height: 16px; 1857 1858 } 1858 1859 1859 - .large-post .post-labels { 1860 + .large-post .label-badges { 1860 1861 margin-top: 3px; 1861 1862 margin-bottom: 4px; 1862 1863 } ··· 3801 3802 font-size: 15px; 3802 3803 } 3803 3804 3805 + .chat-info-panel { 3806 + display: flex; 3807 + flex-direction: column; 3808 + align-items: center; 3809 + gap: 4px; 3810 + padding: 24px 20px; 3811 + text-align: center; 3812 + } 3813 + 3814 + .chat-info-panel > .avatar .avatar-image-frame, 3815 + .chat-info-panel-avatars > .avatar .avatar-image-frame { 3816 + width: var(--avatar-size-chat-info-panel); 3817 + height: var(--avatar-size-chat-info-panel); 3818 + } 3819 + 3820 + .chat-info-panel-avatars .avatar-group { 3821 + width: var(--avatar-size-chat-info-panel); 3822 + height: var(--avatar-size-chat-info-panel); 3823 + } 3824 + 3825 + .chat-info-panel-name { 3826 + display: flex; 3827 + align-items: center; 3828 + gap: 4px; 3829 + margin-top: 8px; 3830 + font-size: 20px; 3831 + font-weight: 700; 3832 + color: var(--text-color); 3833 + } 3834 + 3835 + .chat-info-panel-handle { 3836 + font-size: 14px; 3837 + color: var(--text-color-muted); 3838 + } 3839 + 3840 + .chat-info-panel-go-to-profile-button { 3841 + margin-top: 12px; 3842 + color: var(--text-color); 3843 + text-decoration: none; 3844 + } 3845 + 3846 + .chat-info-panel .label-badges { 3847 + display: flex; 3848 + flex-wrap: wrap; 3849 + justify-content: center; 3850 + gap: 4px; 3851 + } 3852 + 3853 + .chat-info-panel .label-badge { 3854 + font-size: 13px; 3855 + padding: 3px 8px 4px; 3856 + background: light-dark(var(--lightest-gray), var(--gray)); 3857 + } 3858 + 3859 + @media (hover: hover) { 3860 + .chat-info-panel .label-badge:hover { 3861 + background: light-dark(var(--lighter-gray), var(--light-gray)); 3862 + } 3863 + } 3864 + 3865 + .chat-info-panel .label-badge-image { 3866 + width: 16px; 3867 + height: 16px; 3868 + } 3869 + 3804 3870 .message-day-title { 3805 3871 font-size: 12px; 3806 3872 color: var(--text-color-muted); ··· 5060 5126 justify-content: flex-start; 5061 5127 gap: 8px; 5062 5128 margin-bottom: 8px; 5129 + } 5130 + 5131 + .profile-card .label-badges { 5132 + display: flex; 5133 + flex-wrap: wrap; 5134 + gap: 4px; 5135 + margin-top: 12px; 5136 + padding: 0 16px; 5137 + } 5138 + 5139 + .profile-card .label-badge { 5140 + font-size: 13px; 5141 + padding: 3px 8px 4px; 5142 + background: light-dark(var(--lightest-gray), var(--gray)); 5143 + } 5144 + 5145 + @media (hover: hover) { 5146 + .profile-card .label-badge:hover { 5147 + background: light-dark(var(--lighter-gray), var(--light-gray)); 5148 + } 5149 + } 5150 + 5151 + .profile-card .label-badge-image { 5152 + width: 16px; 5153 + height: 16px; 5063 5154 } 5064 5155 5065 5156 .profile-follows-you {
+49 -29
src/js/api.js
··· 7 7 PUBLIC_SERVICE_ENDPOINT_URL, 8 8 BSKY_APPVIEW_SERVICE_DID, 9 9 BSKY_CHAT_SERVICE_DID, 10 + BSKY_LABELER_DID, 10 11 VIDEO_SERVICE_URL, 11 12 VIDEO_SERVICE_DID, 12 13 } from "/js/config.js"; 14 + 15 + // Matches the header format in @atproto/api 16 + function buildAcceptLabelersHeader(labelers) { 17 + return labelers 18 + .map((did) => (did === BSKY_LABELER_DID ? `${did};redact` : did)) 19 + .join(", "); 20 + } 13 21 14 22 export class ApiError extends Error { 15 23 constructor(res) { ··· 275 283 parentHeight: 1000, // max height, just so we don't set the wrong reply root by accident. This should be really rare - the default is 80. 276 284 }, 277 285 headers: { 278 - "atproto-accept-labelers": labelers.join(","), 286 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 279 287 "atproto-proxy": this.bskyAppViewServiceDid, 280 288 }, 281 289 }); ··· 286 294 const res = await this.request(`app.bsky.unspecced.getPostThreadOtherV2`, { 287 295 query: { anchor: postUri }, 288 296 headers: { 289 - "atproto-accept-labelers": labelers.join(","), 297 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 290 298 }, 291 299 }); 292 300 return res.data.thread; ··· 300 308 cursor, 301 309 }, 302 310 headers: { 303 - "atproto-accept-labelers": labelers.join(","), 311 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 304 312 "atproto-proxy": this.bskyAppViewServiceDid, 305 313 }, 306 314 }); ··· 359 367 const res = await this.request(`app.bsky.feed.getListFeed`, { 360 368 query, 361 369 headers: { 362 - "atproto-accept-labelers": labelers.join(","), 370 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 363 371 "atproto-proxy": this.bskyAppViewServiceDid, 364 372 }, 365 373 }); ··· 429 437 const res = await this.request(`app.bsky.feed.getTimeline`, { 430 438 query: { limit, cursor }, 431 439 headers: { 432 - "atproto-accept-labelers": labelers.join(","), 440 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 433 441 "atproto-proxy": this.bskyAppViewServiceDid, 434 442 }, 435 443 }); ··· 443 451 const res = await this.request(`app.bsky.feed.getPosts`, { 444 452 query: { uris: batch }, 445 453 headers: { 446 - "atproto-accept-labelers": labelers.join(","), 454 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 447 455 "atproto-proxy": this.bskyAppViewServiceDid, 448 456 }, 449 457 }); ··· 498 506 const res = await this.request(`app.bsky.actor.getProfile`, { 499 507 query: { actor: did }, 500 508 headers: { 501 - "atproto-accept-labelers": labelers.join(","), 509 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 502 510 "atproto-proxy": this.bskyAppViewServiceDid, 503 511 }, 504 512 }); ··· 511 519 this.request(`app.bsky.actor.getProfiles`, { 512 520 query: { actors: chunk }, 513 521 headers: { 514 - "atproto-accept-labelers": labelers.join(","), 522 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 515 523 "atproto-proxy": this.bskyAppViewServiceDid, 516 524 }, 517 525 }), ··· 528 536 const res = await this.request(`app.bsky.actor.searchActors`, { 529 537 query: queryParams, 530 538 headers: { 531 - "atproto-accept-labelers": labelers.join(","), 539 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 532 540 "atproto-proxy": this.bskyAppViewServiceDid, 533 541 }, 534 542 }); ··· 539 547 const res = await this.request(`app.bsky.actor.searchActorsTypeahead`, { 540 548 query: { q: query, limit }, 541 549 headers: { 542 - "atproto-accept-labelers": labelers.join(","), 550 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 543 551 "atproto-proxy": this.bskyAppViewServiceDid, 544 552 }, 545 553 }); ··· 557 565 const res = await this.request(`app.bsky.feed.searchPosts`, { 558 566 query: queryParams, 559 567 headers: { 560 - "atproto-accept-labelers": labelers.join(","), 568 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 561 569 "atproto-proxy": this.bskyAppViewServiceDid, 562 570 }, 563 571 }); ··· 588 596 const res = await this.request(`app.bsky.feed.getAuthorFeed`, { 589 597 query: { actor: did, limit, cursor, filter, includePins }, 590 598 headers: { 591 - "atproto-accept-labelers": labelers.join(","), 599 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 592 600 "atproto-proxy": this.bskyAppViewServiceDid, 593 601 }, 594 602 }); ··· 603 611 const res = await this.request(`app.bsky.feed.getActorLikes`, { 604 612 query, 605 613 headers: { 606 - "atproto-accept-labelers": labelers.join(","), 614 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 607 615 "atproto-proxy": this.bskyAppViewServiceDid, 608 616 }, 609 617 }); ··· 666 674 const res = await this.request("app.bsky.notification.listNotifications", { 667 675 query, 668 676 headers: { 669 - "atproto-accept-labelers": labelers.join(","), 677 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 670 678 "atproto-proxy": this.bskyAppViewServiceDid, 671 679 }, 672 680 }); ··· 684 692 }); 685 693 } 686 694 687 - async listConvos({ cursor, limit = 30, readState, status } = {}) { 695 + async listConvos({ 696 + cursor, 697 + limit = 30, 698 + readState, 699 + status, 700 + labelers = [], 701 + } = {}) { 688 702 const query = { limit }; 689 703 if (cursor) { 690 704 query.cursor = cursor; ··· 698 712 const res = await this.request("chat.bsky.convo.listConvos", { 699 713 query, 700 714 headers: { 715 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 701 716 "atproto-proxy": this.chatAppViewServiceDid, 702 717 }, 703 718 }); 704 719 return res.data; 705 720 } 706 721 707 - async getConvo(convoId) { 722 + async getConvo(convoId, { labelers = [] } = {}) { 708 723 const res = await this.request("chat.bsky.convo.getConvo", { 709 724 query: { convoId }, 710 725 headers: { 726 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 711 727 "atproto-proxy": this.chatAppViewServiceDid, 712 728 }, 713 729 }); 714 730 return res.data; 715 731 } 716 732 717 - async getMessages(convoId, { cursor, limit = 50 } = {}) { 733 + async getMessages(convoId, { cursor, limit = 50, labelers = [] } = {}) { 718 734 const query = { convoId, limit }; 719 735 if (cursor) { 720 736 query.cursor = cursor; ··· 722 738 const res = await this.request("chat.bsky.convo.getMessages", { 723 739 query, 724 740 headers: { 741 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 725 742 "atproto-proxy": this.chatAppViewServiceDid, 726 743 }, 727 744 }); ··· 775 792 return res.data; 776 793 } 777 794 778 - async getConvoAvailability(memberDids) { 795 + async getConvoAvailability(memberDids, { labelers = [] } = {}) { 779 796 const res = await this.request("chat.bsky.convo.getConvoAvailability", { 780 797 query: { members: memberDids }, 781 798 headers: { 799 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 782 800 "atproto-proxy": this.chatAppViewServiceDid, 783 801 }, 784 802 }); 785 803 return res.data; 786 804 } 787 805 788 - async getConvoForMembers(memberDids) { 806 + async getConvoForMembers(memberDids, { labelers = [] } = {}) { 789 807 const res = await this.request("chat.bsky.convo.getConvoForMembers", { 790 808 query: { members: memberDids }, 791 809 headers: { 810 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 792 811 "atproto-proxy": this.chatAppViewServiceDid, 793 812 }, 794 813 }); 795 814 return res.data; 796 815 } 797 816 798 - async getChatLogs({ cursor }) { 817 + async getChatLogs({ cursor, labelers = [] }) { 799 818 const res = await this.request("chat.bsky.convo.getLog", { 800 819 query: { cursor }, 801 820 headers: { 821 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 802 822 "atproto-proxy": this.chatAppViewServiceDid, 803 823 }, 804 824 }); ··· 886 906 const res = await this.request("app.bsky.feed.getLikes", { 887 907 query, 888 908 headers: { 889 - "atproto-accept-labelers": labelers.join(","), 909 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 890 910 "atproto-proxy": this.bskyAppViewServiceDid, 891 911 }, 892 912 }); ··· 901 921 const res = await this.request("app.bsky.feed.getQuotes", { 902 922 query, 903 923 headers: { 904 - "atproto-accept-labelers": labelers.join(","), 924 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 905 925 "atproto-proxy": this.bskyAppViewServiceDid, 906 926 }, 907 927 }); ··· 916 936 const res = await this.request("app.bsky.feed.getRepostedBy", { 917 937 query, 918 938 headers: { 919 - "atproto-accept-labelers": labelers.join(","), 939 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 920 940 "atproto-proxy": this.bskyAppViewServiceDid, 921 941 }, 922 942 }); ··· 931 951 const res = await this.request("app.bsky.bookmark.getBookmarks", { 932 952 query, 933 953 headers: { 934 - "atproto-accept-labelers": labelers.join(","), 954 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 935 955 "atproto-proxy": this.bskyAppViewServiceDid, 936 956 }, 937 957 }); ··· 946 966 const res = await this.request("app.bsky.graph.getFollowers", { 947 967 query, 948 968 headers: { 949 - "atproto-accept-labelers": labelers.join(","), 969 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 950 970 "atproto-proxy": this.bskyAppViewServiceDid, 951 971 }, 952 972 }); ··· 961 981 const res = await this.request("app.bsky.graph.getKnownFollowers", { 962 982 query, 963 983 headers: { 964 - "atproto-accept-labelers": labelers.join(","), 984 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 965 985 "atproto-proxy": this.bskyAppViewServiceDid, 966 986 }, 967 987 }); ··· 976 996 const res = await this.request("app.bsky.graph.getFollows", { 977 997 query, 978 998 headers: { 979 - "atproto-accept-labelers": labelers.join(","), 999 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 980 1000 "atproto-proxy": this.bskyAppViewServiceDid, 981 1001 }, 982 1002 }); ··· 1051 1071 const res = await this.request("app.bsky.graph.getBlocks", { 1052 1072 query, 1053 1073 headers: { 1054 - "atproto-accept-labelers": labelers.join(","), 1074 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 1055 1075 "atproto-proxy": this.bskyAppViewServiceDid, 1056 1076 }, 1057 1077 }); ··· 1066 1086 const res = await this.request("app.bsky.graph.getMutes", { 1067 1087 query, 1068 1088 headers: { 1069 - "atproto-accept-labelers": labelers.join(","), 1089 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 1070 1090 "atproto-proxy": this.bskyAppViewServiceDid, 1071 1091 }, 1072 1092 });
+18 -4
src/js/dataLayer/derived.js
··· 64 64 65 65 function applyLabelsToPost(post, preferences) { 66 66 let result = post; 67 - const badgeLabels = preferences.getBadgeLabels(post); 67 + const badgeLabels = preferences.getBadgeLabelsForPost(post); 68 68 if (badgeLabels.length > 0) { 69 69 result = { ...result, badgeLabels }; 70 70 } ··· 358 358 if (!profile) return null; 359 359 const preferences = this.$preferences.get(); 360 360 if (!preferences) return profile; 361 + let result = profile; 361 362 const blurLabel = preferences.getProfileBlurLabel(profile); 362 - if (!blurLabel) return profile; 363 - return { ...profile, blurLabel }; 363 + if (blurLabel) { 364 + result = { ...result, blurLabel }; 365 + } 366 + const badgeLabels = preferences.getBadgeLabelsForProfile(profile); 367 + if (badgeLabels.length > 0) { 368 + result = { ...result, badgeLabels }; 369 + } 370 + return result; 364 371 }); 365 372 this.$hydratedAuthorFeeds = new ComputedMap((feedURI) => { 366 373 const rawFeed = this.dataStore.$authorFeeds.get(feedURI); ··· 495 502 .filter((did) => !convo.members.some((member) => member.did === did)) 496 503 .map((did) => this.$hydratedProfiles.get(did)) 497 504 .filter(Boolean); 498 - return [...convo.members, ...referencedProfiles]; 505 + const preferences = this.$preferences.get(); 506 + const members = convo.members.map((member) => { 507 + if (!preferences) return member; 508 + const badgeLabels = preferences.getBadgeLabelsForProfile(member); 509 + if (badgeLabels.length === 0) return member; 510 + return { ...member, badgeLabels }; 511 + }); 512 + return [...members, ...referencedProfiles]; 499 513 }); 500 514 this.$convoForProfile = new ComputedMap((profileDid) => { 501 515 const convoIds = [...this.dataStore.$convos.keys()];
+25 -7
src/js/dataLayer/requests.js
··· 813 813 const cursor = reload 814 814 ? "" 815 815 : readCollectionCursor(this.dataStore.$convoList); 816 - const res = await this.api.listConvos({ cursor, limit }); 816 + const labelers = this.requireLabelers(); 817 + const res = await this.api.listConvos({ cursor, limit, labelers }); 817 818 // Store individual convos 818 819 for (const convo of res.convos) { 819 820 this.dataStore.$convos.set(convo.id, convo); ··· 828 829 const cursor = reload 829 830 ? "" 830 831 : readCollectionCursor(this.dataStore.$convoRequestList); 831 - const res = await this.api.listConvos({ cursor, limit, status: "request" }); 832 + const labelers = this.requireLabelers(); 833 + const res = await this.api.listConvos({ 834 + cursor, 835 + limit, 836 + status: "request", 837 + labelers, 838 + }); 832 839 // Store individual convos 833 840 for (const convo of res.convos) { 834 841 this.dataStore.$convos.set(convo.id, convo); ··· 840 847 } 841 848 842 849 async loadConvo(convoId) { 843 - const res = await this.api.getConvo(convoId); 850 + const labelers = this.requireLabelers(); 851 + const res = await this.api.getConvo(convoId, { labelers }); 844 852 this.dataStore.$convos.set(convoId, res.convo); 845 853 } 846 854 ··· 873 881 } 874 882 875 883 async loadConvoForProfile(profileDid) { 876 - const res = await this.api.getConvoForMembers([profileDid]); 884 + const labelers = this.requireLabelers(); 885 + const res = await this.api.getConvoForMembers([profileDid], { labelers }); 877 886 this.dataStore.$convos.set(res.convo.id, res.convo); 878 887 } 879 888 ··· 881 890 const cursor = reload 882 891 ? "" 883 892 : readCollectionCursor(this.dataStore.$convoMessages, { key: convoId }); 884 - const res = await this.api.getMessages(convoId, { cursor, limit }); 893 + const labelers = this.requireLabelers(); 894 + const res = await this.api.getMessages(convoId, { 895 + cursor, 896 + limit, 897 + labelers, 898 + }); 885 899 if (res.messages.length === 0 && res.cursor) { 886 900 console.warn("getMessages returned an empty page with a cursor", { 887 901 convoId, ··· 909 923 } 910 924 911 925 async pollConvoMessages(convoId, { cursor = "" } = {}) { 912 - const res = await this.api.getChatLogs({ cursor }); 926 + const labelers = this.requireLabelers(); 927 + const res = await this.api.getChatLogs({ cursor, labelers }); 913 928 const logsForConvo = res.logs.filter((log) => log.convoId === convoId); 914 929 const newMessages = []; 915 930 for (const log of logsForConvo) { ··· 1352 1367 } 1353 1368 1354 1369 async loadProfileChatStatus(profileDid) { 1355 - const res = await this.api.getConvoAvailability([profileDid]); 1370 + const labelers = this.requireLabelers(); 1371 + const res = await this.api.getConvoAvailability([profileDid], { 1372 + labelers, 1373 + }); 1356 1374 this.dataStore.$profileChatStatus.set(profileDid, res); 1357 1375 } 1358 1376
+22 -3
src/js/preferences.js
··· 269 269 return contentLabelPrefs.filter((pref) => pref.labelerDid === labelerDid); 270 270 } 271 271 272 - getBadgeLabels(post) { 273 - const labels = getPostLabels(post); 272 + _getBadgeLabelsFromLabels(labels, { includeBlurLabels = false } = {}) { 274 273 const badgeLabels = []; 275 274 for (const label of labels) { 276 275 const labeler = getLabelerForLabel(label, this.labelerDefs); 277 276 if (!labeler) continue; 278 277 const labelDefinition = getDefinitionForLabel(label, labeler); 279 - if (!labelDefinition || !isBadgeLabel(labelDefinition)) continue; 278 + if (!labelDefinition) continue; 279 + const rendersAsBadge = 280 + isBadgeLabel(labelDefinition) || 281 + (includeBlurLabels && 282 + ["alert", "inform"].includes(labelDefinition.severity)); 283 + if (!rendersAsBadge) continue; 280 284 const visibility = this.getLabelVisibility(label, labelDefinition); 281 285 if (visibility === "ignore") continue; 282 286 badgeLabels.push({ ··· 287 291 }); 288 292 } 289 293 return badgeLabels; 294 + } 295 + 296 + getBadgeLabelsForPost(post) { 297 + return this._getBadgeLabelsFromLabels(getPostLabels(post)); 298 + } 299 + 300 + getBadgeLabelsForProfile(profile) { 301 + // Blur labels on a post surface as its content warning, so only 302 + // blurs: "none" labels become pills there. A profile-targeted blur label 303 + // blurs the account's content/media, not the profile card, so it still 304 + // renders as a pill when its severity is alert/inform (mirrors 305 + // social-app's profileList/profileView behaviors). 306 + return this._getBadgeLabelsFromLabels(profile?.labels ?? [], { 307 + includeBlurLabels: true, 308 + }); 290 309 } 291 310 292 311 _getLabelByBlurType(post, blurType) {
+2 -2
src/js/templates/largePost.template.js
··· 11 11 import { postEmbedTemplate } from "/js/templates/postEmbed.template.js"; 12 12 import { postActionBarTemplate } from "/js/templates/postActionBar.template.js"; 13 13 import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; 14 - import { postLabelsTemplate } from "/js/templates/postLabels.template.js"; 14 + import { labelBadgesTemplate } from "/js/templates/labelBadges.template.js"; 15 15 import { blockedPostTemplate } from "/js/templates/blockedPost.template.js"; 16 16 import { whoCanReplyBadgeTemplate } from "/js/templates/whoCanReplyBadge.template.js"; 17 17 import { notFoundPostTemplate } from "/js/templates/notFoundPost.template.js"; ··· 145 145 })} 146 146 </div> 147 147 </div> 148 - ${badgeLabels.length > 0 ? postLabelsTemplate({ badgeLabels }) : ""} 148 + ${badgeLabels.length > 0 ? labelBadgesTemplate({ badgeLabels }) : ""} 149 149 <div class="post-content-bottom"> 150 150 ${mutedWarningTemplate({ 151 151 post,
+2 -2
src/js/templates/postEmbed.template.js
··· 11 11 import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; 12 12 import "/js/components/plugin-rich-text.js"; 13 13 import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; 14 - import { postLabelsTemplate } from "/js/templates/postLabels.template.js"; 14 + import { labelBadgesTemplate } from "/js/templates/labelBadges.template.js"; 15 15 import { linkToPost, linkToFeed } from "/js/navigation.js"; 16 16 import { moderationWarningTemplate } from "/js/templates/moderationWarning.template.js"; 17 17 import "/js/components/container-link.js"; ··· 220 220 })} 221 221 </div> 222 222 ${quotedPost.badgeLabels 223 - ? postLabelsTemplate({ badgeLabels: quotedPost.badgeLabels }) 223 + ? labelBadgesTemplate({ badgeLabels: quotedPost.badgeLabels }) 224 224 : ""} 225 225 <div class="quoted-post-body"> 226 226 ${postText.length > 0
+2 -2
src/js/templates/postLabels.template.js src/js/templates/labelBadges.template.js
··· 1 1 import { html } from "/js/lib/lit-html.js"; 2 2 import { getLabelNameAndDescription } from "/js/dataHelpers.js"; 3 3 4 - export function postLabelsTemplate({ badgeLabels }) { 5 - return html`<div class="post-labels" data-testid="post-labels"> 4 + export function labelBadgesTemplate({ badgeLabels }) { 5 + return html`<div class="label-badges" data-testid="label-badges"> 6 6 ${badgeLabels.map(({ labelDefinition, labeler }) => { 7 7 const { name: displayName } = getLabelNameAndDescription(labelDefinition); 8 8 return html`<a
+4
src/js/templates/profileCard.template.js
··· 23 23 import "/js/components/detected-rich-text.js"; 24 24 import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; 25 25 import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; 26 + import { labelBadgesTemplate } from "/js/templates/labelBadges.template.js"; 26 27 import "/js/components/context-menu.js"; 27 28 import "/js/components/context-menu-item.js"; 28 29 import "/js/components/context-menu-item-group.js"; ··· 86 87 ${!isLabeler && !isCurrentUser 87 88 ? knownFollowersSummaryTemplate({ profile }) 88 89 : null} 90 + ${!isCurrentUser && profile.badgeLabels?.length 91 + ? labelBadgesTemplate({ badgeLabels: profile.badgeLabels }) 92 + : ""} 89 93 `; 90 94 } 91 95
+2 -2
src/js/templates/smallPost.template.js
··· 17 17 import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js"; 18 18 import { infoIconTemplate } from "/js/templates/icons/infoIcon.template.js"; 19 19 import { cornerDownRightIconTemplate } from "/js/templates/icons/cornerDownRightIcon.template.js"; 20 - import { postLabelsTemplate } from "/js/templates/postLabels.template.js"; 20 + import { labelBadgesTemplate } from "/js/templates/labelBadges.template.js"; 21 21 import { blockedPostTemplate } from "/js/templates/blockedPost.template.js"; 22 22 import { notFoundPostTemplate } from "/js/templates/notFoundPost.template.js"; 23 23 import { unavailablePostTemplate } from "/js/templates/unavailablePost.template.js"; ··· 137 137 timestamp: post.indexedAt, 138 138 })} 139 139 ${post.badgeLabels 140 - ? postLabelsTemplate({ badgeLabels: post.badgeLabels }) 140 + ? labelBadgesTemplate({ badgeLabels: post.badgeLabels }) 141 141 : ""} 142 142 ${showReplyToLabel 143 143 ? html`<div class="reply-to-author">
+129 -8
src/js/views/chatDetail.view.js
··· 12 12 import { 13 13 getDisplayName, 14 14 getGroupConvoDetails, 15 + hasValidHandle, 15 16 isGroupConvo, 16 17 getSystemMessageDisplayText, 17 18 groupReactions, ··· 19 20 import { parseRecordLink, resolveRecordFromLink } from "/js/embedHelpers.js"; 20 21 import { avatarTemplate } from "/js/templates/avatar.template.js"; 21 22 import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js"; 23 + import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; 24 + import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; 25 + import { labelBadgesTemplate } from "/js/templates/labelBadges.template.js"; 22 26 import { 23 27 postEmbedTemplate, 24 28 recordEmbedTemplate, ··· 34 38 pinScrollPosition, 35 39 } from "/js/utils.js"; 36 40 import { Signal, ReactiveStore } from "/js/signals.js"; 37 - import { getPermalinkForConvo } from "/js/navigation.js"; 41 + import { getPermalinkForConvo, linkToProfile } from "/js/navigation.js"; 38 42 import { emojiIconTemplate } from "/js/templates/icons/emojiIcon.template.js"; 39 43 import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; 40 44 import { cornerDownRightIconTemplate } from "/js/templates/icons/cornerDownRightIcon.template.js"; ··· 1095 1099 </div>`; 1096 1100 } 1097 1101 1102 + function directInfoPanelTemplate({ profile }) { 1103 + const isFollowedBy = 1104 + !!profile.viewer?.followedBy && 1105 + !profile.viewer?.blocking && 1106 + !profile.viewer?.blockedBy; 1107 + const hasBadgeLabels = !!profile.badgeLabels?.length; 1108 + return html`<div class="chat-info-panel" data-testid="chat-info-panel"> 1109 + ${avatarTemplate({ author: profile })} 1110 + <div class="chat-info-panel-name"> 1111 + ${getDisplayName(profile)}${verificationBadgeTemplate({ 1112 + profile, 1113 + })}${automatedAccountBadgeTemplate({ profile })} 1114 + </div> 1115 + ${hasValidHandle(profile) 1116 + ? html`<div class="chat-info-panel-handle">@${profile.handle}</div>` 1117 + : ""} 1118 + ${isFollowedBy 1119 + ? html`<div 1120 + class="profile-follows-you" 1121 + data-testid="follows-you-badge" 1122 + > 1123 + Follows you 1124 + </div>` 1125 + : ""} 1126 + ${hasBadgeLabels 1127 + ? labelBadgesTemplate({ badgeLabels: profile.badgeLabels }) 1128 + : ""} 1129 + <a 1130 + class="rounded-button chat-info-panel-go-to-profile-button" 1131 + data-testid="chat-info-panel-go-to-profile" 1132 + href="${linkToProfile(profile)}" 1133 + > 1134 + Go to profile 1135 + </a> 1136 + </div>`; 1137 + } 1138 + 1139 + function getNewGroupChatDescription({ otherMembers, memberCount }) { 1140 + const names = otherMembers.map((member) => getDisplayName(member)); 1141 + if (names.length === 0) { 1142 + return "New group chat."; 1143 + } 1144 + if (names.length === 1) { 1145 + return `New chat with ${names[0]}.`; 1146 + } 1147 + // memberCount includes the current user, so subtract them plus the 1148 + // two named members 1149 + const remainingCount = memberCount - 3; 1150 + if (remainingCount > 0) { 1151 + return `New chat with ${names[0]}, ${names[1]}, and ${remainingCount} more.`; 1152 + } 1153 + return `New chat with ${names[0]} and ${names[1]}.`; 1154 + } 1155 + 1156 + function groupInfoPanelTemplate({ convo, groupDetails, currentUserDid }) { 1157 + const otherMembers = convo.members.filter( 1158 + (member) => member.did !== currentUserDid, 1159 + ); 1160 + return html`<div class="chat-info-panel" data-testid="chat-info-panel"> 1161 + <div class="chat-info-panel-avatars"> 1162 + ${avatarGroupTemplate({ authors: otherMembers })} 1163 + </div> 1164 + ${groupDetails.name 1165 + ? html`<div class="chat-info-panel-name">${groupDetails.name}</div>` 1166 + : ""} 1167 + <div class="chat-info-panel-handle"> 1168 + ${getNewGroupChatDescription({ 1169 + otherMembers, 1170 + memberCount: groupDetails.memberCount, 1171 + })} 1172 + </div> 1173 + </div>`; 1174 + } 1175 + 1176 + function chatInfoPanelTemplate({ convo, isGroup, currentUserDid }) { 1177 + if (!convo) { 1178 + return null; 1179 + } 1180 + if (isGroup) { 1181 + return groupInfoPanelTemplate({ 1182 + convo, 1183 + groupDetails: getGroupConvoDetails(convo), 1184 + currentUserDid, 1185 + }); 1186 + } 1187 + const otherMember = convo.members.find( 1188 + (member) => member.did !== currentUserDid, 1189 + ); 1190 + if (!otherMember) { 1191 + return null; 1192 + } 1193 + const profile = getMemberProfile(convo, otherMember.did) ?? otherMember; 1194 + return directInfoPanelTemplate({ profile }); 1195 + } 1196 + 1197 + function chatEmptyTemplate({ convo, isGroup, currentUserDid }) { 1198 + const infoPanel = chatInfoPanelTemplate({ 1199 + convo, 1200 + isGroup, 1201 + currentUserDid, 1202 + }); 1203 + return html`<div class="chat-detail-empty"> 1204 + ${infoPanel ?? html`<div>No messages yet!</div>`} 1205 + </div>`; 1206 + } 1207 + 1098 1208 function messagesTemplate({ 1099 1209 loadingEnabled, 1100 1210 messages, ··· 1105 1215 canReactNow, 1106 1216 }) { 1107 1217 if (!messages || messages.length === 0) { 1108 - return html`<div class="chat-detail-empty"> 1109 - <div>No messages yet!</div> 1110 - </div>`; 1218 + return chatEmptyTemplate({ convo, isGroup, currentUserDid }); 1111 1219 } 1112 1220 const reversedMessages = messages.toReversed(); 1113 1221 const messageGroups = groupMessages(reversedMessages, currentUserDid); ··· 1126 1234 @load-more=${async (e) => { 1127 1235 if (hasMore) { 1128 1236 const scrollContainer = getMessageScroller(); 1237 + // In a short chat the top sentinel is visible while the user 1238 + // sits at the bottom; when the fetch inserts the info panel 1239 + // above, keep them pinned to the bottom instead of restoring 1240 + // a stale offset 1241 + const shouldStickToBottom = isScrolledToBottom(); 1129 1242 // Maintain scroll position using scrollHeight difference 1130 1243 const previousScrollHeight = scrollContainer.scrollHeight; 1131 1244 const previousScrollTop = scrollContainer.scrollTop; 1132 1245 await loadMessages(); 1133 1246 await raf(); 1134 1247 await raf(); 1135 - // Restore scroll position 1136 - const newScrollHeight = scrollContainer.scrollHeight; 1137 - const heightDifference = newScrollHeight - previousScrollHeight; 1138 - scrollContainer.scrollTop = previousScrollTop + heightDifference; 1248 + if (shouldStickToBottom) { 1249 + pinScrollToBottom(); 1250 + } else { 1251 + // Restore scroll position 1252 + const newScrollHeight = scrollContainer.scrollHeight; 1253 + const heightDifference = newScrollHeight - previousScrollHeight; 1254 + scrollContainer.scrollTop = 1255 + previousScrollTop + heightDifference; 1256 + } 1139 1257 await wait(100); // wait for the scroll to complete so that we don't accidentally trigger the load more event again 1140 1258 e.detail.resume(); 1141 1259 } ··· 1146 1264 <div class="loading-spinner"></div> 1147 1265 </div>` 1148 1266 : ""} 1267 + ${hasMore 1268 + ? "" 1269 + : chatInfoPanelTemplate({ convo, isGroup, currentUserDid })} 1149 1270 <div class="message-list"> 1150 1271 ${days.map((day) => { 1151 1272 return html`<div class="message-day">
+167 -6
tests/e2e/specs/views/chatDetail.view.test.js
··· 4 4 import { 5 5 createConvo, 6 6 createGroupConvo, 7 + createLabelerView, 7 8 createMessage, 8 9 createMessageLog, 9 10 createPost, ··· 1649 1650 await expect(preview).toBeVisible(); 1650 1651 }); 1651 1652 1652 - test("should show empty state when there are no messages", async ({ 1653 + test("should show a user card empty state when there are no messages", async ({ 1653 1654 page, 1654 1655 }) => { 1655 1656 const mockServer = new MockServer(); ··· 1657 1658 did: "did:plc:alice1", 1658 1659 handle: "alice.bsky.social", 1659 1660 displayName: "Alice", 1661 + viewer: { 1662 + followedBy: "at://did:plc:alice1/app.bsky.graph.follow/follow1", 1663 + }, 1660 1664 }); 1661 1665 const convo = createConvo({ 1662 1666 id: "convo-1", ··· 1670 1674 await page.goto("/messages/convo-1"); 1671 1675 1672 1676 const chatDetailView = page.locator("#chat-detail-view"); 1677 + const infoPanel = chatDetailView.locator('[data-testid="chat-info-panel"]'); 1678 + await expect(infoPanel).toBeVisible({ timeout: 10000 }); 1679 + await expect(infoPanel).toContainText("Alice"); 1680 + await expect(infoPanel).toContainText("@alice.bsky.social"); 1673 1681 await expect( 1674 - chatDetailView.locator('[data-testid="header-title"]'), 1675 - ).toContainText("Alice", { timeout: 10000 }); 1676 - await expect(chatDetailView.locator(".chat-detail-empty")).toContainText( 1677 - "No messages yet!", 1678 - { timeout: 10000 }, 1682 + infoPanel.locator('[data-testid="avatar-image"]'), 1683 + ).toBeVisible(); 1684 + await expect( 1685 + infoPanel.locator('[data-testid="follows-you-badge"]'), 1686 + ).toBeVisible(); 1687 + 1688 + await infoPanel 1689 + .locator('[data-testid="chat-info-panel-go-to-profile"]') 1690 + .click(); 1691 + await expect(page).toHaveURL(/\/profile\/alice\.bsky\.social/); 1692 + }); 1693 + 1694 + test("should show moderation label pills on the empty state user card", async ({ 1695 + page, 1696 + }) => { 1697 + const mockServer = new MockServer(); 1698 + const labelerDid = "did:plc:customlabeler1"; 1699 + const labeler = createLabelerView({ 1700 + did: labelerDid, 1701 + handle: "safety.example.com", 1702 + displayName: "Safety Labeler", 1703 + labelDefinitions: [ 1704 + { 1705 + identifier: "spam", 1706 + blurs: "none", 1707 + severity: "inform", 1708 + defaultSetting: "warn", 1709 + locales: [ 1710 + { 1711 + lang: "en", 1712 + name: "Spam", 1713 + description: "Likely spam content", 1714 + }, 1715 + ], 1716 + }, 1717 + ], 1718 + }); 1719 + mockServer.addLabelerViews([labeler]); 1720 + mockServer.addLabelerSubscription(labelerDid); 1721 + 1722 + const alice = createProfile({ 1723 + did: "did:plc:alice1", 1724 + handle: "alice.bsky.social", 1725 + displayName: "Alice", 1726 + labels: [ 1727 + { 1728 + val: "spam", 1729 + src: labelerDid, 1730 + uri: "did:plc:alice1", 1731 + cts: "2025-01-01T00:00:00.000Z", 1732 + }, 1733 + ], 1734 + }); 1735 + const convo = createConvo({ 1736 + id: "convo-1", 1737 + otherMember: alice, 1738 + }); 1739 + mockServer.addConvos([convo]); 1740 + mockServer.addConvoMessages("convo-1", []); 1741 + await mockServer.setup(page); 1742 + 1743 + await login(page); 1744 + await page.goto("/messages/convo-1"); 1745 + 1746 + const chatDetailView = page.locator("#chat-detail-view"); 1747 + const infoPanel = chatDetailView.locator('[data-testid="chat-info-panel"]'); 1748 + await expect(infoPanel).toBeVisible({ timeout: 10000 }); 1749 + const labelBadge = infoPanel.locator('[data-testid="label-badge"]'); 1750 + await expect(labelBadge).toBeVisible(); 1751 + await expect( 1752 + labelBadge.locator('[data-testid="label-badge-text"]'), 1753 + ).toContainText("Spam"); 1754 + }); 1755 + 1756 + test("should show a group card empty state when a group convo has no messages", async ({ 1757 + page, 1758 + }) => { 1759 + const mockServer = new MockServer(); 1760 + const alice = createProfile({ 1761 + did: "did:plc:alice1", 1762 + handle: "alice.bsky.social", 1763 + displayName: "Alice", 1764 + }); 1765 + const bob = createProfile({ 1766 + did: "did:plc:bob1", 1767 + handle: "bob.bsky.social", 1768 + displayName: "Bob", 1769 + }); 1770 + const carol = createProfile({ 1771 + did: "did:plc:carol1", 1772 + handle: "carol.bsky.social", 1773 + displayName: "Carol", 1774 + }); 1775 + const convo = createGroupConvo({ 1776 + id: "convo-1", 1777 + name: "Cool Group", 1778 + otherMembers: [alice, bob, carol], 1779 + }); 1780 + mockServer.addConvos([convo]); 1781 + mockServer.addConvoMessages("convo-1", []); 1782 + await mockServer.setup(page); 1783 + 1784 + await login(page); 1785 + await page.goto("/messages/convo-1"); 1786 + 1787 + const chatDetailView = page.locator("#chat-detail-view"); 1788 + const infoPanel = chatDetailView.locator('[data-testid="chat-info-panel"]'); 1789 + await expect(infoPanel).toBeVisible({ timeout: 10000 }); 1790 + await expect(infoPanel).toContainText("Cool Group"); 1791 + await expect(infoPanel).toContainText( 1792 + "New chat with Alice, Bob, and 1 more.", 1679 1793 ); 1794 + await expect( 1795 + infoPanel.locator('[data-testid="avatar-group"]'), 1796 + ).toBeVisible(); 1797 + }); 1798 + 1799 + test("should show the user card above the first message once all history is loaded", async ({ 1800 + page, 1801 + }) => { 1802 + const mockServer = new MockServer(); 1803 + const alice = createProfile({ 1804 + did: "did:plc:alice1", 1805 + handle: "alice.bsky.social", 1806 + displayName: "Alice", 1807 + }); 1808 + const convo = createConvo({ 1809 + id: "convo-1", 1810 + otherMember: alice, 1811 + }); 1812 + mockServer.addConvos([convo]); 1813 + mockServer.addConvoMessages("convo-1", [ 1814 + createMessage({ 1815 + id: "msg-1", 1816 + text: "Hey there!", 1817 + senderDid: alice.did, 1818 + sentAt: "2025-01-15T12:00:00.000Z", 1819 + }), 1820 + ]); 1821 + await mockServer.setup(page); 1822 + 1823 + await login(page); 1824 + await page.goto("/messages/convo-1"); 1825 + 1826 + const chatDetailView = page.locator("#chat-detail-view"); 1827 + await expect(chatDetailView).toContainText("Hey there!", { 1828 + timeout: 10000, 1829 + }); 1830 + const infoPanel = chatDetailView.locator( 1831 + 'infinite-scroll-container [data-testid="chat-info-panel"]', 1832 + ); 1833 + await expect(infoPanel).toBeVisible(); 1834 + await expect(infoPanel).toContainText("Alice"); 1835 + 1836 + const panelBox = await infoPanel.boundingBox(); 1837 + const messageBox = await chatDetailView 1838 + .locator('.message-wrapper[data-message-id="msg-1"]') 1839 + .boundingBox(); 1840 + expect(panelBox.y).toBeLessThan(messageBox.y); 1680 1841 }); 1681 1842 1682 1843 test.describe("Scrolling on new messages", () => {
+38
tests/e2e/specs/views/profile.view.test.js
··· 109 109 ); 110 110 }); 111 111 112 + test("should display badge label pills on a labeled profile", async ({ 113 + page, 114 + }) => { 115 + const mockServer = new MockServer(); 116 + const labeledUser = createProfile({ 117 + did: "did:plc:labeleduser1", 118 + handle: "labeled.bsky.social", 119 + displayName: "Labeled User", 120 + labels: [ 121 + { 122 + val: "badge-label", 123 + src: "did:plc:labeler123", 124 + uri: "did:plc:labeleduser1", 125 + cts: "2025-01-01T00:00:00.000Z", 126 + }, 127 + ], 128 + }); 129 + mockServer.addProfile(labeledUser); 130 + mockServer.addLabelerViews([labelerView]); 131 + mockServer.addLabelerSubscription("did:plc:labeler123"); 132 + await mockServer.setup(page); 133 + await login(page); 134 + await page.goto(`/profile/${labeledUser.did}`); 135 + 136 + const view = page.locator("#profile-view"); 137 + await expect(view.locator('[data-testid="profile-name"]')).toContainText( 138 + "Labeled User", 139 + { timeout: 10000 }, 140 + ); 141 + const labelBadge = view.locator( 142 + '.profile-card [data-testid="label-badge"]', 143 + ); 144 + await expect(labelBadge).toBeVisible(); 145 + await expect( 146 + labelBadge.locator('[data-testid="label-badge-text"]'), 147 + ).toContainText("Badge Label"); 148 + }); 149 + 112 150 test("should display profile description", async ({ page }) => { 113 151 const mockServer = new MockServer(); 114 152 mockServer.addProfile(otherUser);
+2 -1
tests/shared/factories.js
··· 146 146 postsCount, 147 147 associated, 148 148 viewer, 149 + labels, 149 150 }) { 150 151 return { 151 152 did, ··· 155 156 avatar: avatar || "", 156 157 banner: banner || "", 157 158 viewer: { muted: false, blockedBy: false, ...viewer }, 158 - labels: [], 159 + labels: labels || [], 159 160 createdAt: "2025-01-01T00:00:00.000Z", 160 161 ...(associated ? { associated } : {}), 161 162 ...(followersCount !== undefined ? { followersCount } : {}),
+84 -2
tests/unit/specs/api.test.js
··· 512 512 const { options } = session.getLastFetchOptions(); 513 513 assert.deepEqual( 514 514 options.headers["atproto-accept-labelers"], 515 - "did:plc:labeler1,did:plc:labeler2", 515 + "did:plc:labeler1, did:plc:labeler2", 516 516 ); 517 517 }); 518 518 }); ··· 544 544 const { options } = session.getLastFetchOptions(); 545 545 assert.deepEqual( 546 546 options.headers["atproto-accept-labelers"], 547 - "did:plc:labeler1,did:plc:labeler2", 547 + "did:plc:labeler1, did:plc:labeler2", 548 548 ); 549 549 }); 550 550 }); ··· 970 970 const { url } = session.getLastFetchOptions(); 971 971 assert(url.includes("readState=unread")); 972 972 }); 973 + 974 + it("should include labelers header when provided", async () => { 975 + const session = createMockSession({ convos: [] }); 976 + const api = new Api(session); 977 + 978 + await api.listConvos({ labelers: ["did:plc:labeler1"] }); 979 + 980 + const { options } = session.getLastFetchOptions(); 981 + assert.deepEqual( 982 + options.headers["atproto-accept-labelers"], 983 + "did:plc:labeler1", 984 + ); 985 + }); 973 986 }); 974 987 975 988 describe("getConvo", () => { ··· 984 997 assert(url.includes("convoId=convo1")); 985 998 assert.deepEqual(result.convo.id, "convo1"); 986 999 }); 1000 + 1001 + it("should include labelers header when provided", async () => { 1002 + const session = createMockSession({ convo: { id: "convo1", members: [] } }); 1003 + const api = new Api(session); 1004 + 1005 + await api.getConvo("convo1", { labelers: ["did:plc:labeler1"] }); 1006 + 1007 + const { options } = session.getLastFetchOptions(); 1008 + assert.deepEqual( 1009 + options.headers["atproto-accept-labelers"], 1010 + "did:plc:labeler1", 1011 + ); 1012 + }); 987 1013 }); 988 1014 989 1015 describe("getMessages", () => { ··· 1000 1026 assert(url.includes("chat.bsky.convo.getMessages")); 1001 1027 assert(url.includes("convoId=convo1")); 1002 1028 assert.deepEqual(result.messages.length, 1); 1029 + }); 1030 + 1031 + it("should include labelers header when provided", async () => { 1032 + const session = createMockSession({ messages: [] }); 1033 + const api = new Api(session); 1034 + 1035 + await api.getMessages("convo1", { labelers: ["did:plc:labeler1"] }); 1036 + 1037 + const { options } = session.getLastFetchOptions(); 1038 + assert.deepEqual( 1039 + options.headers["atproto-accept-labelers"], 1040 + "did:plc:labeler1", 1041 + ); 1003 1042 }); 1004 1043 }); 1005 1044 ··· 1087 1126 assert(url.includes("chat.bsky.convo.getConvoAvailability")); 1088 1127 assert.deepEqual(result.canChat, true); 1089 1128 }); 1129 + 1130 + it("should include labelers header when provided", async () => { 1131 + const session = createMockSession({ canChat: true }); 1132 + const api = new Api(session); 1133 + 1134 + await api.getConvoAvailability(["did:plc:member1"], { 1135 + labelers: ["did:plc:labeler1"], 1136 + }); 1137 + 1138 + const { options } = session.getLastFetchOptions(); 1139 + assert.deepEqual( 1140 + options.headers["atproto-accept-labelers"], 1141 + "did:plc:labeler1", 1142 + ); 1143 + }); 1090 1144 }); 1091 1145 1092 1146 describe("getConvoForMembers", () => { ··· 1103 1157 assert(url.includes("chat.bsky.convo.getConvoForMembers")); 1104 1158 assert.deepEqual(result.convo.id, "convo1"); 1105 1159 }); 1160 + 1161 + it("should include labelers header when provided", async () => { 1162 + const session = createMockSession({ convo: { id: "convo1" } }); 1163 + const api = new Api(session); 1164 + 1165 + await api.getConvoForMembers(["did:plc:member1"], { 1166 + labelers: ["did:plc:labeler1"], 1167 + }); 1168 + 1169 + const { options } = session.getLastFetchOptions(); 1170 + assert.deepEqual( 1171 + options.headers["atproto-accept-labelers"], 1172 + "did:plc:labeler1", 1173 + ); 1174 + }); 1106 1175 }); 1107 1176 1108 1177 describe("getChatLogs", () => { ··· 1116 1185 assert(url.includes("chat.bsky.convo.getLog")); 1117 1186 assert(url.includes("cursor=somecursor")); 1118 1187 assert.deepEqual(result.cursor, "next"); 1188 + }); 1189 + 1190 + it("should include labelers header when provided", async () => { 1191 + const session = createMockSession({ logs: [] }); 1192 + const api = new Api(session); 1193 + 1194 + await api.getChatLogs({ cursor: "", labelers: ["did:plc:labeler1"] }); 1195 + 1196 + const { options } = session.getLastFetchOptions(); 1197 + assert.deepEqual( 1198 + options.headers["atproto-accept-labelers"], 1199 + "did:plc:labeler1", 1200 + ); 1119 1201 }); 1120 1202 }); 1121 1203
+3 -3
tests/unit/specs/dataLayer/derived.test.js
··· 31 31 postHasMutedWord: () => false, 32 32 quotedPostHasMutedWord: () => false, 33 33 isPostHidden: () => false, 34 - getBadgeLabels: () => [], 34 + getBadgeLabelsForPost: () => [], 35 35 getContentLabel: () => null, 36 36 getMediaLabel: () => null, 37 37 getProfileBlurLabel: () => null, ··· 587 587 const dataStore = new DataStore(); 588 588 const { derived } = makeDerived(dataStore, { 589 589 preferences: fakePreferences({ 590 - getBadgeLabels: () => ["badge"], 590 + getBadgeLabelsForPost: () => ["badge"], 591 591 getContentLabel: () => "warn", 592 592 getMediaLabel: () => "blur", 593 593 }), ··· 617 617 preferences: fakePreferences({ 618 618 postHasMutedWord: () => true, 619 619 isPostHidden: () => true, 620 - getBadgeLabels: () => ["b"], 620 + getBadgeLabelsForPost: () => ["b"], 621 621 }), 622 622 }); 623 623 dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } });
+119 -5
tests/unit/specs/preferences.test.js
··· 1567 1567 }); 1568 1568 }); 1569 1569 1570 - describe("Preferences.getBadgeLabels", () => { 1570 + describe("Preferences.getBadgeLabelsForPost", () => { 1571 1571 it("should return empty array when post has no labels", () => { 1572 1572 const preferences = new Preferences([], []); 1573 1573 const post = { labels: [] }; 1574 - const result = preferences.getBadgeLabels(post); 1574 + const result = preferences.getBadgeLabelsForPost(post); 1575 1575 1576 1576 assert.deepEqual(result, []); 1577 1577 }); ··· 1596 1596 const post = { 1597 1597 labels: [{ src: "did:labeler1", val: "verified" }], 1598 1598 }; 1599 - const result = preferences.getBadgeLabels(post); 1599 + const result = preferences.getBadgeLabelsForPost(post); 1600 1600 1601 1601 assert.deepEqual(result.length, 1); 1602 1602 assert.deepEqual(result[0].labelDefinition.identifier, "verified"); ··· 1623 1623 const post = { 1624 1624 labels: [{ src: "did:labeler1", val: "nsfw" }], 1625 1625 }; 1626 - const result = preferences.getBadgeLabels(post); 1626 + const result = preferences.getBadgeLabelsForPost(post); 1627 1627 1628 1628 assert.deepEqual(result.length, 0); 1629 1629 }); ··· 1648 1648 const post = { 1649 1649 labels: [{ src: "did:labeler1", val: "nudity" }], 1650 1650 }; 1651 - const result = preferences.getBadgeLabels(post); 1651 + const result = preferences.getBadgeLabelsForPost(post); 1652 + 1653 + assert.deepEqual(result.length, 0); 1654 + }); 1655 + }); 1656 + 1657 + describe("Preferences.getBadgeLabelsForProfile", () => { 1658 + const labelerDefs = [ 1659 + { 1660 + creator: { did: "did:labeler1", handle: "labeler.test" }, 1661 + policies: { 1662 + labelValueDefinitions: [ 1663 + { 1664 + identifier: "spam", 1665 + blurs: "none", 1666 + severity: "inform", 1667 + locales: [{ lang: "en", name: "Spam" }], 1668 + }, 1669 + { 1670 + identifier: "nsfw", 1671 + blurs: "content", 1672 + locales: [{ lang: "en", name: "NSFW" }], 1673 + }, 1674 + { 1675 + identifier: "impersonation", 1676 + blurs: "content", 1677 + severity: "alert", 1678 + locales: [{ lang: "en", name: "Impersonation" }], 1679 + }, 1680 + ], 1681 + }, 1682 + }, 1683 + ]; 1684 + 1685 + it("should return empty array when profile has no labels", () => { 1686 + const preferences = new Preferences([], labelerDefs); 1687 + const result = preferences.getBadgeLabelsForProfile({ labels: [] }); 1688 + 1689 + assert.deepEqual(result, []); 1690 + }); 1691 + 1692 + it("should return empty array when profile labels are missing", () => { 1693 + const preferences = new Preferences([], labelerDefs); 1694 + const result = preferences.getBadgeLabelsForProfile({}); 1695 + 1696 + assert.deepEqual(result, []); 1697 + }); 1698 + 1699 + it("should return badge labels on the profile", () => { 1700 + const preferences = new Preferences([], labelerDefs); 1701 + const profile = { 1702 + labels: [{ src: "did:labeler1", val: "spam" }], 1703 + }; 1704 + const result = preferences.getBadgeLabelsForProfile(profile); 1705 + 1706 + assert.deepEqual(result.length, 1); 1707 + assert.deepEqual(result[0].labelDefinition.identifier, "spam"); 1708 + assert.deepEqual(result[0].labeler.creator.did, "did:labeler1"); 1709 + }); 1710 + 1711 + it("should not return blur labels without alert/inform severity", () => { 1712 + const preferences = new Preferences([], labelerDefs); 1713 + const profile = { 1714 + labels: [{ src: "did:labeler1", val: "nsfw" }], 1715 + }; 1716 + const result = preferences.getBadgeLabelsForProfile(profile); 1717 + 1718 + assert.deepEqual(result.length, 0); 1719 + }); 1720 + 1721 + it("should return blur labels with alert/inform severity", () => { 1722 + const preferences = new Preferences([], labelerDefs); 1723 + const profile = { 1724 + labels: [{ src: "did:labeler1", val: "impersonation" }], 1725 + }; 1726 + const result = preferences.getBadgeLabelsForProfile(profile); 1727 + 1728 + assert.deepEqual(result.length, 1); 1729 + assert.deepEqual(result[0].labelDefinition.identifier, "impersonation"); 1730 + }); 1731 + 1732 + it("should not return blur labels as badges on posts", () => { 1733 + const preferences = new Preferences([], labelerDefs); 1734 + const post = { 1735 + labels: [{ src: "did:labeler1", val: "impersonation" }], 1736 + }; 1737 + const result = preferences.getBadgeLabelsForPost(post); 1738 + 1739 + assert.deepEqual(result.length, 0); 1740 + }); 1741 + 1742 + it("should skip labels from unknown labelers", () => { 1743 + const preferences = new Preferences([], labelerDefs); 1744 + const profile = { 1745 + labels: [{ src: "did:labeler-unknown", val: "spam" }], 1746 + }; 1747 + const result = preferences.getBadgeLabelsForProfile(profile); 1748 + 1749 + assert.deepEqual(result.length, 0); 1750 + }); 1751 + 1752 + it("should not return labels the user set to ignore", () => { 1753 + const obj = [ 1754 + { 1755 + $type: "app.bsky.actor.defs#contentLabelPref", 1756 + label: "spam", 1757 + labelerDid: "did:labeler1", 1758 + visibility: "ignore", 1759 + }, 1760 + ]; 1761 + const preferences = new Preferences(obj, labelerDefs); 1762 + const profile = { 1763 + labels: [{ src: "did:labeler1", val: "spam" }], 1764 + }; 1765 + const result = preferences.getBadgeLabelsForProfile(profile); 1652 1766 1653 1767 assert.deepEqual(result.length, 0); 1654 1768 });
+13 -13
tests/unit/specs/templates/postLabels.template.test.js tests/unit/specs/templates/labelBadges.template.test.js
··· 1 1 import { describe, it } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 - import { postLabelsTemplate } from "/js/templates/postLabels.template.js"; 3 + import { labelBadgesTemplate } from "/js/templates/labelBadges.template.js"; 4 4 import { render } from "/js/lib/lit-html.js"; 5 5 6 6 const mockLabeler = { ··· 29 29 }, 30 30 ]; 31 31 32 - describe("postLabelsTemplate", () => { 32 + describe("labelBadgesTemplate", () => { 33 33 it("should render label badge for each label", () => { 34 - const result = postLabelsTemplate({ badgeLabels: mockBadgeLabels }); 34 + const result = labelBadgesTemplate({ badgeLabels: mockBadgeLabels }); 35 35 const container = document.createElement("div"); 36 36 render(result, container); 37 37 const badges = container.querySelectorAll("[data-testid='label-badge']"); ··· 39 39 }); 40 40 41 41 it("should render label badge as link to labeler profile", () => { 42 - const result = postLabelsTemplate({ badgeLabels: mockBadgeLabels }); 42 + const result = labelBadgesTemplate({ badgeLabels: mockBadgeLabels }); 43 43 const container = document.createElement("div"); 44 44 render(result, container); 45 45 const badge = container.querySelector("[data-testid='label-badge']"); ··· 48 48 }); 49 49 50 50 it("should render labeler avatar image", () => { 51 - const result = postLabelsTemplate({ badgeLabels: mockBadgeLabels }); 51 + const result = labelBadgesTemplate({ badgeLabels: mockBadgeLabels }); 52 52 const container = document.createElement("div"); 53 53 render(result, container); 54 54 const img = container.querySelector("[data-testid='label-badge-image']"); ··· 57 57 }); 58 58 59 59 it("should render label name text", () => { 60 - const result = postLabelsTemplate({ badgeLabels: mockBadgeLabels }); 60 + const result = labelBadgesTemplate({ badgeLabels: mockBadgeLabels }); 61 61 const container = document.createElement("div"); 62 62 render(result, container); 63 63 const text = container.querySelector("[data-testid='label-badge-text']"); ··· 66 66 }); 67 67 }); 68 68 69 - describe("postLabelsTemplate - multiple labels", () => { 69 + describe("labelBadgesTemplate - multiple labels", () => { 70 70 it("should render multiple label badges", () => { 71 71 const secondLabelDefinition = { 72 72 identifier: "educational", ··· 86 86 { labelDefinition: mockLabelDefinition, labeler: mockLabeler }, 87 87 { labelDefinition: secondLabelDefinition, labeler: secondLabeler }, 88 88 ]; 89 - const result = postLabelsTemplate({ badgeLabels: multipleBadgeLabels }); 89 + const result = labelBadgesTemplate({ badgeLabels: multipleBadgeLabels }); 90 90 const container = document.createElement("div"); 91 91 render(result, container); 92 92 const badges = container.querySelectorAll("[data-testid='label-badge']"); ··· 94 94 }); 95 95 }); 96 96 97 - describe("postLabelsTemplate - fallback avatar", () => { 97 + describe("labelBadgesTemplate - fallback avatar", () => { 98 98 it("should use fallback avatar when labeler has no avatar", () => { 99 99 const labelerWithoutAvatar = { 100 100 ...mockLabeler, ··· 109 109 labeler: labelerWithoutAvatar, 110 110 }, 111 111 ]; 112 - const result = postLabelsTemplate({ badgeLabels }); 112 + const result = labelBadgesTemplate({ badgeLabels }); 113 113 const container = document.createElement("div"); 114 114 render(result, container); 115 115 const img = container.querySelector("[data-testid='label-badge-image']"); ··· 117 117 }); 118 118 }); 119 119 120 - describe("postLabelsTemplate - empty labels", () => { 120 + describe("labelBadgesTemplate - empty labels", () => { 121 121 it("should render empty container when no badge labels", () => { 122 - const result = postLabelsTemplate({ badgeLabels: [] }); 122 + const result = labelBadgesTemplate({ badgeLabels: [] }); 123 123 const container = document.createElement("div"); 124 124 render(result, container); 125 - assert(container.querySelector("[data-testid='post-labels']") !== null); 125 + assert(container.querySelector("[data-testid='label-badges']") !== null); 126 126 assert.deepEqual( 127 127 container.querySelectorAll("[data-testid='label-badge']").length, 128 128 0,
+68
tests/unit/specs/templates/profileCard.template.test.js
··· 17 17 }, 18 18 }; 19 19 20 + const mockBadgeLabels = [ 21 + { 22 + visibility: "warn", 23 + label: { val: "spam", src: "did:plc:labeler1" }, 24 + labelDefinition: { 25 + identifier: "spam", 26 + blurs: "none", 27 + severity: "inform", 28 + locales: [{ lang: "en", name: "Spam", description: "Spam account" }], 29 + }, 30 + labeler: { 31 + creator: { 32 + did: "did:plc:labeler1", 33 + handle: "labeler.test", 34 + avatar: null, 35 + }, 36 + }, 37 + }, 38 + ]; 39 + 20 40 describe("profileCardTemplate", () => { 41 + it("should render badge label pills for another user's labeled profile", () => { 42 + const profile = { ...mockProfile, badgeLabels: mockBadgeLabels }; 43 + const result = profileCardTemplate({ 44 + profile, 45 + isCurrentUser: false, 46 + onClickFollow: () => {}, 47 + }); 48 + const container = document.createElement("div"); 49 + render(result, container); 50 + const badge = container.querySelector("[data-testid='label-badge']"); 51 + assert(badge); 52 + assert.deepEqual( 53 + badge 54 + .querySelector("[data-testid='label-badge-text']") 55 + .textContent.trim(), 56 + "Spam", 57 + ); 58 + }); 59 + 60 + it("should not render badge label pills on the current user's profile", () => { 61 + const profile = { ...mockProfile, badgeLabels: mockBadgeLabels }; 62 + const result = profileCardTemplate({ 63 + profile, 64 + isCurrentUser: true, 65 + onClickFollow: () => {}, 66 + }); 67 + const container = document.createElement("div"); 68 + render(result, container); 69 + assert.deepEqual( 70 + container.querySelector("[data-testid='label-badge']"), 71 + null, 72 + ); 73 + }); 74 + 75 + it("should not render a label pill row for an unlabeled profile", () => { 76 + const result = profileCardTemplate({ 77 + profile: mockProfile, 78 + isCurrentUser: false, 79 + onClickFollow: () => {}, 80 + }); 81 + const container = document.createElement("div"); 82 + render(result, container); 83 + assert.deepEqual( 84 + container.querySelector("[data-testid='label-badges']"), 85 + null, 86 + ); 87 + }); 88 + 21 89 it("should render profile card", () => { 22 90 const result = profileCardTemplate({ 23 91 profile: mockProfile,