Compare commits

...

28 Commits

Author SHA1 Message Date
Jose Olarte 3 4b1a724246 Merge pull request 'feat: meeting members admission dialog' (#210) from meeting-members-admission-dialog into master 4 days ago
Jose Olarte III d7db7731cf Merge branch 'master' into meeting-members-admission-dialog 4 days ago
Jose Olarte III 9628d5c8c6 refactor: move display text logic to BulkMembersDialog component 5 days ago
Jose Olarte III b37051f25d refactor: unify member dialogs into reusable BulkMembersDialog component 5 days ago
Jose Olarte III 7b87ab2a5c feat: add "Select All" footer to member selection dialogs 6 days ago
Jose Olarte III ca7ead224b fix: resolve PostCSS parsing error in FeedFilters.vue 6 days ago
Jose Olarte III bfc2f07326 fix: resolve admission status styling issues for non-organizers in MembersList 6 days ago
Jose Olarte III 562713d5a4 feat: hide contact instruction when no non-contact members exist 6 days ago
Jose Olarte III 8100ee5be4 refactor: optimize success message logic in AdmitPendingMembersDialog 6 days ago
Jose Olarte III 966ca8276d refactor: simplify pending members dialog description text 7 days ago
Jose Olarte III 27e38f583b feat: improve auto-refresh handling during member admission dialogs 7 days ago
Jose Olarte III 1e3ecf6d0f refactor: migrate dialog styles from scoped CSS to Tailwind utilities 7 days ago
Trent Larson e8e00d3eae refactor: remove mistakenly-committed file 1 week ago
Trent Larson 5c0ce2d1fb fix: linting 1 week ago
Trent Larson 9e1c267bc0 refactor: make the meeting member "set visibility" screen much like the organizer's "admit" screen 1 week ago
Trent Larson 723a0095a0 feat: prompt user if the pre-commit lint-fix changed anything 1 week ago
Trent Larson 9a94843b68 fix: linting 1 week ago
Trent Larson 9f3c62a29c test: trying the new pre-commit logic (with a bad linting change) 1 week ago
Trent Larson 39173a8db2 fix: linting 1 week ago
Trent Larson 7ea6a2ef69 refactor: simplify logic for opening onboarding dialogs 1 week ago
Trent Larson f0f0f1681e chore: move a variable into most local scope 1 week ago
Jose Olarte III 2f1eeb6700 fix: resolve duplicate names in Visibility dialog after Admit dialog 2 weeks ago
Jose Olarte III e048e4c86b fix: restrict pending member styling to organizers only 2 weeks ago
Jose Olarte III 16ed5131c4 feat: restrict dialog access based on user roles 2 weeks ago
Jose Olarte III ad51c187aa Update AdmitPendingMembersDialog.vue 2 weeks ago
Jose Olarte III 6fbc9c2a5b feat: Add AdmitPendingMembersDialog for bulk member admission 2 weeks ago
Jose Olarte III 035509224b feat: change icon for pending members 2 weeks ago
Jose Olarte III e9ea89edae feat: enhance members list UI with visual indicators and improved styling 2 weeks ago
  1. 34
      .husky/pre-commit
  2. 2
      src/assets/styles/tailwind.css
  3. 250
      src/components/BulkMembersDialog.vue
  4. 6
      src/components/FeedFilters.vue
  5. 645
      src/components/MembersList.vue
  6. 9
      src/interfaces/user.ts
  7. 6
      src/libs/fontawesome.ts
  8. 2
      src/views/OnboardMeetingListView.vue

34
.husky/pre-commit

@ -9,6 +9,10 @@ echo "🔍 Running pre-commit hooks..."
# Run lint-fix first # Run lint-fix first
echo "📝 Running lint-fix..." echo "📝 Running lint-fix..."
# Capture git status before lint-fix to detect changes
git_status_before=$(git status --porcelain)
npm run lint-fix || { npm run lint-fix || {
echo echo
echo "❌ Linting failed. Please fix the issues and try again." echo "❌ Linting failed. Please fix the issues and try again."
@ -18,6 +22,36 @@ npm run lint-fix || {
exit 1 exit 1
} }
# Check if lint-fix made any changes
git_status_after=$(git status --porcelain)
if [ "$git_status_before" != "$git_status_after" ]; then
echo
echo "⚠️ lint-fix made changes to your files!"
echo "📋 Changes detected:"
git diff --name-only
echo
echo "❓ What would you like to do?"
echo " [c] Continue commit without the new changes"
echo " [a] Abort commit (recommended - review and stage the changes)"
echo
printf "Choose [c/a]: "
# The `< /dev/tty` is necessary to make read work in git's non-interactive shell
read choice < /dev/tty
case $choice in
[Cc]* )
echo "✅ Continuing commit without lint-fix changes..."
sleep 3
;;
[Aa]* | * )
echo "🛑 Commit aborted. Please review the changes made by lint-fix."
echo "💡 You can stage the changes with 'git add .' and commit again."
exit 1
;;
esac
fi
# Then run Build Architecture Guard # Then run Build Architecture Guard
#echo "🏗️ Running Build Architecture Guard..." #echo "🏗️ Running Build Architecture Guard..."

2
src/assets/styles/tailwind.css

@ -38,7 +38,7 @@
} }
.dialog { .dialog {
@apply bg-white p-4 rounded-lg w-full max-w-lg; @apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[90%] overflow-y-auto;
} }
/* Markdown content styling to restore list elements */ /* Markdown content styling to restore list elements */

250
src/components/SetBulkVisibilityDialog.vue → src/components/BulkMembersDialog.vue

@ -3,18 +3,18 @@
<div class="dialog"> <div class="dialog">
<div class="text-slate-900 text-center"> <div class="text-slate-900 text-center">
<h3 class="text-lg font-semibold leading-[1.25] mb-2"> <h3 class="text-lg font-semibold leading-[1.25] mb-2">
Set Visibility to Meeting Members {{ title }}
</h3> </h3>
<p class="text-sm mb-4"> <p class="text-sm mb-4">
Would you like to <b>make your activities visible</b> to the following {{ description }}
members? (This will also add them as contacts if they aren't already.)
</p> </p>
<!-- Custom table area - you can customize this --> <!-- Member Selection Table -->
<div v-if="shouldInitializeSelection" class="mb-4"> <div class="mb-4">
<table <table
class="w-full border-collapse border border-slate-300 text-sm text-start" class="w-full border-collapse border border-slate-300 text-sm text-start"
> >
<!-- Select All Header -->
<thead v-if="membersData && membersData.length > 0"> <thead v-if="membersData && membersData.length > 0">
<tr class="bg-slate-100 font-medium"> <tr class="bg-slate-100 font-medium">
<th class="border border-slate-300 px-3 py-2"> <th class="border border-slate-300 px-3 py-2">
@ -31,14 +31,15 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- Dynamic data from MembersList --> <!-- Empty State -->
<tr v-if="!membersData || membersData.length === 0"> <tr v-if="!membersData || membersData.length === 0">
<td <td
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500" class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
> >
No members need visibility settings {{ emptyStateText }}
</td> </td>
</tr> </tr>
<!-- Member Rows -->
<tr <tr
v-for="member in membersData || []" v-for="member in membersData || []"
:key="member.member.memberId" :key="member.member.memberId"
@ -51,10 +52,24 @@
:checked="isMemberSelected(member.did)" :checked="isMemberSelected(member.did)"
@change="toggleMemberSelection(member.did)" @change="toggleMemberSelection(member.did)"
/> />
{{ member.name || SOMEONE_UNNAMED }} <div class="">
<div class="text-sm font-semibold">
{{ member.name || SOMEONE_UNNAMED }}
</div>
<div
class="flex items-center gap-0.5 text-xs text-slate-500"
>
<span class="font-semibold sm:hidden">DID:</span>
<span
class="w-[35vw] sm:w-auto truncate text-left"
style="direction: rtl"
>{{ member.did }}</span
>
</div>
</div>
</label> </label>
<!-- Friend indicator - only show if they are already a contact --> <!-- Contact indicator - only show if they are already a contact -->
<font-awesome <font-awesome
v-if="member.isContact" v-if="member.isContact"
icon="user-circle" icon="user-circle"
@ -65,10 +80,28 @@
</td> </td>
</tr> </tr>
</tbody> </tbody>
<!-- Select All Footer -->
<tfoot 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>
</tfoot>
</table> </table>
</div> </div>
<!-- Action Buttons -->
<div class="space-y-2"> <div class="space-y-2">
<!-- Main Action Button -->
<button <button
v-if="membersData && membersData.length > 0" v-if="membersData && membersData.length > 0"
:disabled="!hasSelectedMembers" :disabled="!hasSelectedMembers"
@ -78,17 +111,16 @@
? 'bg-blue-600 text-white cursor-pointer' ? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed', : 'bg-slate-400 text-slate-200 cursor-not-allowed',
]" ]"
@click="setVisibilityForSelectedMembers" @click="handleMainAction"
> >
Set Visibility {{ buttonText }}
</button> </button>
<!-- Cancel Button -->
<button <button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="cancel" @click="cancel"
> >
{{ Maybe Later
membersData && membersData.length > 0 ? "Maybe Later" : "Cancel"
}}
</button> </button>
</div> </div>
</div> </div>
@ -101,26 +133,19 @@ import { Vue, Component, Prop } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities"; import { SOMEONE_UNNAMED } from "@/constants/entities";
import { setVisibilityUtil } from "@/libs/endorserServer"; import { MemberData } from "@/interfaces";
import { setVisibilityUtil, getHeaders } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify"; import { createNotifyHelpers } from "@/utils/notify";
interface MemberData {
did: string;
name: string;
isContact: boolean;
member: {
memberId: string;
};
}
@Component({ @Component({
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
emits: ["close"],
}) })
export default class SetBulkVisibilityDialog extends Vue { export default class BulkMembersDialog extends Vue {
@Prop({ default: false }) visible!: boolean;
@Prop({ default: () => [] }) membersData!: MemberData[];
@Prop({ default: "" }) activeDid!: string; @Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string; @Prop({ default: "" }) apiServer!: string;
@Prop({ required: true }) dialogType!: "admit" | "visibility";
@Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system // Vue notification system
$notify!: ( $notify!: (
@ -132,8 +157,9 @@ export default class SetBulkVisibilityDialog extends Vue {
notify!: ReturnType<typeof createNotifyHelpers>; notify!: ReturnType<typeof createNotifyHelpers>;
// Component state // Component state
membersData: MemberData[] = [];
selectedMembers: string[] = []; selectedMembers: string[] = [];
selectionInitialized = false; visible = false;
// Constants // Constants
// In Vue templates, imported constants need to be explicitly made available to the template // In Vue templates, imported constants need to be explicitly made available to the template
@ -158,29 +184,46 @@ export default class SetBulkVisibilityDialog extends Vue {
return selectedCount > 0 && selectedCount < this.membersData.length; return selectedCount > 0 && selectedCount < this.membersData.length;
} }
get shouldInitializeSelection() { get title() {
// This method will initialize selection when the dialog opens return this.isOrganizer
if (!this.selectionInitialized) { ? "Admit Pending Members"
this.initializeSelection(); : "Add Members to Contacts";
this.selectionInitialized = true; }
}
return true; get description() {
return this.isOrganizer
? "Would you like to admit these members to the meeting and add them to your contacts?"
: "Would you like to add these members to your contacts?";
}
get buttonText() {
return this.isOrganizer ? "Admit + Add to Contacts" : "Add to Contacts";
}
get emptyStateText() {
return this.isOrganizer
? "No pending members to admit"
: "No members are not in your contacts";
} }
created() { created() {
this.notify = createNotifyHelpers(this.$notify); this.notify = createNotifyHelpers(this.$notify);
} }
initializeSelection() { open(members: MemberData[]) {
// Reset selection when dialog opens this.visible = true;
this.selectedMembers = []; this.membersData = members;
// Select all by default // Select all by default
this.selectedMembers = this.membersData.map((member) => member.did); this.selectedMembers = this.membersData.map((member) => member.did);
} }
resetSelection() { close(notSelectedMemberDids: string[]) {
this.selectedMembers = []; this.visible = false;
this.selectionInitialized = false; this.$emit("close", { notSelectedMemberDids: notSelectedMemberDids });
}
cancel() {
this.close(this.membersData.map((member) => member.did));
} }
toggleSelectAll() { toggleSelectAll() {
@ -208,25 +251,95 @@ export default class SetBulkVisibilityDialog extends Vue {
return this.selectedMembers.includes(memberDid); return this.selectedMembers.includes(memberDid);
} }
async setVisibilityForSelectedMembers() { async handleMainAction() {
if (this.dialogType === "admit") {
await this.admitWithVisibility();
} else {
await this.addContactWithVisibility();
}
}
async admitWithVisibility() {
try { try {
const selectedMembers = this.membersData.filter((member) => const selectedMembers = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did), this.selectedMembers.includes(member.did),
); );
const notSelectedMembers = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let successCount = 0; let admittedCount = 0;
let contactAddedCount = 0;
for (const member of selectedMembers) { for (const member of selectedMembers) {
try { try {
// If they're not a contact yet, add them as a contact first // First, admit the member
await this.admitMember(member);
admittedCount++;
// If they're not a contact yet, add them as a contact
if (!member.isContact) { if (!member.isContact) {
await this.addAsContact(member); await this.addAsContact(member);
contactAddedCount++;
} }
// Set their seesMe to true // Set their seesMe to true
await this.updateContactVisibility(member.did, true); await this.updateContactVisibility(member.did, true);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
successCount++; // Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
},
10000,
);
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to admit some members. Please try again.",
},
5000,
);
}
}
async addContactWithVisibility() {
try {
const selectedMembers = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let contactsAddedCount = 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);
contactsAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error); console.error(`Error processing member ${member.did}:`, error);
@ -239,30 +352,47 @@ export default class SetBulkVisibilityDialog extends Vue {
{ {
group: "alert", group: "alert",
type: "success", type: "success",
title: "Visibility Set Successfully", title: "Contacts Added Successfully",
text: `Visibility set for ${successCount} member${successCount === 1 ? "" : "s"}.`, text: `${contactsAddedCount} member${contactsAddedCount === 1 ? "" : "s"} added as contact${contactsAddedCount === 1 ? "" : "s"}.`,
}, },
5000, 5000,
); );
// Emit success event this.close(notSelectedMembers.map((member) => member.did));
this.$emit("success", successCount);
this.close();
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error("Error setting visibility:", error); console.error("Error adding contacts:", error);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "Failed to set visibility for some members. Please try again.", text: "Failed to add some members as contacts. Please try again.",
}, },
5000, 5000,
); );
} }
} }
async admitMember(member: {
did: string;
name: string;
member: { memberId: string };
}) {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${member.member.memberId}`,
{ admitted: true },
{ headers },
);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error admitting member:", err);
throw err;
}
}
async addAsContact(member: { did: string; name: string }) { async addAsContact(member: { did: string; name: string }) {
try { try {
const newContact = { const newContact = {
@ -310,24 +440,20 @@ export default class SetBulkVisibilityDialog extends Vue {
} }
showContactInfo() { showContactInfo() {
const message =
this.dialogType === "admit"
? "This user is already your contact, but they are not yet admitted to the meeting."
: "This user is already your contact, but your activities are not visible to them yet.";
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Contact Info", title: "Contact Info",
text: "This user is already your contact, but your activities are not visible to them yet.", text: message,
}, },
5000, 5000,
); );
} }
close() {
this.resetSelection();
this.$emit("close");
}
cancel() {
this.close();
}
} }
</script> </script>

6
src/components/FeedFilters.vue

@ -211,8 +211,6 @@ export default class FeedFilters extends Vue {
} }
</script> </script>
<style> <style scoped>
#dialogFeedFilters.dialog-overlay { /* Component-specific styles if needed */
overflow: scroll;
}
</style> </style>

645
src/components/MembersList.vue

@ -1,197 +1,235 @@
<template> <template>
<div class="space-y-4"> <div>
<!-- Loading State --> <div class="space-y-4">
<div <!-- Loading State -->
v-if="isLoading" <div
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto" v-if="isLoading"
> class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
<font-awesome icon="spinner" class="fa-spin-pulse" /> >
</div> <font-awesome icon="spinner" class="fa-spin-pulse" />
<!-- Members List -->
<div v-else>
<div class="text-center text-red-600 my-4">
{{ decryptionErrorMessage() }}
</div>
<div v-if="missingMyself" class="py-4 text-red-600">
You are not currently admitted by the organizer.
</div>
<div v-if="!firstName" class="py-4 text-red-600">
Your name is not set, so others may not recognize you. Reload this page
to set it.
</div> </div>
<ul class="list-disc text-sm ps-4 space-y-2 mb-4"> <!-- Members List -->
<li
v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer" <div v-else>
> <div class="text-center text-red-600 my-4">
Click {{ decryptionErrorMessage() }}
<span </div>
class="inline-block w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-center"
<div v-if="missingMyself" class="py-4 text-red-600">
You are not currently admitted by the organizer.
</div>
<div v-if="!firstName" class="py-4 text-red-600">
Your name is not set, so others may not recognize you. Reload this
page to set it.
</div>
<ul class="list-disc text-sm ps-4 space-y-2 mb-4">
<li
v-if="
membersToShow().length > 0 && showOrganizerTools && isOrganizer
"
> >
<font-awesome icon="plus" class="text-sm" /> Click
</span> <font-awesome icon="circle-plus" class="text-blue-500 text-sm" />
/ /
<span <font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
class="inline-block w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-center" to add/remove them to/from the meeting.
</li>
<li
v-if="
membersToShow().length > 0 && getNonContactMembers().length > 0
"
> >
<font-awesome icon="minus" class="text-sm" /> Click
</span> <font-awesome icon="circle-user" class="text-green-600 text-sm" />
to add/remove them to/from the meeting. to add them to your contacts.
</li> </li>
<li v-if="membersToShow().length > 0"> </ul>
Click
<span <div class="flex justify-between">
class="inline-block w-5 h-5 rounded-full bg-green-100 text-green-600 text-center" <!--
>
<font-awesome icon="circle-user" class="text-sm" />
</span>
to add them to your contacts.
</li>
</ul>
<div class="flex justify-between">
<!--
always have at least one refresh button even without members in case the organizer always have at least one refresh button even without members in case the organizer
changes the password changes the password
--> -->
<button <button
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" 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="Refresh members list now" title="Refresh members list now"
@click="manualRefresh" @click="refreshData(false)"
> >
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" /> <font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh Refresh
<span class="text-xs">({{ countdownTimer }}s)</span> <span class="text-xs">({{ countdownTimer }}s)</span>
</button> </button>
</div> </div>
<ul <ul
v-if="membersToShow().length > 0" v-if="membersToShow().length > 0"
class="border-t border-slate-300 my-2" class="border-t border-slate-300 my-2"
>
<li
v-for="member in membersToShow()"
:key="member.member.memberId"
class="border-b border-slate-300 py-1.5"
> >
<div class="flex items-center gap-2 justify-between"> <li
<div class="flex items-center gap-1 overflow-hidden"> v-for="member in membersToShow()"
<h3 class="font-semibold truncate"> :key="member.member.memberId"
{{ member.name || unnamedMember }} :class="[
</h3> 'border-b px-2 sm:px-3 py-1.5',
<div {
v-if="!getContactFor(member.did) && member.did !== activeDid" 'bg-blue-50 border-t border-blue-300 -mt-[1px]':
class="flex items-center gap-1" !member.member.admitted &&
(isOrganizer || member.did === activeDid),
},
{ 'border-slate-300': member.member.admitted },
]"
>
<div class="flex items-center gap-2 justify-between">
<div class="flex items-center gap-1 overflow-hidden">
<h3
:class="[
'font-semibold truncate',
{
'text-slate-500':
!member.member.admitted &&
(isOrganizer || member.did === activeDid),
},
]"
>
<font-awesome
v-if="member.member.memberId === members[0]?.memberId"
icon="crown"
class="fa-fw text-amber-400"
/>
<font-awesome
v-if="member.did === activeDid"
icon="hand"
class="fa-fw text-blue-500"
/>
<font-awesome
v-if="
!member.member.admitted &&
(isOrganizer || member.did === activeDid)
"
icon="hourglass-half"
class="fa-fw text-slate-400"
/>
{{ member.name || unnamedMember }}
</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex items-center gap-1.5 ms-1"
>
<button
class="btn-add-contact"
title="Add as contact"
@click="addAsContact(member)"
>
<font-awesome icon="circle-user" />
</button>
<button
class="btn-info-contact"
title="Contact Info"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
>
<font-awesome icon="circle-info" />
</button>
</div>
</div>
<span
v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid
"
class="flex items-center gap-1.5"
> >
<button <button
class="btn-add-contact" :class="
title="Add as contact" member.member.admitted
@click="addAsContact(member)" ? 'btn-admission-remove'
: 'btn-admission-add'
"
:title="
member.member.admitted ? 'Remove member' : 'Admit member'
"
@click="checkWhetherContactBeforeAdmitting(member)"
> >
<font-awesome icon="circle-user" /> <font-awesome
:icon="
member.member.admitted ? 'circle-minus' : 'circle-plus'
"
/>
</button> </button>
<button <button
class="btn-info-contact" class="btn-info-admission"
title="Contact Info" title="Admission Info"
@click=" @click="informAboutAdmission()"
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
> >
<font-awesome icon="circle-info" class="text-sm" /> <font-awesome icon="circle-info" />
</button> </button>
</div> </span>
</div> </div>
<span <p class="text-xs text-gray-600 truncate">
v-if=" {{ member.did }}
showOrganizerTools && isOrganizer && member.did !== activeDid </p>
" </li>
class="flex items-center gap-1" </ul>
>
<button <div v-if="membersToShow().length > 0" class="flex justify-between">
class="btn-admission" <!--
:title="
member.member.admitted ? 'Remove member' : 'Admit member'
"
@click="checkWhetherContactBeforeAdmitting(member)"
>
<font-awesome
:icon="member.member.admitted ? 'minus' : 'plus'"
/>
</button>
<button
class="btn-info-admission"
title="Admission Info"
@click="informAboutAdmission()"
>
<font-awesome icon="circle-info" class="text-sm" />
</button>
</span>
</div>
<p class="text-xs text-gray-600 truncate">
{{ member.did }}
</p>
</li>
</ul>
<div v-if="membersToShow().length > 0" class="flex justify-between">
<!--
always have at least one refresh button even without members in case the organizer always have at least one refresh button even without members in case the organizer
changes the password changes the password
--> -->
<button <button
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" 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="Refresh members list now" title="Refresh members list now"
@click="manualRefresh" @click="refreshData(false)"
> >
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" /> <font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh Refresh
<span class="text-xs">({{ countdownTimer }}s)</span> <span class="text-xs">({{ countdownTimer }}s)</span>
</button> </button>
</div>
<p v-if="members.length === 0" class="text-gray-500 py-4">
No members have joined this meeting yet
</p>
</div> </div>
<p v-if="members.length === 0" class="text-gray-500 py-4">
No members have joined this meeting yet
</p>
</div> </div>
</div>
<!-- Set Visibility Dialog Component --> <!-- Bulk Members Dialog for both admitting and setting visibility -->
<SetBulkVisibilityDialog <BulkMembersDialog
:visible="showSetVisibilityDialog" ref="bulkMembersDialog"
:members-data="visibilityDialogMembers" :active-did="activeDid"
:active-did="activeDid" :api-server="apiServer"
:api-server="apiServer" :dialog-type="isOrganizer ? 'admit' : 'visibility'"
@close="closeSetVisibilityDialog" :is-organizer="isOrganizer"
/> @close="closeBulkMembersDialogCallback"
/>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop, Emit } from "vue-facing-decorator"; import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import {
NOTIFY_ADD_CONTACT_FIRST,
NOTIFY_CONTINUE_WITHOUT_ADDING,
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { import {
errorStringForLog, errorStringForLog,
getHeaders, getHeaders,
register, register,
serverMessageForUser, serverMessageForUser,
} from "../libs/endorserServer"; } from "@/libs/endorserServer";
import { decryptMessage } from "../libs/crypto"; import { decryptMessage } from "@/libs/crypto";
import { Contact } from "../db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import * as libsUtil from "../libs/util"; import { MemberData } from "@/interfaces";
import { NotificationIface } from "../constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { import BulkMembersDialog from "./BulkMembersDialog.vue";
NOTIFY_ADD_CONTACT_FIRST,
NOTIFY_CONTINUE_WITHOUT_ADDING,
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import SetBulkVisibilityDialog from "./SetBulkVisibilityDialog.vue";
interface Member { interface Member {
admitted: boolean; admitted: boolean;
@ -208,7 +246,7 @@ interface DecryptedMember {
@Component({ @Component({
components: { components: {
SetBulkVisibilityDialog, BulkMembersDialog,
}, },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
@ -216,7 +254,6 @@ export default class MembersList extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
notify!: ReturnType<typeof createNotifyHelpers>; notify!: ReturnType<typeof createNotifyHelpers>;
libsUtil = libsUtil;
@Prop({ required: true }) password!: string; @Prop({ required: true }) password!: string;
@Prop({ default: false }) showOrganizerTools!: boolean; @Prop({ default: false }) showOrganizerTools!: boolean;
@ -227,6 +264,7 @@ export default class MembersList extends Vue {
return message; return message;
} }
contacts: Array<Contact> = [];
decryptedMembers: DecryptedMember[] = []; decryptedMembers: DecryptedMember[] = [];
firstName = ""; firstName = "";
isLoading = true; isLoading = true;
@ -237,23 +275,11 @@ export default class MembersList extends Vue {
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
// Set Visibility Dialog state
showSetVisibilityDialog = false;
visibilityDialogMembers: Array<{
did: string;
name: string;
isContact: boolean;
member: { memberId: string };
}> = [];
contacts: Array<Contact> = [];
// Auto-refresh functionality // Auto-refresh functionality
countdownTimer = 10; countdownTimer = 10;
autoRefreshInterval: NodeJS.Timeout | null = null; autoRefreshInterval: NodeJS.Timeout | null = null;
lastRefreshTime = 0; lastRefreshTime = 0;
previousMemberDidsIgnored: string[] = [];
// Track previous visibility members to detect changes
previousVisibilityMembers: string[] = [];
/** /**
* Get the unnamed member constant * Get the unnamed member constant
@ -274,23 +300,8 @@ export default class MembersList extends Vue {
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || ""; this.firstName = settings.firstName || "";
await this.fetchMembers();
await this.loadContacts();
// Start auto-refresh
this.startAutoRefresh();
// Check if we should show the visibility dialog on initial load
this.checkAndShowVisibilityDialog();
}
async refreshData() {
// Force refresh both contacts and members
await this.loadContacts();
await this.fetchMembers();
// Check if we should show the visibility dialog after refresh this.refreshData();
this.checkAndShowVisibilityDialog();
} }
async fetchMembers() { async fetchMembers() {
@ -336,7 +347,10 @@ export default class MembersList extends Vue {
const content = JSON.parse(decryptedContent); const content = JSON.parse(decryptedContent);
this.decryptedMembers.push({ this.decryptedMembers.push({
member: member, member: {
...member,
admitted: member.admitted !== undefined ? member.admitted : true, // Default to true for non-organizers
},
name: content.name, name: content.name,
did: content.did, did: content.did,
isRegistered: !!content.isRegistered, isRegistered: !!content.isRegistered,
@ -378,17 +392,76 @@ export default class MembersList extends Vue {
} }
membersToShow(): DecryptedMember[] { membersToShow(): DecryptedMember[] {
let members: DecryptedMember[] = [];
if (this.isOrganizer) { if (this.isOrganizer) {
if (this.showOrganizerTools) { if (this.showOrganizerTools) {
return this.decryptedMembers; members = this.decryptedMembers;
} else { } else {
return this.decryptedMembers.filter( members = this.decryptedMembers.filter(
(member: DecryptedMember) => member.member.admitted, (member: DecryptedMember) => member.member.admitted,
); );
} }
} else {
// non-organizers only get visible members from server, plus themselves
// Check if current user is already in the decrypted members list
if (
!this.decryptedMembers.find((member) => member.did === this.activeDid)
) {
// this is a stub for this user just in case they are waiting to get in
// which is especially useful so they can see their own DID
const currentUser: DecryptedMember = {
member: {
admitted: false,
content: "{}",
memberId: -1,
},
name: this.firstName,
did: this.activeDid,
isRegistered: false,
};
members = [currentUser, ...this.decryptedMembers];
} else {
members = this.decryptedMembers;
}
} }
// non-organizers only get visible members from server
return this.decryptedMembers; // Sort members according to priority:
// 1. Organizer at the top
// 2. Current user next
// 3. Non-admitted members next
// 4. Everyone else after
return members.sort((a, b) => {
// Check if either member is the organizer (first member in original list)
const aIsOrganizer = a.member.memberId === this.members[0]?.memberId;
const bIsOrganizer = b.member.memberId === this.members[0]?.memberId;
// Check if either member is the current user
const aIsCurrentUser = a.did === this.activeDid;
const bIsCurrentUser = b.did === this.activeDid;
// Organizer always comes first
if (aIsOrganizer && !bIsOrganizer) return -1;
if (!aIsOrganizer && bIsOrganizer) return 1;
// If both are organizers, maintain original order
if (aIsOrganizer && bIsOrganizer) return 0;
// Current user comes second (after organizer)
if (aIsCurrentUser && !bIsCurrentUser && !bIsOrganizer) return -1;
if (!aIsCurrentUser && bIsCurrentUser && !aIsOrganizer) return 1;
// If both are current users, maintain original order
if (aIsCurrentUser && bIsCurrentUser) return 0;
// Non-admitted members come before admitted members
if (!a.member.admitted && b.member.admitted) return -1;
if (a.member.admitted && !b.member.admitted) return 1;
// If admission status is the same, maintain original order
return 0;
});
} }
informAboutAdmission() { informAboutAdmission() {
@ -412,92 +485,85 @@ export default class MembersList extends Vue {
} }
} }
async loadContacts() {
this.contacts = await this.$getAllContacts();
}
getContactFor(did: string): Contact | undefined { getContactFor(did: string): Contact | undefined {
return this.contacts.find((contact) => contact.did === did); return this.contacts.find((contact) => contact.did === did);
} }
getMembersForVisibility() { getPendingMembersToAdmit(): MemberData[] {
return this.decryptedMembers return this.decryptedMembers
.filter((member) => { .filter(
// Exclude the current user (member) => member.did !== this.activeDid && !member.member.admitted,
if (member.did === this.activeDid) { )
return false; .map(this.convertDecryptedMemberToMemberData);
} }
const contact = this.getContactFor(member.did); getNonContactMembers(): MemberData[] {
return this.decryptedMembers
.filter(
(member) =>
member.did !== this.activeDid && !this.getContactFor(member.did),
)
.map(this.convertDecryptedMemberToMemberData);
}
// Include members who: convertDecryptedMemberToMemberData(
// 1. Haven't been added as contacts yet, OR decryptedMember: DecryptedMember,
// 2. Are contacts but don't have visibility set (seesMe property) ): MemberData {
return !contact || !contact.seesMe; return {
}) did: decryptedMember.did,
.map((member) => ({ name: decryptedMember.name,
did: member.did, isContact: !!this.getContactFor(decryptedMember.did),
name: member.name, member: {
isContact: !!this.getContactFor(member.did), memberId: decryptedMember.member.memberId.toString(),
member: { },
memberId: member.member.memberId.toString(), };
},
}));
} }
/** /**
* Check if we should show the visibility dialog * Show the bulk members dialog if conditions are met
* Returns true if there are members for visibility and either: * (admit pending members for organizers, add to contacts for non-organizers)
* - This is the first time (no previous members tracked), OR
* - New members have been added since last check (not removed)
*/ */
shouldShowVisibilityDialog(): boolean { async refreshData(bypassPromptIfAllWereIgnored = true) {
const currentMembers = this.getMembersForVisibility(); // Force refresh both contacts and members
this.contacts = await this.$getAllContacts();
await this.fetchMembers();
if (currentMembers.length === 0) { const pendingMembers = this.isOrganizer
return false; ? this.getPendingMembersToAdmit()
: this.getNonContactMembers();
if (pendingMembers.length === 0) {
this.startAutoRefresh();
return;
} }
if (bypassPromptIfAllWereIgnored) {
// If no previous members tracked, show dialog // only show if there are members that have not been ignored
if (this.previousVisibilityMembers.length === 0) { const pendingMembersNotIgnored = pendingMembers.filter(
return true; (member) => !this.previousMemberDidsIgnored.includes(member.did),
);
if (pendingMembersNotIgnored.length === 0) {
this.startAutoRefresh();
// everyone waiting has been ignored
return;
}
} }
this.stopAutoRefresh();
// Check if new members have been added (not just any change) (this.$refs.bulkMembersDialog as BulkMembersDialog).open(pendingMembers);
const currentMemberIds = currentMembers.map((m) => m.did);
const previousMemberIds = this.previousVisibilityMembers;
// Find new members (members in current but not in previous)
const newMembers = currentMemberIds.filter(
(id) => !previousMemberIds.includes(id),
);
// Only show dialog if there are new members added
return newMembers.length > 0;
} }
/** // Bulk Members Dialog methods
* Update the tracking of previous visibility members async closeBulkMembersDialogCallback(
*/ result: { notSelectedMemberDids: string[] } | undefined,
updatePreviousVisibilityMembers() { ) {
const currentMembers = this.getMembersForVisibility(); this.previousMemberDidsIgnored = result?.notSelectedMemberDids || [];
this.previousVisibilityMembers = currentMembers.map((m) => m.did);
}
/** await this.refreshData();
* Show the visibility dialog if conditions are met
*/
checkAndShowVisibilityDialog() {
if (this.shouldShowVisibilityDialog()) {
this.showSetBulkVisibilityDialog();
}
this.updatePreviousVisibilityMembers();
} }
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) { checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(decrMember.did); const contact = this.getContactFor(decrMember.did);
if (!decrMember.member.admitted && !contact) { if (!decrMember.member.admitted && !contact) {
// If not a contact, show confirmation dialog // If not a contact, stop auto-refresh and show confirmation dialog
this.stopAutoRefresh();
this.$notify( this.$notify(
{ {
group: "modal", group: "modal",
@ -510,6 +576,7 @@ export default class MembersList extends Vue {
await this.addAsContact(decrMember); await this.addAsContact(decrMember);
// After adding as contact, proceed with admission // After adding as contact, proceed with admission
await this.toggleAdmission(decrMember); await this.toggleAdmission(decrMember);
this.startAutoRefresh();
}, },
onNo: async () => { onNo: async () => {
// If they choose not to add as contact, show second confirmation // If they choose not to add as contact, show second confirmation
@ -522,14 +589,19 @@ export default class MembersList extends Vue {
yesText: NOTIFY_CONTINUE_WITHOUT_ADDING.yesText, yesText: NOTIFY_CONTINUE_WITHOUT_ADDING.yesText,
onYes: async () => { onYes: async () => {
await this.toggleAdmission(decrMember); await this.toggleAdmission(decrMember);
this.startAutoRefresh();
}, },
onCancel: async () => { onCancel: async () => {
// Do nothing, effectively canceling the operation // Do nothing, effectively canceling the operation
this.startAutoRefresh();
}, },
}, },
TIMEOUTS.MODAL, TIMEOUTS.MODAL,
); );
}, },
onCancel: async () => {
this.startAutoRefresh();
},
}, },
TIMEOUTS.MODAL, TIMEOUTS.MODAL,
); );
@ -632,19 +704,8 @@ export default class MembersList extends Vue {
} }
} }
showSetBulkVisibilityDialog() {
// Filter members to show only those who need visibility set
const membersForVisibility = this.getMembersForVisibility();
// Pause auto-refresh when dialog opens
this.stopAutoRefresh();
// Open the dialog directly
this.visibilityDialogMembers = membersForVisibility;
this.showSetVisibilityDialog = true;
}
startAutoRefresh() { startAutoRefresh() {
this.stopAutoRefresh();
this.lastRefreshTime = Date.now(); this.lastRefreshTime = Date.now();
this.countdownTimer = 10; this.countdownTimer = 10;
@ -674,33 +735,6 @@ export default class MembersList extends Vue {
} }
} }
manualRefresh() {
// Clear existing auto-refresh interval
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
// Trigger immediate refresh and restart timer
this.refreshData();
this.startAutoRefresh();
// Always show dialog on manual refresh if there are members for visibility
if (this.getMembersForVisibility().length > 0) {
this.showSetBulkVisibilityDialog();
}
}
// Set Visibility Dialog methods
closeSetVisibilityDialog() {
this.showSetVisibilityDialog = false;
this.visibilityDialogMembers = [];
// Refresh data when dialog is closed
this.refreshData();
// Resume auto-refresh when dialog is closed
this.startAutoRefresh();
}
beforeDestroy() { beforeDestroy() {
this.stopAutoRefresh(); this.stopAutoRefresh();
} }
@ -718,23 +752,26 @@ export default class MembersList extends Vue {
.btn-add-contact { .btn-add-contact {
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */
@apply w-6 h-6 flex items-center justify-center rounded-full @apply text-lg text-green-600 hover:text-green-800
bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800
transition-colors; transition-colors;
} }
.btn-info-contact, .btn-info-contact,
.btn-info-admission { .btn-info-admission {
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */
@apply w-6 h-6 flex items-center justify-center rounded-full @apply text-slate-400 hover:text-slate-600
bg-slate-100 text-slate-400 hover:text-slate-600
transition-colors; transition-colors;
} }
.btn-admission { .btn-admission-add {
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */
@apply w-6 h-6 flex items-center justify-center rounded-full @apply text-lg text-blue-500 hover:text-blue-700
bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors;
}
.btn-admission-remove {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply text-lg text-rose-500 hover:text-rose-700
transition-colors; transition-colors;
} }
</style> </style>

9
src/interfaces/user.ts

@ -6,3 +6,12 @@ export interface UserInfo {
profileImageUrl?: string; profileImageUrl?: string;
nextPublicEncKeyHash?: string; nextPublicEncKeyHash?: string;
} }
export interface MemberData {
did: string;
name: string;
isContact: boolean;
member: {
memberId: string;
};
}

6
src/libs/fontawesome.ts

@ -29,6 +29,7 @@ import {
faCircle, faCircle,
faCircleCheck, faCircleCheck,
faCircleInfo, faCircleInfo,
faCircleMinus,
faCirclePlus, faCirclePlus,
faCircleQuestion, faCircleQuestion,
faCircleRight, faCircleRight,
@ -37,6 +38,7 @@ import {
faCoins, faCoins,
faComment, faComment,
faCopy, faCopy,
faCrown,
faDollar, faDollar,
faDownload, faDownload,
faEllipsis, faEllipsis,
@ -58,6 +60,7 @@ import {
faHand, faHand,
faHandHoldingDollar, faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHourglassHalf,
faHouseChimney, faHouseChimney,
faImage, faImage,
faImagePortrait, faImagePortrait,
@ -123,6 +126,7 @@ library.add(
faCircle, faCircle,
faCircleCheck, faCircleCheck,
faCircleInfo, faCircleInfo,
faCircleMinus,
faCirclePlus, faCirclePlus,
faCircleQuestion, faCircleQuestion,
faCircleRight, faCircleRight,
@ -131,6 +135,7 @@ library.add(
faCoins, faCoins,
faComment, faComment,
faCopy, faCopy,
faCrown,
faDollar, faDollar,
faDownload, faDownload,
faEllipsis, faEllipsis,
@ -152,6 +157,7 @@ library.add(
faHand, faHand,
faHandHoldingDollar, faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHourglassHalf,
faHouseChimney, faHouseChimney,
faImage, faImage,
faImagePortrait, faImagePortrait,

2
src/views/OnboardMeetingListView.vue

@ -77,7 +77,7 @@
v-if="meetings.length === 0 && !isRegistered" v-if="meetings.length === 0 && !isRegistered"
class="text-center text-gray-500 py-8" class="text-center text-gray-500 py-8"
> >
No onboarding meetings available No onboarding meetings are available
</p> </p>
</div> </div>

Loading…
Cancel
Save