Compare commits

..

16 Commits

Author SHA1 Message Date
e8e00d3eae refactor: remove mistakenly-committed file 2025-10-26 14:34:36 -06:00
5c0ce2d1fb fix: linting 2025-10-26 14:09:56 -06:00
9e1c267bc0 refactor: make the meeting member "set visibility" screen much like the organizer's "admit" screen 2025-10-26 14:08:30 -06:00
723a0095a0 feat: prompt user if the pre-commit lint-fix changed anything 2025-10-26 07:43:05 -06:00
9a94843b68 fix: linting 2025-10-26 07:42:34 -06:00
9f3c62a29c test: trying the new pre-commit logic (with a bad linting change) 2025-10-26 07:40:24 -06:00
39173a8db2 fix: linting 2025-10-26 07:35:12 -06:00
7ea6a2ef69 refactor: simplify logic for opening onboarding dialogs 2025-10-25 21:15:32 -06:00
f0f0f1681e chore: move a variable into most local scope 2025-10-24 22:06:53 -06:00
Jose Olarte III
2f1eeb6700 fix: resolve duplicate names in Visibility dialog after Admit dialog
- Add deduplication logic to getMembersForVisibility() method to prevent duplicate entries
- Fix timing issue with isManualRefresh flag reset in showSetBulkVisibilityDialog()
- Ensure Visibility dialog shows each member only once when following Admit dialog
- Remove debugging console logs after issue resolution

The issue was caused by multiple calls to getMembersForVisibility() returning
duplicate member entries, which were then displayed in the Visibility dialog.
The fix deduplicates members by DID to ensure each member appears only once.
2025-10-24 17:31:46 +08:00
Jose Olarte III
e048e4c86b fix: restrict pending member styling to organizers only
- Apply special styling (blue background, grayed text, hourglass icon) only when current user is organizer
- Non-organizers now see consistent styling for all visible members
- Maintains organizer's ability to distinguish between admitted and pending members
- Fixes issue where non-organizers saw inconsistent styling for all members
2025-10-24 15:40:34 +08:00
Jose Olarte III
16ed5131c4 feat: restrict dialog access based on user roles
- AdmitPendingMembersDialog now only triggers for meeting organizers
- SetBulkVisibilityDialog now only triggers for members who can see other members
- Removes overly restrictive admission status check for visibility dialog
- Ensures proper role-based access control for meeting management features
2025-10-24 15:23:39 +08:00
Jose Olarte III
ad51c187aa Update AdmitPendingMembersDialog.vue
feat: add DID display to Pending Members dialog

- Restructure member display with better visual hierarchy
- Add DID display with responsive truncation for mobile
- Simplify button labels ("Admit + Add Contacts" and "Admit Only")
2025-10-23 19:59:55 +08:00
Jose Olarte III
6fbc9c2a5b feat: Add AdmitPendingMembersDialog for bulk member admission
- Add new AdmitPendingMembersDialog component with checkbox selection
- Support two action modes: "Admit + Add Contacts" and "Admit Only"
- Integrate dialog into MembersList with proper sequencing
- Show admit dialog before visibility dialog when pending members exist
- Fix auto-refresh pause/resume logic for both dialogs
- Ensure consistent dialog behavior between initial load and manual refresh
- Add proper async/await handling for data refresh operations
- Optimize dialog state management and remove redundant code
- Maintain proper flag timing to prevent race conditions

The admit dialog now shows automatically when there are pending members,
allowing organizers to efficiently admit multiple members at once while
optionally adding them as contacts and setting visibility preferences.
2025-10-22 21:56:00 +08:00
Jose Olarte III
035509224b feat: change icon for pending members
- Changed from an animating spinner to a static hourglass
2025-10-21 22:00:21 +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
29 changed files with 1138 additions and 1146 deletions

View File

@@ -9,6 +9,10 @@ echo "🔍 Running pre-commit hooks..."
# Run lint-fix first
echo "📝 Running lint-fix..."
# Capture git status before lint-fix to detect changes
git_status_before=$(git status --porcelain)
npm run lint-fix || {
echo
echo "❌ Linting failed. Please fix the issues and try again."
@@ -18,6 +22,36 @@ npm run lint-fix || {
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
#echo "🏗️ Running Build Architecture Guard..."

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.1-beta",
"version": "1.1.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.1-beta",
"version": "1.1.0-beta",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",

View File

@@ -38,7 +38,7 @@
}
.dialog {
@apply bg-white p-4 rounded-lg w-full max-w-lg max-h-[calc(100vh-3rem)] overflow-y-auto;
@apply bg-white p-4 rounded-lg w-full max-w-lg;
}
/* Markdown content styling to restore list elements */

View File

@@ -77,86 +77,12 @@
</a>
</div>
<!-- Emoji Section -->
<div
v-if="hasEmojis || isRegistered"
class="float-right ml-3 mb-1 bg-white rounded border border-slate-300 px-1.5 py-0.5 max-w-[240px]"
>
<div class="flex items-center justify-between gap-1">
<!-- Existing Emojis Display -->
<div v-if="hasEmojis" class="flex flex-wrap gap-1">
<button
v-for="(count, emoji) in record.emojiCount"
:key="emoji"
class="inline-flex items-center gap-0.5 px-1 py-0.5 text-xs bg-slate-50 hover:bg-slate-100 rounded border border-slate-200 transition-colors cursor-pointer"
:class="{
'bg-blue-50 border-blue-200': isUserEmojiWithoutLoading(emoji),
'opacity-75 cursor-wait': loadingEmojis,
}"
:title="
loadingEmojis
? 'Loading...'
: !emojisOnActivity?.isResolved
? 'Click to load your emojis'
: isUserEmojiWithoutLoading(emoji)
? 'Click to remove your emoji'
: 'Click to add this emoji'
"
:disabled="!isRegistered"
@click="toggleThisEmoji(emoji)"
>
<!-- Show spinner when loading -->
<div v-if="loadingEmojis" class="animate-spin text-xs">
<font-awesome icon="spinner" class="fa-spin" />
</div>
<span v-else class="text-sm leading-none">{{ emoji }}</span>
<span class="text-xs text-slate-600 font-medium leading-none">{{
count
}}</span>
</button>
</div>
<!-- Add Emoji Button -->
<button
v-if="isRegistered"
class="inline-flex px-1 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded border border-slate-300 transition-colors items-center justify-center ml-2 ml-auto"
:title="showEmojiPicker ? 'Close emoji picker' : 'Add emoji'"
@click="toggleEmojiPicker"
>
<span class="px-2 text-sm leading-none">{{
showEmojiPicker ? "x" : "😊"
}}</span>
</button>
</div>
<!-- Emoji Picker (placeholder for now) -->
<div
v-if="showEmojiPicker"
class="mt-1 p-1.5 bg-slate-50 rounded border border-slate-300"
>
<!-- Temporary emoji buttons for testing -->
<div class="flex flex-wrap gap-3 mt-1">
<button
v-for="emoji in QUICK_EMOJIS"
:key="emoji"
class="p-0.5 hover:bg-slate-200 rounded text-base transition-opacity"
:class="{
'opacity-75 cursor-wait': loadingEmojis,
}"
:disabled="loadingEmojis"
@click="toggleThisEmoji(emoji)"
>
<!-- Show spinner when loading -->
<div v-if="loadingEmojis" class="animate-spin text-sm">⟳</div>
<span v-else>{{ emoji }}</span>
</button>
</div>
</div>
</div>
<!-- Description -->
<p class="font-medium">
<a class="block 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"
@@ -165,7 +91,7 @@
</p>
<div
class="clear-right relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
>
<!-- Source -->
<div
@@ -328,24 +254,17 @@
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import { logger } from "../utils/logger";
import {
createAndSubmitClaim,
getHeaders,
isHiddenDid,
} from "../libs/endorserServer";
import { GiveRecordWithContactInfo } from "@/interfaces/give";
import EntityIcon from "./EntityIcon.vue";
import { isHiddenDid } from "../libs/endorserServer";
import ProjectIcon from "./ProjectIcon.vue";
import { createNotifyHelpers, NotifyFunction, TIMEOUTS } from "@/utils/notify";
import { createNotifyHelpers, NotifyFunction } from "@/utils/notify";
import {
NOTIFY_PERSON_HIDDEN,
NOTIFY_UNKNOWN_PERSON,
} from "@/constants/notifications";
import { EmojiSummaryRecord, GenericVerifiableCredential } from "@/interfaces";
import { GiveRecordWithContactInfo } from "@/interfaces/give";
import { PromiseTracker } from "@/libs/util";
import { TIMEOUTS } from "@/utils/notify";
import VueMarkdown from "vue-markdown-render";
@Component({
components: {
@@ -355,24 +274,15 @@ import { PromiseTracker } from "@/libs/util";
},
})
export default class ActivityListItem extends Vue {
readonly QUICK_EMOJIS = ["👍", "👏", "❤️", "🎉", "😊", "😆", "🔥"];
@Prop() record!: GiveRecordWithContactInfo;
@Prop() lastViewedClaimId?: string;
@Prop() isRegistered!: boolean;
@Prop() activeDid!: string;
@Prop() apiServer!: string;
isHiddenDid = isHiddenDid;
notify!: ReturnType<typeof createNotifyHelpers>;
$notify!: NotifyFunction;
// Emoji-related data
showEmojiPicker = false;
loadingEmojis = false; // Track if emojis are currently loading
emojisOnActivity: PromiseTracker<EmojiSummaryRecord[]> | null = null; // load this only when needed
created() {
this.notify = createNotifyHelpers(this.$notify);
}
@@ -436,186 +346,5 @@ export default class ActivityListItem extends Vue {
day: "numeric",
});
}
// Emoji-related computed properties and methods
get hasEmojis(): boolean {
return Object.keys(this.record.emojiCount).length > 0;
}
triggerUserEmojiLoad(): PromiseTracker<EmojiSummaryRecord[]> {
if (!this.emojisOnActivity) {
const promise = new Promise<EmojiSummaryRecord[]>((resolve) => {
(async () => {
this.axios
.get(
`${this.apiServer}/api/v2/report/emoji?parentHandleId=${encodeURIComponent(this.record.handleId)}`,
{ headers: await getHeaders(this.activeDid) },
)
.then((response) => {
const userEmojiRecords = response.data.data.filter(
(e: EmojiSummaryRecord) => e.issuerDid === this.activeDid,
);
resolve(userEmojiRecords);
})
.catch((error) => {
logger.error("Error loading user emojis:", error);
resolve([]);
});
})();
});
this.emojisOnActivity = new PromiseTracker(promise);
}
return this.emojisOnActivity;
}
/**
*
* @param emoji - The emoji to check.
* @returns True if the emoji is in the user's emojis, false otherwise.
*
* @note This method is quick and synchronous, and can check resolved emojis
* without triggering a server request. Returns false if emojis haven't been loaded yet.
*/
isUserEmojiWithoutLoading(emoji: string): boolean {
if (this.emojisOnActivity?.isResolved && this.emojisOnActivity.value) {
return this.emojisOnActivity.value.some(
(record) => record.text === emoji,
);
}
return false;
}
async toggleEmojiPicker() {
this.triggerUserEmojiLoad(); // trigger it, but don't wait for it to complete
this.showEmojiPicker = !this.showEmojiPicker;
}
async toggleThisEmoji(emoji: string) {
// Start loading indicator
this.loadingEmojis = true;
this.showEmojiPicker = false; // always close the picker when an emoji is clicked
try {
this.triggerUserEmojiLoad(); // trigger just in case
const userEmojiList = await this.emojisOnActivity!.promise; // must wait now that they've chosen
const userHasEmoji: boolean = userEmojiList.some(
(record) => record.text === emoji,
);
if (userHasEmoji) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Remove Emoji",
text: `Do you want to remove your ${emoji} ?`,
yesText: "Remove",
onYes: async () => {
await this.removeEmoji(emoji);
},
},
TIMEOUTS.MODAL,
);
} else {
// User doesn't have this emoji, add it
await this.submitEmoji(emoji);
}
} finally {
// Remove loading indicator
this.loadingEmojis = false;
}
}
async submitEmoji(emoji: string) {
try {
// Create an Emoji claim and send to the server
const emojiClaim: GenericVerifiableCredential = {
"@context": "https://endorser.ch",
"@type": "Emoji",
text: emoji,
parentItem: { lastClaimId: this.record.jwtId },
};
const claim = await createAndSubmitClaim(
emojiClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (claim.success && !claim.embeddedRecordError) {
// Update emoji count
this.record.emojiCount[emoji] =
(this.record.emojiCount[emoji] || 0) + 1;
// Create a new emoji record (we'll get the actual jwtId from the server response later)
const newEmojiRecord: EmojiSummaryRecord = {
issuerDid: this.activeDid,
jwtId: claim.claimId || "",
text: emoji,
parentHandleId: this.record.jwtId,
};
// Update user emojis list by creating a new promise with the updated data
// (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly)
this.triggerUserEmojiLoad();
const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one
this.emojisOnActivity = new PromiseTracker(
Promise.resolve([...currentEmojis, newEmojiRecord]),
);
} else {
this.notify.error("Failed to add emoji.", TIMEOUTS.STANDARD);
}
} catch (error) {
logger.error("Error submitting emoji:", error);
this.notify.error("Got error adding emoji.", TIMEOUTS.STANDARD);
}
}
async removeEmoji(emoji: string) {
try {
// Create an Emoji claim and send to the server
const emojiClaim: GenericVerifiableCredential = {
"@context": "https://endorser.ch",
"@type": "Emoji",
text: emoji,
parentItem: { lastClaimId: this.record.jwtId },
};
const claim = await createAndSubmitClaim(
emojiClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (claim.success && !claim.embeddedRecordError) {
// Update emoji count
const newCount = Math.max(0, (this.record.emojiCount[emoji] || 0) - 1);
if (newCount === 0) {
delete this.record.emojiCount[emoji];
} else {
this.record.emojiCount[emoji] = newCount;
}
// Update user emojis list by creating a new promise with the updated data
// (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly)
this.triggerUserEmojiLoad();
const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one
this.emojisOnActivity = new PromiseTracker(
Promise.resolve(
currentEmojis.filter(
(record) =>
record.issuerDid === this.activeDid && record.text !== emoji,
),
),
);
} else {
this.notify.error("Failed to remove emoji.", TIMEOUTS.STANDARD);
}
} catch (error) {
logger.error("Error removing emoji:", error);
this.notify.error("Got error removing emoji.", TIMEOUTS.STANDARD);
}
}
}
</script>

View File

@@ -0,0 +1,376 @@
<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">
Admit Pending Members
</h3>
<p class="text-sm mb-4">
The following members are waiting to be admitted to the meeting. You
can choose to admit them and optionally add them as contacts with
visibility settings.
</p>
<!-- Custom table area - you can customize this -->
<div 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 pending members to admit
</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)"
/>
<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>
<!-- Contact 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-green-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="admitWithVisibility"
>
Admit + Add to Contacts
</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"
>
Maybe Later
</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 { MemberData } from "@/interfaces";
import { setVisibilityUtil, getHeaders } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify";
@Component({
mixins: [PlatformServiceMixin],
emits: ["close"],
})
export default class AdmitPendingMembersDialog extends Vue {
@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
membersData: MemberData[] = [];
selectedMembers: string[] = [];
visible = 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;
}
created() {
this.notify = createNotifyHelpers(this.$notify);
}
open(members: MemberData[]) {
this.visible = true;
this.membersData = members;
// Select all by default
this.selectedMembers = this.membersData.map((member) => member.did);
}
close(notSelectedMemberDids: string[]) {
this.visible = false;
this.$emit("close", { notSelectedMemberDids: notSelectedMemberDids });
}
cancel() {
this.close(this.membersData.map((member) => member.did));
}
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 admitWithVisibility() {
try {
const selectedMembers = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let admittedCount = 0;
let contactAddedCount = 0;
let visibilitySetCount = 0;
for (const member of selectedMembers) {
try {
// First, admit the member
await this.admitMember(member);
admittedCount++;
// If they're not a contact yet, add them as a contact
if (!member.isContact) {
await this.addAsContact(member);
contactAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
visibilitySetCount++;
} 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: "Members Admitted Successfully",
text: `Admitted ${admittedCount} member${admittedCount === 1 ? "" : "s"}, added ${contactAddedCount} as contact${contactAddedCount === 1 ? "" : "s"}, and set visibility for ${visibilitySetCount} member${visibilitySetCount === 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 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 }) {
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 they are not yet admitted to the meeting.",
},
5000,
);
}
}
</script>
<style scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: white;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -2,55 +2,12 @@
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */
<template>
<!-- Quick Search -->
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
<input
v-model="searchTerm"
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
@input="handleSearchInput"
@keydown.enter="performSearch"
/>
<div
v-show="isSearching && searchTerm"
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
>
<font-awesome
icon="spinner"
class="fa-spin-pulse leading-[1.1]"
></font-awesome>
</div>
<button
:disabled="!searchTerm"
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
@click="clearSearch"
>
<font-awesome
:icon="searchTerm ? 'times' : 'magnifying-glass'"
class="fa-fw"
></font-awesome>
</button>
</div>
<div
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
class="mb-4 text-sm italic text-slate-500 text-center"
>
{{ searchTerm }} doesn't match any
{{ entityType === "people" ? "people" : "projects" }}. Try a different
search.
</div>
<ul
ref="scrollContainer"
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
>
<ul :class="gridClasses">
<!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'">
<!-- "You" entity -->
<SpecialEntityCard
v-if="showYouEntity && !searchTerm.trim()"
v-if="showYouEntity"
entity-type="you"
label="You"
icon="hand"
@@ -64,7 +21,6 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- "Unnamed" entity -->
<SpecialEntityCard
v-if="showUnnamedEntity && !searchTerm.trim()"
entity-type="unnamed"
:label="unnamedEntityName"
icon="circle-question"
@@ -82,60 +38,16 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'">
<!-- When showing contacts without search: split into recent and alphabetical -->
<template v-if="!searchTerm.trim()">
<!-- Recently Added Section -->
<template v-if="recentContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Added
</li>
<PersonCard
v-for="person in recentContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<!-- Alphabetical Section -->
<template v-if="alphabeticalContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Everyone Else
</li>
<PersonCard
v-for="person in alphabeticalContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<template v-else-if="entityType === 'projects'">
@@ -151,27 +63,28 @@ projects, and special entities with selection. * * @author Matthew Raymer */
@project-selected="handleProjectSelected"
/>
</template>
<!-- Show All navigation -->
<ShowAllCard
v-if="shouldShowAll"
:entity-type="entityType"
:route-name="showAllRoute"
:query-params="showAllQueryParams"
/>
</ul>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
import { useInfiniteScroll } from "@vueuse/core";
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue";
import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
/**
* Constants for infinite scroll configuration
*/
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
/**
* EntityGrid - Unified grid layout for displaying people or projects
*
@@ -180,6 +93,7 @@ const RECENT_CONTACTS_COUNT = 3;
* - Special entity integration (You, Unnamed)
* - Conflict detection integration
* - Empty state messaging
* - Show All navigation
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
* - Template streamlined with computed CSS properties
@@ -190,6 +104,7 @@ const RECENT_CONTACTS_COUNT = 3;
PersonCard,
ProjectCard,
SpecialEntityCard,
ShowAllCard,
},
})
export default class EntityGrid extends Vue {
@@ -197,21 +112,14 @@ export default class EntityGrid extends Vue {
@Prop({ required: true })
entityType!: "people" | "projects";
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
/** Array of entities to display */
@Prop({ required: true })
entities!: Contact[] | PlanData[];
/** Maximum number of entities to display */
@Prop({ default: 10 })
maxItems!: number;
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
@@ -232,14 +140,18 @@ export default class EntityGrid extends Vue {
@Prop({ default: true })
showYouEntity!: boolean;
/** Whether to show the "Unnamed" entity for people grids */
@Prop({ default: true })
showUnnamedEntity!: boolean;
/** Whether the "You" entity is selectable */
@Prop({ default: true })
youSelectable!: boolean;
/** Route name for "Show All" navigation */
@Prop({ default: "" })
showAllRoute!: string;
/** Query parameters for "Show All" navigation */
@Prop({ default: () => ({}) })
showAllQueryParams!: Record<string, string>;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
@@ -248,31 +160,42 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" })
conflictContext!: string;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* Function to determine which entities to display (allows parent control)
*
* This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering and sorting.
* Note: Infinite scroll is disabled when this prop is provided.
* are displayed in the grid, enabling advanced filtering, sorting, and
* display logic beyond the default simple slice behavior.
*
* @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects")
* @param maxItems - The maximum number of items to display (from maxItems prop)
* @returns Filtered/sorted array of entities to display
*
* @example
* // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type) =>
* entities.filter(e => e.profileImageUrl)"
* :display-entities-function="(entities, type, max) =>
* entities.filter(e => e.profileImageUrl).slice(0, max)"
*
* @example
* // Custom sorting: sort projects by name
* :display-entities-function="(entities, type) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))"
* :display-entities-function="(entities, type, max) =>
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)"
*
* @example
* // Advanced logic: different limits for different entity types
* :display-entities-function="(entities, type, max) =>
* type === 'projects' ? entities.slice(0, 5) : entities.slice(0, max)"
*/
@Prop({ default: null })
displayEntitiesFunction?: (
entities: Contact[] | PlanData[],
entityType: "people" | "projects",
maxItems: number,
) => Contact[] | PlanData[];
/**
@@ -283,60 +206,33 @@ export default class EntityGrid extends Vue {
}
/**
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied
* Computed CSS classes for the grid layout
*/
get gridClasses(): string {
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
if (this.entityType === "projects") {
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
} else {
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
}
}
/**
* Computed entities to display - uses function prop if provided, otherwise defaults
*/
get displayedEntities(): Contact[] | PlanData[] {
// If searching, return filtered results with infinite scroll
if (this.searchTerm.trim()) {
return this.filteredEntities.slice(0, this.displayedCount);
}
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entities, this.entityType);
return this.displayEntitiesFunction(
this.entities,
this.entityType,
this.maxItems,
);
}
// Default: projects use infinite scroll
if (this.entityType === "projects") {
return (this.entities as PlanData[]).slice(0, this.displayedCount);
}
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
return [];
}
/**
* Get the 3 most recently added contacts (when showing contacts and not searching)
*/
get recentContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Entities are already sorted by date added (newest first)
return (this.entities as Contact[]).slice(0, 3);
}
/**
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Skip the first 3 (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Apply infinite scroll: show based on displayedCount (minus the 3 recent)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
// Default implementation for backward compatibility
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
return this.entities.slice(0, maxDisplay);
}
/**
@@ -350,6 +246,15 @@ export default class EntityGrid extends Vue {
}
}
/**
* Whether to show the "Show All" navigation
*/
get shouldShowAll(): boolean {
return (
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
);
}
/**
* Whether the "You" entity is conflicted
*/
@@ -423,144 +328,6 @@ export default class EntityGrid extends Vue {
});
}
/**
* Handle search input with debouncing
*/
handleSearchInput(): void {
// Show spinner immediately when user types
this.isSearching = true;
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set new timeout for 500ms delay
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, 500);
}
/**
* Perform the actual search
*/
async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) {
this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
return;
}
this.isSearching = true;
try {
// Simulate async search (in case we need to add API calls later)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
if (this.entityType === "people") {
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
} else {
this.filteredEntities = (this.entities as PlanData[])
.filter((project: PlanData) => {
const name = project.name?.toLowerCase() || "";
const handleId = project.handleId.toLowerCase();
return name.includes(searchLower) || handleId.includes(searchLower);
})
.sort((a: PlanData, b: PlanData) => {
// Sort alphabetically by name
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
// Reset displayed count when search completes
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
} finally {
this.isSearching = false;
}
}
/**
* Clear the search
*/
clearSearch(): void {
this.searchTerm = "";
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// Clear any pending timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
}
/**
* Determine if more entities can be loaded
*/
canLoadMore(): boolean {
if (this.displayEntitiesFunction) {
// Custom function disables infinite scroll
return false;
}
if (this.searchTerm.trim()) {
// Search mode: check filtered entities
return this.displayedCount < this.filteredEntities.length;
}
if (this.entityType === "projects") {
// Projects: check if more available
return this.displayedCount < this.entities.length;
}
// People: check if more alphabetical contacts available
// Total available = 3 recent + all alphabetical
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
return this.displayedCount < totalAvailable;
}
/**
* Initialize infinite scroll on mount
*/
mounted(): void {
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
() => {
// Load more: increment displayedCount
this.displayedCount += INCREMENT_SIZE;
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator
@Emit("entity-selected")
@@ -573,33 +340,6 @@ export default class EntityGrid extends Vue {
} {
return data;
}
/**
* Watch for changes in search term to reset displayed count
*/
@Watch("searchTerm")
onSearchTermChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Watch for changes in entities prop to reset displayed count
*/
@Watch("entities")
onEntitiesChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Cleanup timeouts when component is destroyed
*/
beforeUnmount(): void {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
}
</script>

View File

@@ -3,9 +3,10 @@ from GiftedDialog.vue to handle the complete step 1 * entity selection interface
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
based on context * - EntityGrid integration for unified entity display * -
Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
Cancel functionality * - Event delegation for entity selection * - Warning
notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
Show All navigation with context preservation * - Cancel functionality * - Event
delegation for entity selection * - Warning notifications for conflicted
entities * - Template streamlined with computed CSS properties * * @author
Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-4">
@@ -15,14 +16,18 @@ properties * * @author Matthew Raymer */
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:show-all-route="showAllRoute"
:show-all-query-params="showAllQueryParams"
:notify="notify"
:conflict-context="conflictContext"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
/>
@@ -63,6 +68,7 @@ interface EntitySelectionEvent {
* - EntityGrid integration for unified entity display
* - Conflict detection and prevention
* - Special entity handling (You, Unnamed)
* - Show All navigation with context preservation
* - Cancel functionality
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
@@ -148,6 +154,10 @@ export default class EntitySelectionStep extends Vue {
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* CSS classes for the cancel button
*/
@@ -212,6 +222,59 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid);
}
/**
* Route name for "Show All" navigation
*/
get showAllRoute(): string {
if (this.shouldShowProjects) {
return "discover";
} else if (this.allContacts.length > 0) {
return "contact-gift";
}
return "";
}
/**
* Query parameters for "Show All" navigation
*/
get showAllQueryParams(): Record<string, string> {
const baseParams = {
stepType: this.stepType,
giverEntityType: this.giverEntityType,
recipientEntityType: this.recipientEntityType,
// Form field values to preserve
description: this.description,
amountInput: this.amountInput,
unitCode: this.unitCode,
offerId: this.offerId,
fromProjectId: this.fromProjectId,
toProjectId: this.toProjectId,
showProjects: this.showProjects.toString(),
isFromProjectView: this.isFromProjectView.toString(),
};
if (this.shouldShowProjects) {
// For project contexts, still pass entity type information
return baseParams;
}
return {
...baseParams,
// Always pass both giver and recipient info for context preservation
giverProjectId: this.fromProjectId || "",
giverProjectName: this.giver?.name || "",
giverProjectImage: this.giver?.image || "",
giverProjectHandleId: this.giver?.handleId || "",
giverDid: this.giverEntityType === "person" ? this.giver?.did || "" : "",
recipientProjectId: this.toProjectId || "",
recipientProjectName: this.receiver?.name || "",
recipientProjectImage: this.receiver?.image || "",
recipientProjectHandleId: this.receiver?.handleId || "",
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did || "" : "",
};
}
/**
* Handle entity selection from EntityGrid
*/

View File

@@ -29,6 +29,7 @@
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
@cancel="cancel"
/>
@@ -116,6 +117,7 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person" as
| "person"
| "project";
@@ -231,7 +233,7 @@ export default class GiftedDialog extends Vue {
apiServer: this.apiServer,
});
this.allContacts = await this.$contactsByDateAdded();
this.allContacts = await this.$contacts();
this.allMyDids = await retrieveAccountDids();

View File

@@ -1,196 +1,226 @@
<template>
<div class="space-y-4">
<!-- Loading State -->
<div
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>
<!-- 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>
<ul class="list-disc text-sm ps-4 space-y-2 mb-4">
<li
v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer"
>
Click
<span
class="inline-block w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-center"
>
<font-awesome icon="plus" class="text-sm" />
</span>
/
<span
class="inline-block w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-center"
>
<font-awesome icon="minus" class="text-sm" />
</span>
to add/remove them to/from the meeting.
</li>
<li v-if="membersToShow().length > 0">
Click
<span
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
changes the password
-->
<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"
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>
<ul
v-if="membersToShow().length > 0"
class="border-t border-slate-300 my-2"
<div>
<div class="space-y-4">
<!-- Loading State -->
<div
v-if="isLoading"
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
>
<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">
<div class="flex items-center gap-1 overflow-hidden">
<h3 class="font-semibold truncate">
{{ member.name || unnamedMember }}
</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex items-center gap-1"
>
<button
class="btn-add-contact"
title="Add as contact"
@click="addAsContact(member)"
>
<font-awesome icon="circle-user" />
</button>
<font-awesome icon="spinner" class="fa-spin-pulse" />
</div>
<button
class="btn-info-contact"
title="Contact Info"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
>
<font-awesome icon="circle-info" class="text-sm" />
</button>
</div>
</div>
<span
v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid
"
class="flex items-center gap-1"
>
<button
class="btn-admission"
:title="
member.member.admitted ? 'Remove member' : 'Admit member'
"
@click="checkWhetherContactBeforeAdmitting(member)"
>
<font-awesome
:icon="member.member.admitted ? 'minus' : 'plus'"
/>
</button>
<!-- Members List -->
<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-else>
<div class="text-center text-red-600 my-4">
{{ decryptionErrorMessage() }}
</div>
<div v-if="membersToShow().length > 0" class="flex justify-between">
<!--
<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
"
>
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.
</li>
</ul>
<div class="flex justify-between">
<!--
always have at least one refresh button even without members in case the organizer
changes the password
-->
<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"
title="Refresh members list now"
@click="manualRefresh"
<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"
title="Refresh members list now"
@click="refreshData(false)"
>
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh
<span class="text-xs">({{ countdownTimer }}s)</span>
</button>
</div>
<ul
v-if="membersToShow().length > 0"
class="border-t border-slate-300 my-2"
>
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh
<span class="text-xs">({{ countdownTimer }}s)</span>
</button>
<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 && isOrganizer,
},
{ '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,
},
]"
>
<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 && isOrganizer"
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
: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 ? 'circle-minus' : 'circle-plus'
"
/>
</button>
<button
class="btn-info-admission"
title="Admission Info"
@click="informAboutAdmission()"
>
<font-awesome icon="circle-info" />
</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
changes the password
-->
<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"
title="Refresh members list now"
@click="refreshData(false)"
>
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh
<span class="text-xs">({{ countdownTimer }}s)</span>
</button>
</div>
<p v-if="members.length === 0" class="text-gray-500 py-4">
No members have joined this meeting yet
</p>
</div>
<p v-if="members.length === 0" class="text-gray-500 py-4">
No members have joined this meeting yet
</p>
</div>
</div>
<!-- Set Visibility Dialog Component -->
<SetBulkVisibilityDialog
:visible="showSetVisibilityDialog"
:members-data="visibilityDialogMembers"
:active-did="activeDid"
:api-server="apiServer"
@close="closeSetVisibilityDialog"
/>
<!-- This Admit component is for the organizer to admit pending members to the meeting -->
<AdmitPendingMembersDialog
ref="admitPendingMembersDialog"
:active-did="activeDid"
:api-server="apiServer"
@close="closeMemberSelectionDialogCallback"
/>
<!-- This Bulk Visibility component is for non-organizer members to add other members to their contacts and set their visibility -->
<SetBulkVisibilityDialog
ref="setBulkVisibilityDialog"
:active-did="activeDid"
:api-server="apiServer"
@close="closeMemberSelectionDialogCallback"
/>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
import {
errorStringForLog,
getHeaders,
register,
serverMessageForUser,
} from "../libs/endorserServer";
import { decryptMessage } from "../libs/crypto";
import { Contact } from "../db/tables/contacts";
import * as libsUtil from "../libs/util";
import { NotificationIface } from "../constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NotificationIface } from "@/constants/app";
import {
NOTIFY_ADD_CONTACT_FIRST,
NOTIFY_CONTINUE_WITHOUT_ADDING,
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import {
errorStringForLog,
getHeaders,
register,
serverMessageForUser,
} from "@/libs/endorserServer";
import { decryptMessage } from "@/libs/crypto";
import { Contact } from "@/db/tables/contacts";
import { MemberData } from "@/interfaces";
import * as libsUtil from "@/libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import AdmitPendingMembersDialog from "./AdmitPendingMembersDialog.vue";
import SetBulkVisibilityDialog from "./SetBulkVisibilityDialog.vue";
interface Member {
@@ -208,6 +238,7 @@ interface DecryptedMember {
@Component({
components: {
AdmitPendingMembersDialog,
SetBulkVisibilityDialog,
},
mixins: [PlatformServiceMixin],
@@ -227,6 +258,7 @@ export default class MembersList extends Vue {
return message;
}
contacts: Array<Contact> = [];
decryptedMembers: DecryptedMember[] = [];
firstName = "";
isLoading = true;
@@ -237,23 +269,11 @@ export default class MembersList extends Vue {
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[] = [];
previousMemberDidsIgnored: string[] = [];
/**
* Get the unnamed member constant
@@ -274,23 +294,8 @@ export default class MembersList extends Vue {
this.apiServer = settings.apiServer || "";
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();
this.refreshData();
}
async fetchMembers() {
@@ -378,17 +383,58 @@ 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, plus themselves
// 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,
};
const otherMembersPlusUser = [currentUser, ...this.decryptedMembers];
members = otherMembersPlusUser;
}
// 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() {
@@ -412,86 +458,85 @@ export default class MembersList extends Vue {
}
}
async loadContacts() {
this.contacts = await this.$getAllContacts();
}
getContactFor(did: string): Contact | undefined {
return this.contacts.find((contact) => contact.did === did);
}
getMembersForVisibility() {
getPendingMembersToAdmit(): MemberData[] {
return this.decryptedMembers
.filter((member) => {
// Exclude the current user
if (member.did === this.activeDid) {
return false;
}
.filter(
(member) => member.did !== this.activeDid && !member.member.admitted,
)
.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:
// 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(),
},
}));
convertDecryptedMemberToMemberData(
decryptedMember: DecryptedMember,
): MemberData {
return {
did: decryptedMember.did,
name: decryptedMember.name,
isContact: !!this.getContactFor(decryptedMember.did),
member: {
memberId: decryptedMember.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)
* Show the admit pending members dialog if conditions are met
*/
shouldShowVisibilityDialog(): boolean {
const currentMembers = this.getMembersForVisibility();
async refreshData(bypassPromptIfAllWereIgnored = true) {
// Force refresh both contacts and members
this.contacts = await this.$getAllContacts();
await this.fetchMembers();
if (currentMembers.length === 0) {
return false;
const pendingMembers = this.isOrganizer
? this.getPendingMembersToAdmit()
: this.getNonContactMembers();
if (pendingMembers.length === 0) {
this.startAutoRefresh();
return;
}
// If no previous members tracked, show dialog
if (this.previousVisibilityMembers.length === 0) {
return true;
if (bypassPromptIfAllWereIgnored) {
// only show if there are pending members that have not been ignored
const pendingMembersNotIgnored = pendingMembers.filter(
(member) => !this.previousMemberDidsIgnored.includes(member.did),
);
if (pendingMembersNotIgnored.length === 0) {
this.startAutoRefresh();
// everyone waiting has been ignored
return;
}
}
this.stopAutoRefresh();
if (this.isOrganizer) {
(this.$refs.admitPendingMembersDialog as AdmitPendingMembersDialog).open(
pendingMembers,
);
} else {
(this.$refs.setBulkVisibilityDialog as SetBulkVisibilityDialog).open(
pendingMembers,
);
}
// 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);
}
// Admit Pending Members Dialog methods
async closeMemberSelectionDialogCallback(
result: { notSelectedMemberDids: string[] } | undefined,
) {
this.previousMemberDidsIgnored = result?.notSelectedMemberDids || [];
/**
* Show the visibility dialog if conditions are met
*/
checkAndShowVisibilityDialog() {
if (this.shouldShowVisibilityDialog()) {
this.showSetBulkVisibilityDialog();
}
this.updatePreviousVisibilityMembers();
await this.refreshData();
}
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
@@ -632,19 +677,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() {
this.stopAutoRefresh();
this.lastRefreshTime = Date.now();
this.countdownTimer = 10;
@@ -674,33 +708,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() {
this.stopAutoRefresh();
}
@@ -718,23 +725,26 @@ export default class MembersList extends Vue {
.btn-add-contact {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply w-6 h-6 flex items-center justify-center rounded-full
bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-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 w-6 h-6 flex items-center justify-center rounded-full
bg-slate-100 text-slate-400 hover:text-slate-600
@apply text-slate-400 hover:text-slate-600
transition-colors;
}
.btn-admission {
.btn-admission-add {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply 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-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

@@ -3,25 +3,30 @@ GiftedDialog.vue to handle person entity display * with selection states and
conflict detection. * * @author Matthew Raymer */
<template>
<li :class="cardClasses" @click="handleClick">
<div>
<div class="relative w-fit mx-auto">
<EntityIcon
v-if="person.did"
:contact="person"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-5xl mb-1 shrink-0"
class="text-slate-400 text-5xl mb-1"
/>
<!-- Time icon overlay for contacts -->
<div
v-if="person.did && showTimeIcon"
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
>
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
</div>
</div>
<div class="overflow-hidden">
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<p class="text-xs text-slate-500 truncate">{{ person.did }}</p>
</div>
<h3 :class="nameClasses">
{{ displayName }}
</h3>
</li>
</template>
@@ -76,32 +81,29 @@ export default class PersonCard extends Vue {
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) {
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
return "opacity-50 cursor-not-allowed";
}
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
return "cursor-pointer hover:bg-slate-50";
}
/**
* Computed CSS classes for the person name
*/
get nameClasses(): string {
const baseNameClasses = "text-sm font-semibold truncate";
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseNameClasses} text-slate-500`;
return `${baseClasses} text-slate-400`;
}
// Add italic styling for entities without set names
if (!this.person.name) {
return `${baseNameClasses} italic text-slate-500`;
return `${baseClasses} italic text-slate-500`;
}
return baseNameClasses;
return baseClasses;
}
/**

View File

@@ -2,26 +2,25 @@
GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */
<template>
<li
class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
@click="handleClick"
>
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<li class="cursor-pointer" @click="handleClick">
<div class="relative w-fit mx-auto">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
</div>
<div class="overflow-hidden">
<h3 class="text-sm font-semibold truncate">
{{ project.name || unnamedProject }}
</h3>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ project.name || unnamedProject }}
</h3>
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="text-slate-400" />
{{ issuerDisplayName }}
</div>
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="fa-fw text-slate-400" />
{{ issuerDisplayName }}
</div>
</li>
</template>

View File

@@ -3,15 +3,14 @@
<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
Add Members to Contacts
</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.)
Would you like to add these members to your contacts?
</p>
<!-- Custom table area - you can customize this -->
<div v-if="shouldInitializeSelection" class="mb-4">
<div class="mb-4">
<table
class="w-full border-collapse border border-slate-300 text-sm text-start"
>
@@ -36,7 +35,7 @@
<td
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
>
No members need visibility settings
No members are not in your contacts
</td>
</tr>
<tr
@@ -80,15 +79,13 @@
]"
@click="setVisibilityForSelectedMembers"
>
Set Visibility
Add to Contacts
</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"
}}
Maybe Later
</button>
</div>
</div>
@@ -101,24 +98,15 @@ import { Vue, Component, Prop } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { MemberData } from "@/interfaces";
import { setVisibilityUtil } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify";
interface MemberData {
did: string;
name: string;
isContact: boolean;
member: {
memberId: string;
};
}
@Component({
mixins: [PlatformServiceMixin],
emits: ["close"],
})
export default class SetBulkVisibilityDialog extends Vue {
@Prop({ default: false }) visible!: boolean;
@Prop({ default: () => [] }) membersData!: MemberData[];
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
@@ -132,8 +120,9 @@ export default class SetBulkVisibilityDialog extends Vue {
notify!: ReturnType<typeof createNotifyHelpers>;
// Component state
membersData: MemberData[] = [];
selectedMembers: string[] = [];
selectionInitialized = false;
visible = false;
// Constants
// In Vue templates, imported constants need to be explicitly made available to the template
@@ -158,29 +147,24 @@ export default class SetBulkVisibilityDialog extends Vue {
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 = [];
open(members: MemberData[]) {
this.visible = true;
this.membersData = members;
// Select all by default
this.selectedMembers = this.membersData.map((member) => member.did);
}
resetSelection() {
this.selectedMembers = [];
this.selectionInitialized = false;
close(notSelectedMemberDids: string[]) {
this.visible = false;
this.$emit("close", { notSelectedMemberDids: notSelectedMemberDids });
}
cancel() {
this.close(this.membersData.map((member) => member.did));
}
toggleSelectAll() {
@@ -213,6 +197,9 @@ export default class SetBulkVisibilityDialog extends Vue {
const selectedMembers = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let successCount = 0;
@@ -245,9 +232,7 @@ export default class SetBulkVisibilityDialog extends Vue {
5000,
);
// Emit success event
this.$emit("success", successCount);
this.close();
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error setting visibility:", error);
@@ -320,14 +305,5 @@ export default class SetBulkVisibilityDialog extends Vue {
5000,
);
}
close() {
this.resetSelection();
this.$emit("close");
}
cancel() {
this.close();
}
}
</script>

View File

@@ -0,0 +1,66 @@
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
entity types. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer">
<router-link :to="navigationRoute" class="block text-center">
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
<h3
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
>
Show All
</h3>
</router-link>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { RouteLocationRaw } from "vue-router";
/**
* ShowAllCard - Displays "Show All" navigation for entity grids
*
* Features:
* - Provides navigation to full entity listings
* - Supports different routes based on entity type
* - Maintains context through query parameters
* - Consistent visual styling with other cards
*/
@Component({ name: "ShowAllCard" })
export default class ShowAllCard extends Vue {
/** Type of entities being shown */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Route name to navigate to */
@Prop({ required: true })
routeName!: string;
/** Query parameters to pass to the route */
@Prop({ default: () => ({}) })
queryParams!: Record<string, string>;
/**
* Computed navigation route with query parameters
*/
get navigationRoute(): RouteLocationRaw {
return {
name: this.routeName,
query: this.queryParams,
};
}
}
</script>
<style scoped>
/* Ensure router-link styling is consistent */
a {
text-decoration: none;
}
a:hover .fa-circle-right {
transform: scale(1.1);
transition: transform 0.2s ease;
}
</style>

View File

@@ -63,24 +63,23 @@ export default class SpecialEntityCard extends Vue {
conflictContext!: string;
/**
* Computed CSS classes for the card
* Computed CSS classes for the card container
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
const baseClasses = "block";
if (!this.selectable || this.conflicted) {
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
return `${baseClasses} cursor-not-allowed opacity-50`;
}
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
return `${baseClasses} cursor-pointer`;
}
/**
* Computed CSS classes for the icon
*/
get iconClasses(): string {
const baseClasses = "text-[2rem]";
const baseClasses = "text-5xl mb-1";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
@@ -102,7 +101,7 @@ export default class SpecialEntityCard extends Vue {
*/
get nameClasses(): string {
const baseClasses =
"text-sm font-semibold text-ellipsis whitespace-nowrap overflow-hidden";
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;

View File

@@ -234,20 +234,32 @@ export async function runMigrations<T>(
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
logger.debug("[Migration] Starting database migrations");
// Only log migration start in development
const isDevelopment = process.env.VITE_PLATFORM === "development";
if (isDevelopment) {
logger.debug("[Migration] Starting database migrations");
}
for (const migration of MIGRATIONS) {
logger.debug("[Migration] Registering migration:", migration.name);
if (isDevelopment) {
logger.debug("[Migration] Registering migration:", migration.name);
}
registerMigration(migration);
}
logger.debug("[Migration] Running migration service");
if (isDevelopment) {
logger.debug("[Migration] Running migration service");
}
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
logger.debug("[Migration] Database migrations completed");
if (isDevelopment) {
logger.debug("[Migration] Database migrations completed");
}
// Bootstrapping: Ensure active account is selected after migrations
logger.debug("[Migration] Running bootstrapping hooks");
if (isDevelopment) {
logger.debug("[Migration] Running bootstrapping hooks");
}
try {
// Check if we have accounts but no active selection
const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts");
@@ -262,14 +274,18 @@ export async function runMigrations<T>(
activeDid = (extractSingleValue(activeResult) as string) || null;
} catch (error) {
// Table doesn't exist - migration 004 may not have run yet
logger.debug(
"[Migration] active_identity table not found - migration may not have run",
);
if (isDevelopment) {
logger.debug(
"[Migration] active_identity table not found - migration may not have run",
);
}
activeDid = null;
}
if (accountsCount > 0 && (!activeDid || activeDid === "")) {
logger.debug("[Migration] Auto-selecting first account as active");
if (isDevelopment) {
logger.debug("[Migration] Auto-selecting first account as active");
}
const firstAccountResult = await sqlQuery(
"SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1",
);

View File

@@ -14,13 +14,6 @@ export interface AgreeActionClaim extends ClaimObject {
object: Record<string, unknown>;
}
export interface EmojiClaim extends ClaimObject {
// default context is "https://endorser.ch"
"@type": "Emoji";
text: string;
parentItem: { lastClaimId: string };
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4
export interface GiveActionClaim extends ClaimObject {

View File

@@ -81,9 +81,7 @@ export interface UserInfo {
export interface CreateAndSubmitClaimResult {
success: boolean;
embeddedRecordError?: string;
error?: string;
claimId?: string;
handleId?: string;
}

View File

@@ -1,6 +1,37 @@
export * from "./claims";
export * from "./claims-result";
export * from "./common";
export * from "./deepLinks";
export type {
// From common.ts
CreateAndSubmitClaimResult,
GenericCredWrapper,
GenericVerifiableCredential,
KeyMeta,
// Exclude types that are also exported from other files
// GiveVerifiableCredential,
// OfferVerifiableCredential,
// RegisterVerifiableCredential,
// PlanSummaryRecord,
// UserInfo,
} from "./common";
export type {
// From claims.ts
GiveActionClaim,
OfferClaim,
RegisterActionClaim,
} from "./claims";
export type {
// From records.ts
PlanSummaryRecord,
} from "./records";
export type {
// From user.ts
UserInfo,
MemberData,
} from "./user";
export * from "./limits";
export * from "./deepLinks";
export * from "./common";
export * from "./claims-result";
export * from "./records";

View File

@@ -1,26 +1,14 @@
import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims";
import { GenericCredWrapper } from "./common";
export interface EmojiSummaryRecord {
issuerDid: string;
jwtId: string;
text: string;
parentHandleId: string;
}
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
[x: string]:
| PropertyKey
| undefined
| GiveActionClaim
| Record<string, number>;
[x: string]: PropertyKey | undefined | GiveActionClaim;
type?: string;
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
emojiCount: Record<string, number>; // Map of emoji character to count
fullClaim: GiveActionClaim;
fulfillsHandleId: string;
fulfillsPlanHandleId?: string;

View File

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

View File

@@ -630,7 +630,11 @@ async function performPlanRequest(
return cred;
} else {
logger.debug(
// Use debug level for development to reduce console noise
const isDevelopment = process.env.VITE_PLATFORM === "development";
const log = isDevelopment ? logger.debug : logger.log;
log(
"[Plan Loading] ⚠️ Plan cache is empty for handle",
handleId,
" Got data:",
@@ -1222,12 +1226,7 @@ export async function createAndSubmitClaim(
timestamp: new Date().toISOString(),
});
return {
success: true,
claimId: response.data?.claimId,
handleId: response.data?.handleId,
embeddedRecordError: response.data?.embeddedRecordError,
};
return { success: true, handleId: response.data?.handleId };
} catch (error: unknown) {
// Enhanced error logging with comprehensive context
const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;

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

@@ -1147,29 +1147,3 @@ export async function checkForDuplicateAccount(
return (existingAccount?.values?.length ?? 0) > 0;
}
export class PromiseTracker<T> {
private _promise: Promise<T>;
private _resolved = false;
private _value: T | undefined;
constructor(promise: Promise<T>) {
this._promise = promise.then((value) => {
this._resolved = true;
this._value = value;
return value;
});
}
get isResolved(): boolean {
return this._resolved;
}
get value(): T | undefined {
return this._value;
}
get promise(): Promise<T> {
return this._promise;
}
}

View File

@@ -48,13 +48,15 @@ export class WebPlatformService implements PlatformService {
constructor() {
WebPlatformService.instanceCount++;
logger.debug("[WebPlatformService] Initializing web platform service");
// Use debug level logging for development mode to reduce console noise
const isDevelopment = process.env.VITE_PLATFORM === "development";
const log = isDevelopment ? logger.debug : logger.log;
log("[WebPlatformService] Initializing web platform service");
// Only initialize SharedArrayBuffer setup for web platforms
if (this.isWorker()) {
logger.debug(
"[WebPlatformService] Skipping initBackend call in worker context",
);
log("[WebPlatformService] Skipping initBackend call in worker context");
return;
}

View File

@@ -19,6 +19,7 @@
<EntityGrid
entity-type="people"
:entities="people"
:max-items="5"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -38,6 +39,7 @@
<EntityGrid
entity-type="projects"
:entities="projects"
:max-items="3"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -150,8 +152,11 @@ export default class EntityGridFunctionPropTest extends Vue {
customPeopleFunction = (
entities: Contact[],
_entityType: string,
maxItems: number,
): Contact[] => {
return entities.filter((person) => person.profileImageUrl);
return entities
.filter((person) => person.profileImageUrl)
.slice(0, maxItems);
};
/**
@@ -160,6 +165,7 @@ export default class EntityGridFunctionPropTest extends Vue {
customProjectsFunction = (
entities: PlanData[],
_entityType: string,
_maxItems: number,
): PlanData[] => {
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
};
@@ -194,16 +200,16 @@ export default class EntityGridFunctionPropTest extends Vue {
*/
get displayedPeopleCount(): number {
if (this.useCustomFunction) {
return this.customPeopleFunction(this.people, "people").length;
return this.customPeopleFunction(this.people, "people", 5).length;
}
return Math.min(10, this.people.length); // Initial batch size for infinite scroll
return Math.min(5, this.people.length);
}
get displayedProjectsCount(): number {
if (this.useCustomFunction) {
return this.customProjectsFunction(this.projects, "projects").length;
return this.customProjectsFunction(this.projects, "projects", 3).length;
}
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
return Math.min(7, this.projects.length);
}
}
</script>

View File

@@ -970,20 +970,6 @@ export const PlatformServiceMixin = {
return this.$normalizeContacts(rawContacts);
},
/**
* Load all contacts sorted by when they were added (by ID)
* Always fetches fresh data from database for consistency
* Handles JSON string/object duality for contactMethods field
* @returns Promise<Contact[]> Array of normalized contact objects sorted by addition date (newest first)
*/
async $contactsByDateAdded(): Promise<Contact[]> {
const rawContacts = (await this.$query(
"SELECT * FROM contacts ORDER BY id DESC",
)) as ContactMaybeWithJsonStrings[];
return this.$normalizeContacts(rawContacts);
},
/**
* Ultra-concise shortcut for getting number of contacts
* @returns Promise<number> Total number of contacts
@@ -2071,7 +2057,6 @@ declare module "@vue/runtime-core" {
// Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
$contactCount(): Promise<number>;
$settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;

View File

@@ -91,15 +91,12 @@
<div class="text-sm overflow-hidden">
<div
data-testId="description"
class="flex items-start gap-2 overflow-hidden"
class="overflow-hidden text-ellipsis"
>
<font-awesome
icon="message"
class="fa-fw text-slate-400 flex-shrink-0 mt-1"
/>
<font-awesome icon="message" class="fa-fw text-slate-400" />
<vue-markdown
:source="claimDescription"
class="markdown-content flex-1 min-w-0"
class="markdown-content"
/>
</div>
<div class="overflow-hidden text-ellipsis">
@@ -554,7 +551,7 @@ import VueMarkdown from "vue-markdown-render";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { EmojiClaim, GenericVerifiableCredential } from "../interfaces";
import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
@@ -670,10 +667,6 @@ export default class ClaimView extends Vue {
return giveClaim.description || "";
}
if (this.veriClaim.claimType === "Emoji") {
return (claim as EmojiClaim).text || "";
}
// Fallback for other claim types
return (claim as { description?: string })?.description || "";
}

View File

@@ -245,7 +245,6 @@ Raymer * @version 1.0.0 */
:last-viewed-claim-id="feedLastViewedClaimId"
:is-registered="isRegistered"
:active-did="activeDid"
:api-server="apiServer"
@load-claim="onClickLoadClaim"
@view-image="openImageViewer"
/>
@@ -1265,7 +1264,6 @@ export default class HomeView extends Vue {
provider,
fulfillsPlan,
providedByPlan,
record.emojiCount,
);
}
@@ -1489,14 +1487,12 @@ export default class HomeView extends Vue {
provider: Provider | undefined,
fulfillsPlan?: FulfillsPlan,
providedByPlan?: ProvidedByPlan,
emojiCount?: Record<string, number>,
): GiveRecordWithContactInfo {
return {
...record,
jwtId: record.jwtId,
fullClaim: record.fullClaim,
description: record.description || "",
emojiCount: emojiCount || {},
handleId: record.handleId,
issuerDid: record.issuerDid,
fulfillsPlanHandleId: record.fulfillsPlanHandleId,

View File

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