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``;
+}
+
+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}
+
+ ${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`
+
+ ${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}
+
+
+
+ ${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`
`,
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" };