Compare commits

...

17 Commits

Author SHA1 Message Date
Jose Olarte III
b6f9533f07 feat: Add bulk admit pending members functionality
- Add "Admit Pending" button to admit all pending members at once
- Implement confirmation dialog with options to add members to contacts
- Hide button when no pending members exist
- Pause auto-refresh during dialog interaction for better UX
- Add notification constants for dialog text and actions
- Support both "Admit and Add to Contacts" and "Admit Only" workflows
- Include comprehensive error handling and success feedback

The dialog shows pending member count and allows users to choose whether
to add admitted members to their contacts list, streamlining the
admission process for organizers.
2025-10-21 21:18:42 +08:00
Jose Olarte III
e9ea89edae feat: enhance members list UI with visual indicators and improved styling
- Sort members list with organizer first, then non-admitted, then admitted
- Add crown icon for meeting organizer identification
- Add spinner icon for non-admitted members
- Implement conditional styling for non-admitted members
- Update button styling to use circle icons instead of rounded backgrounds
- Improve visual hierarchy with better spacing and color coding
2025-10-21 18:13:10 +08:00
1ce7c0486a Merge pull request 'feat: implement member visibility dialog with checkbox selection and refresh' (#208) from meeting-members-set-visibility into master
Reviewed-on: #208
2025-10-21 04:52:14 -04:00
Jose Olarte III
4f3a1b390d feat: auto-show visibility dialog for meeting members
- Show dialog on initial load if members need visibility settings
- Show dialog during auto-refresh only when new members are added (not removed)
- Show dialog on manual refresh if any members need visibility settings
- Remove manual "Set Visibility" buttons from UI as dialog now appears automatically
- Add logic to track previous visibility members and detect changes
- Improve UX by proactively prompting users to set visibility for new meeting members

The dialog now appears automatically in these scenarios:
- Component initialization with members needing visibility
- Auto-refresh when new members join the meeting
- Manual refresh when members need visibility settings
2025-10-17 19:17:47 +08:00
Jose Olarte III
4de4fbecaf refactor: rename SetVisibilityDialog to SetBulkVisibilityDialog and remove unused code
- Rename SetVisibilityDialog.vue to SetBulkVisibilityDialog.vue for better clarity
- Update all component references in MembersList.vue (import, registration, template usage)
- Update component class name from SetVisibilityDialog to SetBulkVisibilityDialog
- Rename calling function name to showSetBulkVisibilityDialog to match class name change
- Remove unused properties and created() method from App.vue

This cleanup removes dead code and improves component naming consistency.
2025-10-17 17:59:41 +08:00
Jose Olarte III
e3598992e7 feat: pause auto-refresh when SetVisibilityDialog is open
- Pause auto-refresh when SetVisibilityDialog becomes visible
- Resume auto-refresh when dialog is closed
- Prevents background refresh interference during visibility settings
- Fix type compatibility for visibilityDialogMembers data structure

This ensures users can interact with the visibility dialog without
the members list refreshing in the background, providing a better
user experience for setting member visibility preferences.
2025-10-16 17:49:59 +08:00
Jose Olarte III
ea19195850 refactor: extract SetVisibilityDialog into standalone component
- Extract "Set Visibility to Meeting Members" dialog from App.vue into dedicated SetVisibilityDialog.vue component
- Move dialog logic directly to MembersList.vue for better component coupling
- Remove unnecessary intermediate state management from App.vue
- Clean up redundant style definitions (rely on existing Tailwind CSS classes)
- Remove unused logger imports and debug functions
- Add explanatory comment for Vue template constant pattern

This improves maintainability by isolating dialog functionality and follows established component patterns in the codebase.
2025-10-16 17:18:56 +08:00
Jose Olarte III
ca545fd4b8 feat: add auto-refresh with countdown to MembersList
- Auto-refresh members list every 10 seconds
- Display countdown timer in refresh buttons
- Manual refresh resets countdown to 10 seconds
2025-10-15 20:25:01 +08:00
Jose Olarte III
07b538cadc feat: implement member visibility dialog with checkbox selection and refresh
- Add "Set Visibility" dialog for meeting members who need visibility settings
- Filter members to show only those not in contacts or without seesMe set
- Implement checkbox selection with "Select All" functionality
- Add reactive checkbox behavior with proper state management
- Default all checkboxes to checked when dialog opens
- Implement "Set Visibility" action to add contacts and set seesMe property
- Add success notifications with count of affected members
- Disable "Set Visibility" button when no members are selected
- Use notification callbacks for data refresh
- Hide "Set Visibility" buttons when no members need visibility settings
- Add proper dialog state management and cleanup
- Ensure dialog closes before triggering data refresh to prevent stale states

The implementation provides a smooth user experience for managing member visibility settings with proper state synchronization between components.
2025-10-14 21:21:35 +08:00
Jose Olarte III
b84546686a WIP: button and icon additions
- Mirrored "Refresh" and "Visibility" buttons on top and bottom of member list
- Added back info icons for list actions
- When clicked, Person icon shows informative notification
2025-10-13 21:38:12 +08:00
Jose Olarte III
461ee84d2a WIP: meeting members adjustments 2025-10-12 23:30:03 +08:00
Jose Olarte III
acf7d611e8 WIP: mockup for set visibility dialog 2025-10-09 21:56:50 +08:00
b0d13b3cd4 Merge pull request 'feat: disable zoom and fix iOS viewport issues' (#206) from ios-disable-zoom into master
Reviewed-on: #206
2025-10-08 06:04:32 -04:00
Jose Olarte III
5256681089 Merge branch 'master' into ios-disable-zoom 2025-10-08 18:03:27 +08:00
Jose Olarte III
225b34d480 feat: improve text overflow handling across UI components
- Add overflow-hidden, text-ellipsis and truncate classes to long text elements in list items and views to prevent text overflow
- Ensure proper text wrapping and ellipsis display for long content
2025-10-07 19:00:12 +08:00
d9f9460be7 Merge pull request 'refactor: standardize view headings across all components' (#207) from view-headings-refresh into master
Reviewed-on: #207
2025-10-07 05:17:05 -04:00
Jose Olarte III
f4144c7469 feat: disable zoom and fix iOS viewport issues
- Add user-scalable=no and interactive-widget=overlays-content to viewport meta tag
- Implement iOS viewport height fixes to prevent keyboard-related layout shifts
- Use dynamic viewport height (100dvh) for better mobile support
- Add fixed positioning and overflow controls to prevent viewport changes
- Enable scrolling only within #app container for better UX
2025-09-30 21:06:13 +08:00
17 changed files with 899 additions and 152 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, interactive-widget=overlays-content" />
<!-- CORS headers removed to allow images from any domain -->
@@ -13,4 +13,4 @@
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

View File

@@ -7,6 +7,24 @@
html {
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
}
/* Fix iOS viewport height changes when keyboard appears/disappears */
html, body {
height: 100%;
height: 100vh;
height: 100dvh; /* Dynamic viewport height for better mobile support */
overflow: hidden; /* Disable all scrolling on html and body */
position: fixed; /* Force fixed positioning to prevent viewport changes */
width: 100%;
top: 0;
left: 0;
}
#app {
height: 100vh;
height: 100dvh;
overflow-y: auto;
}
}
@layer components {

View File

@@ -78,8 +78,11 @@
</div>
<!-- Description -->
<p class="font-medium">
<a class="cursor-pointer" @click="emitLoadClaim(record.jwtId)">
<p class="font-medium overflow-hidden">
<a
class="block cursor-pointer overflow-hidden text-ellipsis"
@click="emitLoadClaim(record.jwtId)"
>
<vue-markdown
:source="truncatedDescription"
class="markdown-content"

View File

@@ -46,7 +46,7 @@
<span class="text-xs truncate">{{ contact.did }}</span>
</div>
<div class="text-sm">
<div class="text-sm truncate">
{{ contact.notes }}
</div>
</div>

View File

@@ -11,7 +11,7 @@
<!-- Members List -->
<div v-else>
<div class="text-center text-red-600 py-4">
<div class="text-center text-red-600 my-4">
{{ decryptionErrorMessage() }}
</div>
@@ -23,131 +23,160 @@
to set it.
</div>
<div>
<span
<ul class="list-disc text-sm ps-4 space-y-2 mb-4">
<li
v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer"
class="inline-flex items-center flex-wrap"
>
<span class="inline-flex items-center">
&bull; Click
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<font-awesome icon="plus" class="text-sm" />
</span>
/
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<font-awesome icon="minus" class="text-sm" />
</span>
to add/remove them to/from the meeting.
</span>
</span>
</div>
<div>
<span
v-if="membersToShow().length > 0"
class="inline-flex items-center"
>
&bull; Click
<span
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600"
>
<font-awesome icon="circle-user" class="text-xl" />
</span>
Click
<font-awesome icon="circle-plus" class="text-blue-500 text-sm" />
/
<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">
Click
<font-awesome icon="circle-user" class="text-green-600 text-sm" />
to add them to your contacts.
</span>
</div>
</li>
</ul>
<div class="flex justify-center">
<div class="flex justify-between">
<!--
always have at least one refresh button even without members in case the organizer
changes the password
-->
<button
class="btn-action-refresh"
title="Refresh members list"
@click="fetchMembers"
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"
@click="manualRefresh"
>
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh
<span class="text-xs">({{ countdownTimer }}s)</span>
</button>
<button
v-if="getPendingMembersCount() > 0"
class="text-sm bg-gradient-to-b from-blue-100 to-blue-200 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.2)] text-blue-800 px-3 py-1.5 rounded-md"
@click="showAdmitAllPendingDialog"
>
<font-awesome icon="circle-plus" class="text-blue-500" />
Admit Pending
</button>
</div>
<div
v-for="member in membersToShow()"
:key="member.member.memberId"
class="mt-2 p-4 bg-gray-50 rounded-lg"
<ul
v-if="membersToShow().length > 0"
class="border-t border-slate-300 my-2"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-lg font-medium">
{{ member.name || unnamedMember }}
</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex justify-end"
>
<button
class="btn-add-contact"
title="Add as contact"
@click="addAsContact(member)"
<li
v-for="member in membersToShow()"
:key="member.member.memberId"
:class="[
'border-b px-2 sm:px-3 py-1.5',
{
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
!member.member.admitted,
},
{ '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 },
]"
>
<font-awesome icon="circle-user" class="text-xl" />
</button>
<font-awesome
v-if="member.member.memberId === members[0]?.memberId"
icon="crown"
class="fa-fw text-amber-400"
/>
<font-awesome
v-if="!member.member.admitted"
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>
<button
v-if="member.did !== activeDid"
class="btn-info-contact"
title="Contact info"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
>
<font-awesome icon="circle-info" class="text-base" />
</button>
</div>
<div class="flex">
<span
v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid
"
class="flex items-center"
class="flex items-center gap-1.5"
>
<button
class="btn-admission"
:class="
member.member.admitted
? 'btn-admission-remove'
: 'btn-admission-add'
"
:title="
member.member.admitted ? 'Remove member' : 'Admit member'
"
@click="checkWhetherContactBeforeAdmitting(member)"
>
<font-awesome
:icon="member.member.admitted ? 'minus' : 'plus'"
class="text-sm"
:icon="
member.member.admitted ? 'circle-minus' : 'circle-plus'
"
/>
</button>
<button
class="btn-info-admission"
title="Admission info"
title="Admission Info"
@click="informAboutAdmission()"
>
<font-awesome icon="circle-info" class="text-base" />
<font-awesome icon="circle-info" />
</button>
</span>
</div>
</div>
<p class="text-sm text-gray-600 truncate">
{{ member.did }}
</p>
</div>
<div v-if="membersToShow().length > 0" class="flex justify-center mt-4">
<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
changes the password
-->
<button
class="btn-action-refresh"
title="Refresh members list"
@click="fetchMembers"
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"
@click="manualRefresh"
>
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh
<span class="text-xs">({{ countdownTimer }}s)</span>
</button>
</div>
@@ -156,6 +185,15 @@
</p>
</div>
</div>
<!-- Set Visibility Dialog Component -->
<SetBulkVisibilityDialog
:visible="showSetVisibilityDialog"
:members-data="visibilityDialogMembers"
:active-did="activeDid"
:api-server="apiServer"
@close="closeSetVisibilityDialog"
/>
</template>
<script lang="ts">
@@ -176,8 +214,10 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import {
NOTIFY_ADD_CONTACT_FIRST,
NOTIFY_CONTINUE_WITHOUT_ADDING,
NOTIFY_ADMIT_ALL_PENDING,
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import SetBulkVisibilityDialog from "./SetBulkVisibilityDialog.vue";
interface Member {
admitted: boolean;
@@ -193,6 +233,9 @@ interface DecryptedMember {
}
@Component({
components: {
SetBulkVisibilityDialog,
},
mixins: [PlatformServiceMixin],
})
export default class MembersList extends Vue {
@@ -219,8 +262,25 @@ 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
countdownTimer = 10;
autoRefreshInterval: NodeJS.Timeout | null = null;
lastRefreshTime = 0;
// Track previous visibility members to detect changes
previousVisibilityMembers: string[] = [];
/**
* Get the unnamed member constant
*/
@@ -242,6 +302,21 @@ export default class MembersList extends Vue {
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.checkAndShowVisibilityDialog();
}
async fetchMembers() {
@@ -329,22 +404,49 @@ export default class MembersList extends Vue {
}
membersToShow(): DecryptedMember[] {
let members: DecryptedMember[] = [];
if (this.isOrganizer) {
if (this.showOrganizerTools) {
return this.decryptedMembers;
members = this.decryptedMembers;
} else {
return this.decryptedMembers.filter(
members = this.decryptedMembers.filter(
(member: DecryptedMember) => member.member.admitted,
);
}
} else {
// non-organizers only get visible members from server
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. Non-admitted members next
// 3. 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;
// Organizer always comes first
if (aIsOrganizer && !bIsOrganizer) return -1;
if (!aIsOrganizer && bIsOrganizer) return 1;
// If both are organizers or neither are organizers, sort by admission status
if (aIsOrganizer && bIsOrganizer) return 0; // Both organizers, maintain original order
// 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() {
this.notify.info(
"This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.",
"This is to register people in Time Safari and to admit them to the meeting. A (+) symbol means they are not yet admitted and you can register and admit them. A (-) symbol means you can remove them, but they will stay registered.",
TIMEOUTS.VERY_LONG,
);
}
@@ -371,6 +473,80 @@ 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) => ({
did: member.did,
name: member.name,
isContact: !!this.getContactFor(member.did),
member: {
memberId: member.member.memberId.toString(),
},
}));
}
/**
* Check if we should show the visibility dialog
* Returns true if there are members for visibility and either:
* - This is the first time (no previous members tracked), OR
* - New members have been added since last check (not removed)
*/
shouldShowVisibilityDialog(): boolean {
const currentMembers = this.getMembersForVisibility();
if (currentMembers.length === 0) {
return false;
}
// If no previous members tracked, show dialog
if (this.previousVisibilityMembers.length === 0) {
return true;
}
// Check if new members have been added (not just any change)
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;
}
/**
* Update the tracking of previous visibility members
*/
updatePreviousVisibilityMembers() {
const currentMembers = this.getMembersForVisibility();
this.previousVisibilityMembers = currentMembers.map((m) => m.did);
}
/**
* Show the visibility dialog if conditions are met
*/
checkAndShowVisibilityDialog() {
if (this.shouldShowVisibilityDialog()) {
this.showSetBulkVisibilityDialog();
}
this.updatePreviousVisibilityMembers();
}
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(decrMember.did);
if (!decrMember.member.admitted && !contact) {
@@ -508,6 +684,187 @@ export default class MembersList extends Vue {
this.notify.error(message, TIMEOUTS.LONG);
}
}
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() {
this.lastRefreshTime = Date.now();
this.countdownTimer = 10;
this.autoRefreshInterval = setInterval(() => {
const now = Date.now();
const timeSinceLastRefresh = (now - this.lastRefreshTime) / 1000;
if (timeSinceLastRefresh >= 10) {
// Time to refresh
this.refreshData();
this.lastRefreshTime = now;
this.countdownTimer = 10;
} else {
// Update countdown
this.countdownTimer = Math.max(
0,
Math.round(10 - timeSinceLastRefresh),
);
}
}, 1000); // Update every second
}
stopAutoRefresh() {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
}
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();
}
/**
* Get count of pending (non-admitted) members
*/
getPendingMembersCount(): number {
return this.decryptedMembers.filter(
(member) => !member.member.admitted && member.did !== this.activeDid,
).length;
}
/**
* Get list of pending members
*/
getPendingMembers(): DecryptedMember[] {
return this.decryptedMembers.filter(
(member) => !member.member.admitted && member.did !== this.activeDid,
);
}
/**
* Show the admit all pending members dialog
*/
showAdmitAllPendingDialog() {
const pendingCount = this.getPendingMembersCount();
if (pendingCount === 0) {
this.notify.info(
"There are no pending members to admit.",
TIMEOUTS.STANDARD,
);
return;
}
// Pause auto-refresh when dialog opens
this.stopAutoRefresh();
const dialogText = NOTIFY_ADMIT_ALL_PENDING.text.replace(
"{count}",
pendingCount.toString(),
);
this.$notify(
{
group: "modal",
type: "confirm",
title: NOTIFY_ADMIT_ALL_PENDING.title,
text: dialogText,
yesText: NOTIFY_ADMIT_ALL_PENDING.yesText,
noText: NOTIFY_ADMIT_ALL_PENDING.noText,
onYes: async () => {
await this.admitAllPendingMembers(true); // true = add to contacts
// Resume auto-refresh after action
this.startAutoRefresh();
},
onNo: async () => {
await this.admitAllPendingMembers(false); // false = don't add to contacts
// Resume auto-refresh after action
this.startAutoRefresh();
},
onCancel: async () => {
// Do nothing - user cancelled
// Resume auto-refresh after cancellation
this.startAutoRefresh();
},
},
TIMEOUTS.MODAL,
);
}
/**
* Admit all pending members with optional contact addition
*/
async admitAllPendingMembers(addToContacts: boolean) {
const pendingMembers = this.getPendingMembers();
if (pendingMembers.length === 0) {
return;
}
try {
// Process each pending member
for (const member of pendingMembers) {
// Add to contacts if requested and not already a contact
if (addToContacts && !this.getContactFor(member.did)) {
await this.addAsContact(member);
}
// Admit the member
await this.toggleAdmission(member);
}
// Show success message
const contactMessage = addToContacts ? " and added to your contacts" : "";
this.notify.success(
`All ${pendingMembers.length} pending members have been admitted${contactMessage}.`,
TIMEOUTS.STANDARD,
);
} catch (error) {
this.$logAndConsole(
"Error admitting all pending members: " + errorStringForLog(error),
true,
);
this.notify.error(
"Failed to admit some members. Please try again.",
TIMEOUTS.LONG,
);
}
}
beforeDestroy() {
this.stopAutoRefresh();
}
}
</script>
@@ -522,29 +879,26 @@ export default class MembersList extends Vue {
.btn-add-contact {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply ml-2 w-8 h-8 flex items-center justify-center rounded-full
bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800
transition-colors;
}
.btn-info-contact {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply ml-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full
bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800
transition-colors;
}
.btn-admission {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply mr-2 w-6 h-6 flex items-center justify-center rounded-full
bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800
@apply text-lg text-green-600 hover:text-green-800
transition-colors;
}
.btn-info-contact,
.btn-info-admission {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply mr-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full
bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800
@apply text-slate-400 hover:text-slate-600
transition-colors;
}
.btn-admission-add {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply text-lg text-blue-500 hover:text-blue-700
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;
}
</style>

View File

@@ -0,0 +1,333 @@
<template>
<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 SetBulkVisibilityDialog 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>

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

@@ -471,6 +471,14 @@ export const NOTIFY_CONTINUE_WITHOUT_ADDING = {
noText: "No, Cancel",
};
// Used in: MembersList.vue (complex modal for admitting all pending members)
export const NOTIFY_ADMIT_ALL_PENDING = {
title: "Admit All Pending Members",
text: "You are about to admit {count} pending member/s. Would you also like to add them to your Contacts list?",
yesText: "Admit and Add to Contacts",
noText: "Admit Only",
};
// HelpNotificationsView.vue specific constants
// Used in: HelpNotificationsView.vue (sendTestWebPushMessage method - not subscribed error)
export const NOTIFY_PUSH_NOT_SUBSCRIBED = {

View File

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

View File

@@ -88,19 +88,22 @@
</button>
</div>
</div>
<div class="text-sm">
<div data-testId="description">
<div class="text-sm overflow-hidden">
<div
data-testId="description"
class="overflow-hidden text-ellipsis"
>
<font-awesome icon="message" class="fa-fw text-slate-400" />
<vue-markdown
:source="claimDescription"
class="markdown-content"
/>
</div>
<div>
<div class="overflow-hidden text-ellipsis">
<font-awesome icon="user" class="fa-fw text-slate-400" />
{{ didInfo(veriClaim.issuer) }}
</div>
<div>
<div class="overflow-hidden text-ellipsis">
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
Recorded
{{ formattedIssueDate }}

View File

@@ -34,7 +34,7 @@
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"
>
<div>
<h2 class="text-xl font-semibold">
<h2 class="text-xl font-semibold overflow-hidden text-ellipsis">
{{ contactFromDid?.name || "(no name)" }}
<router-link
:to="{ name: 'contact-edit', params: { did: contactFromDid?.did } }"

View File

@@ -282,8 +282,8 @@
/>
</div>
<div class="grow">
<h2 class="text-base font-semibold">
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold truncate">
{{ project.name || unnamedProject }}
</h2>
<div class="text-sm">

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

View File

@@ -77,6 +77,7 @@ import {
} from "../libs/endorserServer";
import { generateSaveAndActivateIdentity } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { NotificationIface } from "../constants/app";
@Component({
components: {
@@ -97,6 +98,7 @@ export default class OnboardMeetingMembersView extends Vue {
projectLink = "";
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
$notify!: (notification: NotificationIface, timeout?: number) => void;
userNameDialog!: InstanceType<typeof UserNameDialog>;

View File

@@ -230,26 +230,28 @@
class="mt-8 p-4 border rounded-lg bg-white shadow"
>
<div class="flex items-center justify-between">
<h2 class="text-2xl">Meeting Members</h2>
</div>
<div class="mt-4">
&bull; Page for Members
<span
class="ml-4 cursor-pointer text-blue-600"
title="Click to copy link for members"
@click="copyMembersLinkToClipboard"
>
<font-awesome icon="link" />
</span>
<a
v-if="!!currentMeeting.password"
:href="onboardMeetingMembersLink()"
class="ml-4 text-blue-600"
target="_blank"
>
<font-awesome icon="external-link" />
</a>
<h2 class="font-bold">Meeting Members</h2>
</div>
<ul class="list-disc text-sm mt-4 mb-2 ps-4 space-y-2">
<li>
Page for Members:
<span
class="ml-4 cursor-pointer text-blue-600"
title="Click to copy link for members"
@click="copyMembersLinkToClipboard"
>
<font-awesome icon="link" />
</span>
<a
v-if="!!currentMeeting.password"
:href="onboardMeetingMembersLink()"
class="ml-4 text-blue-600"
target="_blank"
>
<font-awesome icon="external-link" />
</a>
</li>
</ul>
<MembersList
:password="currentMeeting.password || ''"

View File

@@ -28,7 +28,7 @@
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<h2 class="text-center text-lg font-normal">
<h2 class="text-center text-lg font-normal overflow-hidden text-ellipsis">
{{ name }}
<button
v-if="activeDid === issuer || activeDid === agentDid"
@@ -157,7 +157,7 @@
</div>
<div class="text-sm text-slate-500">
<div v-if="!expanded">
<div v-if="!expanded" class="overflow-hidden text-ellipsis">
<vue-markdown
:source="truncatedDesc"
class="mb-4 markdown-content"
@@ -169,7 +169,7 @@
>... Read More</a
>
</div>
<div v-else>
<div v-else class="overflow-hidden text-ellipsis">
<vue-markdown :source="description" class="mb-4 markdown-content" />
<a
v-if="description.length >= truncateLength"
@@ -308,7 +308,10 @@
/>{{ offer.amount }}
</span>
</div>
<div v-if="offer.objectDescription" class="text-slate-500">
<div
v-if="offer.objectDescription"
class="text-slate-500 overflow-hidden text-ellipsis"
>
<font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ offer.objectDescription }}
</div>
@@ -450,7 +453,10 @@
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<div
v-if="give.description"
class="text-slate-500 overflow-hidden text-ellipsis"
>
<font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
@@ -557,7 +563,10 @@
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<div
v-if="give.description"
class="text-slate-500 overflow-hidden text-ellipsis"
>
<font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>

View File

@@ -118,8 +118,8 @@
/>
</div>
<div>
<div>
<div class="overflow-hidden">
<div class="text-sm truncate">
To
{{
offer.fulfillsPlanHandleId
@@ -132,7 +132,7 @@
)
}}
</div>
<div>
<div class="truncate">
{{ offer.objectDescription }}
</div>
@@ -254,7 +254,7 @@
</div>
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold">
<h2 class="text-base font-semibold truncate">
{{ project.name || unnamedProject }}
</h2>
<div class="text-sm truncate">