Compare commits

..

1 Commits

35 changed files with 780 additions and 1723 deletions

View File

@@ -1151,28 +1151,28 @@ If you need to build manually or want to understand the individual steps:
- ... and you may have to fix these, especially with pkgx: - ... and you may have to fix these, especially with pkgx:
```bash ```bash
gem_path=$(which gem) gem_path=$(which gem)
shortened_path="${gem_path:h:h}" shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path export GEM_PATH=$shortened_path
``` ```
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version; ##### 1. Bump the version in package.json, then here
```bash ```bash
cd ios/App && xcrun agvtool new-version 47 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.2;/g" App.xcodeproj/project.pbxproj && cd - cd ios/App && xcrun agvtool new-version 40 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.7;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly. # Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5 #xcrun agvtool new-marketing-version 0.4.5
``` ```
##### 2. Build ##### 2. Build
Here's prod. Also available: test, dev Here's prod. Also available: test, dev
```bash ```bash
npm run build:ios:prod npm run build:ios:prod
``` ```
3.1. Use Xcode to build and run on simulator or device. 3.1. Use Xcode to build and run on simulator or device.
@@ -1197,8 +1197,7 @@ npm run build:ios:prod
- It can take 15 minutes for the build to show up in the list of builds. - It can take 15 minutes for the build to show up in the list of builds.
- You'll probably have to "Manage" something about encryption, disallowed in France. - You'll probably have to "Manage" something about encryption, disallowed in France.
- Then "Save" and "Add to Review" and "Resubmit to App Review". - Then "Save" and "Add to Review" and "Resubmit to App Review".
- Eventually it'll be "Ready for Distribution" which means it's live - Eventually it'll be "Ready for Distribution" which means
- When finished, bump package.json version
### Android Build ### Android Build
@@ -1316,26 +1315,26 @@ The recommended way to build for Android is using the automated build script:
#### Android Manual Build Process #### Android Manual Build Process
##### 1. Bump the version in package.json, then update these versions & run: ##### 1. Bump the version in package.json, then here: android/app/build.gradle
```bash ```bash
perl -p -i -e 's/versionCode .*/versionCode 47/g' android/app/build.gradle perl -p -i -e 's/versionCode .*/versionCode 40/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.2"/g' android/app/build.gradle perl -p -i -e 's/versionName .*/versionName "1.0.7"/g' android/app/build.gradle
``` ```
##### 2. Build ##### 2. Build
Here's prod. Also available: test, dev Here's prod. Also available: test, dev
```bash ```bash
npm run build:android:prod npm run build:android:prod
``` ```
##### 3. Open the project in Android Studio ##### 3. Open the project in Android Studio
```bash ```bash
npx cap open android npx cap open android
``` ```
##### 4. Use Android Studio to build and run on emulator or device ##### 4. Use Android Studio to build and run on emulator or device
@@ -1380,8 +1379,6 @@ At play.google.com/console:
- Note that if you add testers, you have to go to "Publishing Overview" and send - Note that if you add testers, you have to go to "Publishing Overview" and send
those changes or your (closed) testers won't see it. those changes or your (closed) testers won't see it.
- When finished, bump package.json version
### Capacitor Operations ### Capacitor Operations
```bash ```bash

View File

@@ -5,21 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.2] - 2025.11.06
### Fixed
- Bad page when user follows prompt to backup seed
## [1.1.1] - 2025.11.03
### Added
- Meeting onboarding via prompts
- Emojis on gift feed
- Starred projects with notification
## [1.0.7] - 2025.08.18 ## [1.0.7] - 2025.08.18
### Fixed ### Fixed

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 47 versionCode 41
versionName "1.1.2" versionName "1.0.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -403,7 +403,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 47; CURRENT_PROJECT_VERSION = 41;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +413,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.2; MARKETING_VERSION = 1.0.8;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -430,7 +430,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 47; CURRENT_PROJECT_VERSION = 41;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +440,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.2; MARKETING_VERSION = 1.0.8;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.3-beta", "version": "1.1.1-beta",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"

View File

@@ -436,21 +436,7 @@ fi
log_info "Cleaning dist directory..." log_info "Cleaning dist directory..."
clean_build_artifacts "dist" clean_build_artifacts "dist"
# Step 4: Run TypeScript type checking for test and production builds # Step 4: Build Capacitor version with mode
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3 safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then elif [ "$BUILD_MODE" = "test" ]; then
@@ -459,23 +445,23 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3 safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi fi
# Step 6: Clean Gradle build # Step 5: Clean Gradle build
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4 safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
# Step 7: Build based on type # Step 6: Build based on type
if [ "$BUILD_TYPE" = "debug" ]; then if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5 safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
elif [ "$BUILD_TYPE" = "release" ]; then elif [ "$BUILD_TYPE" = "release" ]; then
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5 safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
fi fi
# Step 8: Sync with Capacitor # Step 7: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6 safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# Step 9: Generate assets # Step 8: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7 safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
# Step 10: Build APK/AAB if requested # Step 9: Build APK/AAB if requested
if [ "$BUILD_APK" = true ]; then if [ "$BUILD_APK" = true ]; then
if [ "$BUILD_TYPE" = "debug" ]; then if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5 safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
@@ -488,7 +474,7 @@ if [ "$BUILD_AAB" = true ]; then
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5 safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
fi fi
# Step 11: Auto-run app if requested # Step 10: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then if [ "$AUTO_RUN" = true ]; then
log_step "Auto-running Android app..." log_step "Auto-running Android app..."
safe_execute "Launching app" "npx cap run android" || { safe_execute "Launching app" "npx cap run android" || {
@@ -499,7 +485,7 @@ if [ "$AUTO_RUN" = true ]; then
log_success "Android app launched successfully!" log_success "Android app launched successfully!"
fi fi
# Step 12: Open Android Studio if requested # Step 11: Open Android Studio if requested
if [ "$OPEN_STUDIO" = true ]; then if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Android Studio" "npx cap open android" || exit 8 safe_execute "Opening Android Studio" "npx cap open android" || exit 8
fi fi

View File

@@ -381,21 +381,7 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
log_info "Cleaning dist directory..." log_info "Cleaning dist directory..."
clean_build_artifacts "dist" clean_build_artifacts "dist"
# Step 4: Run TypeScript type checking for test and production builds # Step 4: Build Capacitor version with mode
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3 safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then elif [ "$BUILD_MODE" = "test" ]; then
@@ -404,16 +390,16 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3 safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi fi
# Step 6: Sync with Capacitor # Step 5: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6 safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# Step 7: Generate assets # Step 6: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7 safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
# Step 8: Build iOS app # Step 7: Build iOS app
safe_execute "Building iOS app" "build_ios_app" || exit 5 safe_execute "Building iOS app" "build_ios_app" || exit 5
# Step 9: Build IPA/App if requested # Step 8: Build IPA/App if requested
if [ "$BUILD_IPA" = true ]; then if [ "$BUILD_IPA" = true ]; then
log_info "Building IPA package..." log_info "Building IPA package..."
cd ios/App cd ios/App
@@ -440,12 +426,12 @@ if [ "$BUILD_APP" = true ]; then
log_success "App bundle built successfully" log_success "App bundle built successfully"
fi fi
# Step 10: Auto-run app if requested # Step 9: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then if [ "$AUTO_RUN" = true ]; then
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9 safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
fi fi
# Step 11: Open Xcode if requested # Step 10: Open Xcode if requested
if [ "$OPEN_STUDIO" = true ]; then if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Xcode" "npx cap open ios" || exit 8 safe_execute "Opening Xcode" "npx cap open ios" || exit 8
fi fi

View File

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

View File

@@ -111,7 +111,7 @@
? 'bg-blue-600 text-white cursor-pointer' ? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed', : 'bg-slate-400 text-slate-200 cursor-not-allowed',
]" ]"
@click="processSelectedMembers" @click="handleMainAction"
> >
{{ buttonText }} {{ buttonText }}
</button> </button>
@@ -134,9 +134,8 @@ import { Vue, Component, Prop } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities"; import { SOMEONE_UNNAMED } from "@/constants/entities";
import { MemberData } from "@/interfaces"; import { MemberData } from "@/interfaces";
import { setVisibilityUtil, getHeaders, register } from "@/libs/endorserServer"; import { setVisibilityUtil, getHeaders } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify"; import { createNotifyHelpers } from "@/utils/notify";
import { Contact } from "@/db/tables/contacts";
@Component({ @Component({
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
@@ -145,7 +144,7 @@ import { Contact } from "@/db/tables/contacts";
export default class BulkMembersDialog extends Vue { export default class BulkMembersDialog extends Vue {
@Prop({ default: "" }) activeDid!: string; @Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string; @Prop({ default: "" }) apiServer!: string;
// isOrganizer: true = organizer mode (admit members), false = member mode (set visibility) @Prop({ required: true }) dialogType!: "admit" | "visibility";
@Prop({ required: true }) isOrganizer!: boolean; @Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system // Vue notification system
@@ -252,35 +251,35 @@ export default class BulkMembersDialog extends Vue {
return this.selectedMembers.includes(memberDid); return this.selectedMembers.includes(memberDid);
} }
async processSelectedMembers() { async handleMainAction() {
if (this.dialogType === "admit") {
await this.admitWithVisibility();
} else {
await this.addContactWithVisibility();
}
}
async admitWithVisibility() {
try { try {
const selectedMembers: MemberData[] = this.membersData.filter((member) => const selectedMembers = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did), this.selectedMembers.includes(member.did),
); );
const notSelectedMembers: MemberData[] = this.membersData.filter( const notSelectedMembers = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did), (member) => !this.selectedMembers.includes(member.did),
); );
let admittedCount = 0; let admittedCount = 0;
let contactAddedCount = 0; let contactAddedCount = 0;
let errors = 0;
for (const member of selectedMembers) { for (const member of selectedMembers) {
try { try {
// Organizer mode: admit and register the member first // First, admit the member
if (this.isOrganizer) { await this.admitMember(member);
await this.admitMember(member); admittedCount++;
await this.registerMember(member);
admittedCount++;
}
// If they're not a contact yet, add them as a contact // If they're not a contact yet, add them as a contact
if (!member.isContact) { if (!member.isContact) {
// Organizer mode: set isRegistered to true, member mode: undefined await this.addAsContact(member);
await this.addAsContact(
member,
this.isOrganizer ? true : undefined,
);
contactAddedCount++; contactAddedCount++;
} }
@@ -290,62 +289,85 @@ export default class BulkMembersDialog extends Vue {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error); console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails // Continue with other members even if one fails
errors++;
} }
} }
// Show success notification // Show success notification
if (this.isOrganizer) { this.$notify(
if (admittedCount > 0) { {
this.$notify( group: "alert",
{ type: "success",
group: "alert", title: "Members Admitted Successfully",
type: "success", text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
title: "Members Admitted Successfully", },
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`, 10000,
}, );
5000,
);
}
if (errors > 0) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to fully admit some members. Work with them individually below.",
},
5000,
);
}
} else {
// Member mode: show contacts added notification
if (contactAddedCount > 0) {
this.$notify(
{
group: "alert",
type: "success",
title: "Contacts Added Successfully",
text: `${contactAddedCount} member${contactAddedCount === 1 ? "" : "s"} added as contact${contactAddedCount === 1 ? "" : "s"}.`,
},
5000,
);
}
}
this.close(notSelectedMembers.map((member) => member.did)); this.close(notSelectedMembers.map((member) => member.did));
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error( console.error("Error admitting members:", error);
`Error ${this.isOrganizer ? "admitting members" : "adding contacts"}:`,
error,
);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "Some errors occurred. Work with members individually below.", text: "Failed to admit some members. Please try again.",
},
5000,
);
}
}
async addContactWithVisibility() {
try {
const selectedMembers = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let contactsAddedCount = 0;
for (const member of selectedMembers) {
try {
// If they're not a contact yet, add them as a contact first
if (!member.isContact) {
await this.addAsContact(member);
contactsAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
} catch (error) {
// 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: "Contacts Added Successfully",
text: `${contactsAddedCount} member${contactsAddedCount === 1 ? "" : "s"} added as contact${contactsAddedCount === 1 ? "" : "s"}.`,
},
5000,
);
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error adding contacts:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to add some members as contacts. Please try again.",
}, },
5000, 5000,
); );
@@ -371,39 +393,11 @@ export default class BulkMembersDialog extends Vue {
} }
} }
async registerMember(member: MemberData) { async addAsContact(member: { did: string; name: string }) {
try { try {
const contact: Contact = { did: member.did }; const newContact = {
const result = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (result.success) {
if (result.embeddedRecordError) {
throw new Error(result.embeddedRecordError);
}
await this.$updateContact(member.did, { registered: true });
} else {
throw result;
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error registering member:", err);
throw err;
}
}
async addAsContact(
member: { did: string; name: string },
isRegistered?: boolean,
) {
try {
const newContact: Contact = {
did: member.did, did: member.did,
name: member.name, name: member.name,
registered: isRegistered,
}; };
await this.$insertContact(newContact); await this.$insertContact(newContact);
@@ -446,10 +440,10 @@ export default class BulkMembersDialog extends Vue {
} }
showContactInfo() { showContactInfo() {
// isOrganizer: true = admit mode, false = visibility mode const message =
const message = this.isOrganizer this.dialogType === "admit"
? "This user is already your contact, but they are not yet admitted to the meeting." ? "This user is already your contact, but they are not yet admitted to the meeting."
: "This user is already your contact, but your activities are not visible to them yet."; : "This user is already your contact, but your activities are not visible to them yet.";
this.$notify( this.$notify(
{ {

View File

@@ -2,55 +2,12 @@
GiftedDialog.vue to provide a reusable grid layout * for displaying people, GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */ projects, and special entities with selection. * * @author Matthew Raymer */
<template> <template>
<!-- Quick Search --> <ul :class="gridClasses">
<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"
>
<!-- Special entities (You, Unnamed) for people grids --> <!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'"> <template v-if="entityType === 'people'">
<!-- "You" entity --> <!-- "You" entity -->
<SpecialEntityCard <SpecialEntityCard
v-if="showYouEntity && !searchTerm.trim()" v-if="showYouEntity"
entity-type="you" entity-type="you"
label="You" label="You"
icon="hand" icon="hand"
@@ -64,7 +21,6 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- "Unnamed" entity --> <!-- "Unnamed" entity -->
<SpecialEntityCard <SpecialEntityCard
v-if="showUnnamedEntity && !searchTerm.trim()"
entity-type="unnamed" entity-type="unnamed"
:label="unnamedEntityName" :label="unnamedEntityName"
icon="circle-question" icon="circle-question"
@@ -76,66 +32,22 @@ projects, and special entities with selection. * * @author Matthew Raymer */
</template> </template>
<!-- Empty state message --> <!-- Empty state message -->
<li v-if="hasNoEntities" :class="emptyStateClasses"> <li v-if="entities.length === 0" :class="emptyStateClasses">
{{ emptyStateMessage }} {{ emptyStateMessage }}
</li> </li>
<!-- Entity cards (people or projects) --> <!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'"> <template v-if="entityType === 'people'">
<!-- When showing contacts without search: split into recent and alphabetical --> <PersonCard
<template v-if="!searchTerm.trim()"> v-for="person in displayedEntities as Contact[]"
<!-- Recently Added Section --> :key="person.did"
<template v-if="recentContacts.length > 0"> :person="person"
<li :conflicted="isPersonConflicted(person.did)"
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300" :show-time-icon="true"
> :notify="notify"
Recently Added :conflict-context="conflictContext"
</li> @person-selected="handlePersonSelected"
<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
</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>
</template> </template>
<template v-else-if="entityType === 'projects'"> <template v-else-if="entityType === 'projects'">
@@ -151,30 +63,27 @@ projects, and special entities with selection. * * @author Matthew Raymer */
@project-selected="handleProjectSelected" @project-selected="handleProjectSelected"
/> />
</template> </template>
<!-- Show All navigation -->
<ShowAllCard
v-if="shouldShowAll"
:entity-type="entityType"
:route-name="showAllRoute"
:query-params="showAllQueryParams"
/>
</ul> </ul>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator"; import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import { useInfiniteScroll } from "@vueuse/core";
import PersonCard from "./PersonCard.vue"; import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue"; import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue"; import SpecialEntityCard from "./SpecialEntityCard.vue";
import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records"; import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app"; import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { getHeaders } from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { TIMEOUTS } from "@/utils/notify";
/**
* 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 * EntityGrid - Unified grid layout for displaying people or projects
@@ -184,6 +93,7 @@ const RECENT_CONTACTS_COUNT = 3;
* - Special entity integration (You, Unnamed) * - Special entity integration (You, Unnamed)
* - Conflict detection integration * - Conflict detection integration
* - Empty state messaging * - Empty state messaging
* - Show All navigation
* - Event delegation for entity selection * - Event delegation for entity selection
* - Warning notifications for conflicted entities * - Warning notifications for conflicted entities
* - Template streamlined with computed CSS properties * - Template streamlined with computed CSS properties
@@ -194,49 +104,21 @@ const RECENT_CONTACTS_COUNT = 3;
PersonCard, PersonCard,
ProjectCard, ProjectCard,
SpecialEntityCard, SpecialEntityCard,
ShowAllCard,
}, },
mixins: [PlatformServiceMixin],
}) })
export default class EntityGrid extends Vue { export default class EntityGrid extends Vue {
/** Type of entities to display */ /** Type of entities to display */
@Prop({ required: true }) @Prop({ required: true })
entityType!: "people" | "projects"; entityType!: "people" | "projects";
// Search state /** Array of entities to display */
searchTerm = ""; @Prop({ required: true })
isSearching = false; entities!: Contact[] | PlanData[];
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
searchBeforeId: string | undefined = undefined;
isLoadingSearchMore = false;
// API server for project searches /** Maximum number of entities to display */
apiServer = ""; @Prop({ default: 10 })
maxItems!: number;
// Internal project state (when entities prop not provided for projects)
allProjects: PlanData[] = [];
loadBeforeId: string | undefined = undefined;
isLoadingProjects = false;
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
/**
* Array of entities to display
*
* For contacts (entityType === 'people'): REQUIRED - Must be a COMPLETE list from local database.
* Use $contactsByDateAdded() to ensure all contacts are included.
* Client-side filtering assumes the complete list is available.
* IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
* (newest first) for the "Recently Added" section to display correctly.
*
* For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads
* projects internally from the API server. If provided, uses the provided list.
*/
@Prop({ required: false })
entities?: Contact[] | PlanData[];
/** Active user's DID */ /** Active user's DID */
@Prop({ required: true }) @Prop({ required: true })
@@ -258,14 +140,18 @@ export default class EntityGrid extends Vue {
@Prop({ default: true }) @Prop({ default: true })
showYouEntity!: boolean; showYouEntity!: boolean;
/** Whether to show the "Unnamed" entity for people grids */
@Prop({ default: true })
showUnnamedEntity!: boolean;
/** Whether the "You" entity is selectable */ /** Whether the "You" entity is selectable */
@Prop({ default: true }) @Prop({ default: true })
youSelectable!: boolean; 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 */ /** Notification function from parent component */
@Prop() @Prop()
notify?: (notification: NotificationIface, timeout?: number) => void; notify?: (notification: NotificationIface, timeout?: number) => void;
@@ -274,31 +160,42 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" }) @Prop({ default: "other party" })
conflictContext!: string; conflictContext!: string;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/** /**
* Function to determine which entities to display (allows parent control) * Function to determine which entities to display (allows parent control)
* *
* This function prop allows parent components to customize which entities * This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering and sorting. * are displayed in the grid, enabling advanced filtering, sorting, and
* Note: Infinite scroll is disabled when this prop is provided. * display logic beyond the default simple slice behavior.
* *
* @param entities - The full array of entities (Contact[] or PlanData[]) * @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects") * @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 * @returns Filtered/sorted array of entities to display
* *
* @example * @example
* // Custom filtering: only show contacts with profile images * // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type) => * :display-entities-function="(entities, type, max) =>
* entities.filter(e => e.profileImageUrl)" * entities.filter(e => e.profileImageUrl).slice(0, max)"
* *
* @example * @example
* // Custom sorting: sort projects by name * // Custom sorting: sort projects by name
* :display-entities-function="(entities, type) => * :display-entities-function="(entities, type, max) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))" * 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 }) @Prop({ default: null })
displayEntitiesFunction?: ( displayEntitiesFunction?: (
entities: Contact[] | PlanData[], entities: Contact[] | PlanData[],
entityType: "people" | "projects", entityType: "people" | "projects",
maxItems: number,
) => Contact[] | PlanData[]; ) => Contact[] | PlanData[];
/** /**
@@ -309,98 +206,33 @@ export default class EntityGrid extends Vue {
} }
/** /**
* Check if there are no entities to display * Computed CSS classes for the grid layout
*/ */
get hasNoEntities(): boolean { get gridClasses(): string {
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
if (this.entityType === "projects") { if (this.entityType === "projects") {
// For projects: check internal state if no entities prop, otherwise check prop return `${baseClasses} grid-cols-3 md:grid-cols-4`;
const projectsToCheck = this.entities || this.allProjects;
return projectsToCheck.length === 0;
} else { } else {
// For people: entities prop is required return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
return !this.entities || this.entities.length === 0;
} }
} }
/** /**
* Get the entities array to use (prop or internal state) * Computed entities to display - uses function prop if provided, otherwise defaults
*/
get entitiesToUse(): Contact[] | PlanData[] {
if (this.entityType === "projects") {
// For projects: use prop if provided, otherwise use internal state
return this.entities || this.allProjects;
} else {
// For people: entities prop is required
return this.entities || [];
}
}
/**
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied
*/ */
get displayedEntities(): Contact[] | PlanData[] { 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) { if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType); return this.displayEntitiesFunction(
this.entities,
this.entityType,
this.maxItems,
);
} }
// Default: projects use infinite scroll // Default implementation for backward compatibility
if (this.entityType === "projects") { const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount); return this.entities.slice(0, maxDisplay);
}
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
return [];
}
/**
* Get the most recently added contacts (when showing contacts and not searching)
*
* NOTE: This assumes entities are already sorted by date added (newest first).
* See the entities prop documentation for details on using $contactsByDateAdded().
*/
get recentContacts(): Contact[] {
if (
this.entityType !== "people" ||
this.searchTerm.trim() ||
!this.entities
) {
return [];
}
// Entities are already sorted by date added (newest first)
return (this.entities as Contact[]).slice(0, RECENT_CONTACTS_COUNT);
}
/**
* 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() ||
!this.entities
) {
return [];
}
// Skip the first few (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const remaining = this.entities as Contact[];
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 recent contacts)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
} }
/** /**
@@ -414,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 * Whether the "You" entity is conflicted
*/ */
@@ -487,440 +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
* Routes to server-side search for projects or client-side filtering for contacts
*/
async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) {
this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
return;
}
this.isSearching = true;
this.searchBeforeId = undefined; // Reset pagination for new search
try {
if (this.entityType === "projects") {
// Server-side search for projects (initial load, no beforeId)
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(undefined, searchLower);
} else {
// Client-side filtering for contacts (complete list)
await this.performContactSearch();
}
// Reset displayed count when search completes
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
} finally {
this.isSearching = false;
}
}
/**
* Fetch projects from API server
* Unified method for both loading all projects and searching projects.
* If claimContents is provided, performs search and updates filteredEntities.
* If claimContents is not provided, loads all projects and updates allProjects.
*
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
*/
async fetchProjects(
beforeId?: string,
claimContents?: string,
): Promise<void> {
if (!this.apiServer) {
if (claimContents) {
this.filteredEntities = [];
} else {
this.allProjects = [];
}
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "API server not configured",
},
TIMEOUTS.SHORT,
);
}
return;
}
const isSearch = !!claimContents;
let url = `${this.apiServer}/api/v2/report/plans`;
// Build query parameters
const params: string[] = [];
if (claimContents) {
params.push(
`claimContents=${encodeURIComponent(claimContents.toLowerCase().trim())}`,
);
}
if (beforeId) {
params.push(`beforeId=${encodeURIComponent(beforeId)}`);
}
if (params.length > 0) {
url += `?${params.join("&")}`;
}
try {
const response = await fetch(url, {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error(
isSearch ? "Failed to search projects" : "Failed to load projects",
);
}
const results = await response.json();
if (results.data) {
const newProjects = results.data.map(
(plan: PlanData & { rowId?: string }) => ({
...plan,
rowId: plan.rowId,
}),
);
if (isSearch) {
// Search mode: update filteredEntities
if (beforeId) {
// Pagination: append new projects to existing search results
this.filteredEntities.push(...newProjects);
} else {
// Initial search: replace array
this.filteredEntities = newProjects;
}
// Update searchBeforeId for next pagination
if (newProjects.length > 0) {
const lastProject = newProjects[newProjects.length - 1];
this.searchBeforeId = lastProject.rowId || undefined;
} else {
this.searchBeforeId = undefined; // No more results
}
} else {
// Load mode: update allProjects
if (beforeId) {
// Pagination: append new projects
this.allProjects.push(...newProjects);
} else {
// Initial load: replace array
this.allProjects = newProjects;
}
// Update loadBeforeId for next pagination
if (newProjects.length > 0) {
const lastProject = newProjects[newProjects.length - 1];
this.loadBeforeId = lastProject.rowId || undefined;
} else {
this.loadBeforeId = undefined; // No more results
}
}
} else {
// No data in response
if (isSearch) {
if (!beforeId) {
// Only clear on initial search, not pagination
this.filteredEntities = [];
}
this.searchBeforeId = undefined;
} else {
if (!beforeId) {
// Only clear on initial load, not pagination
this.allProjects = [];
}
this.loadBeforeId = undefined;
}
}
} catch (error) {
logger.error(
`Error ${isSearch ? "searching" : "loading"} projects:`,
error,
);
if (isSearch) {
if (!beforeId) {
// Only clear on initial search error, not pagination error
this.filteredEntities = [];
}
this.searchBeforeId = undefined;
} else {
if (!beforeId) {
// Only clear on initial load error, not pagination error
this.allProjects = [];
}
this.loadBeforeId = undefined;
}
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: isSearch
? "Failed to search projects. Please try again."
: "Failed to load projects. Please try again.",
},
TIMEOUTS.STANDARD,
);
}
}
}
/**
* Client-side contact search
* Assumes entities prop contains complete contact list from local database
*/
async performContactSearch(): Promise<void> {
if (!this.entities) {
this.filteredEntities = [];
return;
}
// Simulate async (for consistency with project search)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
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);
});
// Contacts don't need pagination (complete list)
this.searchBeforeId = undefined;
}
/**
* Clear the search
*/
clearSearch(): void {
this.searchTerm = "";
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
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 if more results available
if (this.entityType === "projects") {
// Projects: can load more if:
// 1. We have more already-loaded results to show, OR
// 2. We've shown all loaded results AND there's a searchBeforeId to load more
const hasMoreLoaded =
this.displayedCount < this.filteredEntities.length;
const canLoadMoreFromServer =
this.displayedCount >= this.filteredEntities.length &&
!!this.searchBeforeId &&
!this.isLoadingSearchMore;
return hasMoreLoaded || canLoadMoreFromServer;
} else {
// Contacts: client-side filtering returns all results at once
return this.displayedCount < this.filteredEntities.length;
}
}
// Non-search mode
if (this.entityType === "projects") {
// Projects: check internal state or prop
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// Can load more if:
// 1. We have more already-loaded results to show, OR
// 2. We've shown all loaded results AND there's a beforeId to load more (and not using entities prop)
const hasMoreLoaded = this.displayedCount < projectsToCheck.length;
const canLoadMoreFromServer =
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
!!beforeId &&
!this.isLoadingProjects;
return hasMoreLoaded || canLoadMoreFromServer;
}
// People: check if more alphabetical contacts available
// Total available = recent + all alphabetical
if (!this.entities) {
return false;
}
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
return this.displayedCount < totalAvailable;
}
/**
* Initialize infinite scroll on mount
*/
async mounted(): Promise<void> {
// Load apiServer for project searches/loads
if (this.entityType === "projects") {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Load projects on mount if entities prop not provided
if (!this.entities && this.apiServer) {
this.isLoadingProjects = true;
try {
await this.fetchProjects();
} catch (error) {
logger.error("Error loading projects on mount:", error);
} finally {
this.isLoadingProjects = false;
}
}
}
// Validate entities prop for people
if (this.entityType === "people" && !this.entities) {
logger.error(
"EntityGrid: entities prop is required when entityType is 'people'",
);
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Contacts data is required but not provided.",
},
TIMEOUTS.SHORT,
);
}
}
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
async () => {
// Search mode: handle search pagination
if (this.searchTerm.trim()) {
if (this.entityType === "projects") {
// Projects: load more search results if available
if (
this.displayedCount >= this.filteredEntities.length &&
this.searchBeforeId &&
!this.isLoadingSearchMore
) {
this.isLoadingSearchMore = true;
try {
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(this.searchBeforeId, searchLower);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more search results:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingSearchMore = false;
}
} else {
// Show more from already-loaded search results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Contacts: show more from already-filtered results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Non-search mode
if (this.entityType === "projects") {
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// If using internal state and need to load more from server
if (
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
beforeId &&
!this.isLoadingProjects
) {
this.isLoadingProjects = true;
try {
await this.fetchProjects(beforeId);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more projects:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingProjects = false;
}
} else {
// Normal case: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
} else {
// People: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
}
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator // Emit methods using @Emit decorator
@Emit("entity-selected") @Emit("entity-selected")
@@ -933,47 +340,6 @@ export default class EntityGrid extends Vue {
} { } {
return data; return data;
} }
/**
* Watch for changes in search term to reset displayed count and pagination
*/
@Watch("searchTerm")
onSearchTermChange(): void {
// Reset displayed count and pagination when search term changes
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
}
/**
* Watch for changes in entities prop to clear search and reset displayed count
*/
@Watch("entities")
onEntitiesChange(): void {
// Clear search when entities change (fresh dialog open)
if (this.searchTerm) {
this.searchTerm = "";
this.filteredEntities = [];
this.searchBeforeId = undefined;
}
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// For projects: if entities prop is provided, clear internal state
if (this.entityType === "projects" && this.entities) {
this.allProjects = [];
this.loadBeforeId = undefined;
}
}
/**
* Cleanup timeouts when component is destroyed
*/
beforeUnmount(): void {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
} }
</script> </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 with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
based on context * - EntityGrid integration for unified entity display * - based on context * - EntityGrid integration for unified entity display * -
Conflict detection and prevention * - Special entity handling (You, Unnamed) * - Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
Cancel functionality * - Event delegation for entity selection * - Warning Show All navigation with context preservation * - Cancel functionality * - Event
notifications for conflicted entities * - Template streamlined with computed CSS delegation for entity selection * - Warning notifications for conflicted
properties * * @author Matthew Raymer */ entities * - Template streamlined with computed CSS properties * * @author
Matthew Raymer */
<template> <template>
<div id="sectionGiftedGiver"> <div id="sectionGiftedGiver">
<label class="block font-bold mb-4"> <label class="block font-bold mb-4">
@@ -14,15 +15,19 @@ properties * * @author Matthew Raymer */
<EntityGrid <EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'" :entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects || undefined : allContacts" :entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:active-did="activeDid" :active-did="activeDid"
:all-my-dids="allMyDids" :all-my-dids="allMyDids"
:all-contacts="allContacts" :all-contacts="allContacts"
:conflict-checker="conflictChecker" :conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity" :show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable" :you-selectable="youSelectable"
:show-all-route="showAllRoute"
:show-all-query-params="showAllQueryParams"
:notify="notify" :notify="notify"
:conflict-context="conflictContext" :conflict-context="conflictContext"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected" @entity-selected="handleEntitySelected"
/> />
@@ -63,6 +68,7 @@ interface EntitySelectionEvent {
* - EntityGrid integration for unified entity display * - EntityGrid integration for unified entity display
* - Conflict detection and prevention * - Conflict detection and prevention
* - Special entity handling (You, Unnamed) * - Special entity handling (You, Unnamed)
* - Show All navigation with context preservation
* - Cancel functionality * - Cancel functionality
* - Event delegation for entity selection * - Event delegation for entity selection
* - Warning notifications for conflicted entities * - Warning notifications for conflicted entities
@@ -94,9 +100,9 @@ export default class EntitySelectionStep extends Vue {
@Prop({ default: false }) @Prop({ default: false })
isFromProjectView!: boolean; isFromProjectView!: boolean;
/** Array of available projects (optional - EntityGrid loads internally if not provided) */ /** Array of available projects */
@Prop({ required: false }) @Prop({ required: true })
projects?: PlanData[]; projects!: PlanData[];
/** Array of available contacts */ /** Array of available contacts */
@Prop({ required: true }) @Prop({ required: true })
@@ -148,6 +154,10 @@ export default class EntitySelectionStep extends Vue {
@Prop() @Prop()
notify?: (notification: NotificationIface, timeout?: number) => void; notify?: (notification: NotificationIface, timeout?: number) => void;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/** /**
* CSS classes for the cancel button * CSS classes for the cancel button
*/ */
@@ -212,6 +222,59 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid); 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 * Handle entity selection from EntityGrid
*/ */

View File

@@ -15,6 +15,7 @@
giverEntityType === 'project' || recipientEntityType === 'project' giverEntityType === 'project' || recipientEntityType === 'project'
" "
:is-from-project-view="isFromProjectView" :is-from-project-view="isFromProjectView"
:projects="projects"
:all-contacts="allContacts" :all-contacts="allContacts"
:active-did="activeDid" :active-did="activeDid"
:all-my-dids="allMyDids" :all-my-dids="allMyDids"
@@ -28,6 +29,7 @@
:unit-code="unitCode" :unit-code="unitCode"
:offer-id="offerId" :offer-id="offerId"
:notify="$notify" :notify="$notify"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected" @entity-selected="handleEntitySelected"
@cancel="cancel" @cancel="cancel"
/> />
@@ -67,6 +69,7 @@ import {
createAndSubmitGive, createAndSubmitGive,
didInfo, didInfo,
serverMessageForUser, serverMessageForUser,
getHeaders,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
@@ -114,6 +117,7 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = ""; @Prop() fromProjectId = "";
@Prop() toProjectId = ""; @Prop() toProjectId = "";
@Prop() isFromProjectView = false; @Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person" as @Prop({ default: "person" }) giverEntityType = "person" as
| "person" | "person"
| "project"; | "project";
@@ -132,6 +136,7 @@ export default class GiftedDialog extends Vue {
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description) firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
offerId = ""; offerId = "";
projects: PlanData[] = [];
prompt = ""; prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo; receiver?: libsUtil.GiverReceiverInputInfo;
stepType = "giver"; stepType = "giver";
@@ -228,9 +233,19 @@ export default class GiftedDialog extends Vue {
apiServer: this.apiServer, apiServer: this.apiServer,
}); });
this.allContacts = await this.$contactsByDateAdded(); this.allContacts = await this.$contacts();
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();
if (
this.giverEntityType === "project" ||
this.recipientEntityType === "project"
) {
await this.loadProjects();
} else {
// Clear projects array when not needed
this.projects = [];
}
} catch (err: unknown) { } catch (err: unknown) {
logger.error("Error retrieving settings from database:", err); logger.error("Error retrieving settings from database:", err);
this.safeNotify.error( this.safeNotify.error(
@@ -476,6 +491,27 @@ export default class GiftedDialog extends Vue {
this.firstStep = false; this.firstStep = false;
} }
async loadProjects() {
try {
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error("Failed to load projects");
}
const results = await response.json();
if (results.data) {
this.projects = results.data;
}
} catch (error) {
logger.error("Error loading projects:", error);
this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD);
}
}
selectProject(project: PlanData) { selectProject(project: PlanData) {
this.giver = { this.giver = {
did: project.handleId, did: project.handleId,

View File

@@ -1,130 +0,0 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<h2 class="text-lg font-semibold leading-[1.25] mb-4">Select Project</h2>
<!-- EntityGrid for projects -->
<EntityGrid
:entity-type="'projects'"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="() => false"
:show-you-entity="false"
:show-unnamed-entity="false"
:notify="notify"
:conflict-context="'project'"
@entity-selected="handleEntitySelected"
/>
<!-- Cancel Button -->
<div class="flex gap-2 mt-4">
<button
class="block w-full text-center text-md uppercase 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-2 py-2 rounded-md"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
/**
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
*
* Features:
* - EntityGrid integration for project selection
* - No special entities (You, Unnamed)
* - Immediate assignment on project selection
* - Cancel button to close without selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class MeetingProjectDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** All contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/**
* Handle entity selection from EntityGrid
* Immediately assigns the selected project and closes the dialog
*/
handleEntitySelected(event: {
type: "person" | "project";
data: Contact | PlanData;
}) {
const project = event.data as PlanData;
this.emitAssign(project);
this.close();
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.close();
}
/**
* Open the dialog
*/
open(): void {
this.visible = true;
this.emitOpen();
}
/**
* Close the dialog
*/
close(): void {
this.visible = false;
this.emitClose();
}
// Emit methods using @Emit decorator
@Emit("assign")
emitAssign(project: PlanData): PlanData {
return project;
}
@Emit("open")
emitOpen(): void {
// Emit when dialog opens
}
@Emit("close")
emitClose(): void {
// Emit when dialog closes
}
}
</script>
<style scoped></style>

View File

@@ -99,7 +99,7 @@
<font-awesome <font-awesome
v-if="member.did === activeDid" v-if="member.did === activeDid"
icon="hand" icon="hand"
class="fa-fw text-slate-500" class="fa-fw text-blue-500"
/> />
<font-awesome <font-awesome
v-if=" v-if="
@@ -113,10 +113,10 @@
</h3> </h3>
<div <div
v-if="!getContactFor(member.did) && member.did !== activeDid" v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex items-center gap-1.5 ml-2 ms-1" class="flex items-center gap-1.5 ms-1"
> >
<button <button
class="btn-add-contact ml-2" class="btn-add-contact"
title="Add as contact" title="Add as contact"
@click="addAsContact(member)" @click="addAsContact(member)"
> >
@@ -124,7 +124,7 @@
</button> </button>
<button <button
class="btn-info-contact ml-2" class="btn-info-contact"
title="Contact Info" title="Contact Info"
@click=" @click="
informAboutAddingContact( informAboutAddingContact(
@@ -135,27 +135,6 @@
<font-awesome icon="circle-info" /> <font-awesome icon="circle-info" />
</button> </button>
</div> </div>
<div
v-if="getContactFor(member.did) && member.did !== activeDid"
class="flex items-center gap-1.5 ms-1"
>
<router-link
:to="{ name: 'contact-edit', params: { did: member.did } }"
>
<font-awesome
icon="pen"
class="text-sm text-blue-500 ml-2 mb-1"
/>
</router-link>
<router-link
:to="{ name: 'did', params: { did: member.did } }"
>
<font-awesome
icon="arrow-up-right-from-square"
class="text-sm text-blue-500 ml-2 mb-1"
/>
</router-link>
</div>
</div> </div>
<span <span
v-if=" v-if="
@@ -223,6 +202,7 @@
ref="bulkMembersDialog" ref="bulkMembersDialog"
:active-did="activeDid" :active-did="activeDid"
:api-server="apiServer" :api-server="apiServer"
:dialog-type="isOrganizer ? 'admit' : 'visibility'"
:is-organizer="isOrganizer" :is-organizer="isOrganizer"
@close="closeBulkMembersDialogCallback" @close="closeBulkMembersDialogCallback"
/> />

View File

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

View File

@@ -1,117 +0,0 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<h2 class="text-lg font-semibold leading-[1.25] mb-4">
Select Representative
</h2>
<!-- EntityGrid for contacts -->
<EntityGrid
:entity-type="'people'"
:entities="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="() => false"
:show-you-entity="false"
:show-unnamed-entity="false"
:notify="notify"
:conflict-context="'representative'"
@entity-selected="handleEntitySelected"
/>
<!-- Cancel Button -->
<div class="flex gap-2 mt-4">
<button
class="block w-full text-center text-md uppercase 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-2 py-2 rounded-md"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { NotificationIface } from "../constants/app";
/**
* ProjectRepresentativeDialog - Dialog for selecting an authorized representative
*
* Features:
* - EntityGrid integration for contact selection
* - No special entities (You, Unnamed)
* - Immediate assignment on contact selection
* - Cancel button to close without selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class ProjectRepresentativeDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
/** Array of available contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/**
* Handle entity selection from EntityGrid
* Immediately assigns the selected contact and closes the dialog
*/
handleEntitySelected(event: { type: "person" | "project"; data: Contact }) {
const contact = event.data as Contact;
this.emitAssign(contact);
this.close();
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.close();
}
/**
* Open the dialog
*/
open(): void {
this.visible = true;
}
/**
* Close the dialog
*/
close(): void {
this.visible = false;
}
// Emit methods using @Emit decorator
@Emit("assign")
emitAssign(contact: Contact): Contact {
return contact;
}
}
</script>
<style scoped></style>

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; conflictContext!: string;
/** /**
* Computed CSS classes for the card * Computed CSS classes for the card container
*/ */
get cardClasses(): string { get cardClasses(): string {
const baseCardClasses = const baseClasses = "block";
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) { 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 * Computed CSS classes for the icon
*/ */
get iconClasses(): string { get iconClasses(): string {
const baseClasses = "text-[2rem]"; const baseClasses = "text-5xl mb-1";
if (this.conflicted) { if (this.conflicted) {
return `${baseClasses} text-slate-400`; return `${baseClasses} text-slate-400`;
@@ -102,7 +101,7 @@ export default class SpecialEntityCard extends Vue {
*/ */
get nameClasses(): string { get nameClasses(): string {
const baseClasses = 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) { if (this.conflicted) {
return `${baseClasses} text-slate-400`; return `${baseClasses} text-slate-400`;

View File

@@ -510,6 +510,14 @@ export const NOTIFY_REGISTER_CONTACT = {
text: "Do you want to register them?", text: "Do you want to register them?",
}; };
// Used in: ContactsView.vue (showOnboardMeetingDialog method - complex modal for onboarding meeting)
export const NOTIFY_ONBOARDING_MEETING = {
title: "Onboarding Meeting",
text: "Would you like to start a new meeting?",
yesText: "Start New Meeting",
noText: "Join Existing Meeting",
};
// TestView.vue specific constants // TestView.vue specific constants
// Used in: TestView.vue (executeSql method - SQL error handling) // Used in: TestView.vue (executeSql method - SQL error handling)
export const NOTIFY_SQL_ERROR = { export const NOTIFY_SQL_ERROR = {

View File

@@ -70,6 +70,15 @@ export interface AxiosErrorResponse {
[key: string]: unknown; [key: string]: unknown;
} }
export interface UserInfo {
did: string;
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}
export interface CreateAndSubmitClaimResult { export interface CreateAndSubmitClaimResult {
success: boolean; success: boolean;
embeddedRecordError?: string; embeddedRecordError?: string;

View File

@@ -4,4 +4,3 @@ export * from "./common";
export * from "./deepLinks"; export * from "./deepLinks";
export * from "./limits"; export * from "./limits";
export * from "./records"; export * from "./records";
export * from "./user";

View File

@@ -42,6 +42,9 @@ import {
PlanActionClaim, PlanActionClaim,
RegisterActionClaim, RegisterActionClaim,
TenureClaim, TenureClaim,
} from "../interfaces/claims";
import {
GenericCredWrapper, GenericCredWrapper,
GenericVerifiableCredential, GenericVerifiableCredential,
AxiosErrorResponse, AxiosErrorResponse,
@@ -52,11 +55,13 @@ import {
QuantitativeValue, QuantitativeValue,
KeyMetaWithPrivate, KeyMetaWithPrivate,
KeyMetaMaybeWithPrivate, KeyMetaMaybeWithPrivate,
} from "../interfaces/common";
import {
OfferSummaryRecord, OfferSummaryRecord,
OfferToPlanSummaryRecord, OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim, PlanSummaryAndPreviousClaim,
PlanSummaryRecord, PlanSummaryRecord,
} from "../interfaces"; } from "../interfaces/records";
import { logger, safeStringify } from "../utils/logger"; import { logger, safeStringify } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { APP_SERVER } from "@/constants/app"; import { APP_SERVER } from "@/constants/app";
@@ -1657,39 +1662,31 @@ export async function register(
message?: string; message?: string;
}>(url, { jwtEncoded: vcJwt }); }>(url, { jwtEncoded: vcJwt });
if (resp.data?.success?.embeddedRecordError) { if (resp.data?.success?.handleId) {
return { success: true };
} else if (resp.data?.success?.embeddedRecordError) {
let message = let message =
"There was some problem with the registration and so it may not be complete."; "There was some problem with the registration and so it may not be complete.";
if (typeof resp.data.success.embeddedRecordError === "string") { if (typeof resp.data.success.embeddedRecordError === "string") {
message += " " + resp.data.success.embeddedRecordError; message += " " + resp.data.success.embeddedRecordError;
} }
return { error: message }; return { error: message };
} else if (resp.data?.success?.handleId) {
return { success: true };
} else { } else {
logger.error("Registration non-thrown error:", JSON.stringify(resp.data)); logger.error("Registration error:", JSON.stringify(resp.data));
return { return { error: "Got a server error when registering." };
error:
(resp.data?.error as { message?: string })?.message ||
(resp.data?.error as string) ||
"Got a server error when registering.",
};
} }
} catch (error: unknown) { } catch (error: unknown) {
if (error && typeof error === "object") { if (error && typeof error === "object") {
const err = error as AxiosErrorResponse; const err = error as AxiosErrorResponse;
const errorMessage = const errorMessage =
err.response?.data?.error?.message || err.message ||
err.response?.data?.error || (err.response?.data &&
err.message; typeof err.response.data === "object" &&
logger.error( "message" in err.response.data
"Registration thrown error:", ? (err.response.data as { message: string }).message
errorMessage || JSON.stringify(err), : undefined);
); logger.error("Registration error:", errorMessage || JSON.stringify(err));
return { return { error: errorMessage || "Got a server error when registering." };
error:
(errorMessage as string) || "Got a server error when registering.",
};
} }
return { error: "Got a server error when registering." }; return { error: "Got a server error when registering." };
} }

View File

@@ -5,5 +5,6 @@ export interface UserProfile {
locLat2?: number; locLat2?: number;
locLon2?: number; locLon2?: number;
issuerDid: string; issuerDid: string;
issuerDidVisibleToDids?: Array<string>;
rowId?: string; // set on profile retrieved from server rowId?: string; // set on profile retrieved from server
} }

View File

@@ -91,92 +91,16 @@ export class CapacitorPlatformService
} }
try { try {
// Try to create/Open database connection // Create/Open database
try { this.db = await this.sqlite.createConnection(
this.db = await this.sqlite.createConnection( this.dbName,
this.dbName, false,
false, "no-encryption",
"no-encryption", 1,
1, false,
false, );
);
} catch (createError: unknown) {
// If connection already exists, try to retrieve it or handle gracefully
const errorMessage =
createError instanceof Error
? createError.message
: String(createError);
const errorObj =
typeof createError === "object" && createError !== null
? (createError as { errorMessage?: string; message?: string })
: {};
const fullErrorMessage = await this.db.open();
errorObj.errorMessage || errorObj.message || errorMessage;
if (fullErrorMessage.includes("already exists")) {
logger.debug(
"[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve",
);
// Check if connection exists in JavaScript Map
const isConnResult = await this.sqlite.isConnection(
this.dbName,
false,
);
if (isConnResult.result) {
// Connection exists in Map, retrieve it
this.db = await this.sqlite.retrieveConnection(this.dbName, false);
logger.debug(
"[CapacitorPlatformService] Successfully retrieved existing connection from Map",
);
} else {
// Connection exists on native side but not in JavaScript Map
// This can happen when the app is restarted but native connections persist
// Try to close the native connection first, then create a new one
logger.debug(
"[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating",
);
try {
await this.sqlite.closeConnection(this.dbName, false);
} catch (closeError) {
// Ignore close errors - connection might not be properly tracked
logger.debug(
"[CapacitorPlatformService] Error closing connection (may be expected):",
closeError,
);
}
// Now try to create the connection again
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
logger.debug(
"[CapacitorPlatformService] Successfully created connection after cleanup",
);
}
} else {
// Re-throw if it's a different error
throw createError;
}
}
// Open the connection if it's not already open
try {
await this.db.open();
} catch (openError: unknown) {
const openErrorMessage =
openError instanceof Error ? openError.message : String(openError);
// If already open, that's fine - continue
if (!openErrorMessage.includes("already open")) {
throw openError;
}
logger.debug(
"[CapacitorPlatformService] Database connection already open",
);
}
// Set journal mode to WAL for better performance // Set journal mode to WAL for better performance
// await this.db.execute("PRAGMA journal_mode=WAL;"); // await this.db.execute("PRAGMA journal_mode=WAL;");

View File

@@ -19,6 +19,7 @@
<EntityGrid <EntityGrid
entity-type="people" entity-type="people"
:entities="people" :entities="people"
:max-items="5"
:active-did="activeDid" :active-did="activeDid"
:all-my-dids="allMyDids" :all-my-dids="allMyDids"
:all-contacts="people" :all-contacts="people"
@@ -38,6 +39,7 @@
<EntityGrid <EntityGrid
entity-type="projects" entity-type="projects"
:entities="projects" :entities="projects"
:max-items="3"
:active-did="activeDid" :active-did="activeDid"
:all-my-dids="allMyDids" :all-my-dids="allMyDids"
:all-contacts="people" :all-contacts="people"
@@ -150,8 +152,11 @@ export default class EntityGridFunctionPropTest extends Vue {
customPeopleFunction = ( customPeopleFunction = (
entities: Contact[], entities: Contact[],
_entityType: string, _entityType: string,
maxItems: number,
): Contact[] => { ): 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 = ( customProjectsFunction = (
entities: PlanData[], entities: PlanData[],
_entityType: string, _entityType: string,
_maxItems: number,
): PlanData[] => { ): PlanData[] => {
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3); 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 { get displayedPeopleCount(): number {
if (this.useCustomFunction) { 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 { get displayedProjectsCount(): number {
if (this.useCustomFunction) { 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> </script>

View File

@@ -970,20 +970,6 @@ export const PlatformServiceMixin = {
return this.$normalizeContacts(rawContacts); 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 * Ultra-concise shortcut for getting number of contacts
* @returns Promise<number> Total 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 // Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>; $contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
$contactCount(): Promise<number>; $contactCount(): Promise<number>;
$settings(defaults?: Settings): Promise<Settings>; $settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>; $accountSettings(did?: string, defaults?: Settings): Promise<Settings>;

View File

@@ -2171,7 +2171,7 @@ export default class AccountViewView extends Vue {
const headers = await getHeaders(did); const headers = await getHeaders(did);
const response = await this.axios.delete( const response = await this.axios.delete(
`${this.partnerApiServer}/api/partner/userProfile/${did}`, `${this.partnerApiServer}/api/partner/userProfile`,
{ headers }, { headers },
); );

View File

@@ -346,7 +346,9 @@ export default class ContactEditView extends Vue {
// Notify success and redirect // Notify success and redirect
this.notify.success(NOTIFY_CONTACT_SAVED.message, TIMEOUTS.STANDARD); this.notify.success(NOTIFY_CONTACT_SAVED.message, TIMEOUTS.STANDARD);
this.$router.back(); (this.$router as Router).push({
path: "/did/" + encodeURIComponent(this.contact?.did || ""),
});
} }
} }
</script> </script>

View File

@@ -171,11 +171,9 @@ import {
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_URL_PATH_ENDORSER_CH_OLD, CONTACT_URL_PATH_ENDORSER_CH_OLD,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { import { GiveSummaryRecord } from "@/interfaces/records";
GiveSummaryRecord, import { UserInfo } from "@/interfaces/common";
UserInfo, import { VerifiableCredential } from "@/interfaces/claims-result";
VerifiableCredential,
} from "@/interfaces";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { import {
generateSaveAndActivateIdentity, generateSaveAndActivateIdentity,

View File

@@ -12,12 +12,12 @@
</h1> </h1>
<!-- Back --> <!-- Back -->
<button <router-link
class="order-first text-lg text-center leading-none p-1" class="order-first text-lg text-center leading-none p-1"
@click="goBack()" :to="{ name: 'contacts' }"
> >
<font-awesome icon="chevron-left" class="block text-center w-[1em]" /> <font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</button> </router-link>
<!-- Help button --> <!-- Help button -->
<router-link <router-link
@@ -476,7 +476,7 @@ export default class DIDView extends Vue {
* Navigation helper methods * Navigation helper methods
*/ */
goBack() { goBack() {
this.$router.back(); this.$router.go(-1);
} }
/** /**

View File

@@ -60,60 +60,12 @@
</div> </div>
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" /> <ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
<!-- Authorized Representative Selection --> <input
<div class="w-full flex items-stretch my-4"> v-model="agentDid"
<div type="text"
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100" placeholder="Other Authorized Representative"
@click="openRepresentativeDialog" class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
>
<div>
<EntityIcon
v-if="selectedRepresentative"
:contact="selectedRepresentative"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome v-else icon="user" class="text-slate-400" />
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': selectedRepresentative,
'text-slate-400': !selectedRepresentative,
}"
class="truncate"
>
{{
selectedRepresentative
? selectedRepresentative.name || AppString.NO_CONTACT_NAME
: "Assign Authorized Representative…"
}}
</div>
<div
v-if="selectedRepresentative"
class="text-xs text-slate-500 truncate"
>
{{ agentDid }}
</div>
</div>
</div>
<button
v-if="selectedRepresentative"
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
@click="unsetRepresentative"
>
<font-awesome icon="trash-can" />
</button>
</div>
<ProjectRepresentativeDialog
ref="representativeDialog"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:notify="$notify"
@assign="handleRepresentativeAssigned"
/> />
<div class="mb-4"> <div class="mb-4">
<p v-if="shouldShowOwnershipWarning"> <p v-if="shouldShowOwnershipWarning">
<span class="text-red-500">Beware!</span> <span class="text-red-500">Beware!</span>
@@ -280,12 +232,9 @@ import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { LeafletMouseEvent } from "leaflet"; import { LeafletMouseEvent } from "leaflet";
import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue"; import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { import {
AppString,
DEFAULT_IMAGE_API_SERVER, DEFAULT_IMAGE_API_SERVER,
DEFAULT_PARTNER_API_SERVER, DEFAULT_PARTNER_API_SERVER,
NotificationIface, NotificationIface,
@@ -319,7 +268,6 @@ import {
retrieveAccountCount, retrieveAccountCount,
retrieveFullyDecryptedAccount, retrieveFullyDecryptedAccount,
} from "../libs/util"; } from "../libs/util";
import { Contact } from "../db/tables/contacts";
import { import {
EventTemplate, EventTemplate,
@@ -375,15 +323,7 @@ import { logger } from "../utils/logger";
*/ */
@Component({ @Component({
components: { components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
EntityIcon,
ImageMethodDialog,
ProjectRepresentativeDialog,
LMap,
LMarker,
LTileLayer,
QuickNav,
},
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
export default class NewEditProjectView extends Vue { export default class NewEditProjectView extends Vue {
@@ -394,9 +334,6 @@ export default class NewEditProjectView extends Vue {
// Notification helpers // Notification helpers
private notify!: ReturnType<typeof createNotifyHelpers>; private notify!: ReturnType<typeof createNotifyHelpers>;
// Constants
AppString = AppString;
/** /**
* Display error notification to user * Display error notification to user
* Provides consistent error messaging with 5-second timeout * Provides consistent error messaging with 5-second timeout
@@ -409,8 +346,6 @@ export default class NewEditProjectView extends Vue {
// Component state properties // Component state properties
activeDid = ""; activeDid = "";
agentDid = ""; agentDid = "";
allContacts: Array<Contact> = [];
allMyDids: string[] = [];
apiServer = ""; apiServer = "";
endDateInput?: string; endDateInput?: string;
endTimeInput?: string; endTimeInput?: string;
@@ -457,24 +392,16 @@ export default class NewEditProjectView extends Vue {
const activeIdentity = await (this as any).$getActiveIdentity(); const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || ""; this.activeDid = activeIdentity.activeDid || "";
// Get all user's DIDs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allMyDids = await (this as any).$getAllAccountDids();
// Load contacts sorted by date added (newest first) for consistent "Recently Added" display
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allContacts = await (this as any).$contactsByDateAdded();
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced; this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.projectId = (this.$route.query["projectId"] as string) || ""; this.projectId = (this.$route.query["projectId"] as string) || "";
if (this.isSavedProject()) { if (this.projectId) {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message); this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
} else { } else {
this.loadProject(this.activeDid, this.projectId); this.loadProject(this.activeDid);
} }
} }
} }
@@ -484,9 +411,11 @@ export default class NewEditProjectView extends Vue {
* Retrieves project information from the API and populates form fields * Retrieves project information from the API and populates form fields
* @param userDid - User's decentralized identifier * @param userDid - User's decentralized identifier
*/ */
async loadProject(userDid: string, projectId: string) { async loadProject(userDid: string) {
const url = const url =
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId); this.apiServer +
"/api/claim/byHandle/" +
encodeURIComponent(this.projectId);
const headers = await getHeaders(userDid); const headers = await getHeaders(userDid);
try { try {
@@ -503,12 +432,6 @@ export default class NewEditProjectView extends Vue {
} }
if (this.fullClaim?.agent?.identifier) { if (this.fullClaim?.agent?.identifier) {
this.agentDid = this.fullClaim.agent.identifier; this.agentDid = this.fullClaim.agent.identifier;
if (this.activeDid !== this.projectIssuerDid) {
this.agentDid = this.projectIssuerDid;
this.notify.warning(
"You were previously the agent, so the agent has been set to the previous owner. You can change it.",
);
}
} }
if (this.fullClaim.startTime) { if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO( const localDateTime = DateTime.fromISO(
@@ -613,7 +536,7 @@ export default class NewEditProjectView extends Vue {
private async saveProject() { private async saveProject() {
// Make a claim // Make a claim
const vcClaim: PlanActionClaim = this.fullClaim; const vcClaim: PlanActionClaim = this.fullClaim;
if (this.isSavedProject()) { if (this.projectId) {
vcClaim.lastClaimId = this.lastClaimJwtId; vcClaim.lastClaimId = this.lastClaimJwtId;
} }
if (this.agentDid) { if (this.agentDid) {
@@ -947,10 +870,6 @@ export default class NewEditProjectView extends Vue {
this.longitude = event.latlng.lng; this.longitude = event.latlng.lng;
} }
private isSavedProject(): boolean {
return !!this.projectId;
}
/** /**
* Computed property for character count display * Computed property for character count display
* Shows current description length and maximum character limit * Shows current description length and maximum character limit
@@ -966,7 +885,6 @@ export default class NewEditProjectView extends Vue {
*/ */
get shouldShowOwnershipWarning(): boolean { get shouldShowOwnershipWarning(): boolean {
return ( return (
this.isSavedProject() &&
this.activeDid !== this.projectIssuerDid && this.activeDid !== this.projectIssuerDid &&
this.agentDid !== this.projectIssuerDid this.agentDid !== this.projectIssuerDid
); );
@@ -1043,37 +961,5 @@ export default class NewEditProjectView extends Vue {
get shouldShowSpinner(): boolean { get shouldShowSpinner(): boolean {
return !this.isHiddenSpinner; return !this.isHiddenSpinner;
} }
/**
* Computed property for selected representative contact
* Derives the contact from agentDid by finding it in allContacts
*/
get selectedRepresentative(): Contact | null {
if (!this.agentDid) {
return null;
}
return this.allContacts.find((c) => c.did === this.agentDid) || null;
}
/**
* Open the representative selection dialog
*/
openRepresentativeDialog(): void {
(this.$refs.representativeDialog as ProjectRepresentativeDialog).open();
}
/**
* Handle representative assignment from dialog
*/
handleRepresentativeAssigned(contact: Contact): void {
this.agentDid = contact.did;
}
/**
* Unset the representative and revert to initial state
*/
unsetRepresentative(): void {
this.agentDid = "";
}
} }
</script> </script>

View File

@@ -186,59 +186,16 @@
<div> <div>
<label <label
for="projectLink" for="projectLink"
class="block text-sm font-medium text-gray-700 mb-1" class="block text-sm font-medium text-gray-700"
>Project Link</label >Project Link</label
> >
<div class="w-full flex items-stretch"> <input
<div id="projectLink"
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100" v-model="newOrUpdatedMeetingInputs.projectLink"
@click="openProjectLinkDialog" type="text"
> class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
<div> placeholder="Project ID"
<ProjectIcon />
v-if="selectedProject"
:entity-id="selectedProject.handleId"
:icon-size="30"
:image-url="selectedProject.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome
v-else
icon="folder-open"
class="text-slate-400"
/>
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': selectedProject,
'text-slate-400': !selectedProject,
}"
class="truncate"
>
{{
selectedProject
? selectedProject.name || "Unnamed Project"
: "Select Project…"
}}
</div>
<div
v-if="selectedProject"
class="text-xs text-slate-500 truncate"
>
<font-awesome icon="user" class="text-slate-400" />
{{ selectedProjectIssuerName }}
</div>
</div>
</div>
<button
v-if="selectedProject"
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
@click="unsetProjectLink"
>
<font-awesome icon="trash-can" />
</button>
</div>
</div> </div>
<button <button
@@ -267,17 +224,6 @@
</form> </form>
</div> </div>
<MeetingProjectDialog
ref="meetingProjectDialog"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="$notify"
@assign="handleProjectLinkAssigned"
@open="handleDialogOpen"
@close="handleDialogClose"
/>
<!-- Members Section --> <!-- Members Section -->
<div <div
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password" v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
@@ -308,7 +254,6 @@
</ul> </ul>
<MembersList <MembersList
ref="membersList"
:password="currentMeeting.password || ''" :password="currentMeeting.password || ''"
:show-organizer-tools="true" :show-organizer-tools="true"
class="mt-4" class="mt-4"
@@ -347,13 +292,10 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import MembersList from "../components/MembersList.vue"; import MembersList from "../components/MembersList.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import { import {
errorStringForLog, errorStringForLog,
getHeaders, getHeaders,
serverMessageForUser, serverMessageForUser,
didInfo,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { encryptMessage } from "../libs/crypto"; import { encryptMessage } from "../libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
@@ -367,8 +309,6 @@ import {
NOTIFY_MEETING_DELETED, NOTIFY_MEETING_DELETED,
NOTIFY_MEETING_LINK_COPIED, NOTIFY_MEETING_LINK_COPIED,
} from "@/constants/notifications"; } from "@/constants/notifications";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
interface ServerMeeting { interface ServerMeeting {
groupId: number; // from the server groupId: number; // from the server
name: string; // to & from the server name: string; // to & from the server
@@ -391,8 +331,6 @@ interface MeetingSetupInputs {
QuickNav, QuickNav,
TopMessage, TopMessage,
MembersList, MembersList,
MeetingProjectDialog,
ProjectIcon,
}, },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
@@ -416,9 +354,6 @@ export default class OnboardMeetingView extends Vue {
isRegistered = false; isRegistered = false;
showDeleteConfirm = false; showDeleteConfirm = false;
fullName = ""; fullName = "";
allContacts: Contact[] = [];
allMyDids: string[] = [];
selectedProjectData: PlanData | null = null;
get minDateTime() { get minDateTime() {
const now = new Date(); const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
@@ -435,17 +370,7 @@ export default class OnboardMeetingView extends Vue {
this.fullName = settings?.firstName || ""; this.fullName = settings?.firstName || "";
this.isRegistered = !!settings?.isRegistered; this.isRegistered = !!settings?.isRegistered;
// Load contacts and DIDs for dialog
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allContacts = await (this as any).$contactsByDateAdded();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allMyDids = await (this as any).$getAllAccountDids();
await this.fetchCurrentMeeting(); await this.fetchCurrentMeeting();
// Ensure selected project is loaded if projectLink exists
await this.ensureSelectedProjectLoaded();
this.isLoading = false; this.isLoading = false;
} }
@@ -517,54 +442,6 @@ export default class OnboardMeetingView extends Vue {
} }
} }
/**
* Ensure the selected project is loaded if projectLink exists
*/
async ensureSelectedProjectLoaded(): Promise<void> {
const projectLink =
this.currentMeeting?.projectLink ||
this.newOrUpdatedMeetingInputs?.projectLink;
if (!projectLink) {
this.selectedProjectData = null;
return;
}
await this.fetchProjectByHandleId(projectLink);
}
/**
* Fetch a single project by handleId
* @param handleId - The project handleId to fetch
*/
async fetchProjectByHandleId(handleId: string): Promise<void> {
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.apiServer}/api/v2/report/plans?handleId=${encodeURIComponent(handleId)}`;
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data.data && resp.data.data.length > 0) {
const project = resp.data.data[0];
this.selectedProjectData = {
name: project.name,
description: project.description,
image: project.image,
handleId: project.handleId,
issuerDid: project.issuerDid,
rowId: project.rowId,
};
} else {
this.selectedProjectData = null;
}
} catch (error) {
this.$logAndConsole(
"Error fetching project by handleId: " + errorStringForLog(error),
true,
);
this.selectedProjectData = null;
}
}
async createMeeting() { async createMeeting() {
this.isLoading = true; this.isLoading = true;
@@ -596,7 +473,6 @@ export default class OnboardMeetingView extends Vue {
); );
return; return;
} }
const password: string = this.newOrUpdatedMeetingInputs.password;
// create content with user's name & DID encrypted with password // create content with user's name & DID encrypted with password
const content = { const content = {
@@ -606,7 +482,7 @@ export default class OnboardMeetingView extends Vue {
}; };
const encryptedContent = await encryptMessage( const encryptedContent = await encryptMessage(
JSON.stringify(content), JSON.stringify(content),
password, this.newOrUpdatedMeetingInputs.password,
); );
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
@@ -629,11 +505,6 @@ export default class OnboardMeetingView extends Vue {
this.newOrUpdatedMeetingInputs = null; this.newOrUpdatedMeetingInputs = null;
this.notify.success(NOTIFY_MEETING_CREATED.message, TIMEOUTS.STANDARD); this.notify.success(NOTIFY_MEETING_CREATED.message, TIMEOUTS.STANDARD);
// redirect to the same page with the password parameter set
this.$router.push({
name: "onboard-meeting-setup",
query: { password: password },
});
} else { } else {
throw { response: response }; throw { response: response };
} }
@@ -699,7 +570,7 @@ export default class OnboardMeetingView extends Vue {
} }
} }
async startEditing() { startEditing() {
// Populate form with existing meeting data // Populate form with existing meeting data
if (this.currentMeeting) { if (this.currentMeeting) {
const localExpiresAt = new Date(this.currentMeeting.expiresAt); const localExpiresAt = new Date(this.currentMeeting.expiresAt);
@@ -710,10 +581,6 @@ export default class OnboardMeetingView extends Vue {
password: this.currentMeeting.password || "", password: this.currentMeeting.password || "",
projectLink: this.currentMeeting.projectLink || "", projectLink: this.currentMeeting.projectLink || "",
}; };
// Ensure selected project is loaded if projectLink exists
if (this.currentMeeting.projectLink) {
await this.ensureSelectedProjectLoaded();
}
} else { } else {
this.$logError( this.$logError(
"There is no current meeting to edit. We should never get here.", "There is no current meeting to edit. We should never get here.",
@@ -721,15 +588,9 @@ export default class OnboardMeetingView extends Vue {
} }
} }
async cancelEditing() { cancelEditing() {
// Reset form data // Reset form data
this.newOrUpdatedMeetingInputs = null; this.newOrUpdatedMeetingInputs = null;
// Restore selected project from currentMeeting if it exists
if (this.currentMeeting?.projectLink) {
await this.ensureSelectedProjectLoaded();
} else {
this.selectedProjectData = null;
}
} }
async updateMeeting() { async updateMeeting() {
@@ -843,78 +704,5 @@ export default class OnboardMeetingView extends Vue {
this.notify.error("Failed to copy meeting link to clipboard."); this.notify.error("Failed to copy meeting link to clipboard.");
} }
} }
/**
* Computed property for selected project
* Returns the separately stored selected project data
*/
get selectedProject(): PlanData | null {
return this.selectedProjectData;
}
/**
* Computed property for selected project issuer display name
* Uses didInfo to format the issuer name similar to ProjectCard
*/
get selectedProjectIssuerName(): string {
if (!this.selectedProject) {
return "";
}
return didInfo(
this.selectedProject.issuerDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
/**
* Open the project link selection dialog
*/
openProjectLinkDialog(): void {
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
}
/**
* Handle project assignment from dialog
*/
handleProjectLinkAssigned(project: PlanData): void {
// Store the selected project directly
this.selectedProjectData = project;
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = project.handleId;
}
}
/**
* Unset the project link and revert to initial state
*/
unsetProjectLink(): void {
this.selectedProjectData = null;
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = "";
}
}
/**
* Handle dialog open event - stop auto-refresh in MembersList
*/
handleDialogOpen(): void {
const membersList = this.$refs.membersList as MembersList;
if (membersList) {
membersList.stopAutoRefresh();
}
}
/**
* Handle dialog close event - start auto-refresh in MembersList
*/
handleDialogClose(): void {
const membersList = this.$refs.membersList as MembersList;
if (membersList) {
membersList.startAutoRefresh();
}
}
} }
</script> </script>

View File

@@ -54,6 +54,161 @@
</p> </p>
</div> </div>
<!-- Issuer Information Section -->
<div v-if="profile" class="mt-6">
<div
v-if="issuerContact"
class="bg-blue-50 border border-blue-200 rounded-lg p-4"
>
<h3 class="text-lg font-semibold text-blue-800 mb-2">
<font-awesome icon="user-check" class="fa-fw mr-2" />
Contact Information
</h3>
<p class="text-sm text-blue-700 mb-3">
{{ issuerContact.name || "Contact" }} is in your contacts.
</p>
<div
v-if="
issuerContact.contactMethods &&
issuerContact.contactMethods.length > 0
"
class="space-y-2"
>
<h4 class="font-medium text-blue-800">Contact Methods:</h4>
<div
v-for="method in issuerContact.contactMethods"
:key="method.label"
class="flex items-center text-sm"
>
<span class="font-medium text-blue-700 w-20"
>{{ method.label }}:</span
>
<span class="text-blue-600">{{ method.value }}</span>
<span class="text-xs text-blue-500 ml-2"
>({{ method.type }})</span
>
</div>
</div>
</div>
<div
v-else
class="bg-yellow-50 border border-yellow-200 rounded-lg p-4"
>
<h3 class="text-lg font-semibold text-yellow-800 mb-2">
<font-awesome icon="user-plus" class="fa-fw mr-2" />
Not in Contacts
</h3>
<p class="text-sm text-yellow-700 mb-3">
This person has been connected but they're not in your contacts on
this device.
</p>
<button
class="text-blue-600 hover:text-blue-800 font-medium text-sm flex items-center"
@click="toggleConnectInstructions"
>
<font-awesome icon="link" class="fa-fw mr-1" />
Connect Me...
<font-awesome
:icon="showConnectInstructions ? 'chevron-up' : 'chevron-down'"
class="fa-fw ml-1"
/>
</button>
<!-- Expandable Instructions -->
<div
v-if="showConnectInstructions"
class="mt-4 p-4 bg-white border border-yellow-300 rounded-md"
>
<h4 class="font-medium text-gray-800 mb-3">How to Connect:</h4>
<!-- Copy Profile Link Instructions -->
<div class="mb-4">
<p class="text-sm text-gray-700 mb-2">
1. Copy a deep link to this profile and send it to someone in
your network:
</p>
<button
class="bg-blue-500 hover:bg-blue-600 text-white text-sm px-3 py-1 rounded flex items-center"
@click="onCopyLinkClick()"
>
<font-awesome icon="copy" class="fa-fw mr-1" />
Copy Profile Link
</button>
</div>
<!-- Contact List -->
<div class="mb-4">
<p class="text-sm text-gray-700 mb-2">
2. Send the link to one of these people:
</p>
<!-- Contacts from issuerDidVisibleToDids -->
<div v-if="visibleToContacts.length > 0" class="mb-3">
<h5 class="text-xs font-medium text-gray-600 mb-2">
People who can see this profile:
</h5>
<div class="space-y-1">
<div
v-for="contact in visibleToContacts"
:key="contact.did"
class="flex items-center text-sm bg-gray-50 p-2 rounded"
>
<font-awesome
icon="user"
class="fa-fw text-gray-500 mr-2"
/>
<span class="font-medium">{{
contact.name || "Unnamed Contact"
}}</span>
<span class="text-xs text-gray-500 ml-2"
>({{ contact.did.substring(0, 20) }}...)</span
>
</div>
</div>
</div>
<!-- Nearest Neighbors -->
<div v-if="nearestNeighbors.length > 0" class="mb-3">
<h5 class="text-xs font-medium text-gray-600 mb-2">
<span v-if="visibleToContacts.length > 0"
>Other nearby people:</span
>
<span v-else>Nearby people:</span>
</h5>
<div class="space-y-1">
<div
v-for="neighbor in nearestNeighbors"
:key="neighbor.issuerDid"
class="flex items-center text-sm bg-gray-50 p-2 rounded"
>
<font-awesome
icon="user"
class="fa-fw text-gray-500 mr-2"
/>
<span class="font-medium">{{
neighborDisplayName(neighbor)
}}</span>
<span class="text-xs text-gray-500 ml-2"
>({{ neighbor.issuerDid.substring(0, 20) }}...)</span
>
</div>
</div>
</div>
<!-- Loading state for nearest neighbors -->
<div
v-if="loadingNearestNeighbors"
class="text-sm text-gray-500 flex items-center"
>
<font-awesome icon="spinner" class="fa-spin fa-fw mr-2" />
Loading nearby people...
</div>
</div>
</div>
</div>
</div>
<!-- Map for first coordinates --> <!-- Map for first coordinates -->
<div v-if="hasFirstLocation" class="mt-4"> <div v-if="hasFirstLocation" class="mt-4">
<h2 class="text-lg font-semibold">Location</h2> <h2 class="text-lg font-semibold">Location</h2>
@@ -113,7 +268,12 @@ import {
APP_SERVER, APP_SERVER,
} from "../constants/app"; } from "../constants/app";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { didInfo, getHeaders } from "../libs/endorserServer"; import {
didInfo,
getHeaders,
isHiddenDid,
contactForDid,
} from "../libs/endorserServer";
import { UserProfile } from "../libs/partnerServer"; import { UserProfile } from "../libs/partnerServer";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@@ -162,9 +322,13 @@ export default class UserProfileView extends Vue {
isLoading = true; isLoading = true;
partnerApiServer = DEFAULT_PARTNER_API_SERVER; partnerApiServer = DEFAULT_PARTNER_API_SERVER;
profile: UserProfile | null = null; profile: UserProfile | null = null;
showConnectInstructions = false;
nearestNeighbors: Array<UserProfile> = [];
loadingNearestNeighbors = false;
// make this function available to the Vue template // make this function available to the Vue template
didInfo = didInfo; didInfo = didInfo;
isHiddenDid = isHiddenDid;
/** Production share domain for deep links */ /** Production share domain for deep links */
APP_SERVER = APP_SERVER; APP_SERVER = APP_SERVER;
@@ -266,6 +430,63 @@ export default class UserProfileView extends Vue {
} }
} }
/**
* Loads nearest neighbors from the partner API
* Called when the Connect Me section is expanded
*/
async loadNearestNeighbors() {
if (!this.profile?.issuerDid || this.loadingNearestNeighbors) return;
this.loadingNearestNeighbors = true;
try {
const response = await fetch(
`${this.partnerApiServer}/api/partner/userProfileNearestNeighbors?issuerDid=${encodeURIComponent(this.profile.issuerDid)}`,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
if (response.status === 200) {
const result = await response.json();
this.nearestNeighbors = result.data || [];
} else {
logger.warn("Failed to load nearest neighbors:", response.status);
}
} catch (error) {
logger.error("Error loading nearest neighbors:", error);
} finally {
this.loadingNearestNeighbors = false;
}
}
/**
* Gets display name for a neighbor profile
* @param neighbor UserProfile object
* @returns Display name for the neighbor
*/
neighborDisplayName(neighbor: UserProfile) {
return this.didInfo(
neighbor.issuerDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
/**
* Toggles the Connect Me instructions and loads nearest neighbors if needed
*/
async toggleConnectInstructions() {
this.showConnectInstructions = !this.showConnectInstructions;
// Load nearest neighbors when expanding for the first time
if (this.showConnectInstructions && this.nearestNeighbors.length === 0) {
await this.loadNearestNeighbors();
}
}
/** /**
* Computed properties for template logic streamlining * Computed properties for template logic streamlining
*/ */
@@ -283,6 +504,27 @@ export default class UserProfileView extends Vue {
); );
} }
/**
* Gets the contact information for the profile issuer
* @returns Contact object if issuer is in contacts, undefined otherwise
*/
get issuerContact() {
if (!this.profile?.issuerDid) return undefined;
return contactForDid(this.profile.issuerDid, this.allContacts);
}
/**
* Gets contacts that are in the issuerDidVisibleToDids list
* @returns Array of contacts who can see this profile
*/
get visibleToContacts() {
if (!this.profile?.issuerDidVisibleToDids) return [];
return this.profile.issuerDidVisibleToDids
.map((did) => contactForDid(did, this.allContacts))
.filter((contact): contact is Contact => contact !== undefined);
}
/** /**
* Checks if the profile has first location coordinates * Checks if the profile has first location coordinates
* @returns True if both latitude and longitude are available * @returns True if both latitude and longitude are available