meeting matches: add the ability to exclude individuals altogether or groups from matching one another

This commit is contained in:
2026-03-03 20:39:33 -07:00
parent 41149ad28a
commit b4b7d71330
4 changed files with 675 additions and 31 deletions

View File

@@ -0,0 +1,249 @@
<template>
<div>
<div v-for="group in groups" :key="group.id" class="mb-3">
<div
:class="[
'rounded-lg border p-3',
colorSet(group.colorIndex).bg,
colorSet(group.colorIndex).border,
]"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 flex-1 min-w-0">
<span
:class="[
'w-3 h-3 rounded-full shrink-0',
colorSet(group.colorIndex).dot,
]"
></span>
<input
:value="group.name"
:disabled="disabled"
:class="[
'text-sm font-medium bg-transparent border-none',
'outline-none flex-1 min-w-0 placeholder-gray-400',
{ 'cursor-default': disabled },
]"
placeholder="Group name…"
@input="
updateGroupName(
group.id,
($event.target as HTMLInputElement).value,
)
"
/>
</div>
<button
v-if="!disabled"
class="text-slate-400 hover:text-red-600 transition-colors ml-2 shrink-0"
title="Delete group"
@click="removeGroup(group.id)"
>
<font-awesome icon="trash-can" class="text-sm" />
</button>
</div>
<div class="flex flex-wrap gap-1.5 mb-2">
<span
v-for="did in group.memberDids"
:key="did"
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs',
colorSet(group.colorIndex).chip,
]"
>
{{ getMemberName(did) }}
<button
v-if="!disabled"
class="hover:opacity-70"
@click="removeMemberFromGroup(group.id, did)"
>
<font-awesome icon="xmark" class="text-xs" />
</button>
</span>
<span
v-if="group.memberDids.length === 0"
class="text-xs text-slate-400 italic"
>
No members yet
</span>
</div>
<template v-if="!disabled">
<div v-if="addingToGroupId === group.id" class="mt-2">
<div
class="flex flex-wrap gap-1.5 p-2 bg-white bg-opacity-60 rounded border border-gray-200"
>
<button
v-for="member in availableMembersForGroup(group)"
:key="member.did"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-white border border-gray-300 hover:bg-gray-100 transition-colors"
@click="addMemberToGroup(group.id, member.did)"
>
<font-awesome icon="plus" class="text-xs text-green-600" />
{{ member.name }}
</button>
<span
v-if="availableMembersForGroup(group).length === 0"
class="text-xs text-slate-400 italic"
>
All members already assigned
</span>
</div>
<button
class="text-xs text-slate-500 mt-1"
@click="addingToGroupId = ''"
>
Done
</button>
</div>
<button
v-else
class="text-xs text-blue-600 hover:text-blue-800 transition-colors"
@click="addingToGroupId = group.id"
>
<font-awesome icon="plus" class="text-xs" />
Add member
</button>
</template>
</div>
</div>
<button
v-if="!disabled"
class="text-sm text-blue-600 hover:text-blue-800 transition-colors"
@click="addGroup"
>
<font-awesome icon="plus" class="text-sm" />
New Group
</button>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
import { DoNotPairGroup } from "@/interfaces";
interface MemberInfo {
did: string;
name: string;
}
const GROUP_COLORS = [
{
bg: "bg-orange-50",
border: "border-orange-200",
dot: "bg-orange-400",
chip: "bg-orange-200 text-orange-800",
},
{
bg: "bg-purple-50",
border: "border-purple-200",
dot: "bg-purple-400",
chip: "bg-purple-200 text-purple-800",
},
{
bg: "bg-teal-50",
border: "border-teal-200",
dot: "bg-teal-400",
chip: "bg-teal-200 text-teal-800",
},
{
bg: "bg-pink-50",
border: "border-pink-200",
dot: "bg-pink-400",
chip: "bg-pink-200 text-pink-800",
},
{
bg: "bg-indigo-50",
border: "border-indigo-200",
dot: "bg-indigo-400",
chip: "bg-indigo-200 text-indigo-800",
},
{
bg: "bg-yellow-50",
border: "border-yellow-200",
dot: "bg-yellow-400",
chip: "bg-yellow-200 text-yellow-800",
},
];
@Component
export default class MeetingExclusionGroups extends Vue {
@Prop({ required: true }) groups!: DoNotPairGroup[];
@Prop({ required: true }) availableMembers!: MemberInfo[];
@Prop({ default: false }) disabled!: boolean;
addingToGroupId = "";
colorSet(colorIndex: number): (typeof GROUP_COLORS)[0] {
return GROUP_COLORS[colorIndex % GROUP_COLORS.length];
}
getMemberName(did: string): string {
const member = this.availableMembers.find((m) => m.did === did);
return member?.name || did.substring(0, 16) + "…";
}
availableMembersForGroup(group: DoNotPairGroup): MemberInfo[] {
const allAssignedDids = new Set(this.groups.flatMap((g) => g.memberDids));
return this.availableMembers
.filter(
(m) => !allAssignedDids.has(m.did) || group.memberDids.includes(m.did),
)
.filter((m) => !group.memberDids.includes(m.did));
}
@Emit("update")
emitUpdate(): DoNotPairGroup[] {
return [...this.groups];
}
addGroup(): void {
const newGroup: DoNotPairGroup = {
id: Date.now().toString(36) + Math.random().toString(36).substring(2, 6),
name: "",
colorIndex: this.groups.length % GROUP_COLORS.length,
memberDids: [],
};
this.groups.push(newGroup);
this.addingToGroupId = newGroup.id;
this.emitUpdate();
}
removeGroup(groupId: string): void {
const idx = this.groups.findIndex((g) => g.id === groupId);
if (idx !== -1) {
this.groups.splice(idx, 1);
if (this.addingToGroupId === groupId) {
this.addingToGroupId = "";
}
this.emitUpdate();
}
}
updateGroupName(groupId: string, name: string): void {
const group = this.groups.find((g) => g.id === groupId);
if (group) {
group.name = name;
this.emitUpdate();
}
}
addMemberToGroup(groupId: string, did: string): void {
const group = this.groups.find((g) => g.id === groupId);
if (group && !group.memberDids.includes(did)) {
group.memberDids.push(did);
this.emitUpdate();
}
}
removeMemberFromGroup(groupId: string, did: string): void {
const group = this.groups.find((g) => g.id === groupId);
if (group) {
group.memberDids = group.memberDids.filter((d) => d !== did);
this.emitUpdate();
}
}
}
</script>

View File

@@ -36,6 +36,15 @@
<font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
to add/remove them to/from the meeting.
</li>
<li
v-if="
membersToShow().length > 0 && showOrganizerTools && isOrganizer
"
>
Click
<font-awesome icon="ban" class="text-amber-500 text-sm" />
to exclude someone from matching.
</li>
<li
v-if="
membersToShow().length > 0 && getNonContactMembers().length > 0
@@ -82,6 +91,8 @@
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
!member.member.admitted &&
(isOrganizer || member.did === activeDid),
'bg-amber-50 opacity-60':
member.member.admitted && excludedDids.includes(member.did),
},
{ 'border-slate-300': member.member.admitted },
]"
@@ -95,6 +106,9 @@
'text-slate-500':
!member.member.admitted &&
(isOrganizer || member.did === activeDid),
'line-through text-slate-400':
member.member.admitted &&
excludedDids.includes(member.did),
},
]"
>
@@ -168,8 +182,31 @@
v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid
"
class="flex items-center gap-1.5"
class="flex items-center gap-6"
>
<button
v-if="member.member.admitted"
:class="[
'btn-exclusion-toggle',
exclusionLocked
? excludedDids.includes(member.did)
? 'text-amber-400 opacity-50'
: 'text-slate-300 opacity-50'
: excludedDids.includes(member.did)
? 'text-amber-600'
: 'text-slate-500',
]"
:title="
exclusionLocked
? 'Erase matches to change exclusions'
: excludedDids.includes(member.did)
? 'Include in matching'
: 'Exclude from matching'
"
@click="handleExclusionClick(member.did)"
>
<font-awesome icon="ban" />
</button>
<button
:class="
member.member.admitted
@@ -290,6 +327,8 @@ export default class MeetingMembersList extends Vue {
@Prop({ required: true }) password!: string;
@Prop({ default: false }) showOrganizerTools!: boolean;
@Prop({ default: null }) matchPairs!: MatchPair[] | null;
@Prop({ default: () => [] }) excludedDids!: string[];
@Prop({ default: false }) exclusionLocked!: boolean;
// Emit methods using @Emit decorator
@Emit("error")
@@ -297,6 +336,16 @@ export default class MeetingMembersList extends Vue {
return message;
}
@Emit("toggle-exclusion")
emitToggleExclusion(did: string) {
return did;
}
@Emit("members-loaded")
emitMembersLoaded() {
return;
}
contacts: Array<Contact> = [];
decryptedMembers: DecryptedMember[] = [];
firstName = "";
@@ -358,6 +407,7 @@ export default class MeetingMembersList extends Vue {
this.emitError(serverMessageForUser(error) || "Failed to fetch members.");
} finally {
this.isLoading = false;
this.emitMembersLoaded();
}
}
@@ -499,7 +549,7 @@ export default class MeetingMembersList extends Vue {
informAboutAdmission() {
this.notify.info(
"This is to register people in the app and to admit them to the meeting. A green (+) symbol means they are not yet admitted and you can register and admit them. A red (-) symbol means you can remove them, but they will stay registered.",
"Click the 'ban' button to exclude from matching. The '+/-' buttons are for admissions: A blue (+) symbol means they are not yet admitted and you can register and admit them. A red (-) symbol means you can remove them, but they will stay registered.",
TIMEOUTS.VERY_LONG,
);
}
@@ -746,6 +796,23 @@ export default class MeetingMembersList extends Vue {
}
}
getAdmittedMembers(): Array<{ did: string; name: string }> {
return this.decryptedMembers
.filter((m) => m.member.admitted)
.map((m) => ({ did: m.did, name: m.name }));
}
handleExclusionClick(did: string): void {
if (this.exclusionLocked) {
this.notify.warning(
"Erase the current matches before changing exclusions.",
TIMEOUTS.LONG,
);
return;
}
this.emitToggleExclusion(did);
}
startAutoRefresh() {
this.stopAutoRefresh();
this.lastRefreshTime = Date.now();
@@ -811,6 +878,11 @@ export default class MeetingMembersList extends Vue {
transition-colors;
}
.btn-exclusion-toggle {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply text-lg transition-colors;
}
.btn-admission-remove {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply text-lg text-rose-500 hover:text-rose-700

View File

@@ -34,3 +34,16 @@ export interface MemberData {
memberId: string;
};
}
export interface DoNotPairGroup {
id: string;
name: string;
colorIndex: number;
memberDids: string[];
}
export interface MeetingExclusionState {
meetingGroupId: string;
excludedDids: string[];
doNotPairGroups: DoNotPairGroup[];
}

View File

@@ -312,8 +312,12 @@
:match-pairs="matchPairs"
:password="currentMeeting.password || ''"
:show-organizer-tools="true"
:excluded-dids="excludedDids"
:exclusion-locked="hasActiveMatches"
class="mt-4"
@error="handleMembersError"
@toggle-exclusion="toggleExclusion"
@members-loaded="refreshAdmittedMembers"
/>
</div>
@@ -329,7 +333,7 @@
type="button"
class="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isPostingMatch"
@click="postNewMatchesThenRefresh()"
@click="promptPreMatchConfirm()"
>
<font-awesome
v-if="isPostingMatch"
@@ -340,9 +344,13 @@
</button>
<button
type="button"
class="px-3 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isPostingMatch || !matchPairs?.length"
@click="clearMatchesThenRefresh()"
:class="[
'px-3 py-2 text-sm rounded',
isPostingMatch || !matchPairs?.length
? 'bg-red-400 text-white opacity-50 cursor-not-allowed'
: 'bg-red-600 text-white hover:bg-red-700',
]"
@click="handleEraseClick()"
>
<font-awesome
v-if="isPostingMatch"
@@ -352,6 +360,25 @@
Erase to Start Over
</button>
</div>
<!-- Do Not Pair Groups -->
<div class="mb-4 pt-2 border-t border-gray-100">
<h4 class="text-sm font-medium text-gray-700 mb-2">
Do Not Pair Together
</h4>
<p class="text-xs text-gray-500 mb-2">
People in the same group will not be matched with each other.
</p>
<p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2">
Erase matches to change restrictions.
</p>
<MeetingExclusionGroups
:groups="doNotPairGroups"
:available-members="admittedMembers"
:disabled="hasActiveMatches"
@update="handleDoNotPairGroupsUpdate"
/>
</div>
<div v-if="isLoadingMatches" class="text-sm text-gray-500 py-2">
<font-awesome icon="spinner" class="fa-spin fa-fw" />
Loading matches…
@@ -395,9 +422,123 @@
v-else-if="matchPairs && matchPairs.length === 0"
class="text-sm text-gray-500 py-2"
>
No matches yet. Click “Get matches to pair members by profile
No matches yet. Click "Make New Matches" to pair members by profile
similarity.
</p>
<div
v-if="unmatchedMembers.length > 0"
class="mt-3 p-3 rounded border border-amber-200 bg-amber-50 text-sm"
>
<h4 class="font-medium text-amber-800 mb-1">
Not Paired ({{ unmatchedMembers.length }})
</h4>
<ul class="space-y-0.5">
<li
v-for="m in unmatchedMembers"
:key="m.did"
class="text-amber-700"
>
{{ m.name }} —
<span class="text-amber-600 italic">{{ m.reason }}</span>
</li>
</ul>
</div>
</div>
</div>
<!-- Pre-Match Confirmation Dialog -->
<div
v-if="showPreMatchConfirm"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-white rounded-lg p-6 max-w-md w-full max-h-[80vh] overflow-y-auto"
>
<h3 class="text-lg font-medium mb-4">Confirm Matching</h3>
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-1">
Will be matched ({{ includedMembers.length }})
</h4>
<div class="flex flex-wrap gap-1">
<span
v-for="member in includedMembers"
:key="member.did"
class="inline-block px-2 py-0.5 bg-green-100 text-green-800 rounded-full text-xs"
>
{{ member.name }}
</span>
<span
v-if="includedMembers.length === 0"
class="text-xs text-gray-400 italic"
>
No participants
</span>
</div>
</div>
<div v-if="excludedDids.length > 0" class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-1">
Excluded ({{ excludedDids.length }})
</h4>
<div class="flex flex-wrap gap-1">
<span
v-for="did in excludedDids"
:key="did"
class="inline-block px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs line-through"
>
{{ getMemberNameByDid(did) }}
</span>
</div>
</div>
<div
v-if="doNotPairGroups.some((g) => g.memberDids.length >= 2)"
class="mb-4"
>
<h4 class="text-sm font-medium text-gray-700 mb-1">
Do Not Pair Groups
</h4>
<div class="space-y-1">
<div
v-for="group in doNotPairGroups.filter(
(g) => g.memberDids.length >= 2,
)"
:key="group.id"
class="text-xs text-gray-600"
>
<span class="font-medium">{{
group.name || "Unnamed group"
}}</span
>:
{{
group.memberDids.map((d) => getMemberNameByDid(d)).join(", ")
}}
</div>
</div>
</div>
<div
v-if="previousMatchedPairs.length > 0"
class="mb-4 text-xs text-gray-500"
>
{{ previousMatchedPairs.length }} previous pair(s) will be avoided.
</div>
<div class="flex justify-between space-x-4 pt-2">
<button
class="px-4 py-2 bg-slate-500 text-white rounded hover:bg-slate-700"
@click="cancelPreMatchConfirm"
>
Cancel
</button>
<button
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
@click="confirmAndMatch"
>
Run Matching
</button>
</div>
</div>
</div>
@@ -433,6 +574,7 @@ import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import MeetingMembersList from "../components/MeetingMembersList.vue";
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
import MeetingExclusionGroups from "../components/MeetingExclusionGroups.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import {
@@ -455,7 +597,12 @@ import {
} from "@/constants/notifications";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
import { AxiosErrorResponse, MatchPair } from "@/interfaces";
import {
AxiosErrorResponse,
DoNotPairGroup,
MatchPair,
MeetingExclusionState,
} from "@/interfaces";
interface ServerMeeting {
groupId: number; // from the server
name: string; // to & from the server
@@ -479,6 +626,7 @@ interface MeetingSetupInputs {
TopMessage,
MeetingMembersList,
MeetingMemberMatch,
MeetingExclusionGroups,
MeetingProjectDialog,
ProjectIcon,
},
@@ -512,6 +660,10 @@ export default class OnboardMeetingView extends Vue {
/** Accumulated pair DIDs from every match run; sent as previousPairDids on future posts. */
previousMatchedPairs: [string, string][] = [];
excludedDids: string[] = [];
doNotPairGroups: DoNotPairGroup[] = [];
showPreMatchConfirm = false;
selectedProjectData: PlanData | null = null;
showDeleteConfirm = false;
@@ -521,6 +673,86 @@ export default class OnboardMeetingView extends Vue {
return this.formatDateForInput(now);
}
private static readonly EXCLUSION_STORAGE_KEY = "meeting-exclusion-state";
get meetingGroupIdStr(): string {
return this.currentMeeting?.groupId?.toString() || "";
}
get hasActiveMatches(): boolean {
return Array.isArray(this.matchPairs) && this.matchPairs.length > 0;
}
admittedMembers: Array<{ did: string; name: string }> = [];
get includedMembers(): Array<{ did: string; name: string }> {
return this.admittedMembers.filter(
(m) => !this.excludedDids.includes(m.did),
);
}
get unmatchedMembers(): Array<{ did: string; name: string; reason: string }> {
if (!this.matchPairs?.length || !this.admittedMembers.length) return [];
const matchedDids = new Set<string>();
for (const pair of this.matchPairs) {
for (const p of pair.participants) {
matchedDids.add(p.issuerDid);
}
}
return this.admittedMembers
.filter((m) => !matchedDids.has(m.did))
.map((m) => {
let reason = "not paired (odd number of participants)";
if (this.excludedDids.includes(m.did)) {
reason = "individually excluded";
} else {
const inGroup = this.doNotPairGroups.find((g) =>
g.memberDids.includes(m.did),
);
if (inGroup) {
reason = `in do-not-pair group "${inGroup.name || "Unnamed"}"`;
}
}
return { did: m.did, name: m.name, reason };
});
}
get excludedPairDids(): [string, string][] {
const pairs: [string, string][] = [];
for (const group of this.doNotPairGroups) {
for (let i = 0; i < group.memberDids.length; i++) {
for (let j = i + 1; j < group.memberDids.length; j++) {
pairs.push([group.memberDids[i], group.memberDids[j]]);
}
}
}
return pairs;
}
/**
* Computed property for selected project
* Returns the separately stored selected project data
*/
get selectedProject(): PlanData | null {
return this.selectedProjectData;
}
/**
* Computed property for selected project issuer display name
* Uses didInfo to format the issuer name similar to ProjectCard
*/
get selectedProjectIssuerName(): string {
if (!this.selectedProject) {
return "";
}
return didInfo(
this.selectedProject.issuerDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
async created() {
this.notify = createNotifyHelpers(
this.$notify as Parameters<typeof createNotifyHelpers>[0],
@@ -547,6 +779,7 @@ export default class OnboardMeetingView extends Vue {
await this.fetchMatchPairs();
}
this.loadExclusionState();
this.isLoading = false;
}
@@ -1051,14 +1284,51 @@ export default class OnboardMeetingView extends Vue {
}
}
promptPreMatchConfirm(): void {
this.refreshAdmittedMembers();
this.showPreMatchConfirm = true;
}
refreshAdmittedMembers(): void {
const membersList = this.$refs.membersList as MeetingMembersList;
this.admittedMembers = membersList?.getAdmittedMembers() ?? [];
}
cancelPreMatchConfirm(): void {
this.showPreMatchConfirm = false;
}
getMemberNameByDid(did: string): string {
const member = this.admittedMembers.find((m) => m.did === did);
return member?.name || did.substring(0, 16) + "…";
}
async confirmAndMatch(): Promise<void> {
this.showPreMatchConfirm = false;
await this.postNewMatchesThenRefresh();
}
async postNewMatchesThenRefresh(): Promise<void> {
this.isPostingMatch = true;
try {
const previousPairDids = this.previousMatchedPairs.length
? this.previousMatchedPairs
: undefined;
const body: {
excludedDids?: string[];
excludedPairDids?: [string, string][];
previousPairDids?: [string, string][];
} = {};
if (this.excludedDids.length > 0) {
body.excludedDids = this.excludedDids;
}
if (this.excludedPairDids.length > 0) {
body.excludedPairDids = this.excludedPairDids;
}
if (this.previousMatchedPairs.length > 0) {
body.previousPairDids = this.previousMatchedPairs;
}
const pairs = await this.postMatch(
previousPairDids ? { previousPairDids } : undefined,
Object.keys(body).length > 0 ? body : undefined,
);
if (Array.isArray(pairs) && pairs.length > 0) {
const tempMatchPairs: MatchPair[] = [];
@@ -1081,6 +1351,24 @@ export default class OnboardMeetingView extends Vue {
}
}
handleEraseClick(): void {
if (this.isPostingMatch) {
this.notify.warning(
"Matching is currently in progress. Please wait for it to finish.",
TIMEOUTS.LONG,
);
return;
}
if (!this.matchPairs?.length) {
this.notify.warning(
"There are no matches to erase. Run matching first to create pairs.",
TIMEOUTS.LONG,
);
return;
}
this.clearMatchesThenRefresh();
}
async clearMatchesThenRefresh(): Promise<void> {
try {
const headers = await getHeaders(this.activeDid);
@@ -1120,30 +1408,52 @@ export default class OnboardMeetingView extends Vue {
}
}
/**
* Computed property for selected project
* Returns the separately stored selected project data
*/
get selectedProject(): PlanData | null {
return this.selectedProjectData;
loadExclusionState(): void {
if (!this.meetingGroupIdStr) return;
try {
const raw = localStorage.getItem(
OnboardMeetingView.EXCLUSION_STORAGE_KEY,
);
if (!raw) return;
const state: MeetingExclusionState = JSON.parse(raw);
if (state.meetingGroupId !== this.meetingGroupIdStr) {
localStorage.removeItem(OnboardMeetingView.EXCLUSION_STORAGE_KEY);
return;
}
this.excludedDids = state.excludedDids || [];
this.doNotPairGroups = state.doNotPairGroups || [];
} catch {
this.excludedDids = [];
this.doNotPairGroups = [];
}
}
/**
* Computed property for selected project issuer display name
* Uses didInfo to format the issuer name similar to ProjectCard
*/
get selectedProjectIssuerName(): string {
if (!this.selectedProject) {
return "";
}
return didInfo(
this.selectedProject.issuerDid,
this.activeDid,
this.allMyDids,
this.allContacts,
saveExclusionState(): void {
if (!this.meetingGroupIdStr) return;
const state: MeetingExclusionState = {
meetingGroupId: this.meetingGroupIdStr,
excludedDids: this.excludedDids,
doNotPairGroups: this.doNotPairGroups,
};
localStorage.setItem(
OnboardMeetingView.EXCLUSION_STORAGE_KEY,
JSON.stringify(state),
);
}
toggleExclusion(did: string): void {
if (this.excludedDids.includes(did)) {
this.excludedDids = this.excludedDids.filter((d) => d !== did);
} else {
this.excludedDids = [...this.excludedDids, did];
}
this.saveExclusionState();
}
handleDoNotPairGroupsUpdate(): void {
this.saveExclusionState();
}
/**
* Open the project link selection dialog
*/