[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 group chat details view

Grace Kind (Jul 18, 2026, 5:22 PM -0500) 0a06afeb b8bc2121

+974 -22
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.8", 3 + "version": "0.18.9", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+55
src/css/style.css
··· 982 982 min-width: 0; 983 983 } 984 984 985 + .header .header-title-link { 986 + display: flex; 987 + align-items: center; 988 + flex: 1; 989 + min-width: 0; 990 + cursor: pointer; 991 + } 992 + 985 993 .header .header-title-container { 986 994 display: flex; 987 995 flex-direction: column; ··· 3874 3882 height: var(--avatar-size-chat-info-panel); 3875 3883 } 3876 3884 3885 + .chat-info-panel-avatars { 3886 + height: var(--avatar-size-chat-info-panel); 3887 + } 3888 + 3877 3889 .chat-info-panel-avatars .avatar-group { 3878 3890 width: var(--avatar-size-chat-info-panel); 3879 3891 height: var(--avatar-size-chat-info-panel); ··· 3922 3934 .chat-info-panel .label-badge-image { 3923 3935 width: 16px; 3924 3936 height: 16px; 3937 + } 3938 + 3939 + .group-chat-header-skeleton-avatar { 3940 + width: var(--avatar-size-chat-info-panel); 3941 + height: var(--avatar-size-chat-info-panel); 3942 + } 3943 + 3944 + .group-chat-header-skeleton-handle-line { 3945 + display: inline-block; 3946 + } 3947 + 3948 + .group-chat-members-heading { 3949 + display: flex; 3950 + align-items: center; 3951 + justify-content: space-between; 3952 + padding: 12px 16px; 3953 + font-weight: 600; 3954 + color: var(--text-color); 3955 + border-bottom: var(--hair) solid var(--post-border-color); 3956 + } 3957 + 3958 + .group-chat-member-count { 3959 + font-weight: 400; 3960 + font-size: 14px; 3961 + color: var(--text-color-muted); 3962 + } 3963 + 3964 + .group-chat-member-added-by { 3965 + align-self: center; 3966 + flex-shrink: 0; 3967 + font-size: 13px; 3968 + text-align: right; 3969 + color: var(--text-color-muted); 3970 + } 3971 + 3972 + .group-chat-admin-badge { 3973 + align-self: center; 3974 + padding: 3px 10px 4px; 3975 + border-radius: 999px; 3976 + font-size: 13px; 3977 + font-weight: 500; 3978 + color: var(--highlight-color); 3979 + background: color-mix(in srgb, var(--highlight-color) 12%, transparent); 3925 3980 } 3926 3981 3927 3982 .message-day-title {
+8
src/index.html
··· 127 127 import chatView from "/js/views/chat.view.js"; 128 128 import chatRequestsView from "/js/views/chatRequests.view.js"; 129 129 import chatDetailView from "/js/views/chatDetail.view.js"; 130 + import groupChatDetailsView from "/js/views/groupChatDetails.view.js"; 130 131 import feedsView from "/js/views/feeds.view.js"; 131 132 import profileView from "/js/views/profile.view.js"; 132 133 import profileFollowersView from "/js/views/profileFollowers.view.js"; ··· 340 341 router.addRoute("/messages/inbox", () => chatRequestsView, { 341 342 layoutOptions: { activeNavItem: "chat" }, 342 343 }); 344 + router.addRoute( 345 + "/messages/:convoId/settings", 346 + () => groupChatDetailsView, 347 + { 348 + layoutOptions: { activeNavItem: "chat" }, 349 + }, 350 + ); 343 351 router.addRoute("/messages/:convoId", () => chatDetailView, { 344 352 layoutOptions: { activeNavItem: "chat" }, 345 353 });
+15
src/js/api.js
··· 782 782 return res.data; 783 783 } 784 784 785 + async getConvoMembers(convoId, { cursor, limit = 50, labelers = [] } = {}) { 786 + const query = { convoId, limit }; 787 + if (cursor) { 788 + query.cursor = cursor; 789 + } 790 + const res = await this.request("chat.bsky.convo.getConvoMembers", { 791 + query, 792 + headers: { 793 + "atproto-accept-labelers": buildAcceptLabelersHeader(labelers), 794 + "atproto-proxy": this.chatAppViewServiceDid, 795 + }, 796 + }); 797 + return res.data; 798 + } 799 + 785 800 async getMessages(convoId, { cursor, limit = 50, labelers = [] } = {}) { 786 801 const query = { convoId, limit }; 787 802 if (cursor) {
+1
src/js/dataLayer/dataStore.js
··· 37 37 this.$unavailablePosts = new SignalMap(); 38 38 this.$reposts = new SignalMap(); 39 39 this.$convos = new SignalMap(); 40 + this.$convoMemberLists = new SignalMap(); 40 41 this.$convoMessages = new SignalMap(); 41 42 this.$messages = new SignalMap(); 42 43 this.$postLikes = new SignalMap();
+3
src/js/dataLayer/derived.js
··· 549 549 this.$convoMembers = new ComputedMap((convoId) => { 550 550 return this.dataStore.$convos.get(convoId)?.members ?? null; 551 551 }); 552 + this.$groupConvoMemberList = new ComputedMap((convoId) => { 553 + return this.dataStore.$convoMemberLists.get(convoId); 554 + }); 552 555 this.$convoMessages = new ComputedMap((convoId) => { 553 556 const messages = this.dataStore.$convoMessages.get(convoId); 554 557 if (!messages) return null;
+19
src/js/dataLayer/requests.js
··· 213 213 this.enableStatus(this.loadConvoRequestList, "loadConvoRequestList"); 214 214 this.enableStatus(this.loadConvo, (convoId) => "loadConvo-" + convoId); 215 215 this.enableStatus( 216 + this.loadConvoMembers, 217 + (convoId) => "loadConvoMembers-" + convoId, 218 + ); 219 + this.enableStatus( 216 220 this.loadConvoMessages, 217 221 (convoId) => "loadConvoMessages-" + convoId, 218 222 ); ··· 865 869 const labelers = this.requireLabelers(); 866 870 const res = await this.api.getConvo(convoId, { labelers }); 867 871 this.dataStore.$convos.set(convoId, res.convo); 872 + } 873 + 874 + async loadConvoMembers(convoId, { reload = false } = {}) { 875 + const cursor = reload 876 + ? "" 877 + : readCollectionCursor(this.dataStore.$convoMemberLists, { 878 + key: convoId, 879 + }); 880 + const labelers = this.requireLabelers(); 881 + const res = await this.api.getConvoMembers(convoId, { cursor, labelers }); 882 + writePageToCollection(this.dataStore.$convoMemberLists, "members", res, { 883 + key: convoId, 884 + requestCursor: cursor, 885 + overwrite: reload, 886 + }); 868 887 } 869 888 870 889 async _loadPostDependencies(posts) {
+4
src/js/navigation.js
··· 95 95 return `${getPermalinkOrigin()}/messages/${encodePathSegment(convoId)}`; 96 96 } 97 97 98 + export function linkToGroupChatDetails(convoId) { 99 + return `/messages/${encodePathSegment(convoId)}/settings`; 100 + } 101 + 98 102 export function linkToCommunityPlugin(pluginId) { 99 103 return `/plugins/community/${encodePathSegment(pluginId)}`; 100 104 }
+26 -18
src/js/templates/header.template.js
··· 2 2 import { menuIconTemplate } from "/js/templates/icons/menuIcon.template.js"; 3 3 import { arrowLeftIconTemplate } from "/js/templates/icons/arrowLeft.template.js"; 4 4 import { classnames } from "/js/utils.js"; 5 + import "/js/components/container-link.js"; 6 + 7 + function avatarAndTitleTemplate({ title, subtitle, avatarTemplate }) { 8 + return html`${avatarTemplate 9 + ? html`<div class="header-avatar">${avatarTemplate()}</div>` 10 + : ""} 11 + ${title 12 + ? html`<div 13 + class="header-title-container" 14 + data-testid="header-title-container" 15 + > 16 + <span class="header-title" data-testid="header-title">${title}</span> 17 + ${subtitle 18 + ? html`<span class="header-subtitle" data-testid="header-subtitle" 19 + >${subtitle}</span 20 + >` 21 + : ""} 22 + </div>` 23 + : ""}`; 24 + } 5 25 6 26 export function headerTemplate({ 7 27 title = null, 8 28 subtitle = null, 29 + titleHref = null, 9 30 avatarTemplate = null, 10 31 showLoadingSpinner = false, 11 32 leftButton = "back", ··· 38 59 > 39 60 ${arrowLeftIconTemplate()} 40 61 </button>`} 41 - ${avatarTemplate 42 - ? html`<div class="header-avatar">${avatarTemplate()}</div>` 43 - : ""} 44 - ${title 45 - ? html`<div 46 - class="header-title-container" 47 - data-testid="header-title-container" 48 - > 49 - <span class="header-title" data-testid="header-title" 50 - >${title}</span 51 - > 52 - ${subtitle 53 - ? html`<span class="header-subtitle" data-testid="header-subtitle" 54 - >${subtitle}</span 55 - >` 56 - : ""} 57 - </div>` 58 - : ""} 62 + ${titleHref 63 + ? html`<container-link class="header-title-link" href=${titleHref}> 64 + ${avatarAndTitleTemplate({ title, subtitle, avatarTemplate })} 65 + </container-link>` 66 + : avatarAndTitleTemplate({ title, subtitle, avatarTemplate })} 59 67 ${showLoadingSpinner 60 68 ? html`<div class="header-spacer"></div> 61 69 <div class="loading-spinner" data-testid="loading-spinner"></div>`
+4
src/js/templates/profileFeed.template.js
··· 15 15 currentUserDid = null, 16 16 profileInteractionHandler = null, 17 17 showFollowButton = true, 18 + badgeTemplate = null, 18 19 }) { 19 20 const displayName = getDisplayName(actor); 20 21 const isCurrentUser = currentUserDid && actor.did === currentUserDid; ··· 54 55 @${actor.handle} 55 56 </div> 56 57 </div> 58 + ${badgeTemplate ? (badgeTemplate(actor) ?? "") : ""} 57 59 ${showsFollowControl 58 60 ? html`<button 59 61 @click=${() => { ··· 132 134 currentUserDid = null, 133 135 profileInteractionHandler = null, 134 136 showFollowButton = true, 137 + badgeTemplate = null, 135 138 }) { 136 139 if (!profiles) { 137 140 return html`<div class="profile-list"> ··· 162 165 currentUserDid, 163 166 profileInteractionHandler, 164 167 showFollowButton, 168 + badgeTemplate, 165 169 }), 166 170 )} 167 171 </div>
+10
src/js/utils.js
··· 178 178 return formatted + suffix; 179 179 } 180 180 181 + // E.g. September 29, 2025 182 + export function formatFullDate(timestamp) { 183 + const date = new Date(timestamp); 184 + return date.toLocaleDateString("en-US", { 185 + month: "long", 186 + day: "numeric", 187 + year: "numeric", 188 + }); 189 + } 190 + 181 191 // E.g. September 29, 2025 at 3:44 PM 182 192 export function formatFullTimestamp(timestamp) { 183 193 const date = new Date(timestamp);
+16 -1
src/js/views/chatDetail.view.js
··· 39 39 } from "/js/utils.js"; 40 40 import { Signal, ReactiveStore } from "/js/signals.js"; 41 41 import { ApiError } from "/js/api.js"; 42 - import { getPermalinkForConvo, linkToProfile } from "/js/navigation.js"; 42 + import { 43 + getPermalinkForConvo, 44 + linkToGroupChatDetails, 45 + linkToProfile, 46 + } from "/js/navigation.js"; 43 47 import { emojiIconTemplate } from "/js/templates/icons/emojiIcon.template.js"; 44 48 import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; 45 49 import { cornerDownRightIconTemplate } from "/js/templates/icons/cornerDownRightIcon.template.js"; ··· 1388 1392 }, 1389 1393 title, 1390 1394 subtitle, 1395 + titleHref: groupDetails ? linkToGroupChatDetails(convoId) : null, 1391 1396 backButtonFallbackRoute: "/messages", 1392 1397 rightItemTemplate: () => html` 1393 1398 <button ··· 1401 1406 <span>...</span> 1402 1407 </button> 1403 1408 <context-menu> 1409 + ${groupDetails 1410 + ? html`<context-menu-item 1411 + data-testid="menu-action-group-chat-details" 1412 + @click=${() => { 1413 + router.go(linkToGroupChatDetails(convoId)); 1414 + }} 1415 + > 1416 + Group chat settings 1417 + </context-menu-item>` 1418 + : ""} 1404 1419 <context-menu-item 1405 1420 data-testid="menu-action-chat-open-in-bsky" 1406 1421 @click=${() => {
+226
src/js/views/groupChatDetails.view.js
··· 1 + import { html, render } from "/js/lib/lit-html.js"; 2 + import { auth } from "/js/auth.js"; 3 + import { View } from "/js/views/view.js"; 4 + import { pageEffect } from "/js/router.js"; 5 + import { headerTemplate } from "/js/templates/header.template.js"; 6 + import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js"; 7 + import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; 8 + import { 9 + getDisplayName, 10 + getGroupConvoDetails, 11 + getGroupConvoOwner, 12 + } from "/js/dataHelpers.js"; 13 + import { formatFullDate } from "/js/utils.js"; 14 + import { Signal } from "/js/signals.js"; 15 + import { ApiError } from "/js/api.js"; 16 + import "/js/components/infinite-scroll-container.js"; 17 + 18 + function sortMembers({ members, ownerDid, currentUserDid }) { 19 + const rank = (member) => { 20 + if (ownerDid && member.did === ownerDid) return 0; 21 + if (currentUserDid && member.did === currentUserDid) return 1; 22 + return 2; 23 + }; 24 + return members 25 + .filter( 26 + (member) => 27 + // deleted accounts can still be rendered 28 + member.kind == null || 29 + member.kind.$type === "chat.bsky.actor.defs#groupConvoMember", 30 + ) 31 + .map((member, index) => ({ member, index })) 32 + .sort((a, b) => rank(a.member) - rank(b.member) || a.index - b.index) 33 + .map((entry) => entry.member); 34 + } 35 + 36 + function adminBadgeTemplate() { 37 + return html`<div class="group-chat-admin-badge" data-testid="admin-badge"> 38 + Admin 39 + </div>`; 40 + } 41 + 42 + function memberTrailingTemplate({ member, ownerDid }) { 43 + if (ownerDid && member.did === ownerDid) { 44 + return adminBadgeTemplate(); 45 + } 46 + if (member.kind?.$type !== "chat.bsky.actor.defs#groupConvoMember") { 47 + return null; 48 + } 49 + const addedBy = member.kind.addedBy; 50 + return html`<div 51 + class="group-chat-member-added-by" 52 + data-testid="member-added-by" 53 + > 54 + ${addedBy ? `Added by ${getDisplayName(addedBy)}` : "Added by invite link"} 55 + </div>`; 56 + } 57 + 58 + function groupHeaderCardTemplate({ convo, groupDetails, currentUserDid }) { 59 + const otherMembers = convo.members.filter( 60 + (member) => member.did !== currentUserDid, 61 + ); 62 + return html`<div class="chat-info-panel"> 63 + <div class="chat-info-panel-avatars"> 64 + ${avatarGroupTemplate({ authors: otherMembers })} 65 + </div> 66 + ${groupDetails.name 67 + ? html`<div class="chat-info-panel-name" data-testid="group-name"> 68 + ${groupDetails.name} 69 + </div>` 70 + : ""} 71 + ${groupDetails.createdAt 72 + ? html`<div class="chat-info-panel-handle" data-testid="group-created-at"> 73 + Created ${formatFullDate(groupDetails.createdAt)} 74 + </div>` 75 + : ""} 76 + </div>`; 77 + } 78 + 79 + function groupHeaderCardSkeletonTemplate() { 80 + return html`<div class="chat-info-panel group-chat-header-skeleton"> 81 + <div class="chat-info-panel-avatars"> 82 + <div 83 + class="skeleton-avatar skeleton-animate group-chat-header-skeleton-avatar" 84 + data-testid="skeleton-avatar" 85 + ></div> 86 + </div> 87 + <div class="chat-info-panel-name"> 88 + &#8203; 89 + <div class="skeleton-line-short skeleton-animate"></div> 90 + </div> 91 + <div class="chat-info-panel-handle"> 92 + &#8203;<span 93 + class="skeleton-line-shorter skeleton-animate group-chat-header-skeleton-handle-line" 94 + ></span> 95 + </div> 96 + </div>`; 97 + } 98 + 99 + function membersHeadingTemplate({ groupDetails = null } = {}) { 100 + return html`<div class="group-chat-members-heading"> 101 + <span>Members</span> 102 + ${groupDetails 103 + ? html`<span class="group-chat-member-count" data-testid="member-count" 104 + >${groupDetails.memberCount}/${groupDetails.memberLimit}</span 105 + >` 106 + : ""} 107 + </div>`; 108 + } 109 + 110 + function detailsErrorTemplate({ error }) { 111 + if (error instanceof ApiError && error.data?.error === "InvalidConvo") { 112 + return html`<div class="error-state" data-testid="convo-not-found"> 113 + <h3>Not Found</h3> 114 + <div>Conversation not found</div> 115 + <button @click=${() => window.location.reload()}>Try again</button> 116 + </div>`; 117 + } 118 + console.error(error); 119 + return html`<div class="error-state" data-testid="group-details-error"> 120 + <div>There was an error loading the group chat.</div> 121 + <button @click=${() => window.location.reload()}>Try again</button> 122 + </div>`; 123 + } 124 + 125 + function notGroupConvoTemplate() { 126 + return html`<div class="error-state" data-testid="not-group-convo"> 127 + <div>This screen is only available for group conversations.</div> 128 + </div>`; 129 + } 130 + 131 + class GroupChatDetailsView extends View { 132 + async render({ root, params, context: { dataLayer, isAuthenticated } }) { 133 + await auth.requireAuth(); 134 + 135 + const convoId = params.convoId; 136 + 137 + const $requestError = new Signal.Computed(() => { 138 + return ( 139 + dataLayer.requests.statusStore.$errors.get("loadConvo-" + convoId) ?? 140 + dataLayer.requests.statusStore.$errors.get( 141 + "loadConvoMembers-" + convoId, 142 + ) ?? 143 + null 144 + ); 145 + }); 146 + 147 + async function loadMoreMembers() { 148 + await dataLayer.requests.loadConvoMembers(convoId); 149 + } 150 + 151 + pageEffect(root, () => { 152 + const currentUser = dataLayer.derived.$currentUser.get(); 153 + const convo = dataLayer.derived.$convos.get(convoId); 154 + const memberFeed = dataLayer.derived.$groupConvoMemberList.get(convoId); 155 + const requestError = $requestError.get(); 156 + const groupDetails = convo ? getGroupConvoDetails(convo) : null; 157 + const ownerDid = convo ? getGroupConvoOwner(convo)?.did : null; 158 + const members = memberFeed 159 + ? sortMembers({ 160 + members: memberFeed.members, 161 + ownerDid, 162 + currentUserDid: currentUser?.did ?? null, 163 + }) 164 + : null; 165 + const hasMore = !!memberFeed?.cursor; 166 + 167 + render( 168 + html`<div id="group-chat-details-view"> 169 + ${headerTemplate({ 170 + title: "Group chat settings", 171 + backButtonFallbackRoute: `/messages/${encodeURIComponent(convoId)}`, 172 + })} 173 + <main> 174 + ${(() => { 175 + if (convo && !groupDetails) { 176 + return notGroupConvoTemplate(); 177 + } 178 + if (requestError && (!convo || !memberFeed)) { 179 + return detailsErrorTemplate({ error: requestError }); 180 + } 181 + return html`${convo 182 + ? groupHeaderCardTemplate({ 183 + convo, 184 + groupDetails, 185 + currentUserDid: currentUser?.did ?? null, 186 + }) 187 + : groupHeaderCardSkeletonTemplate()} 188 + ${membersHeadingTemplate({ groupDetails })} 189 + ${profileFeedTemplate({ 190 + profiles: members, 191 + hasMore, 192 + onLoadMore: loadMoreMembers, 193 + isAuthenticated, 194 + currentUserDid: currentUser?.did ?? null, 195 + showFollowButton: false, 196 + badgeTemplate: (actor) => 197 + memberTrailingTemplate({ member: actor, ownerDid }), 198 + })}`; 199 + })()} 200 + </main> 201 + </div>`, 202 + root, 203 + ); 204 + }); 205 + 206 + root.addEventListener("page-enter", () => { 207 + dataLayer.requests.loadConvo(convoId).catch((error) => { 208 + console.error("Failed to load convo", error); 209 + }); 210 + dataLayer.requests 211 + .loadConvoMembers(convoId, { reload: true }) 212 + .catch((error) => { 213 + console.error("Failed to load convo members", error); 214 + }); 215 + }); 216 + 217 + root.addEventListener("page-restore", (e) => { 218 + const scrollY = e.detail?.scrollY ?? 0; 219 + if (scrollY > 0) { 220 + window.scrollTo(0, scrollY); 221 + } 222 + }); 223 + } 224 + } 225 + 226 + export default new GroupChatDetailsView();
+1
src/oauthScopes.js
··· 142 142 "rpc:chat.bsky.convo.updateRead", 143 143 "rpc:chat.bsky.group.getJoinLinkPreviews", 144 144 "rpc:chat.bsky.group.requestJoin", 145 + "rpc:chat.bsky.convo.getConvoMembers", 145 146 ]; 146 147 147 148 const CHAT_OAUTH_REPO_SCOPES = ["repo:chat.bsky.actor.declaration"];
+58 -1
tests/e2e/mockServer.js
··· 13 13 this.bookmarks = []; 14 14 this.convos = []; 15 15 this.convoMessages = new Map(); 16 + this.convoMemberLists = new Map(); 17 + this.convoDelays = new Map(); 16 18 this.chatLogs = []; 17 19 this.joinLinkPreviews = new Map(); 18 20 this.joinLinkJoinedConvos = new Map(); ··· 262 264 } 263 265 } 264 266 this.convos.push(...convos); 267 + } 268 + 269 + addConvoMembers(convoId, members) { 270 + this.convoMemberLists.set(convoId, members); 271 + } 272 + 273 + failConvoMembers({ status = 500, error = "InternalServerError", message }) { 274 + this.convoMembersFailure = { status, error, message }; 275 + } 276 + 277 + setConvoDelay(convoId, delayMs) { 278 + this.convoDelays.set(convoId, delayMs); 265 279 } 266 280 267 281 // Queued logs are returned (and drained) by the next chat.bsky.convo.getLog ··· 746 760 }); 747 761 }); 748 762 749 - await page.route("**/xrpc/chat.bsky.convo.getConvo*", (route) => { 763 + await page.route("**/xrpc/chat.bsky.convo.getConvo*", async (route) => { 750 764 const url = new URL(route.request().url()); 751 765 const convoId = url.searchParams.get("convoId"); 766 + const delayMs = this.convoDelays.get(convoId); 767 + if (delayMs) { 768 + await new Promise((resolve) => setTimeout(resolve, delayMs)); 769 + } 752 770 const convo = this.convos.find((c) => c.id === convoId); 753 771 if (!convo) { 754 772 return route.fulfill({ ··· 764 782 status: 200, 765 783 contentType: "application/json", 766 784 body: JSON.stringify({ convo }), 785 + }); 786 + }); 787 + 788 + await page.route("**/xrpc/chat.bsky.convo.getConvoMembers*", (route) => { 789 + if (this.convoMembersFailure) { 790 + const { status, error, message } = this.convoMembersFailure; 791 + return route.fulfill({ 792 + status, 793 + contentType: "application/json", 794 + body: JSON.stringify({ error, message }), 795 + }); 796 + } 797 + const url = new URL(route.request().url()); 798 + const convoId = url.searchParams.get("convoId"); 799 + const convo = this.convos.find((c) => c.id === convoId); 800 + if (!convo) { 801 + return route.fulfill({ 802 + status: 400, 803 + contentType: "application/json", 804 + body: JSON.stringify({ 805 + error: "InvalidConvo", 806 + message: "Conversation not found", 807 + }), 808 + }); 809 + } 810 + const allMembers = this.convoMemberLists.get(convoId) ?? convo.members; 811 + const cursor = url.searchParams.get("cursor") || ""; 812 + const limit = parseInt(url.searchParams.get("limit") || "50", 10); 813 + const offset = cursor ? parseInt(cursor, 10) : 0; 814 + const members = allMembers.slice(offset, offset + limit); 815 + const nextCursor = 816 + offset + limit < allMembers.length ? String(offset + limit) : ""; 817 + return route.fulfill({ 818 + status: 200, 819 + contentType: "application/json", 820 + body: JSON.stringify({ 821 + members, 822 + ...(nextCursor ? { cursor: nextCursor } : {}), 823 + }), 767 824 }); 768 825 }); 769 826
+88
tests/e2e/specs/views/chatDetail.view.test.js
··· 659 659 expect(popup.url()).toBe("https://bsky.app/messages/convo-1"); 660 660 }); 661 661 662 + test("should navigate to group chat details from the chat menu", async ({ 663 + page, 664 + }) => { 665 + const mockServer = new MockServer(); 666 + const alice = createProfile({ 667 + did: "did:plc:alice1", 668 + handle: "alice.bsky.social", 669 + displayName: "Alice", 670 + }); 671 + const convo = createGroupConvo({ 672 + id: "convo-1", 673 + name: "Cool Group", 674 + otherMembers: [alice], 675 + }); 676 + mockServer.addConvos([convo]); 677 + await mockServer.setup(page); 678 + 679 + await login(page); 680 + await page.goto("/messages/convo-1"); 681 + 682 + const chatDetailView = page.locator("#chat-detail-view"); 683 + await chatDetailView.locator('[data-testid="chat-menu-button"]').click(); 684 + await chatDetailView 685 + .locator('[data-testid="menu-action-group-chat-details"]') 686 + .click(); 687 + 688 + await expect( 689 + page.locator('#group-chat-details-view [data-testid="group-name"]'), 690 + ).toContainText("Cool Group", { timeout: 10000 }); 691 + }); 692 + 693 + test("should navigate to group chat details when clicking the header title", async ({ 694 + page, 695 + }) => { 696 + const mockServer = new MockServer(); 697 + const alice = createProfile({ 698 + did: "did:plc:alice1", 699 + handle: "alice.bsky.social", 700 + displayName: "Alice", 701 + }); 702 + const convo = createGroupConvo({ 703 + id: "convo-1", 704 + name: "Cool Group", 705 + otherMembers: [alice], 706 + }); 707 + mockServer.addConvos([convo]); 708 + await mockServer.setup(page); 709 + 710 + await login(page); 711 + await page.goto("/messages/convo-1"); 712 + 713 + const chatDetailView = page.locator("#chat-detail-view"); 714 + await chatDetailView.locator('[data-testid="header-title"]').click(); 715 + 716 + await expect( 717 + page.locator('#group-chat-details-view [data-testid="group-name"]'), 718 + ).toContainText("Cool Group", { timeout: 10000 }); 719 + }); 720 + 721 + test("should not show group chat details menu item for direct chats", async ({ 722 + page, 723 + }) => { 724 + const mockServer = new MockServer(); 725 + const alice = createProfile({ 726 + did: "did:plc:alice1", 727 + handle: "alice.bsky.social", 728 + displayName: "Alice", 729 + }); 730 + const convo = createConvo({ 731 + id: "convo-1", 732 + otherMember: alice, 733 + }); 734 + mockServer.addConvos([convo]); 735 + await mockServer.setup(page); 736 + 737 + await login(page); 738 + await page.goto("/messages/convo-1"); 739 + 740 + const chatDetailView = page.locator("#chat-detail-view"); 741 + await chatDetailView.locator('[data-testid="chat-menu-button"]').click(); 742 + await expect( 743 + chatDetailView.locator('[data-testid="menu-action-chat-open-in-bsky"]'), 744 + ).toBeVisible(); 745 + await expect( 746 + chatDetailView.locator('[data-testid="menu-action-group-chat-details"]'), 747 + ).toHaveCount(0); 748 + }); 749 + 662 750 test("should add emoji reaction to a message", async ({ page }) => { 663 751 const mockServer = new MockServer(); 664 752 const alice = createProfile({
+257
tests/e2e/specs/views/groupChatDetails.view.test.js
··· 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { MockServer } from "../../mockServer.js"; 4 + import { 5 + createConvo, 6 + createGroupConvo, 7 + createGroupConvoMember, 8 + createProfile, 9 + } from "../../../shared/factories.js"; 10 + 11 + function createTestMembers() { 12 + const alice = createProfile({ 13 + did: "did:plc:alice1", 14 + handle: "alice.bsky.social", 15 + displayName: "Alice", 16 + }); 17 + const bob = createProfile({ 18 + did: "did:plc:bob1", 19 + handle: "bob.bsky.social", 20 + displayName: "Bob", 21 + }); 22 + return { alice, bob }; 23 + } 24 + 25 + test.describe("Group chat details view", () => { 26 + test("should display group info and member rows", async ({ page }) => { 27 + const mockServer = new MockServer(); 28 + const { alice, bob } = createTestMembers(); 29 + const convo = createGroupConvo({ 30 + id: "convo-1", 31 + name: "Cool Group", 32 + otherMembers: [alice, bob], 33 + ownerDid: alice.did, 34 + }); 35 + mockServer.addConvos([convo]); 36 + await mockServer.setup(page); 37 + 38 + await login(page); 39 + await page.goto("/messages/convo-1/settings"); 40 + 41 + const view = page.locator("#group-chat-details-view"); 42 + await expect(view.locator('[data-testid="header-title"]')).toBeVisible({ 43 + timeout: 10000, 44 + }); 45 + await expect(view.locator('[data-testid="group-name"]')).toContainText( 46 + "Cool Group", 47 + ); 48 + await expect( 49 + view.locator('[data-testid="group-created-at"]'), 50 + ).toBeVisible(); 51 + await expect(view.locator('[data-testid="member-count"]')).toContainText( 52 + "3/100", 53 + ); 54 + const rows = view.locator(".profile-list-item"); 55 + await expect(rows).toHaveCount(3); 56 + await expect(rows.first()).toContainText("Alice"); 57 + await expect(rows.first()).toContainText("@alice.bsky.social"); 58 + await expect(rows.nth(1)).toContainText("Test User"); 59 + await expect(rows.nth(2)).toContainText("Bob"); 60 + await expect(view.locator('[data-testid="follow-button"]')).toHaveCount(0); 61 + }); 62 + 63 + test("should show admin badge, added-by subtext, and owner-first sorting", async ({ 64 + page, 65 + }) => { 66 + const mockServer = new MockServer(); 67 + const { alice, bob } = createTestMembers(); 68 + const convo = createGroupConvo({ 69 + id: "convo-1", 70 + name: "Cool Group", 71 + otherMembers: [alice, bob], 72 + ownerDid: alice.did, 73 + }); 74 + mockServer.addConvos([convo]); 75 + // Serve members in a different order to prove sorting 76 + mockServer.addConvoMembers("convo-1", [ 77 + createGroupConvoMember({ profile: bob }), 78 + createGroupConvoMember({ 79 + profile: convo.members.find( 80 + (member) => member.did === "did:plc:testuser123", 81 + ), 82 + addedBy: alice, 83 + }), 84 + createGroupConvoMember({ profile: alice, role: "owner" }), 85 + ]); 86 + await mockServer.setup(page); 87 + 88 + await login(page); 89 + await page.goto("/messages/convo-1/settings"); 90 + 91 + const view = page.locator("#group-chat-details-view"); 92 + const rows = view.locator(".profile-list-item"); 93 + await expect(rows).toHaveCount(3, { timeout: 10000 }); 94 + 95 + const ownerRow = rows.first(); 96 + await expect(ownerRow).toContainText("Alice"); 97 + await expect(ownerRow.locator('[data-testid="admin-badge"]')).toBeVisible(); 98 + await expect( 99 + ownerRow.locator('[data-testid="member-added-by"]'), 100 + ).toHaveCount(0); 101 + 102 + const selfRow = rows.nth(1); 103 + await expect(selfRow).toContainText("Test User"); 104 + await expect( 105 + selfRow.locator('[data-testid="member-added-by"]'), 106 + ).toContainText("Added by Alice"); 107 + 108 + const bobRow = rows.nth(2); 109 + await expect(bobRow).toContainText("Bob"); 110 + await expect( 111 + bobRow.locator('[data-testid="member-added-by"]'), 112 + ).toContainText("Added by invite link"); 113 + await expect(view.locator('[data-testid="admin-badge"]')).toHaveCount(1); 114 + }); 115 + 116 + test("should load more members when scrolling to the bottom", async ({ 117 + page, 118 + }) => { 119 + const mockServer = new MockServer(); 120 + const manyMembers = Array.from({ length: 120 }, (unused, index) => 121 + createProfile({ 122 + did: `did:plc:member${index}`, 123 + handle: `member${index}.bsky.social`, 124 + displayName: `Member ${index}`, 125 + }), 126 + ); 127 + const convo = createGroupConvo({ 128 + id: "convo-1", 129 + name: "Big Group", 130 + otherMembers: manyMembers.slice(0, 3), 131 + memberCount: 120, 132 + }); 133 + mockServer.addConvos([convo]); 134 + mockServer.addConvoMembers("convo-1", manyMembers); 135 + await mockServer.setup(page); 136 + 137 + await login(page); 138 + await page.goto("/messages/convo-1/settings"); 139 + 140 + const view = page.locator("#group-chat-details-view"); 141 + const rows = view.locator(".profile-list-item"); 142 + 143 + await expect(rows.first()).toBeVisible({ timeout: 10000 }); 144 + const initialCount = await rows.count(); 145 + expect(initialCount).toBeLessThan(120); 146 + 147 + // Scroll the window to the bottom to trigger infinite scroll. Loop 148 + // because a single fetch may not yield the full set in one batch. 149 + await expect 150 + .poll( 151 + async () => { 152 + await page.evaluate(() => 153 + window.scrollTo(0, document.body.scrollHeight), 154 + ); 155 + return await rows.count(); 156 + }, 157 + { timeout: 10000, intervals: [200] }, 158 + ) 159 + .toBe(120); 160 + await expect(view).toContainText("Member 119"); 161 + }); 162 + 163 + test("should show a top card skeleton while the convo loads", async ({ 164 + page, 165 + }) => { 166 + const mockServer = new MockServer(); 167 + const { alice, bob } = createTestMembers(); 168 + const convo = createGroupConvo({ 169 + id: "convo-1", 170 + name: "Cool Group", 171 + otherMembers: [alice, bob], 172 + }); 173 + mockServer.addConvos([convo]); 174 + mockServer.setConvoDelay("convo-1", 1000); 175 + await mockServer.setup(page); 176 + 177 + await login(page); 178 + await page.goto("/messages/convo-1/settings"); 179 + 180 + const view = page.locator("#group-chat-details-view"); 181 + const skeleton = view.locator(".group-chat-header-skeleton"); 182 + await expect(skeleton).toBeVisible({ timeout: 10000 }); 183 + await expect(view.locator(".group-chat-members-heading")).toBeVisible(); 184 + await expect(view.locator('[data-testid="member-count"]')).toHaveCount(0); 185 + const skeletonHeight = (await skeleton.boundingBox()).height; 186 + await expect(view.locator('[data-testid="group-name"]')).toContainText( 187 + "Cool Group", 188 + { timeout: 10000 }, 189 + ); 190 + await expect(skeleton).toHaveCount(0); 191 + await expect(view.locator('[data-testid="member-count"]')).toContainText( 192 + "3/100", 193 + ); 194 + // The loaded card must not shift the layout below it 195 + const panelHeight = (await view.locator(".chat-info-panel").boundingBox()) 196 + .height; 197 + expect(panelHeight).toBe(skeletonHeight); 198 + }); 199 + 200 + test("should show an error state when the member list fails to load", async ({ 201 + page, 202 + }) => { 203 + const mockServer = new MockServer(); 204 + const { alice, bob } = createTestMembers(); 205 + const convo = createGroupConvo({ 206 + id: "convo-1", 207 + name: "Cool Group", 208 + otherMembers: [alice, bob], 209 + }); 210 + mockServer.addConvos([convo]); 211 + mockServer.failConvoMembers({ message: "Something went wrong" }); 212 + await mockServer.setup(page); 213 + 214 + await login(page); 215 + await page.goto("/messages/convo-1/settings"); 216 + 217 + const view = page.locator("#group-chat-details-view"); 218 + await expect( 219 + view.locator('[data-testid="group-details-error"]'), 220 + ).toBeVisible({ timeout: 10000 }); 221 + await expect(view.locator(".profile-list-item")).toHaveCount(0); 222 + }); 223 + 224 + test("should show an error state for non-group conversations", async ({ 225 + page, 226 + }) => { 227 + const mockServer = new MockServer(); 228 + const { alice } = createTestMembers(); 229 + const convo = createConvo({ id: "convo-1", otherMember: alice }); 230 + mockServer.addConvos([convo]); 231 + await mockServer.setup(page); 232 + 233 + await login(page); 234 + await page.goto("/messages/convo-1/settings"); 235 + 236 + const view = page.locator("#group-chat-details-view"); 237 + await expect(view.locator('[data-testid="not-group-convo"]')).toBeVisible({ 238 + timeout: 10000, 239 + }); 240 + await expect(view.locator(".profile-list-item")).toHaveCount(0); 241 + }); 242 + 243 + test("should show not found for an unknown conversation", async ({ 244 + page, 245 + }) => { 246 + const mockServer = new MockServer(); 247 + await mockServer.setup(page); 248 + 249 + await login(page); 250 + await page.goto("/messages/unknown-convo/settings"); 251 + 252 + const view = page.locator("#group-chat-details-view"); 253 + await expect(view.locator('[data-testid="convo-not-found"]')).toBeVisible({ 254 + timeout: 10000, 255 + }); 256 + }); 257 + });
+15
tests/shared/factories.js
··· 81 81 }; 82 82 } 83 83 84 + export function createGroupConvoMember({ 85 + profile, 86 + role = "standard", 87 + addedBy, 88 + }) { 89 + return { 90 + ...profile, 91 + kind: { 92 + $type: "chat.bsky.actor.defs#groupConvoMember", 93 + role, 94 + ...(addedBy ? { addedBy } : {}), 95 + }, 96 + }; 97 + } 98 + 84 99 export function createMessageLog({ convoId, message, relatedProfiles }) { 85 100 return { 86 101 $type: "chat.bsky.convo.defs#logCreateMessage",
+25
tests/unit/specs/dataLayer/derived.test.js
··· 1124 1124 assert.deepEqual(posts[1].embedRecords, undefined); 1125 1125 }); 1126 1126 }); 1127 + 1128 + describe("$groupConvoMemberList", () => { 1129 + const convoId = "convo1"; 1130 + 1131 + it("should return null when nothing is loaded", () => { 1132 + const dataStore = new DataStore(); 1133 + const { derived } = makeDerived(dataStore); 1134 + assert.deepEqual(derived.$groupConvoMemberList.get(convoId), null); 1135 + }); 1136 + 1137 + it("should pass through the stored members and cursor", () => { 1138 + const dataStore = new DataStore(); 1139 + dataStore.$convoMemberLists.set(convoId, { 1140 + members: [{ did: "did:plc:alice" }, { did: "did:plc:bob" }], 1141 + cursor: "2", 1142 + }); 1143 + const { derived } = makeDerived(dataStore); 1144 + const result = derived.$groupConvoMemberList.get(convoId); 1145 + assert.deepEqual( 1146 + result.members.map((member) => member.did), 1147 + ["did:plc:alice", "did:plc:bob"], 1148 + ); 1149 + assert.deepEqual(result.cursor, "2"); 1150 + }); 1151 + });
+103
tests/unit/specs/dataLayer/requests.test.js
··· 1878 1878 }); 1879 1879 }); 1880 1880 1881 + describe("loadConvoMembers", () => { 1882 + const convoId = "convo1"; 1883 + 1884 + it("should store the first page with its cursor", async () => { 1885 + const dataStore = new DataStore(); 1886 + const mockApi = { 1887 + getConvoMembers: async () => ({ 1888 + members: [{ did: "did:plc:alice" }, { did: "did:plc:bob" }], 1889 + cursor: "2", 1890 + }), 1891 + }; 1892 + const requests = makeRequests(mockApi, dataStore); 1893 + 1894 + await requests.loadConvoMembers(convoId); 1895 + 1896 + const stored = dataStore.$convoMemberLists.get(convoId); 1897 + assert.deepEqual( 1898 + stored.members.map((member) => member.did), 1899 + ["did:plc:alice", "did:plc:bob"], 1900 + ); 1901 + assert.deepEqual(stored.cursor, "2"); 1902 + }); 1903 + 1904 + it("should append the next page using the stored cursor", async () => { 1905 + const dataStore = new DataStore(); 1906 + const capturedCursors = []; 1907 + const pages = [ 1908 + { members: [{ did: "did:plc:alice" }], cursor: "1" }, 1909 + { members: [{ did: "did:plc:bob" }] }, 1910 + ]; 1911 + const mockApi = { 1912 + getConvoMembers: async (id, { cursor }) => { 1913 + capturedCursors.push(cursor); 1914 + return pages.shift(); 1915 + }, 1916 + }; 1917 + const requests = makeRequests(mockApi, dataStore); 1918 + 1919 + await requests.loadConvoMembers(convoId); 1920 + await requests.loadConvoMembers(convoId); 1921 + 1922 + assert.deepEqual(capturedCursors, ["", "1"]); 1923 + const stored = dataStore.$convoMemberLists.get(convoId); 1924 + assert.deepEqual( 1925 + stored.members.map((member) => member.did), 1926 + ["did:plc:alice", "did:plc:bob"], 1927 + ); 1928 + assert.deepEqual(stored.cursor, null); 1929 + }); 1930 + 1931 + it("should overwrite the stored list on reload", async () => { 1932 + const dataStore = new DataStore(); 1933 + dataStore.$convoMemberLists.set(convoId, { 1934 + members: [{ did: "did:plc:stale" }], 1935 + cursor: "5", 1936 + }); 1937 + const capturedCursors = []; 1938 + const mockApi = { 1939 + getConvoMembers: async (id, { cursor }) => { 1940 + capturedCursors.push(cursor); 1941 + return { members: [{ did: "did:plc:alice" }] }; 1942 + }, 1943 + }; 1944 + const requests = makeRequests(mockApi, dataStore); 1945 + 1946 + await requests.loadConvoMembers(convoId, { reload: true }); 1947 + 1948 + assert.deepEqual(capturedCursors, [""]); 1949 + const stored = dataStore.$convoMemberLists.get(convoId); 1950 + assert.deepEqual( 1951 + stored.members.map((member) => member.did), 1952 + ["did:plc:alice"], 1953 + ); 1954 + }); 1955 + 1956 + it("should record an ApiError under the namespaced key without rethrowing", async () => { 1957 + const apiError = new ApiError({ 1958 + status: 400, 1959 + statusText: "Bad Request", 1960 + data: { error: "InvalidConvo" }, 1961 + headers: {}, 1962 + url: "/x", 1963 + }); 1964 + const dataStore = new DataStore(); 1965 + const mockApi = { 1966 + getConvoMembers: async () => { 1967 + throw apiError; 1968 + }, 1969 + }; 1970 + const requests = makeRequests(mockApi, dataStore); 1971 + 1972 + await requests.loadConvoMembers(convoId); 1973 + 1974 + const status = requests.getStatus("loadConvoMembers-" + convoId); 1975 + assert.deepEqual(status.loading, false); 1976 + assert( 1977 + status.error === apiError, 1978 + "expected status.error to be the ApiError", 1979 + ); 1980 + assert.deepEqual(dataStore.$convoMemberLists.get(convoId), null); 1981 + }); 1982 + }); 1983 + 1881 1984 describe("loadConvoMessages", () => { 1882 1985 const convoId = "convo1"; 1883 1986
+39 -1
tests/unit/specs/templates/profileFeed.template.test.js
··· 5 5 profileListItemSkeletonTemplate, 6 6 profileFeedTemplate, 7 7 } from "/js/templates/profileFeed.template.js"; 8 - import { render } from "/js/lib/lit-html.js"; 8 + import { html, render } from "/js/lib/lit-html.js"; 9 9 10 10 const mockActor = { 11 11 did: "did:plc:testuser", ··· 188 188 }); 189 189 }); 190 190 191 + describe("profileListItemTemplate - custom badge", () => { 192 + it("should render the badge returned by badgeTemplate", () => { 193 + const result = profileListItemTemplate({ 194 + actor: mockActor, 195 + badgeTemplate: (actor) => 196 + html`<div data-testid="custom-badge">${actor.handle}</div>`, 197 + }); 198 + const container = document.createElement("div"); 199 + render(result, container); 200 + const badge = container.querySelector("[data-testid='custom-badge']"); 201 + assert(badge !== null); 202 + assert(badge.textContent.includes(mockActor.handle)); 203 + }); 204 + 205 + it("should render nothing when badgeTemplate returns null", () => { 206 + const result = profileListItemTemplate({ 207 + actor: mockActor, 208 + badgeTemplate: () => null, 209 + }); 210 + const container = document.createElement("div"); 211 + render(result, container); 212 + assert.deepEqual( 213 + container.querySelector("[data-testid='custom-badge']"), 214 + null, 215 + ); 216 + }); 217 + }); 218 + 191 219 describe("profileListItemSkeletonTemplate", () => { 192 220 it("should render skeleton avatar", () => { 193 221 const result = profileListItemSkeletonTemplate(); ··· 279 307 const msg = container.querySelector("[data-testid='feed-end-message']"); 280 308 assert(msg !== null); 281 309 assert(msg.textContent.includes("End of feed")); 310 + }); 311 + 312 + it("should pass badgeTemplate through to list items", () => { 313 + const result = profileFeedTemplate({ 314 + profiles: [mockActor], 315 + hasMore: false, 316 + badgeTemplate: () => html`<div data-testid="custom-badge">Admin</div>`, 317 + }); 318 + render(result, container); 319 + assert(container.querySelector("[data-testid='custom-badge']") !== null); 282 320 }); 283 321 284 322 it("should render loading indicator when hasMore is true", () => {