feat: implement member visibility dialog with checkbox selection and refresh #208

Merged
jose merged 9 commits from meeting-members-set-visibility into master 2025-10-21 08:52:15 +00:00
3 changed files with 366 additions and 340 deletions
Showing only changes of commit ea19195850 - Show all commits

View File

@@ -352,130 +352,6 @@
</div>
</div>
</div>
<div
v-if="notification.type === 'set-visibility-meeting-members'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
class="flex w-11/12 max-w-lg mx-auto my-4 overflow-y-scroll bg-white rounded-lg shadow-lg"
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<h3 class="text-lg font-semibold leading-[1.25] mb-2">
Set Visibility to Meeting Members
</h3>
<p class="text-sm mb-4">
Would you like to <b>make your activities visible</b> to the
following members? (This will also add them as contacts if
they aren't already.)
</p>
<!-- Custom table area - you can customize this -->
<div
v-if="shouldInitializeSelection(notification)"
class="mb-4"
>
<table
class="w-full border-collapse border border-slate-300 text-sm text-start"
>
<thead
v-if="
notification.membersData &&
notification.membersData.length > 0
"
>
<tr class="bg-slate-100 font-medium">
<th class="border border-slate-300 px-3 py-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isAllSelected(notification)"
:indeterminate="isIndeterminate(notification)"
@change="toggleSelectAll(notification)"
/>
Select All
</label>
</th>
</tr>
</thead>
<tbody>
<!-- Dynamic data from MembersList -->
<tr
v-if="
!notification.membersData ||
notification.membersData.length === 0
"
>
<td
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
>
No members need visibility settings
</td>
</tr>
<tr
v-for="member in notification.membersData || []"
:key="member.member.memberId"
>
<td class="border border-slate-300 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isMemberSelected(member.did)"
@change="toggleMemberSelection(member.did)"
/>
{{ member.name || SOMEONE_UNNAMED }}
</label>
<!-- Friend indicator - only show if they are already a contact -->
<font-awesome
v-if="member.isContact"
icon="user-circle"
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600"
@click="showContactInfo"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="space-y-2">
<button
v-if="
notification.membersData &&
notification.membersData.length > 0
"
:disabled="!hasSelectedMembers"
:class="[
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md',
hasSelectedMembers
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="
setVisibilityForSelectedMembers(notification);
closeDialog(notification, close);
"
>
Set Visibility
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="closeDialog(notification, close)"
>
{{
notification.membersData &&
notification.membersData.length > 0
? "Maybe Later"
: "Cancel"
}}
</button>
</div>
</div>
</div>
</div>
</div>
</Notification>
</div>
@@ -488,9 +364,6 @@ import { Vue, Component } from "vue-facing-decorator";
import { NotificationIface } from "./constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { logger } from "./utils/logger";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { setVisibilityUtil } from "./libs/endorserServer";
interface Settings {
notifyingNewActivityTime?: string;
@@ -505,12 +378,6 @@ export default class App extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
stopAsking = false;
selectedMembers: string[] = [];
selectionInitialized = false;
get hasSelectedMembers() {
return this.selectedMembers.length > 0;
}
activeDid = "";
apiServer = "";
@@ -525,201 +392,6 @@ export default class App extends Vue {
this.activeDid = activeIdentity.activeDid || "";
}

Since these are not used, I don't see any reason to add this section.

This might make these variables accessible in other components and your intent may be to load them in one place, which makes sense. However, I do see a few potential side-effects that I don't love:

  1. There are multiple DB "settings" loads in many of these places (one here and and another in each specific View), so we would want to remove all the other loads to avoid so many DB loads.

  2. If there are errors in the DB accesses, that shuts down the whole app, and I believe some pages like Help should have almost no logic so that people can get there even if errors happen elsewhere. (Maybe there are "catch" statements so that doesn't happen... I'd have to look.)

  3. The variables are a bit magic: when you're in another class, it's hard to know from where these values are loaded. (It may still be worthwhile if it saves user error in all these places. Will ponder.)

So I look forward to your thoughts... if you think it's worthwhile, let me know... and we'd want to work on issue #1 across the codebase, so it might be worthwhile in a separate PR.

Since these are not used, I don't see any reason to add this section. This might make these variables accessible in other components and your intent may be to load them in one place, which makes sense. However, I do see a few potential side-effects that I don't love: 1) There are multiple DB "settings" loads in many of these places (one here and and another in each specific View), so we would want to remove all the other loads to avoid so many DB loads. 2) If there are errors in the DB accesses, that shuts down the whole app, and I believe some pages like Help should have almost no logic so that people can get there even if errors happen elsewhere. (Maybe there are "catch" statements so that doesn't happen... I'd have to look.) 3) The variables are a bit magic: when you're in another class, it's hard to know from where these values are loaded. (It may still be worthwhile if it saves user error in all these places. Will ponder.) So I look forward to your thoughts... if you think it's worthwhile, let me know... and we'd want to work on issue #1 across the codebase, so it might be worthwhile in a separate PR.
Outdated
Review

Thanks for catching this. It's leftover code from when the rest of the logic was housed in here. I'll clean that up.

Thanks for catching this. It's leftover code from when the rest of the logic was housed in here. I'll clean that up.
isAllSelected(notification: NotificationIface) {
const membersData = notification?.membersData || [];
if (!membersData || membersData.length === 0) return false;
return membersData.every((member) =>
this.selectedMembers.includes(member.did),
);
}
isIndeterminate(notification: NotificationIface) {
const membersData = notification?.membersData || [];
if (!membersData || membersData.length === 0) return false;
const selectedCount = membersData.filter((member) =>
this.selectedMembers.includes(member.did),
).length;
return selectedCount > 0 && selectedCount < membersData.length;
}
toggleSelectAll(notification: NotificationIface) {
const membersData = notification?.membersData || [];
if (!membersData || membersData.length === 0) return;
if (this.isAllSelected(notification)) {
// Deselect all
this.selectedMembers = [];
} else {
// Select all
this.selectedMembers = membersData.map((member) => member.did);
}
}
toggleMemberSelection(memberDid: string) {
const index = this.selectedMembers.indexOf(memberDid);
if (index > -1) {
this.selectedMembers.splice(index, 1);
} else {
this.selectedMembers.push(memberDid);
}
}
isMemberSelected(memberDid: string) {
return this.selectedMembers.includes(memberDid);
}
shouldInitializeSelection(notification: NotificationIface) {
// This method will initialize selection when the dialog opens
if (
notification?.type === "set-visibility-meeting-members" &&
!this.selectionInitialized
) {
this.initializeSelection(notification);
this.selectionInitialized = true;
}
return true;
}
initializeSelection(notification: NotificationIface) {
// Reset selection when dialog opens
this.selectedMembers = [];
// Select all by default
const membersData = notification?.membersData || [];
this.selectedMembers = membersData.map((member) => member.did);
}
resetSelection() {
this.selectedMembers = [];
this.selectionInitialized = false;
}
async setVisibilityForSelectedMembers(notification: NotificationIface) {
try {
const membersData = notification?.membersData || [];
const selectedMembers = membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
let successCount = 0;
for (const member of selectedMembers) {
try {
// If they're not a contact yet, add them as a contact first
if (!member.isContact) {
await this.addAsContact(member);
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
successCount++;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set Successfully",
text: `Visibility set for ${successCount} member${successCount === 1 ? "" : "s"}.`,
},
5000,
);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error setting visibility:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to set visibility for some members. Please try again.",
},
5000,
);
}
}
async addAsContact(member: { did: string; name: string }) {
try {
const newContact = {
did: member.did,
name: member.name,
};
await this.$insertContact(newContact);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error adding contact:", err);
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
// Contact already exists, continue
} else {
throw err; // Re-throw if it's not a duplicate error
}
}
}
async updateContactVisibility(did: string, seesMe: boolean) {
try {
// Get the contact object
const contact = await this.$getContact(did);
if (!contact) {
throw new Error(`Contact not found for DID: ${did}`);
}
// Use the proper API to set visibility on the server
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
contact,
seesMe,
);
if (!result.success) {
throw new Error(result.error || "Failed to set visibility");
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error updating contact visibility:", err);
throw err;
}
}
async closeDialog(
notification: NotificationIface,
closeFn: (id: string) => void,
) {
this.resetSelection();
// Close the notification first
closeFn(notification.id);
// Then call the callback after a short delay to ensure dialog is closed
setTimeout(async () => {
if (notification.callback) {
await notification.callback();
}
}, 100);
}
showContactInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Info",
text: "This user is already your contact, but your activities are not visible to them yet.",
},
5000,
);
}
async turnOffNotifications(
notification: NotificationIface,
): Promise<boolean> {

View File

@@ -180,6 +180,15 @@
</p>
</div>
</div>
<!-- Set Visibility Dialog Component -->
<SetVisibilityDialog
:visible="showSetVisibilityDialog"
:members-data="visibilityDialogMembers"
:active-did="activeDid"
:api-server="apiServer"
@close="closeSetVisibilityDialog"
/>
</template>
<script lang="ts">
@@ -202,6 +211,7 @@ import {
NOTIFY_CONTINUE_WITHOUT_ADDING,
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import SetVisibilityDialog from "./SetVisibilityDialog.vue";
interface Member {
admitted: boolean;
@@ -217,6 +227,9 @@ interface DecryptedMember {
}
@Component({
components: {
SetVisibilityDialog,
},
mixins: [PlatformServiceMixin],
})
export default class MembersList extends Vue {
@@ -243,6 +256,15 @@ export default class MembersList extends Vue {
missingMyself = false;
activeDid = "";
apiServer = "";
// Set Visibility Dialog state
showSetVisibilityDialog = false;
visibilityDialogMembers: Array<{
did: string;
name: string;
isContact: boolean;
member: { memberId: string };
}> = [];
contacts: Array<Contact> = [];
// Auto-refresh functionality
@@ -573,18 +595,9 @@ export default class MembersList extends Vue {
// Filter members to show only those who need visibility set
const membersForVisibility = this.getMembersForVisibility();
this.$notify(
{
group: "modal",
type: "set-visibility-meeting-members",
membersData: membersForVisibility, // Pass the filtered members data
callback: async () => {
// Refresh data when dialog is closed (regardless of action taken)
await this.refreshData();
},
},
-1,
); // -1 means no auto-dismiss, stays open until user acts
// Open the dialog directly
this.visibilityDialogMembers = membersForVisibility;
this.showSetVisibilityDialog = true;
}
startAutoRefresh() {
@@ -629,6 +642,14 @@ export default class MembersList extends Vue {
this.startAutoRefresh();
}
// Set Visibility Dialog methods
closeSetVisibilityDialog() {
this.showSetVisibilityDialog = false;
this.visibilityDialogMembers = [];
// Refresh data when dialog is closed
this.refreshData();
}
beforeDestroy() {
this.stopAutoRefresh();
}

View File

@@ -0,0 +1,333 @@
<template>

I vote for one more descriptive word to say that this is a bulk action dialog... like SetBulkVisibilityDialog or SetMeetingVisibilityDialog or SetMultipleVisibilityDialog

I vote for one more descriptive word to say that this is a bulk action dialog... like SetBulkVisibilityDialog or SetMeetingVisibilityDialog or SetMultipleVisibilityDialog
Outdated
Review

SetBulkVisibilityDialog is perfect. I'll make the change.

`SetBulkVisibilityDialog` is perfect. I'll make the change.
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<div class="text-slate-900 text-center">
<h3 class="text-lg font-semibold leading-[1.25] mb-2">
Set Visibility to Meeting Members
</h3>
<p class="text-sm mb-4">
Would you like to <b>make your activities visible</b> to the following
members? (This will also add them as contacts if they aren't already.)
</p>
<!-- Custom table area - you can customize this -->
<div v-if="shouldInitializeSelection" class="mb-4">
<table
class="w-full border-collapse border border-slate-300 text-sm text-start"
>
<thead v-if="membersData && membersData.length > 0">
<tr class="bg-slate-100 font-medium">
<th class="border border-slate-300 px-3 py-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
/>
Select All
</label>
</th>
</tr>
</thead>
<tbody>
<!-- Dynamic data from MembersList -->
<tr v-if="!membersData || membersData.length === 0">
<td
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
>
No members need visibility settings
</td>
</tr>
<tr
v-for="member in membersData || []"
:key="member.member.memberId"
>
<td class="border border-slate-300 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isMemberSelected(member.did)"
@change="toggleMemberSelection(member.did)"
/>
{{ member.name || SOMEONE_UNNAMED }}
</label>
<!-- Friend indicator - only show if they are already a contact -->
<font-awesome
v-if="member.isContact"
icon="user-circle"
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600"
@click="showContactInfo"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="space-y-2">
<button
v-if="membersData && membersData.length > 0"
:disabled="!hasSelectedMembers"
:class="[
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md',
hasSelectedMembers
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="setVisibilityForSelectedMembers"
>
Set Visibility
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="cancel"
>
{{
membersData && membersData.length > 0 ? "Maybe Later" : "Cancel"
}}
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { setVisibilityUtil } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify";
interface MemberData {
did: string;
name: string;
isContact: boolean;
member: {
memberId: string;
};
}
@Component({
mixins: [PlatformServiceMixin],
})
export default class SetVisibilityDialog extends Vue {
@Prop({ default: false }) visible!: boolean;
@Prop({ default: () => [] }) membersData!: MemberData[];
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
// Vue notification system
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
// Notification system
notify!: ReturnType<typeof createNotifyHelpers>;
// Component state
selectedMembers: string[] = [];
selectionInitialized = false;
// Constants
// In Vue templates, imported constants need to be explicitly made available to the template
readonly SOMEONE_UNNAMED = SOMEONE_UNNAMED;
get hasSelectedMembers() {
return this.selectedMembers.length > 0;
}
get isAllSelected() {
if (!this.membersData || this.membersData.length === 0) return false;
return this.membersData.every((member) =>
this.selectedMembers.includes(member.did),
);
}
get isIndeterminate() {
if (!this.membersData || this.membersData.length === 0) return false;
const selectedCount = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
).length;
return selectedCount > 0 && selectedCount < this.membersData.length;
}
get shouldInitializeSelection() {
// This method will initialize selection when the dialog opens
if (!this.selectionInitialized) {
this.initializeSelection();
this.selectionInitialized = true;
}
return true;
}
created() {
this.notify = createNotifyHelpers(this.$notify);
}
initializeSelection() {
// Reset selection when dialog opens
this.selectedMembers = [];
// Select all by default
this.selectedMembers = this.membersData.map((member) => member.did);
}
resetSelection() {
this.selectedMembers = [];
this.selectionInitialized = false;
}
toggleSelectAll() {
if (!this.membersData || this.membersData.length === 0) return;
if (this.isAllSelected) {
// Deselect all
this.selectedMembers = [];
} else {
// Select all
this.selectedMembers = this.membersData.map((member) => member.did);
}
}
toggleMemberSelection(memberDid: string) {
const index = this.selectedMembers.indexOf(memberDid);
if (index > -1) {
this.selectedMembers.splice(index, 1);
} else {
this.selectedMembers.push(memberDid);
}
}
isMemberSelected(memberDid: string) {
return this.selectedMembers.includes(memberDid);
}
async setVisibilityForSelectedMembers() {
try {
const selectedMembers = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
let successCount = 0;
for (const member of selectedMembers) {
try {
// If they're not a contact yet, add them as a contact first
if (!member.isContact) {
await this.addAsContact(member);
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
successCount++;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set Successfully",
text: `Visibility set for ${successCount} member${successCount === 1 ? "" : "s"}.`,
},
5000,
);
// Emit success event
this.$emit("success", successCount);
this.close();
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error setting visibility:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to set visibility for some members. Please try again.",
},
5000,
);
}
}
async addAsContact(member: { did: string; name: string }) {
try {
const newContact = {
did: member.did,
name: member.name,
};
await this.$insertContact(newContact);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error adding contact:", err);
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
// Contact already exists, continue
} else {
throw err; // Re-throw if it's not a duplicate error
}
}
}
async updateContactVisibility(did: string, seesMe: boolean) {
try {
// Get the contact object
const contact = await this.$getContact(did);
if (!contact) {
throw new Error(`Contact not found for DID: ${did}`);
}
// Use the proper API to set visibility on the server
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
contact,
seesMe,
);
if (!result.success) {
throw new Error(result.error || "Failed to set visibility");
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error updating contact visibility:", err);
throw err;
}
}
showContactInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Info",
text: "This user is already your contact, but your activities are not visible to them yet.",
},
5000,
);
}
close() {
this.resetSelection();
this.$emit("close");
}
cancel() {
this.close();
}
}
</script>