diff --git a/package.json b/package.json index b4e0d978..9dcb445b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.8", + "version": "0.20.9", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 6ed1ede6..d456a9b2 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -10222,6 +10222,173 @@ toggle-switch { flex-shrink: 0; } +.new-chat-dialog .search-dialog-title { + flex: 1; + text-align: left; +} + +.new-chat-dialog .profile-list-item { + border: none; +} + +.new-group-back-button { + display: flex; + align-items: center; + margin-left: -8px; +} + +.search-dialog-header-action { + display: inline-flex; + align-items: center; + gap: 8px; + background: none; + border: none; + font-size: 16px; + font-weight: 600; + cursor: pointer; + color: var(--highlight-color); + padding: 4px 0; +} + +.search-dialog-header-action:disabled:not(.saving) { + color: var(--text-color-muted); + cursor: default; +} + +.search-dialog-header-action .loading-spinner { + border-top-color: var(--highlight-color); +} + +.new-group-entry-button { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: none; + border: none; + text-align: left; + color: var(--text-color); + font-size: 1rem; + cursor: pointer; + margin-bottom: 4px; +} + +.new-group-entry-button .users-icon { + color: var(--highlight-color); +} + +.new-group-entry-button .new-group-entry-label { + flex: 1; + font-weight: 600; +} + +.new-group-entry-button .chevron-right-icon { + color: var(--text-color-muted); +} + +.new-group-entry-button.is-disabled { + opacity: 0.5; +} + +@media (hover: hover) { + .new-group-entry-button:hover { + background: var(--highlight-color-tint); + border-radius: 8px; + } +} + +.new-group-chips { + display: flex; + gap: 8px; + overflow-x: auto; + scrollbar-width: none; + flex-shrink: 0; +} + +.new-group-member-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px 4px 4px; + border-radius: 999px; + background: var(--highlight-color-tint); + flex-shrink: 0; +} + +.new-group-member-chip .avatar-image-frame { + width: 24px; + height: 24px; +} + +.new-group-chip-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.85rem; +} + +.new-group-chip-remove { + display: flex; + align-items: center; + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--text-color-muted); +} + +.new-group-chip-remove .icon { + width: 20px; + height: 20px; +} + +@media (hover: hover) { + .new-group-chip-remove:hover { + color: var(--text-color); + } +} + +.new-group-member-toggle { + align-self: center; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid var(--post-border-color); + color: transparent; + flex-shrink: 0; +} + +.new-group-member-toggle.is-selected { + background: var(--highlight-color); + border-color: var(--highlight-color); + color: white; +} + +.new-group-member-toggle .icon { + line-height: 0; +} + +.new-group-member-toggle .icon svg { + width: 14px; + height: 14px; +} + +.new-group-name-section { + display: flex; + flex-direction: column; + gap: 4px; + padding-bottom: 12px; + flex-shrink: 0; +} + +.new-group-name-input { + padding-left: 12px; +} + .new-chat-result { display: flex; align-items: center; diff --git a/src/js/api.js b/src/js/api.js index 24eb322d..6145da68 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -943,6 +943,27 @@ export class Api { return res.data; } + async createGroupChat(name, memberDids, { labelers = [] } = {}) { + const res = await this.request("chat.bsky.group.createGroup", { + method: "POST", + body: { name, members: memberDids }, + headers: { + "atproto-accept-labelers": Api.buildAcceptLabelersHeader(labelers), + "atproto-proxy": this.chatAppViewServiceDid, + }, + }); + return res.data; + } + + async getChatActorStatus() { + const res = await this.request("chat.bsky.actor.getStatus", { + headers: { + "atproto-proxy": this.chatAppViewServiceDid, + }, + }); + return res.data; + } + async getConvoForMembers(memberDids, { labelers = [] } = {}) { const res = await this.request("chat.bsky.convo.getConvoForMembers", { query: { members: memberDids }, diff --git a/src/js/app.js b/src/js/app.js index 65464d6f..4f44b07e 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -159,7 +159,13 @@ export async function main() { draftsEnabled: await checkDraftsEnabled(auth), }) : null; - const newChatService = session ? new NewChatService(dataLayer) : null; + const newChatService = session + ? new NewChatService(dataLayer, { + groupChatsEnabled: await auth.hasScope( + "rpc:chat.bsky.group.createGroup", + ), + }) + : null; const accountSwitcherService = session ? new AccountSwitcherService(dataLayer, auth) : null; diff --git a/src/js/components/new-chat-dialog.js b/src/js/components/new-chat-dialog.js index 99c4ff97..bf5abe71 100644 --- a/src/js/components/new-chat-dialog.js +++ b/src/js/components/new-chat-dialog.js @@ -6,8 +6,18 @@ import { enableDragToDismiss } from "/js/dragHelpers.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; +import { chevronLeftIconTemplate } from "/js/templates/icons/chevronLeft.template.js"; +import { chevronRightIconTemplate } from "/js/templates/icons/chevronRight.template.js"; +import { checkIconTemplate } from "/js/templates/icons/checkIcon.template.js"; +import { usersIconTemplate } from "/js/templates/icons/usersIcon.template.js"; import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; +import { avatarTemplate } from "/js/templates/avatar.template.js"; +import { getDisplayName } from "/js/dataHelpers.js"; +import { classnames, graphemeCount } from "/js/utils.js"; import { showToast } from "/js/toasts.js"; +import { alertModal } from "/js/modals/alert.modal.js"; + +const MAX_GROUP_NAME_GRAPHEME_LENGTH = 50; const CREATE_CHAT_ERROR_TOASTS = { AccountSuspended: "Suspended accounts cannot participate in chat.", @@ -17,6 +27,18 @@ const CREATE_CHAT_ERROR_TOASTS = { RecipientNotFound: "Unable to find the selected recipient.", }; +const CREATE_GROUP_ERROR_TOASTS = { + AccountSuspended: "Suspended accounts cannot participate in a group chat.", + BlockedActor: + "One of the selected recipients has blocked you and cannot be messaged.", + BlockedSubject: "You have blocked one of the selected recipients.", + NewAccountCannotCreateGroup: "You cannot create a group chat yet.", + NotFollowedBySender: "You don't follow one of the selected recipients.", + RecipientNotFound: "Unable to find a selected recipient.", + UserForbidsGroups: + "One of the selected recipients does not allow group chats.", +}; + function canBeMessaged(profile) { const allowIncoming = profile.associated?.chat?.allowIncoming; switch (allowIncoming) { @@ -32,6 +54,22 @@ function canBeMessaged(profile) { } } +function canBeAddedToGroup(profile) { + const allowGroupInvites = profile.associated?.chat?.allowGroupInvites; + switch (allowGroupInvites) { + case "none": + return false; + case "all": + return true; + case "following": + return Boolean(profile.viewer?.followedBy); + case undefined: + return canBeMessaged(profile); + default: + return false; + } +} + function createChatErrorToastMessage(error) { if (error instanceof TypeError) { return "A network error occurred. Please check your internet connection."; @@ -42,7 +80,17 @@ function createChatErrorToastMessage(error) { ); } -function partitionRows(profiles, currentUserDid) { +function createGroupErrorToastMessage(error) { + if (error instanceof TypeError) { + return "A network error occurred. Please check your internet connection."; + } + return ( + CREATE_GROUP_ERROR_TOASTS[error?.data?.error] ?? + "An issue occurred starting the group chat, please try again." + ); +} + +function partitionRows(profiles, currentUserDid, canJoin = canBeMessaged) { const seenDids = new Set(); const deduped = []; for (const profile of profiles) { @@ -52,8 +100,8 @@ function partitionRows(profiles, currentUserDid) { deduped.push(profile); } return [ - ...deduped.filter((profile) => canBeMessaged(profile)), - ...deduped.filter((profile) => !canBeMessaged(profile)), + ...deduped.filter((profile) => canJoin(profile)), + ...deduped.filter((profile) => !canJoin(profile)), ]; } @@ -67,6 +115,27 @@ function notMessageableRightItem(profile) { `; } +function notAddableRightItem() { + return html`
+ Can't be added +
`; +} + +function memberToggleTemplate({ isSelected }) { + return html`
+ ${isSelected ? checkIconTemplate() : ""} +
`; +} + function profileListTemplate({ profiles, onSelect, emptyMessage = null }) { return profileFeedTemplate({ profiles, @@ -82,21 +151,446 @@ function profileListTemplate({ profiles, onSelect, emptyMessage = null }) { }); } +function groupMemberListTemplate({ + profiles, + selectedDids, + atCap, + onToggle, + emptyMessage = null, +}) { + const disabledDids = (profiles ?? []) + .filter( + (profile) => + !canBeAddedToGroup(profile) || + (atCap && !selectedDids.includes(profile.did)), + ) + .map((profile) => profile.did); + return profileFeedTemplate({ + profiles, + hasMore: false, + skeletonCount: 6, + compact: true, + clickAction: onToggle, + rightItemTemplate: (actor) => + canBeAddedToGroup(actor) + ? memberToggleTemplate({ + isSelected: selectedDids.includes(actor.did), + }) + : notAddableRightItem(), + disabledProfiles: disabledDids, + emptyMessage, + }); +} + +function newGroupEntryTemplate({ canCreateGroups, onClick }) { + return html``; +} + +function memberChipsTemplate({ profiles, onRemove }) { + if (!profiles.length) return ""; + return html`
+ ${profiles.map( + (profile) => + html`
+ ${avatarTemplate({ author: profile, clickAction: "none" })} + ${getDisplayName(profile)} + +
`, + )} +
`; +} + +function dialogHeaderTemplate({ title, onBack = null, action = "" }) { + return html`
+ ${onBack + ? html`` + : ""} +

${title}

+ ${action} +
`; +} + +function searchInputTemplate({ rawQuery, onInput, onClear }) { + return html`
+ ${searchIconTemplate()} + onInput(event.target.value)} + /> + ${rawQuery.length > 0 + ? html` + + ` + : ""} +
`; +} + +function searchErrorTemplate() { + return html`
+ We're having network issues, try again +
`; +} + +function chatStepResultsTemplate({ + query, + currentUserDid, + results, + searchStatus, + profileFollows, + profileFollowsStatus, + groupChatsEnabled, + canCreateGroups, + onSelect, + onNewGroupClick, +}) { + if (query) { + if (searchStatus?.error) { + return searchErrorTemplate(); + } + return profileListTemplate({ + profiles: results ? partitionRows(results, currentUserDid) : null, + onSelect, + emptyMessage: "No results", + }); + } + const groupEntry = groupChatsEnabled + ? newGroupEntryTemplate({ + canCreateGroups, + onClick: onNewGroupClick, + }) + : ""; + let suggestedProfiles = null; + if (profileFollowsStatus?.error) { + suggestedProfiles = []; + } else if (profileFollows) { + suggestedProfiles = partitionRows( + profileFollows.filter((profile) => canBeMessaged(profile)), + currentUserDid, + ); + } + if (!suggestedProfiles?.length) { + return html`${groupEntry} + ${profileListTemplate({ + profiles: suggestedProfiles, + emptyMessage: "Search for someone to message", + })}`; + } + return html` + ${groupEntry} +
+ Suggested +
+ ${profileListTemplate({ + profiles: suggestedProfiles, + onSelect, + })} + `; +} + +function chatStepTemplate({ + rawQuery, + query, + currentUserDid, + results, + searchStatus, + profileFollows, + profileFollowsStatus, + groupChatsEnabled, + canCreateGroups, + onSearchInput, + onClearSearch, + onSelect, + onNewGroupClick, + onClose, +}) { + return html` + ${dialogHeaderTemplate({ + title: "Start a new chat", + action: html``, + })} + ${searchInputTemplate({ + rawQuery, + onInput: onSearchInput, + onClear: onClearSearch, + })} +
+ ${chatStepResultsTemplate({ + query, + currentUserDid, + results, + searchStatus, + profileFollows, + profileFollowsStatus, + groupChatsEnabled, + canCreateGroups, + onSelect, + onNewGroupClick, + })} +
+ `; +} + +function memberSelectStepResultsTemplate({ + query, + currentUserDid, + results, + searchStatus, + profileFollows, + profileFollowsStatus, + selectedDids, + atCap, + onToggle, +}) { + if (query) { + if (searchStatus?.error) { + return searchErrorTemplate(); + } + return groupMemberListTemplate({ + profiles: results + ? partitionRows(results, currentUserDid, canBeAddedToGroup) + : null, + selectedDids, + atCap, + onToggle, + emptyMessage: "No results", + }); + } + let suggestedProfiles = null; + if (profileFollowsStatus?.error) { + suggestedProfiles = []; + } else if (profileFollows) { + suggestedProfiles = partitionRows( + profileFollows.filter((profile) => canBeAddedToGroup(profile)), + currentUserDid, + canBeAddedToGroup, + ); + } + if (!suggestedProfiles?.length) { + return groupMemberListTemplate({ + profiles: suggestedProfiles, + selectedDids, + atCap, + onToggle, + emptyMessage: "Search for people to add", + }); + } + return html` +
+ Suggested +
+ ${groupMemberListTemplate({ + profiles: suggestedProfiles, + selectedDids, + atCap, + onToggle, + })} + `; +} + +function memberSelectStepTemplate({ + rawQuery, + query, + currentUserDid, + results, + searchStatus, + profileFollows, + profileFollowsStatus, + selectedDids, + selectedProfiles, + atCap, + onSearchInput, + onClearSearch, + onToggle, + onRemove, + onBack, + onNext, +}) { + return html` + ${dialogHeaderTemplate({ + title: "New group chat", + onBack, + action: + selectedDids.length > 0 + ? html`` + : "", + })} + ${searchInputTemplate({ + rawQuery, + onInput: onSearchInput, + onClear: onClearSearch, + })} + ${memberChipsTemplate({ profiles: selectedProfiles, onRemove })} +
+ ${memberSelectStepResultsTemplate({ + query, + currentUserDid, + results, + searchStatus, + profileFollows, + profileFollowsStatus, + selectedDids, + atCap, + onToggle, + })} +
+ `; +} + +function groupNameStepTemplate({ + groupName, + selectedProfiles, + creating, + canCreate, + onBack, + onNameInput, + onSubmit, +}) { + const nameCount = graphemeCount(groupName); + return html` + ${dialogHeaderTemplate({ + title: "Group name", + onBack, + action: html``, + })} +
+
+ onNameInput(event.target.value)} + @keydown=${(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onSubmit(); + } + }} + /> +
MAX_GROUP_NAME_GRAPHEME_LENGTH, + })} + > + ${nameCount}/${MAX_GROUP_NAME_GRAPHEME_LENGTH} +
+
+
+ New group chat with: +
+ ${profileFeedTemplate({ + profiles: selectedProfiles, + hasMore: false, + compact: true, + clickAction: "none", + rightItemTemplate: (actor) => + canBeAddedToGroup(actor) ? null : notAddableRightItem(), + })} +
+ `; +} + class NewChatDialog extends Component { connectedCallback() { if (this.initialized) { return; } this.dataLayer = this.dataLayer ?? null; + this.groupChatsEnabled = this.groupChatsEnabled ?? true; this.setAttribute("data-dialog-wrapper", ""); this.scrollLock = null; this.state = new ReactiveStore("new-chat-dialog"); this.state.$query = new Signal.State(""); + this.state.$step = new Signal.State(1); + this._selectedDids = []; + this._selectedProfiles = new Map(); + this._groupName = ""; + this._creating = false; this.innerHTML = ""; this._disposeEffect = effect(() => { this.render(); }); this._loadSuggestions(); + this._loadChatActorStatus(); this.initialized = true; } @@ -109,6 +603,12 @@ class NewChatDialog extends Component { } } + _loadChatActorStatus() { + this.dataLayer.requests.loadChatActorStatus().catch((error) => { + console.warn("Failed to load chat actor status", error); + }); + } + disconnectedCallback() { this._disposeEffect?.(); this._disposeEffect = null; @@ -143,8 +643,170 @@ class NewChatDialog extends Component { } } + _onNewGroupClick() { + const status = this.dataLayer.derived.$chatActorStatus.get(); + if (status?.canCreateGroups === false) { + alertModal( + "Your account must be at least 7 days old to create a new group chat.", + { title: "Your account is too new" }, + ); + return; + } + this.state.$step.set(2); + } + + _goBack() { + const step = this.state.$step.get(); + if (step === 3) { + this._groupName = ""; + this.state.$step.set(2); + } else if (step === 2) { + this._selectedDids = []; + this._selectedProfiles.clear(); + this._groupName = ""; + this.state.$step.set(1); + if (this.state.$query.get()) { + this._onSearchInput(""); + } + } + } + + _maxSelectableMembers() { + const limit = + this.dataLayer.derived.$chatActorStatus.get()?.groupMemberLimit; + return limit ? limit - 1 : Infinity; + } + + _toggleMember(profile) { + if (this._selectedDids.includes(profile.did)) { + this._removeMember(profile); + return; + } + if (this._selectedDids.length >= this._maxSelectableMembers()) return; + this._selectedDids = [...this._selectedDids, profile.did]; + this._selectedProfiles.set(profile.did, profile); + if (this.state.$query.get()) { + this._onSearchInput(""); + } + this.render(); + const chips = this.querySelector(".new-group-chips"); + if (chips) chips.scrollLeft = chips.scrollWidth; + } + + _removeMember(profile) { + this._selectedDids = this._selectedDids.filter( + (did) => did !== profile.did, + ); + this._selectedProfiles.delete(profile.did); + this.render(); + } + + _onNext() { + if (this._selectedDids.length === 0) return; + this.state.$step.set(3); + } + + get _canCreate() { + const name = this._groupName.trim(); + return ( + !this._creating && + name.length > 0 && + graphemeCount(name) <= MAX_GROUP_NAME_GRAPHEME_LENGTH + ); + } + + async _onCreateGroup() { + if (!this._canCreate) return; + const name = this._groupName.trim(); + this._creating = true; + this.render(); + try { + const convo = await this.dataLayer.mutations.createGroupChat( + name, + this._selectedDids, + ); + this._creating = false; + this.close(); + window.router.go(`/messages/${convo.id}`); + } catch (error) { + this._creating = false; + this.render(); + console.warn("Failed to create group chat", error); + showToast(createGroupErrorToastMessage(error), { style: "error" }); + } + } + + _renderStepTemplate( + step, + { + rawQuery, + query, + currentUserDid, + results, + searchStatus, + profileFollows, + profileFollowsStatus, + canCreateGroups, + atCap, + selectedProfiles, + }, + ) { + if (step === 1) { + return chatStepTemplate({ + rawQuery, + query, + currentUserDid, + results, + searchStatus, + profileFollows, + profileFollowsStatus, + groupChatsEnabled: this.groupChatsEnabled, + canCreateGroups, + onSearchInput: (value) => this._onSearchInput(value), + onClearSearch: () => this._onClearSearch(), + onSelect: (profile) => this._onSelect(profile), + onNewGroupClick: () => this._onNewGroupClick(), + onClose: () => this.close(), + }); + } + if (step === 2) { + return memberSelectStepTemplate({ + rawQuery, + query, + currentUserDid, + results, + searchStatus, + profileFollows, + profileFollowsStatus, + selectedDids: this._selectedDids, + selectedProfiles, + atCap, + onSearchInput: (value) => this._onSearchInput(value), + onClearSearch: () => this._onClearSearch(), + onToggle: (profile) => this._toggleMember(profile), + onRemove: (profile) => this._removeMember(profile), + onBack: () => this._goBack(), + onNext: () => this._onNext(), + }); + } + return groupNameStepTemplate({ + groupName: this._groupName, + selectedProfiles, + creating: this._creating, + canCreate: this._canCreate, + onBack: () => this._goBack(), + onNameInput: (value) => { + this._groupName = value; + this.render(); + }, + onSubmit: () => this._onCreateGroup(), + }); + } + render() { - const query = this.state.$query.get().trim(); + const step = this.state.$step.get(); + const rawQuery = this.state.$query.get(); + const query = rawQuery.trim(); const currentUserDid = this.dataLayer.derived.$currentUser.get()?.did; const results = this.dataLayer.derived.$chatRecipientSearchResults.get(); const searchStatus = this.dataLayer.requests.statusStore.$statuses.get( @@ -158,6 +820,12 @@ class NewChatDialog extends Component { `loadProfileFollows-${currentUserDid}`, ) : null; + const chatActorStatus = this.dataLayer.derived.$chatActorStatus.get(); + const canCreateGroups = chatActorStatus?.canCreateGroups ?? true; + const atCap = this._selectedDids.length >= this._maxSelectableMembers(); + const selectedProfiles = this._selectedDids + .map((did) => this._selectedProfiles.get(did)) + .filter(Boolean); render( html`
-
-

Start a new chat

- -
-
- ${searchIconTemplate()} - this._onSearchInput(event.target.value)} - /> - ${this.state.$query.get().length > 0 - ? html` - - ` - : ""} -
-
- ${(() => { - if (query) { - if (searchStatus?.error) { - return html`
- We're having network issues, try again -
`; - } - return profileListTemplate({ - profiles: results - ? partitionRows(results, currentUserDid) - : null, - onSelect: (profile) => this._onSelect(profile), - emptyMessage: "No results", - }); - } - let suggestedProfiles = null; - if (profileFollowsStatus?.error) { - suggestedProfiles = []; - } else if (profileFollows) { - suggestedProfiles = partitionRows( - profileFollows.filter((profile) => canBeMessaged(profile)), - currentUserDid, - ); - } - if (!suggestedProfiles?.length) { - return profileListTemplate({ - profiles: suggestedProfiles, - emptyMessage: "Search for someone to message", - }); - } - return html` -
- Suggested -
- ${profileListTemplate({ - profiles: suggestedProfiles, - onSelect: (profile) => this._onSelect(profile), - })} - `; - })()} -
+ ${this._renderStepTemplate(step, { + rawQuery, + query, + currentUserDid, + results, + searchStatus, + profileFollows, + profileFollowsStatus, + canCreateGroups, + atCap, + selectedProfiles, + })}
`, diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index c0c34ab6..80a4e652 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -22,6 +22,7 @@ export class DataStore extends ReactiveStore { this.$drafts = new Signal.State(null); this.$convoList = new Signal.State(null); this.$convoRequestList = new Signal.State(null); + this.$chatActorStatus = new Signal.State(null); this.$blockedProfiles = new Signal.State(null); this.$mutedProfiles = new Signal.State(null); this.$latestProfileSearchRequestTime = new Signal.State(null); diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index b7ed1026..87c03961 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -353,6 +353,9 @@ export class Derived extends ReactiveStore { return preferences.getRecentGifs(); }); this.$trends = new Signal.Computed(() => this.dataStore.$trends.get()); + this.$chatActorStatus = new Signal.Computed(() => + this.dataStore.$chatActorStatus.get(), + ); this.$selectedFeedUri = new Signal.Computed(() => this.dataStore.$selectedFeedUri.get(), ); diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index ebe8cebd..5ba3eaf4 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -1380,6 +1380,15 @@ export class Mutations { return res; } + async createGroupChat(name, memberDids) { + const preferences = await this.preferencesProvider.requirePreferences(); + const res = await this.api.createGroupChat(name, memberDids, { + labelers: preferences.getLabelerDids(), + }); + this.dataStore.setConvo(res.convo); + return res.convo; + } + async requestJoinGroupChat(code) { const res = await this.api.requestJoinGroupChat(code); const preview = this.dataStore.$joinLinkPreviewsByCode.get(code); diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index 7c9833ac..b87a075d 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -225,6 +225,7 @@ export class Requests { this.loadMentionNotifications, "loadMentionNotifications", ); + this.enableStatus(this.loadChatActorStatus, "loadChatActorStatus"); this.enableStatus(this.loadConvoList, "loadConvoList"); this.enableStatus(this.loadConvoRequestList, "loadConvoRequestList"); this.enableStatus(this.loadConvo, (convoId) => "loadConvo-" + convoId); @@ -1542,6 +1543,11 @@ export class Requests { }); } + async loadChatActorStatus() { + const res = await this.api.getChatActorStatus(); + this.dataStore.$chatActorStatus.set(res); + } + async loadProfileChatStatus(profileDid) { const labelers = await this.requireLabelers(); const res = await this.api.getConvoAvailability([profileDid], { diff --git a/src/js/newChatService.js b/src/js/newChatService.js index 31799abd..96e7faa1 100644 --- a/src/js/newChatService.js +++ b/src/js/newChatService.js @@ -1,8 +1,9 @@ import "/js/components/new-chat-dialog.js"; export class NewChatService { - constructor(dataLayer) { + constructor(dataLayer, { groupChatsEnabled }) { this.dataLayer = dataLayer; + this.groupChatsEnabled = groupChatsEnabled; this.currentDialog = null; } @@ -13,6 +14,7 @@ export class NewChatService { } this.currentDialog = document.createElement("new-chat-dialog"); this.currentDialog.dataLayer = this.dataLayer; + this.currentDialog.groupChatsEnabled = this.groupChatsEnabled; this.currentDialog.addEventListener("dialog-closed", () => { if (this.currentDialog) { this.currentDialog.remove(); diff --git a/src/oauthScopes.js b/src/oauthScopes.js index 2c6d9102..8dd48d19 100644 --- a/src/oauthScopes.js +++ b/src/oauthScopes.js @@ -153,6 +153,7 @@ const OPTIONAL_OAUTH_RPC_SCOPES = [ "rpc:app.bsky.draft.createDraft", "rpc:app.bsky.draft.updateDraft", "rpc:app.bsky.draft.deleteDraft", + "rpc:chat.bsky.group.createGroup", ]; function expandScope(scope) { diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index 57abc490..1e2ac1b9 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -34,6 +34,13 @@ export class MockServer { this.sendMessageFailure = null; this.createRecordFailures = new Map(); this.convoForMembersError = null; + this.chatActorStatus = { + chatDisabled: false, + canCreateGroups: true, + groupMemberLimit: 100, + }; + this.createGroupError = null; + this.createGroupRequests = []; this.leaveConvoError = null; this.typeaheadProfiles = []; this.typeaheadDelayMs = 0; @@ -302,6 +309,14 @@ export class MockServer { this.convoForMembersError = errorName; } + setChatActorStatus(overrides) { + this.chatActorStatus = { ...this.chatActorStatus, ...overrides }; + } + + setCreateGroupError(errorName) { + this.createGroupError = errorName; + } + setExternalLinkCard(url, meta) { this.externalLinkCards.set(url, meta); } @@ -1325,6 +1340,64 @@ export class MockServer { }); }); + await page.route("**/xrpc/chat.bsky.actor.getStatus*", (route) => { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(this.chatActorStatus), + }); + }); + + await page.route("**/xrpc/chat.bsky.group.createGroup*", (route) => { + const body = route.request().postDataJSON(); + this.createGroupRequests.push(body); + if (this.createGroupError) { + return route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: this.createGroupError, + message: this.createGroupError, + }), + }); + } + const memberDids = body?.members ?? []; + const id = `convo-group-${++this.messageCounter}`; + const memberKind = (role) => ({ + $type: "chat.bsky.actor.defs#groupConvoMember", + role, + }); + const convo = { + id, + rev: `rev-${id}`, + members: [ + { ...userProfile, kind: memberKind("owner") }, + ...memberDids.map((did) => ({ + ...(this.profiles.get(did) || { did }), + kind: memberKind("standard"), + })), + ], + status: "accepted", + unreadCount: 0, + muted: false, + kind: { + $type: "chat.bsky.convo.defs#groupConvo", + name: body?.name, + memberCount: memberDids.length + 1, + memberLimit: this.chatActorStatus.groupMemberLimit ?? 100, + lockStatus: "unlocked", + createdAt: new Date().toISOString(), + }, + }; + this.convos.push(convo); + this.convoMessages.set(convo.id, []); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ convo }), + }); + }); + await page.route("**/xrpc/chat.bsky.convo.removeReaction*", (route) => { const body = route.request().postDataJSON(); const { convoId, messageId, value } = body || {}; diff --git a/tests/e2e/specs/flows/createGroupChat.test.js b/tests/e2e/specs/flows/createGroupChat.test.js new file mode 100644 index 00000000..c7c119b8 --- /dev/null +++ b/tests/e2e/specs/flows/createGroupChat.test.js @@ -0,0 +1,191 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { createProfile } from "../../../shared/factories.js"; +import { userProfile } from "../../testData.js"; + +test.describe("Create group chat flow", () => { + function createGroupableProfile({ did, handle, displayName }) { + return createProfile({ + did, + handle, + displayName, + associated: { chat: { allowIncoming: "all", allowGroupInvites: "all" } }, + }); + } + + function makeMembers() { + return [ + createGroupableProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }), + createGroupableProfile({ + did: "did:plc:bob1", + handle: "bob.bsky.social", + displayName: "Bob", + }), + ]; + } + + async function openMemberStep(page) { + await page.goto("/messages"); + await page.locator('#chat-view [data-testid="new-chat-button"]').click(); + const dialog = page.locator('[data-testid="new-chat-dialog"]'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + const groupButton = dialog.locator( + '[data-testid="new-chat-new-group-button"]', + ); + await expect(groupButton).toBeVisible({ timeout: 10000 }); + await groupButton.click(); + return dialog; + } + + async function selectMemberByName(dialog, name) { + const row = dialog + .locator('[data-testid="profile-list-item-button"]') + .filter({ hasText: name }); + await expect(row).toBeVisible({ timeout: 10000 }); + await row.click(); + } + + test("should create a named group and land in the new conversation", async ({ + page, + }) => { + const mockServer = new MockServer(); + const [alice, bob] = makeMembers(); + mockServer.addProfile(alice); + mockServer.addProfile(bob); + mockServer.addProfileFollows(userProfile.did, [alice, bob]); + await mockServer.setup(page); + + await login(page); + const dialog = await openMemberStep(page); + + await selectMemberByName(dialog, "Alice"); + await expect( + dialog.locator('[data-testid="new-group-member-chip"]'), + ).toHaveCount(1); + await selectMemberByName(dialog, "Bob"); + await expect( + dialog.locator('[data-testid="new-group-member-chip"]'), + ).toHaveCount(2); + + await dialog.locator('[data-testid="new-group-next-button"]').click(); + await dialog + .locator('[data-testid="new-group-name-input"]') + .fill("Trip planning"); + await dialog.locator('[data-testid="new-group-create-button"]').click(); + + await expect(dialog).not.toBeVisible(); + await expect(page).toHaveURL(/\/messages\/convo-group-/, { + timeout: 10000, + }); + const chatDetailView = page.locator("#chat-detail-view"); + await expect( + chatDetailView.locator('[data-testid="header-title"]'), + ).toContainText("Trip planning", { timeout: 10000 }); + + expect(mockServer.createGroupRequests).toEqual([ + { name: "Trip planning", members: ["did:plc:alice1", "did:plc:bob1"] }, + ]); + + // Back-navigation restores the chat list from memory without refetching, + // so the new group must already be in the in-memory convo list + await page.goBack(); + await expect(page).toHaveURL(/\/messages$/); + await expect(page.locator("#chat-view .convo-name")).toContainText( + "Trip planning", + { timeout: 10000 }, + ); + }); + + test("should keep the dialog state and show a toast when the create fails", async ({ + page, + }) => { + const mockServer = new MockServer(); + const [alice] = makeMembers(); + mockServer.addProfile(alice); + mockServer.addProfileFollows(userProfile.did, [alice]); + mockServer.setCreateGroupError("UserForbidsGroups"); + await mockServer.setup(page); + + await login(page); + const dialog = await openMemberStep(page); + + await selectMemberByName(dialog, "Alice"); + await dialog.locator('[data-testid="new-group-next-button"]').click(); + await dialog.locator('[data-testid="new-group-name-input"]').fill("Trip"); + await dialog.locator('[data-testid="new-group-create-button"]').click(); + + await expect(page.locator('[data-testid="toast"]')).toContainText( + "One of the selected recipients does not allow group chats.", + { timeout: 10000 }, + ); + await expect(dialog).toBeVisible(); + await expect( + dialog.locator('[data-testid="new-group-name-input"]'), + ).toHaveValue("Trip"); + await expect(page).toHaveURL(/\/messages$/); + }); + + test("should make further selection impossible at the member cap", async ({ + page, + }) => { + const mockServer = new MockServer(); + const [alice, bob] = makeMembers(); + const carol = createGroupableProfile({ + did: "did:plc:carol1", + handle: "carol.bsky.social", + displayName: "Carol", + }); + mockServer.addProfileFollows(userProfile.did, [alice, bob, carol]); + mockServer.setChatActorStatus({ groupMemberLimit: 3 }); + await mockServer.setup(page); + + await login(page); + const dialog = await openMemberStep(page); + + await selectMemberByName(dialog, "Alice"); + await selectMemberByName(dialog, "Bob"); + + const carolRow = dialog + .locator('[data-testid="profile-list-item-button"]') + .filter({ hasText: "Carol" }); + await expect(carolRow).toHaveAttribute("data-teststate", "disabled"); + await expect(carolRow).toBeDisabled(); + await expect( + dialog.locator('[data-testid="new-group-member-chip"]'), + ).toHaveCount(2); + }); + + test("should dim the group row and alert instead of advancing for too-new accounts", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setChatActorStatus({ canCreateGroups: false }); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages"); + await page.locator('#chat-view [data-testid="new-chat-button"]').click(); + const dialog = page.locator('[data-testid="new-chat-dialog"]'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + const groupButton = dialog.locator( + '[data-testid="new-chat-new-group-button"]', + ); + await expect(groupButton).toHaveClass(/is-disabled/, { timeout: 10000 }); + await groupButton.click(); + + const alertModal = page.locator('[data-testid="alert-modal"]'); + await expect(alertModal).toBeVisible({ timeout: 10000 }); + await expect( + alertModal.locator('[data-testid="modal-title"]'), + ).toContainText("Your account is too new"); + await expect( + dialog.locator('[data-testid="new-group-back-button"]'), + ).toHaveCount(0); + }); +}); diff --git a/tests/unit/specs/components/new-chat-dialog.test.js b/tests/unit/specs/components/new-chat-dialog.test.js index 85f4f3fa..71d45cbc 100644 --- a/tests/unit/specs/components/new-chat-dialog.test.js +++ b/tests/unit/specs/components/new-chat-dialog.test.js @@ -34,6 +34,7 @@ describe("new-chat-dialog", () => { function makeDataLayer({ ensureConvoForProfile, + createGroupChat, searchFailure = null, followsFailure = null, } = {}) { @@ -42,6 +43,24 @@ describe("new-chat-dialog", () => { dataLayer.dataStore.$currentUser.set(currentUser); const failures = { search: searchFailure, follows: followsFailure }; + stubStatusTracked( + dataLayer.requests, + "loadChatActorStatus", + "loadChatActorStatus", + async () => {}, + ); + + const createGroupSpy = mock.method( + dataLayer.mutations, + "createGroupChat", + async (name, memberDids) => { + if (createGroupChat) { + return createGroupChat(name, memberDids); + } + return { id: "convo-group-1" }; + }, + ); + const searchSpy = stubStatusTracked( dataLayer.requests, "loadChatRecipientSearch", @@ -77,9 +96,19 @@ describe("new-chat-dialog", () => { searchSpy, followsSpy, ensureSpy, + createGroupSpy, }; } + function seedChatActorStatus(dataLayer, overrides = {}) { + dataLayer.dataStore.$chatActorStatus.set({ + chatDisabled: false, + canCreateGroups: true, + groupMemberLimit: 100, + ...overrides, + }); + } + function seedFollows(dataLayer, follows, did = "did:plc:me") { dataLayer.dataStore.setProfiles(follows); dataLayer.dataStore.$profileFollows.set(did, { follows, cursor: null }); @@ -115,8 +144,13 @@ describe("new-chat-dialog", () => { handle, displayName, allowIncoming, + allowGroupInvites, followedBy, } = {}) { + const chat = { + ...(allowIncoming !== undefined ? { allowIncoming } : {}), + ...(allowGroupInvites !== undefined ? { allowGroupInvites } : {}), + }; return { did, handle, @@ -124,9 +158,7 @@ describe("new-chat-dialog", () => { avatar: "", labels: [], viewer: followedBy ? { followedBy: "at://follow" } : {}, - ...(allowIncoming !== undefined - ? { associated: { chat: { allowIncoming } } } - : {}), + ...(Object.keys(chat).length > 0 ? { associated: { chat } } : {}), }; } @@ -673,6 +705,614 @@ describe("new-chat-dialog", () => { }); }); + async function goToMemberStep(element) { + element.querySelector('[data-testid="new-chat-new-group-button"]').click(); + await nextFrame(); + } + + function memberRows(element) { + return [ + ...element.querySelectorAll('[data-testid="profile-list-item-button"]'), + ]; + } + + async function toggleRowByHandle(element, handle) { + const row = memberRows(element).find((rowElement) => + rowElement.textContent.includes(`@${handle}`), + ); + row.click(); + await nextFrame(); + } + + async function setGroupName(element, value) { + const input = element.querySelector('[data-testid="new-group-name-input"]'); + input.value = value; + input.dispatchEvent(new window.InputEvent("input", { bubbles: true })); + await nextFrame(); + } + + describe("NewChatDialog - group entry", () => { + it("should show the group row only while the search box is empty", async () => { + const { dataLayer } = makeDataLayer(); + seedFollows(dataLayer, []); + const element = createDialog(dataLayer); + assert( + element.querySelector('[data-testid="new-chat-new-group-button"]') !== + null, + ); + seedSearchResults(dataLayer, []); + await typeQuery(element, "alice"); + assert.deepEqual( + element.querySelector('[data-testid="new-chat-new-group-button"]'), + null, + ); + }); + + it("should hide the group row when group chats are not enabled", () => { + const { dataLayer } = makeDataLayer(); + seedFollows(dataLayer, []); + const element = document.createElement("new-chat-dialog"); + element.dataLayer = dataLayer; + element.groupChatsEnabled = false; + document.body.appendChild(element); + assert.deepEqual( + element.querySelector('[data-testid="new-chat-new-group-button"]'), + null, + ); + }); + + it("should advance to the member-select step when groups are allowed", async () => { + const { dataLayer } = makeDataLayer(); + seedChatActorStatus(dataLayer); + seedFollows(dataLayer, []); + const element = createDialog(dataLayer); + await goToMemberStep(element); + assert( + element.querySelector('[data-testid="new-group-back-button"]') !== null, + ); + assert( + element + .querySelector(".search-dialog-title") + .textContent.includes("New group chat"), + ); + }); + + it("should fail open and advance while the actor status has not loaded", async () => { + const { dataLayer } = makeDataLayer(); + seedFollows(dataLayer, []); + const element = createDialog(dataLayer); + const row = element.querySelector( + '[data-testid="new-chat-new-group-button"]', + ); + assert(!row.classList.contains("is-disabled")); + await goToMemberStep(element); + assert( + element.querySelector('[data-testid="new-group-back-button"]') !== null, + ); + }); + + it("should dim the row and show the too-new alert when canCreateGroups is false", async () => { + const { dataLayer } = makeDataLayer(); + seedChatActorStatus(dataLayer, { canCreateGroups: false }); + seedFollows(dataLayer, []); + const element = createDialog(dataLayer); + await nextFrame(); + const row = element.querySelector( + '[data-testid="new-chat-new-group-button"]', + ); + assert(row.classList.contains("is-disabled")); + row.click(); + await nextFrame(); + const alert = document.body.querySelector('[data-testid="alert-modal"]'); + assert(alert !== null, "alert modal should be shown"); + assert(alert.textContent.includes("Your account is too new")); + assert.deepEqual( + element.querySelector('[data-testid="new-group-back-button"]'), + null, + ); + }); + }); + + describe("NewChatDialog - member select", () => { + it("should filter ineligible profiles out of the suggested follows", async () => { + const { dataLayer } = makeDataLayer(); + seedChatActorStatus(dataLayer); + seedFollows(dataLayer, [ + createProfile({ + did: "did:plc:alice", + handle: "alice.test", + allowGroupInvites: "all", + }), + createProfile({ + did: "did:plc:carol", + handle: "carol.test", + allowGroupInvites: "none", + allowIncoming: "all", + }), + ]); + const element = createDialog(dataLayer); + await goToMemberStep(element); + const rows = memberRows(element); + assert.deepEqual(rows.length, 1); + assert(rows[0].textContent.includes("@alice.test")); + }); + + it("should apply the eligibility matrix to search results", async () => { + const { dataLayer } = makeDataLayer(); + seedChatActorStatus(dataLayer); + seedFollows(dataLayer, []); + const element = createDialog(dataLayer); + await goToMemberStep(element); + seedSearchResults(dataLayer, [ + createProfile({ + did: "did:plc:all", + handle: "all.test", + allowGroupInvites: "all", + }), + createProfile({ + did: "did:plc:mutual", + handle: "mutual.test", + allowGroupInvites: "following", + followedBy: true, + }), + createProfile({ + did: "did:plc:stranger", + handle: "stranger.test", + allowGroupInvites: "following", + }), + createProfile({ + did: "did:plc:delegated", + handle: "delegated.test", + allowIncoming: "all", + }), + createProfile({ + did: "did:plc:unknown", + handle: "unknown.test", + allowGroupInvites: "sometimes", + }), + createProfile({ + did: "did:plc:closed", + handle: "closed.test", + allowGroupInvites: "none", + }), + ]); + await typeQuery(element, "test"); + const rows = memberRows(element); + assert.deepEqual(rows.length, 6); + const stateByHandle = Object.fromEntries( + rows.map((rowElement) => [ + rowElement + .querySelector('[data-testid="profile-list-item-handle"]') + .textContent.trim() + .replace("@", ""), + rowElement.dataset.teststate, + ]), + ); + assert.deepEqual(stateByHandle, { + "all.test": "enabled", + "mutual.test": "enabled", + "delegated.test": "enabled", + "stranger.test": "disabled", + "unknown.test": "disabled", + "closed.test": "disabled", + }); + const disabledRow = rows.find((rowElement) => + rowElement.textContent.includes("@closed.test"), + ); + assert( + disabledRow.querySelector('[data-testid="not-addable-hint"]') !== null, + ); + }); + + it("should add a chip and clear the search when a member is selected", async () => { + const { dataLayer, searchSpy } = makeDataLayer(); + seedChatActorStatus(dataLayer); + seedFollows(dataLayer, []); + const element = createDialog(dataLayer); + await goToMemberStep(element); + seedSearchResults(dataLayer, [ + createProfile({ + did: "did:plc:alice", + handle: "alice.test", + displayName: "Alice", + allowGroupInvites: "all", + }), + ]); + await typeQuery(element, "alice"); + await toggleRowByHandle(element, "alice.test"); + const chips = element.querySelectorAll( + '[data-testid="new-group-member-chip"]', + ); + assert.deepEqual(chips.length, 1); + assert(chips[0].textContent.includes("Alice")); + const input = element.querySelector( + '[data-testid="new-chat-search-input"]', + ); + assert.deepEqual(input.value, ""); + assert.deepEqual( + searchSpy.mock.calls[searchSpy.mock.calls.length - 1].arguments[0], + "", + ); + }); + + it("should keep the search text when a chip is removed", async () => { + const { dataLayer } = makeDataLayer(); + seedChatActorStatus(dataLayer); + seedFollows(dataLayer, [ + createProfile({ + did: "did:plc:alice", + handle: "alice.test", + allowGroupInvites: "all", + }), + ]); + const element = createDialog(dataLayer); + await goToMemberStep(element); + await toggleRowByHandle(element, "alice.test"); + seedSearchResults(dataLayer, []); + await typeQuery(element, "bob"); + element + .querySelector('[data-testid="new-group-member-chip-remove"]') + .click(); + await nextFrame(); + assert.deepEqual( + element.querySelectorAll('[data-testid="new-group-member-chip"]') + .length, + 0, + ); + assert.deepEqual( + element.querySelector('[data-testid="new-chat-search-input"]').value, + "bob", + ); + }); + + it("should unselect a selected member on a second row toggle", async () => { + const { dataLayer } = makeDataLayer(); + seedChatActorStatus(dataLayer); + seedFollows(dataLayer, [ + createProfile({ + did: "did:plc:alice", + handle: "alice.test", + allowGroupInvites: "all", + }), + ]); + const element = createDialog(dataLayer); + await goToMemberStep(element); + await toggleRowByHandle(element, "alice.test"); + assert.deepEqual( + element + .querySelector('[data-testid="new-group-member-toggle"]') + .getAttribute("data-teststate"), + "selected", + ); + await toggleRowByHandle(element, "alice.test"); + assert.deepEqual( + element.querySelectorAll('[data-testid="new-group-member-chip"]') + .length, + 0, + ); + assert.deepEqual( + element + .querySelector('[data-testid="new-group-member-toggle"]') + .getAttribute("data-teststate"), + "unselected", + ); + }); + + it("should disable unselected rows once the member cap is reached", async () => { + const { dataLayer } = makeDataLayer(); + seedChatActorStatus(dataLayer, { groupMemberLimit: 3 }); + seedFollows(dataLayer, [ + createProfile({ + did: "did:plc:alice", + handle: "alice.test", + allowGroupInvites: "all", + }), + createProfile({ + did: "did:plc:bob", + handle: "bob.test", + allowGroupInvites: "all", + }), + createProfile({ + did: "did:plc:carol", + handle: "carol.test", + allowGroupInvites: "all", + }), + ]); + const element = createDialog(dataLayer); + await goToMemberStep(element); + await toggleRowByHandle(element, "alice.test"); + await toggleRowByHandle(element, "bob.test"); + const carolRow = memberRows(element).find((rowElement) => + rowElement.textContent.includes("@carol.test"), + ); + assert.deepEqual(carolRow.dataset.teststate, "disabled"); + assert.deepEqual(carolRow.disabled, true); + const aliceRow = memberRows(element).find((rowElement) => + rowElement.textContent.includes("@alice.test"), + ); + assert.deepEqual(aliceRow.dataset.teststate, "enabled"); + }); + + it("should show the Next button only once a member is selected", async () => { + const { dataLayer } = makeDataLayer(); + seedChatActorStatus(dataLayer); + seedFollows(dataLayer, [ + createProfile({ + did: "did:plc:alice", + handle: "alice.test", + allowGroupInvites: "all", + }), + ]); + const element = createDialog(dataLayer); + await goToMemberStep(element); + assert.deepEqual( + element.querySelector('[data-testid="new-group-next-button"]'), + null, + ); + await toggleRowByHandle(element, "alice.test"); + const nextButton = element.querySelector( + '[data-testid="new-group-next-button"]', + ); + assert(nextButton !== null); + nextButton.click(); + await nextFrame(); + assert( + element.querySelector('[data-testid="new-group-name-input"]') !== null, + ); + }); + }); + + describe("NewChatDialog - back semantics", () => { + async function goToNameStep(element) { + await toggleRowByHandle(element, "alice.test"); + element.querySelector('[data-testid="new-group-next-button"]').click(); + await nextFrame(); + } + + function makeGroupDialog() { + const { dataLayer } = makeDataLayer(); + seedChatActorStatus(dataLayer); + seedFollows(dataLayer, [ + createProfile({ + did: "did:plc:alice", + handle: "alice.test", + allowGroupInvites: "all", + }), + ]); + return createDialog(dataLayer); + } + + it("should keep members and clear the name when backing out of the name step", async () => { + const element = makeGroupDialog(); + await goToMemberStep(element); + await goToNameStep(element); + await setGroupName(element, "Trip"); + element.querySelector('[data-testid="new-group-back-button"]').click(); + await nextFrame(); + assert.deepEqual( + element.querySelectorAll('[data-testid="new-group-member-chip"]') + .length, + 1, + ); + element.querySelector('[data-testid="new-group-next-button"]').click(); + await nextFrame(); + assert.deepEqual( + element.querySelector('[data-testid="new-group-name-input"]').value, + "", + ); + }); + + it("should discard everything when backing out of the member step", async () => { + const element = makeGroupDialog(); + await goToMemberStep(element); + await toggleRowByHandle(element, "alice.test"); + element.querySelector('[data-testid="new-group-back-button"]').click(); + await nextFrame(); + assert( + element.querySelector('[data-testid="new-chat-new-group-button"]') !== + null, + ); + await goToMemberStep(element); + assert.deepEqual( + element.querySelectorAll('[data-testid="new-group-member-chip"]') + .length, + 0, + ); + }); + }); + + describe("NewChatDialog - group name step", () => { + async function goToNameStep(element, handles = ["alice.test"]) { + await goToMemberStep(element); + for (const handle of handles) { + await toggleRowByHandle(element, handle); + } + element.querySelector('[data-testid="new-group-next-button"]').click(); + await nextFrame(); + } + + function makeGroupDialog(options = {}) { + const result = makeDataLayer(options); + seedChatActorStatus(result.dataLayer); + seedFollows(result.dataLayer, [ + createProfile({ + did: "did:plc:alice", + handle: "alice.test", + displayName: "Alice", + allowGroupInvites: "all", + }), + createProfile({ + did: "did:plc:bob", + handle: "bob.test", + displayName: "Bob", + allowGroupInvites: "all", + }), + ]); + return { ...result, element: createDialog(result.dataLayer) }; + } + + it("should list the chosen members under the review header", async () => { + const { element } = makeGroupDialog(); + await goToNameStep(element, ["alice.test", "bob.test"]); + assert( + element.querySelector('[data-testid="new-group-members-header"]') !== + null, + ); + const rows = element.querySelectorAll(".profile-list-item"); + assert.deepEqual(rows.length, 2); + assert(rows[0].textContent.includes("@alice.test")); + assert(rows[1].textContent.includes("@bob.test")); + }); + + it("should disable Create for empty, whitespace-only, and over-limit names", async () => { + const { element } = makeGroupDialog(); + await goToNameStep(element); + const createButton = element.querySelector( + '[data-testid="new-group-create-button"]', + ); + assert.deepEqual(createButton.disabled, true); + await setGroupName(element, " "); + assert.deepEqual(createButton.disabled, true); + await setGroupName(element, "a".repeat(51)); + assert.deepEqual(createButton.disabled, true); + assert( + element.querySelector(".form-dialog-char-count.overflow") !== null, + ); + await setGroupName(element, "a".repeat(50)); + assert.deepEqual(createButton.disabled, false); + }); + + it("should create the group with the trimmed name, close, and navigate", async () => { + const { element, createGroupSpy } = makeGroupDialog(); + element.open(); + let closed = false; + element.addEventListener("dialog-closed", () => { + closed = true; + }); + await goToNameStep(element, ["alice.test", "bob.test"]); + await setGroupName(element, " Trip planning "); + element.querySelector('[data-testid="new-group-create-button"]').click(); + await flushMicrotasks(); + assert.deepEqual(createGroupSpy.mock.callCount(), 1); + assert.deepEqual(createGroupSpy.mock.calls[0].arguments, [ + "Trip planning", + ["did:plc:alice", "did:plc:bob"], + ]); + assert(closed, "the dialog closes after a successful create"); + assert.deepEqual( + window.router.go.mock.calls.map((call) => call.arguments), + [["/messages/convo-group-1"]], + ); + }); + + it("should submit on Enter in the name field", async () => { + const { element, createGroupSpy } = makeGroupDialog(); + element.open(); + await goToNameStep(element); + await setGroupName(element, "Trip"); + const input = element.querySelector( + '[data-testid="new-group-name-input"]', + ); + input.dispatchEvent( + new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + await flushMicrotasks(); + assert.deepEqual(createGroupSpy.mock.callCount(), 1); + }); + + it("should toast and keep the dialog state when the create fails", async () => { + const failure = createApiError("UserForbidsGroups"); + const { element } = makeGroupDialog({ + createGroupChat: () => Promise.reject(failure), + }); + element.open(); + let closed = false; + element.addEventListener("dialog-closed", () => { + closed = true; + }); + await goToNameStep(element); + await setGroupName(element, "Trip"); + element.querySelector('[data-testid="new-group-create-button"]').click(); + await flushMicrotasks(); + const toast = document.body.querySelector('[data-testid="toast"]'); + assert(toast !== null, "toast should be shown"); + assert( + toast.textContent.includes( + "One of the selected recipients does not allow group chats.", + ), + ); + assert(!closed, "the dialog stays open on failure"); + assert.deepEqual( + element.querySelector('[data-testid="new-group-name-input"]').value, + "Trip", + ); + assert.deepEqual(window.router.go.mock.callCount(), 0); + }); + + it("should map group create errors to their toasts", async () => { + const cases = [ + [ + createApiError("BlockedSubject"), + "You have blocked one of the selected recipients.", + ], + [ + createApiError("NewAccountCannotCreateGroup"), + "You cannot create a group chat yet.", + ], + [ + new TypeError("Failed to fetch"), + "A network error occurred. Please check your internet connection.", + ], + [ + createApiError("SomethingUnexpected"), + "An issue occurred starting the group chat, please try again.", + ], + ]; + for (const [failure, expectedText] of cases) { + document.body.innerHTML = ""; + const { element } = makeGroupDialog({ + createGroupChat: () => Promise.reject(failure), + }); + element.open(); + await goToNameStep(element); + await setGroupName(element, "Trip"); + element + .querySelector('[data-testid="new-group-create-button"]') + .click(); + await flushMicrotasks(); + const toast = document.body.querySelector('[data-testid="toast"]'); + assert(toast !== null, "toast should be shown"); + assert( + toast.textContent.includes(expectedText), + `expected toast for ${failure?.data?.error ?? failure}`, + ); + } + }); + + it("should fire exactly one create request on a double submit", async () => { + let resolveCreate; + const { element, createGroupSpy } = makeGroupDialog({ + createGroupChat: () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + }); + element.open(); + await goToNameStep(element); + await setGroupName(element, "Trip"); + const createButton = element.querySelector( + '[data-testid="new-group-create-button"]', + ); + createButton.click(); + createButton.click(); + await flushMicrotasks(); + assert.deepEqual(createGroupSpy.mock.callCount(), 1); + resolveCreate({ id: "convo-group-1" }); + await flushMicrotasks(); + assert.deepEqual(window.router.go.mock.callCount(), 1); + }); + }); + describe("NewChatDialog - dismissal", () => { it("should close on the close button", async () => { const { dataLayer } = makeDataLayer(); diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 532cdd8f..2928a864 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -2788,6 +2788,78 @@ describe("acceptConvo", () => { }); }); +describe("createGroupChat", () => { + function setup({ convoList, apiFailure } = {}) { + const dataStore = new DataStore(createSessionState(null)); + const patchStore = new PatchStore(dataStore); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + if (convoList) { + dataStore.$convoList.set({ convos: convoList, cursor: "list-cursor" }); + } + const groupConvo = { + id: "convo-group-1", + status: "accepted", + kind: { $type: "chat.bsky.convo.defs#groupConvo", name: "Trip" }, + }; + const calls = []; + const mutations = makeMutations( + { + createGroupChat: async (name, memberDids, options) => { + calls.push({ name, memberDids, options }); + if (apiFailure) throw apiFailure; + return { convo: groupConvo }; + }, + }, + dataStore, + patchStore, + mockPreferencesProvider, + ); + return { mutations, dataStore, calls, groupConvo }; + } + + it("should call the api and return the created convo", async () => { + const { mutations, calls, groupConvo } = setup(); + const result = await mutations.createGroupChat("Trip", [ + "did:plc:alice", + "did:plc:bob", + ]); + assert.deepEqual(calls.length, 1); + assert.deepEqual(calls[0].name, "Trip"); + assert.deepEqual(calls[0].memberDids, ["did:plc:alice", "did:plc:bob"]); + assert.deepEqual(result, groupConvo); + }); + + it("should save the convo and add it to the convo list", async () => { + const otherConvo = { id: "convo-2", status: "accepted" }; + const { mutations, dataStore, groupConvo } = setup({ + convoList: [otherConvo], + }); + await mutations.createGroupChat("Trip", ["did:plc:alice"]); + assert.deepEqual(dataStore.$convos.get(groupConvo.id), groupConvo); + const list = dataStore.$convoList.get(); + assert.deepEqual( + list.convos.map((convo) => convo.id), + [groupConvo.id, otherConvo.id], + ); + assert.deepEqual(list.cursor, "list-cursor"); + }); + + it("should rethrow api failures without touching the store", async () => { + const failure = new Error("BlockedActor"); + const { mutations, dataStore } = setup({ + convoList: [], + apiFailure: failure, + }); + await assert.rejects( + () => mutations.createGroupChat("Trip", ["did:plc:alice"]), + failure, + ); + assert.deepEqual(dataStore.$convoList.get().convos, []); + }); +}); + describe("rejectConvo", () => { const convo = { id: "convo-1", status: "request" };