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
4 changed files with 314 additions and 56 deletions
Showing only changes of commit 07b538cadc - Show all commits

View File

@@ -371,32 +371,65 @@
</p>
<!-- Custom table area - you can customize this -->
<div class="mb-4">
<div
v-if="shouldInitializeSelection(notification)"
class="mb-4"
>
<table
class="w-full border-collapse border border-slate-300 text-sm text-start"
>
<thead>
<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 />
<input
type="checkbox"
:checked="isAllSelected(notification)"
:indeterminate="isIndeterminate(notification)"

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.
@change="toggleSelectAll(notification)"
/>
Select All
</label>
</th>
</tr>
</thead>
<tbody>
<!-- Sample data - replace with your actual data -->
<tr>
<!-- 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 />
John Doe
<input
type="checkbox"
:checked="isMemberSelected(member.did)"
@change="toggleMemberSelection(member.did)"
/>
{{ member.name || SOMEONE_UNNAMED }}
</label>
<!-- Friend indicator -->
<!-- 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"
@@ -404,62 +437,40 @@
</div>
</td>
</tr>
<tr>
<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 />
Jane Smith
</label>
<!-- Friend indicator -->
<font-awesome
icon="user-circle"
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600"
@click="showContactInfo"
/>
</div>
</td>
</tr>
<tr>
<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 />
Jim Beam
</label>
</div>
</td>
</tr>
<tr>
<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 />
Susie Q
</label>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="space-y-2">
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md"
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="
notification.onYes ? notification.onYes() : null;
close(notification.id);
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="close(notification.id)"
@click="closeDialog(notification, close)"
>
Maybe Later
{{
notification.membersData &&
notification.membersData.length > 0
? "Maybe Later"
: "Cancel"
}}
</button>
</div>
</div>
@@ -477,6 +488,9 @@ 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;
@@ -491,6 +505,208 @@ 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 = "";
async created() {
// Initialize settings
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Get activeDid from active_identity table
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
}
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(

View File

@@ -67,6 +67,7 @@
</button>
<button
v-if="getMembersForVisibility().length > 0"
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
title="Set visibility for meeting members"
@click="showAddMembersNotification"
@@ -162,6 +163,7 @@
</button>
<button
v-if="getMembersForVisibility().length > 0"
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
title="Set visibility for meeting members"
@click="showAddMembersNotification"
@@ -264,6 +266,12 @@ export default class MembersList extends Vue {
await this.loadContacts();
}
async refreshData() {
// Force refresh both contacts and members
await this.loadContacts();
await this.fetchMembers();
}
async fetchMembers() {
try {
this.isLoading = true;
@@ -391,6 +399,28 @@ export default class MembersList extends Vue {
return this.contacts.find((contact) => contact.did === did);
}
getMembersForVisibility() {
return this.decryptedMembers
.filter((member) => {
// Exclude the current user
if (member.did === this.activeDid) {
return false;
}
const contact = this.getContactFor(member.did);
// Include members who:
// 1. Haven't been added as contacts yet, OR
// 2. Are contacts but don't have visibility set (seesMe property)
return !contact || !contact.seesMe;
})
.map((member) => ({
...member,
isContact: !!this.getContactFor(member.did),
contact: this.getContactFor(member.did),
}));
}
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(decrMember.did);
if (!decrMember.member.admitted && !contact) {
@@ -530,14 +560,17 @@ export default class MembersList extends Vue {
}
showAddMembersNotification() {
// Filter members to show only those who need visibility set
const membersForVisibility = this.getMembersForVisibility();
this.$notify(
{
group: "modal",
type: "set-visibility-meeting-members",
title: "Set Visibility for Meeting Members",
onYes: async () => {
// Handle the "Add Selected" action - you can implement the actual logic here
console.log("User confirmed adding selected members as contacts");
membersData: membersForVisibility, // Pass the filtered members data
callback: async () => {
// Refresh data when dialog is closed (regardless of action taken)
await this.refreshData();
},
},
-1,

View File

@@ -59,7 +59,7 @@ export const PASSKEYS_ENABLED =
export interface NotificationIface {
group: string; // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
title?: string;
text?: string;
callback?: (success: boolean) => Promise<void>; // if this triggered an action
noText?: string;
@@ -68,4 +68,11 @@ export interface NotificationIface {
onYes?: () => Promise<void>;
promptToStopAsking?: boolean;
yesText?: string;
membersData?: Array<{
member: { admitted: boolean; content: string; memberId: number };
name: string;
did: string;
isContact: boolean;
contact?: { did: string; name?: string; seesMe?: boolean };
}>; // For passing member data to visibility dialog
}

View File

@@ -60,7 +60,9 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2
2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1
1 0 011 1v4a1 1 0 001 1m-6 0h6"
></path>
</svg>
Go Home