Merge branch 'master' into daily-notification-plugin-integration
This commit is contained in:
@@ -6,10 +6,13 @@ VITE_LOG_LEVEL=debug
|
|||||||
# iOS doesn't like spaces in the app title.
|
# iOS doesn't like spaces in the app title.
|
||||||
TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
|
TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
|
||||||
VITE_APP_SERVER=http://localhost:8080
|
VITE_APP_SERVER=http://localhost:8080
|
||||||
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not
|
|
||||||
|
|
||||||
|
|
||||||
|
# This is the claim ID for actions in the BVC project, with the JWT ID on the environment
|
||||||
|
# test server
|
||||||
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
|
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
|
||||||
|
# production server
|
||||||
|
#VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
|
||||||
|
|
||||||
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
||||||
# Using shared server by default to ease setup, which works for shared test users.
|
# Using shared server by default to ease setup, which works for shared test users.
|
||||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||||
|
|||||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -6,11 +6,44 @@ 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.3.6] - 2026
|
## [1.3.8] - 2026
|
||||||
### Added
|
### Added
|
||||||
- Device wake-up for notifications
|
- Device wake-up for notifications
|
||||||
|
|
||||||
|
|
||||||
|
## [1.3.7]
|
||||||
|
### Added
|
||||||
|
- Attendee exclusion and do-not-pair groups for meeting matching.
|
||||||
|
### Fixed
|
||||||
|
- Contact deep-links clicked or pasted act consistenly
|
||||||
|
|
||||||
|
|
||||||
|
## [1.3.5] - 2026.02.22
|
||||||
|
### Fixed
|
||||||
|
- SQL error on startup (contact_labels -> contacts foreign key)
|
||||||
|
### Added
|
||||||
|
- Ability to toggle embeddings on list of contacts
|
||||||
|
|
||||||
|
|
||||||
|
## [1.3.3] - 2026.02.17
|
||||||
|
### Added
|
||||||
|
- People can be marked as vector-embeddings users.
|
||||||
|
- People can be matched during a meeting.
|
||||||
|
### Fixed
|
||||||
|
- Problem hiding new contacts in feed
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.6] - 2026.01.21
|
||||||
|
### Added
|
||||||
|
- Labels on contacts
|
||||||
|
- Ability to switch giver & recipient on the gift-details page
|
||||||
|
### Changed
|
||||||
|
- Invitations now must be explicitly accepted.
|
||||||
|
### Fixed
|
||||||
|
- Show all starred projects.
|
||||||
|
- Incorrect contacts as "most recent" on gift-details page
|
||||||
|
|
||||||
|
|
||||||
## [1.1.5] - 2025.12.28
|
## [1.1.5] - 2025.12.28
|
||||||
### Fixed
|
### Fixed
|
||||||
- Incorrect prompts in give-dialog on a project or offer
|
- Incorrect prompts in give-dialog on a project or offer
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Large Components (>500 lines): 5 components (12.5%)
|
|||||||
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
|
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
|
||||||
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
|
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
|
||||||
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
|
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
|
||||||
└── MembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
|
└── MeetingMembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
|
||||||
|
|
||||||
Medium Components (200-500 lines): 12 components (30%)
|
Medium Components (200-500 lines): 12 components (30%)
|
||||||
├── GiftDetailsStep.vue (450 lines)
|
├── GiftDetailsStep.vue (450 lines)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Quick start:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run build:web:serve -- --test
|
npm run build:web:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
|
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 64
|
versionCode 64
|
||||||
versionName "1.3.6"
|
versionName "1.3.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.
|
||||||
|
|||||||
@@ -534,7 +534,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.6;
|
MARKETING_VERSION = 1.3.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)";
|
||||||
@@ -562,7 +562,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.6;
|
MARKETING_VERSION = 1.3.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 = "";
|
||||||
@@ -594,7 +594,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.3.6;
|
MARKETING_VERSION = 1.3.8;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
@@ -632,7 +632,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.3.6;
|
MARKETING_VERSION = 1.3.8;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|||||||
11806
package-lock.json
generated
11806
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.3.6",
|
"version": "1.3.8-beta",
|
||||||
"description": "Time Safari Application",
|
"description": "Gift Economies Application",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Time Safari Team"
|
"name": "Gift Economies Team"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ echo "=============================="
|
|||||||
|
|
||||||
# Analyze critical files identified in the assessment
|
# Analyze critical files identified in the assessment
|
||||||
critical_files=(
|
critical_files=(
|
||||||
src/components/MembersList.vue"
|
src/components/MeetingMembersList.vue"
|
||||||
"src/views/ContactsView.vue"
|
"src/views/ContactsView.vue"
|
||||||
src/views/OnboardMeetingSetupView.vue"
|
src/views/OnboardMeetingSetupView.vue"
|
||||||
src/db/databaseUtil.ts"
|
src/db/databaseUtil.ts"
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ echo "=========================="
|
|||||||
|
|
||||||
# Critical files from our assessment
|
# Critical files from our assessment
|
||||||
files=(
|
files=(
|
||||||
src/components/MembersList.vue"
|
src/components/MeetingMembersList.vue"
|
||||||
"src/views/ContactsView.vue"
|
"src/views/ContactsView.vue"
|
||||||
src/views/OnboardMeetingSetupView.vue"
|
src/views/OnboardMeetingSetupView.vue"
|
||||||
src/db/databaseUtil.ts"
|
src/db/databaseUtil.ts"
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ echo "=========================="
|
|||||||
|
|
||||||
# Critical files from our assessment
|
# Critical files from our assessment
|
||||||
files=(
|
files=(
|
||||||
src/components/MembersList.vue"
|
src/components/MeetingMembersList.vue"
|
||||||
"src/views/ContactsView.vue"
|
"src/views/ContactsView.vue"
|
||||||
src/views/OnboardMeetingSetupView.vue"
|
src/views/OnboardMeetingSetupView.vue"
|
||||||
src/db/databaseUtil.ts"
|
src/db/databaseUtil.ts"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
icon="circle-info"
|
icon="circle-info"
|
||||||
class="text-2xl text-blue-500 ml-2"
|
class="text-2xl text-blue-500 ml-4"
|
||||||
@click="emitShowCopyInfo"
|
@click="emitShowCopyInfo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:selectable="youSelectable"
|
:selectable="youSelectable"
|
||||||
:conflicted="youConflicted"
|
:conflicted="youConflicted"
|
||||||
:entity-data="youEntityData"
|
:entity-data="youEntityData"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -69,8 +69,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:label="unnamedEntityName"
|
:label="unnamedEntityName"
|
||||||
icon="circle-question"
|
icon="circle-question"
|
||||||
:entity-data="unnamedEntityData"
|
:entity-data="unnamedEntityData"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -97,8 +97,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:person="person"
|
:person="person"
|
||||||
:conflicted="isPersonConflicted(person.did)"
|
:conflicted="isPersonConflicted(person.did)"
|
||||||
:show-time-icon="true"
|
:show-time-icon="true"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@person-selected="handlePersonSelected"
|
@person-selected="handlePersonSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -116,8 +116,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:person="person"
|
:person="person"
|
||||||
:conflicted="isPersonConflicted(person.did)"
|
:conflicted="isPersonConflicted(person.did)"
|
||||||
:show-time-icon="true"
|
:show-time-icon="true"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@person-selected="handlePersonSelected"
|
@person-selected="handlePersonSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -131,40 +131,40 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:person="person"
|
:person="person"
|
||||||
:conflicted="isPersonConflicted(person.did)"
|
:conflicted="isPersonConflicted(person.did)"
|
||||||
:show-time-icon="true"
|
:show-time-icon="true"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@person-selected="handlePersonSelected"
|
@person-selected="handlePersonSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="entityType === 'projects'">
|
<template v-else-if="entityType === 'projects'">
|
||||||
<!-- When showing projects without search: split into recently bookmarked and rest -->
|
<!-- When showing projects without search: split into recently starred and rest -->
|
||||||
<template v-if="!searchTerm.trim()">
|
<template v-if="!searchTerm.trim()">
|
||||||
<!-- Recently Bookmarked Section -->
|
<!-- Recently Starred Section -->
|
||||||
<template v-if="recentBookmarkedProjects.length > 0">
|
<template v-if="recentStarredProjectsToShow.length > 0">
|
||||||
<li
|
<li
|
||||||
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||||
>
|
>
|
||||||
Recently Bookmarked
|
Recently Starred
|
||||||
</li>
|
</li>
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
v-for="project in recentBookmarkedProjects"
|
v-for="project in recentStarredProjectsToShow"
|
||||||
:key="project.handleId"
|
:key="project.handleId"
|
||||||
:project="project"
|
:project="project"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:conflicted="isProjectConflicted(project.handleId)"
|
:conflicted="isProjectConflicted(project.handleId)"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@project-selected="handleProjectSelected"
|
@project-selected="handleProjectSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Rest of Projects Section -->
|
<!-- Rest of Projects Section -->
|
||||||
<li
|
<li
|
||||||
v-if="recentBookmarkedProjects.length > 0"
|
v-if="remainingProjects.length > 0"
|
||||||
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||||
>
|
>
|
||||||
All Projects
|
All Projects
|
||||||
@@ -177,8 +177,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:conflicted="isProjectConflicted(project.handleId)"
|
:conflicted="isProjectConflicted(project.handleId)"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@project-selected="handleProjectSelected"
|
@project-selected="handleProjectSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -193,8 +193,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:conflicted="isProjectConflicted(project.handleId)"
|
:conflicted="isProjectConflicted(project.handleId)"
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
@project-selected="handleProjectSelected"
|
@project-selected="handleProjectSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -223,7 +223,7 @@ import { TIMEOUTS } from "@/utils/notify";
|
|||||||
const INITIAL_BATCH_SIZE = 20;
|
const INITIAL_BATCH_SIZE = 20;
|
||||||
const INCREMENT_SIZE = 20;
|
const INCREMENT_SIZE = 20;
|
||||||
const RECENT_CONTACTS_COUNT = 3;
|
const RECENT_CONTACTS_COUNT = 3;
|
||||||
const RECENT_BOOKMARKED_PROJECTS_COUNT = 10;
|
const RECENT_STARRED_PROJECTS_COUNT = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EntityGrid - Unified grid layout for displaying people or projects
|
* EntityGrid - Unified grid layout for displaying people or projects
|
||||||
@@ -251,30 +251,6 @@ export default class EntityGrid extends Vue {
|
|||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
entityType!: "people" | "projects";
|
entityType!: "people" | "projects";
|
||||||
|
|
||||||
// Search state
|
|
||||||
searchTerm = "";
|
|
||||||
isSearching = false;
|
|
||||||
searchTimeout: NodeJS.Timeout | null = null;
|
|
||||||
filteredEntities: Contact[] | PlanData[] = [];
|
|
||||||
searchBeforeId: string | undefined = undefined;
|
|
||||||
isLoadingSearchMore = false;
|
|
||||||
|
|
||||||
// API server for project searches
|
|
||||||
apiServer = "";
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Starred projects state (for showing recently bookmarked projects)
|
|
||||||
starredPlanHandleIds: string[] = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Array of entities to display
|
* Array of entities to display
|
||||||
*
|
*
|
||||||
@@ -326,32 +302,30 @@ export default class EntityGrid extends Vue {
|
|||||||
@Prop({ default: "other party" })
|
@Prop({ default: "other party" })
|
||||||
conflictContext!: string;
|
conflictContext!: string;
|
||||||
|
|
||||||
/**
|
// Search state
|
||||||
* Function to determine which entities to display (allows parent control)
|
searchTerm = "";
|
||||||
*
|
isSearching = false;
|
||||||
* This function prop allows parent components to customize which entities
|
searchTimeout: NodeJS.Timeout | null = null;
|
||||||
* are displayed in the grid, enabling advanced filtering and sorting.
|
filteredEntities: Contact[] | PlanData[] = [];
|
||||||
* Note: Infinite scroll is disabled when this prop is provided.
|
searchBeforeId: string | undefined = undefined;
|
||||||
*
|
isLoadingSearchMore = false;
|
||||||
* @param entities - The full array of entities (Contact[] or PlanData[])
|
|
||||||
* @param entityType - The type of entities being displayed ("people" or "projects")
|
// API server for project searches
|
||||||
* @returns Filtered/sorted array of entities to display
|
apiServer = "";
|
||||||
*
|
|
||||||
* @example
|
// Internal project state (when entities prop not provided for projects)
|
||||||
* // Custom filtering: only show contacts with profile images
|
allProjects: PlanData[] = [];
|
||||||
* :display-entities-function="(entities, type) =>
|
loadBeforeId: string | undefined = undefined;
|
||||||
* entities.filter(e => e.profileImageUrl)"
|
isLoadingProjects = false;
|
||||||
*
|
|
||||||
* @example
|
// Infinite scroll state
|
||||||
* // Custom sorting: sort projects by name
|
displayedCount = INITIAL_BATCH_SIZE;
|
||||||
* :display-entities-function="(entities, type) =>
|
infiniteScrollReset?: () => void;
|
||||||
* entities.sort((a, b) => a.name.localeCompare(b.name))"
|
scrollContainer?: HTMLElement;
|
||||||
*/
|
|
||||||
@Prop({ default: null })
|
// Starred projects state (for showing recently starred projects)
|
||||||
displayEntitiesFunction?: (
|
starredPlanHandleIds: string[] = [];
|
||||||
entities: Contact[] | PlanData[],
|
recentStarredProjects: PlanData[] = [];
|
||||||
entityType: "people" | "projects",
|
|
||||||
) => Contact[] | PlanData[];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the empty state message
|
* CSS classes for the empty state message
|
||||||
@@ -397,11 +371,6 @@ export default class EntityGrid extends Vue {
|
|||||||
return this.filteredEntities.slice(0, this.displayedCount);
|
return this.filteredEntities.slice(0, this.displayedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If custom function provided, use it (disables infinite scroll)
|
|
||||||
if (this.displayEntitiesFunction) {
|
|
||||||
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: projects use infinite scroll
|
// Default: projects use infinite scroll
|
||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
|
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
|
||||||
@@ -457,40 +426,19 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the 3 most recently bookmarked projects (when showing projects and not searching)
|
* Get the 3 most recently starred projects (when showing projects and not searching)
|
||||||
* The starredPlanHandleIds array order represents bookmark order (newest at the end)
|
* Returns the cached member field
|
||||||
*/
|
*/
|
||||||
get recentBookmarkedProjects(): PlanData[] {
|
get recentStarredProjectsToShow(): PlanData[] {
|
||||||
if (
|
if (this.entityType !== "projects" || this.searchTerm.trim()) {
|
||||||
this.entityType !== "projects" ||
|
|
||||||
this.searchTerm.trim() ||
|
|
||||||
this.starredPlanHandleIds.length === 0
|
|
||||||
) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
return this.recentStarredProjects;
|
||||||
const projects = this.entitiesToUse as PlanData[];
|
|
||||||
if (projects.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the last 3 starred IDs (most recently bookmarked)
|
|
||||||
const recentStarredIds = this.starredPlanHandleIds.slice(
|
|
||||||
-RECENT_BOOKMARKED_PROJECTS_COUNT,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find projects matching those IDs, sorting with newest first
|
|
||||||
const recentProjects = recentStarredIds
|
|
||||||
.map((id) => projects.find((p) => p.handleId === id))
|
|
||||||
.filter((p): p is PlanData => p !== undefined)
|
|
||||||
.reverse();
|
|
||||||
|
|
||||||
return recentProjects;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all projects (when showing projects and not searching)
|
* Get all projects (when showing projects and not searching)
|
||||||
* Includes projects shown in "Recently Bookmarked" section as well
|
* Includes projects shown in "Recently Starred" section as well
|
||||||
* Uses infinite scroll to control how many are displayed
|
* Uses infinite scroll to control how many are displayed
|
||||||
*/
|
*/
|
||||||
get remainingProjects(): PlanData[] {
|
get remainingProjects(): PlanData[] {
|
||||||
@@ -552,6 +500,115 @@ export default class EntityGrid extends Vue {
|
|||||||
return UNNAMED_ENTITY_NAME;
|
return UNNAMED_ENTITY_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize infinite scroll on mount
|
||||||
|
*/
|
||||||
|
async mounted(): Promise<void> {
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
this.apiServer = settings.apiServer || "";
|
||||||
|
|
||||||
|
// Load starred project IDs for showing recently starred projects
|
||||||
|
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
||||||
|
|
||||||
|
// Load projects on mount if entities prop not provided
|
||||||
|
this.isLoadingProjects = true;
|
||||||
|
if (!this.entities) {
|
||||||
|
await this.loadProjects();
|
||||||
|
}
|
||||||
|
await this.loadRecentStarredProjects();
|
||||||
|
this.isLoadingProjects = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate entities prop for people
|
||||||
|
if (this.entityType === "people") {
|
||||||
|
if (!this.entities) {
|
||||||
|
logger.error(
|
||||||
|
"EntityGrid: entities prop or allContacts prop is required when entityType is 'people'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.loadProjects(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 loadProjects
|
||||||
|
} 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.loadProjects(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 loadProjects
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a person DID is conflicted
|
* Check if a person DID is conflicted
|
||||||
*/
|
*/
|
||||||
@@ -636,7 +693,7 @@ export default class EntityGrid extends Vue {
|
|||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
// Server-side search for projects (initial load, no beforeId)
|
// Server-side search for projects (initial load, no beforeId)
|
||||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
await this.fetchProjects(undefined, searchLower);
|
await this.loadProjects(undefined, searchLower);
|
||||||
} else {
|
} else {
|
||||||
// Client-side filtering for contacts (complete list)
|
// Client-side filtering for contacts (complete list)
|
||||||
await this.performContactSearch();
|
await this.performContactSearch();
|
||||||
@@ -659,10 +716,7 @@ export default class EntityGrid extends Vue {
|
|||||||
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
||||||
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
|
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
|
||||||
*/
|
*/
|
||||||
async fetchProjects(
|
async loadProjects(beforeId?: string, claimContents?: string): Promise<void> {
|
||||||
beforeId?: string,
|
|
||||||
claimContents?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.apiServer) {
|
if (!this.apiServer) {
|
||||||
if (claimContents) {
|
if (claimContents) {
|
||||||
this.filteredEntities = [];
|
this.filteredEntities = [];
|
||||||
@@ -806,6 +860,57 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the most recently starred projects
|
||||||
|
* The starredPlanHandleIds array order represents starred order (newest at the end)
|
||||||
|
*/
|
||||||
|
async loadRecentStarredProjects(): Promise<void> {
|
||||||
|
if (
|
||||||
|
this.entityType !== "projects" ||
|
||||||
|
this.searchTerm.trim() ||
|
||||||
|
this.starredPlanHandleIds.length === 0
|
||||||
|
) {
|
||||||
|
this.recentStarredProjects = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the last 3 starred IDs (most recently starred)
|
||||||
|
const recentStarredIds = this.starredPlanHandleIds.slice(
|
||||||
|
-RECENT_STARRED_PROJECTS_COUNT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find projects matching those IDs, sorting with newest first
|
||||||
|
const projects = this.entitiesToUse as PlanData[];
|
||||||
|
const recentProjects = recentStarredIds
|
||||||
|
.map((id) => projects.find((p) => p.handleId === id))
|
||||||
|
.filter((p): p is PlanData => p !== undefined)
|
||||||
|
.reverse();
|
||||||
|
|
||||||
|
// If any projects are not found, fetch them from the API server
|
||||||
|
if (recentProjects.length < recentStarredIds.length) {
|
||||||
|
const missingIds = recentStarredIds.filter(
|
||||||
|
(id) => !recentProjects.some((p) => p.handleId === id),
|
||||||
|
);
|
||||||
|
const missingProjects = await this.fetchProjectsByIds(missingIds);
|
||||||
|
recentProjects.push(...missingProjects);
|
||||||
|
}
|
||||||
|
this.recentStarredProjects = recentProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchProjectsByIds(ids: string[]): Promise<PlanData[]> {
|
||||||
|
const idsString = encodeURIComponent(JSON.stringify(ids));
|
||||||
|
const url = `${this.apiServer}/api/v2/report/plans?planHandleIds=${idsString}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: await getHeaders(this.activeDid),
|
||||||
|
});
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error("Failed to fetch projects");
|
||||||
|
}
|
||||||
|
const results = await response.json();
|
||||||
|
return results.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side contact search
|
* Client-side contact search
|
||||||
* Assumes entities prop contains complete contact list from local database
|
* Assumes entities prop contains complete contact list from local database
|
||||||
@@ -860,11 +965,6 @@ export default class EntityGrid extends Vue {
|
|||||||
* Determine if more entities can be loaded
|
* Determine if more entities can be loaded
|
||||||
*/
|
*/
|
||||||
canLoadMore(): boolean {
|
canLoadMore(): boolean {
|
||||||
if (this.displayEntitiesFunction) {
|
|
||||||
// Custom function disables infinite scroll
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.searchTerm.trim()) {
|
if (this.searchTerm.trim()) {
|
||||||
// Search mode: check if more results available
|
// Search mode: check if more results available
|
||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
@@ -911,129 +1011,6 @@ export default class EntityGrid extends Vue {
|
|||||||
return this.displayedCount < this.entities.length;
|
return this.displayedCount < this.entities.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 starred project IDs for showing recently bookmarked projects
|
|
||||||
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
|
||||||
|
|
||||||
// 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")
|
||||||
@@ -1061,26 +1038,15 @@ export default class EntityGrid extends Vue {
|
|||||||
|
|
||||||
// When switching to projects, load them if not provided via entities prop
|
// When switching to projects, load them if not provided via entities prop
|
||||||
if (newType === "projects" && !this.entities) {
|
if (newType === "projects" && !this.entities) {
|
||||||
// Ensure apiServer is loaded
|
const settings = await this.$accountSettings();
|
||||||
if (!this.apiServer) {
|
this.apiServer = settings.apiServer || "";
|
||||||
const settings = await this.$accountSettings();
|
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
||||||
this.apiServer = settings.apiServer || "";
|
|
||||||
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load projects if we have an API server
|
if (this.allProjects.length === 0) {
|
||||||
if (this.apiServer && this.allProjects.length === 0) {
|
|
||||||
this.isLoadingProjects = true;
|
this.isLoadingProjects = true;
|
||||||
try {
|
await this.loadProjects();
|
||||||
await this.fetchProjects();
|
await this.loadRecentStarredProjects();
|
||||||
} catch (error) {
|
this.isLoadingProjects = false;
|
||||||
logger.error(
|
|
||||||
"Error loading projects when switching to projects:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
this.isLoadingProjects = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ properties * * @author Matthew Raymer */
|
|||||||
|
|
||||||
<EntityGrid
|
<EntityGrid
|
||||||
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
||||||
:entities="shouldShowProjects ? projects || undefined : allContacts"
|
:entities="shouldShowProjects ? undefined : allContacts"
|
||||||
: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"
|
||||||
:you-selectable="youSelectable"
|
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:notify="notify"
|
||||||
|
:you-selectable="youSelectable"
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -45,16 +45,7 @@ import EntityGrid from "./EntityGrid.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 { GiverReceiverInputInfo } from "@/libs/util";
|
||||||
/**
|
|
||||||
* Entity data interface for giver/receiver
|
|
||||||
*/
|
|
||||||
interface EntityData {
|
|
||||||
did?: string;
|
|
||||||
handleId?: string;
|
|
||||||
name?: string;
|
|
||||||
image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity selection event data structure
|
* Entity selection event data structure
|
||||||
@@ -87,10 +78,6 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
stepType!: "giver" | "recipient";
|
stepType!: "giver" | "recipient";
|
||||||
|
|
||||||
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
|
|
||||||
@Prop({ required: false })
|
|
||||||
projects?: PlanData[];
|
|
||||||
|
|
||||||
/** Array of available contacts */
|
/** Array of available contacts */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
allContacts!: Contact[];
|
allContacts!: Contact[];
|
||||||
@@ -107,35 +94,13 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
conflictChecker!: (did: string) => boolean;
|
conflictChecker!: (did: string) => boolean;
|
||||||
|
|
||||||
/** Project ID for context (giver) */
|
|
||||||
@Prop({ default: "" })
|
|
||||||
fromProjectId!: string;
|
|
||||||
|
|
||||||
/** Project ID for context (recipient) */
|
|
||||||
@Prop({ default: "" })
|
|
||||||
toProjectId!: string;
|
|
||||||
|
|
||||||
/** Current giver entity for context */
|
/** Current giver entity for context */
|
||||||
@Prop()
|
@Prop()
|
||||||
giver?: EntityData | null;
|
giver?: GiverReceiverInputInfo | null;
|
||||||
|
|
||||||
/** Current receiver entity for context */
|
/** Current receiver entity for context */
|
||||||
@Prop()
|
@Prop()
|
||||||
receiver?: EntityData | null;
|
receiver?: GiverReceiverInputInfo | null;
|
||||||
|
|
||||||
/** Form field values to preserve when navigating to "Show All" */
|
|
||||||
@Prop({ default: "" })
|
|
||||||
description!: string;
|
|
||||||
|
|
||||||
@Prop({ default: "0" })
|
|
||||||
amountInput!: string;
|
|
||||||
|
|
||||||
@Prop({ default: "HUR" })
|
|
||||||
unitCode!: string;
|
|
||||||
|
|
||||||
/** Offer ID for context when fulfilling an offer */
|
|
||||||
@Prop({ default: "" })
|
|
||||||
offerId!: string;
|
|
||||||
|
|
||||||
/** Notification function from parent component */
|
/** Notification function from parent component */
|
||||||
@Prop()
|
@Prop()
|
||||||
|
|||||||
@@ -50,16 +50,7 @@ import EntityIcon from "./EntityIcon.vue";
|
|||||||
import ProjectIcon from "./ProjectIcon.vue";
|
import ProjectIcon from "./ProjectIcon.vue";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||||
|
import { GiverReceiverInputInfo } from "@/libs/util";
|
||||||
/**
|
|
||||||
* Entity interface for both person and project entities
|
|
||||||
*/
|
|
||||||
interface EntityData {
|
|
||||||
did?: string;
|
|
||||||
handleId?: string;
|
|
||||||
name?: string;
|
|
||||||
image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EntitySummaryButton - Displays selected entity with edit capability
|
* EntitySummaryButton - Displays selected entity with edit capability
|
||||||
@@ -81,11 +72,7 @@ interface EntityData {
|
|||||||
export default class EntitySummaryButton extends Vue {
|
export default class EntitySummaryButton extends Vue {
|
||||||
/** Entity data to display */
|
/** Entity data to display */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
entity!: EntityData | Contact | null;
|
entity!: GiverReceiverInputInfo | Contact | null;
|
||||||
|
|
||||||
/** Type of entity: 'person' or 'project' */
|
|
||||||
@Prop({ required: true })
|
|
||||||
entityType!: "person" | "project";
|
|
||||||
|
|
||||||
/** Display label for the entity role */
|
/** Display label for the entity role */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
@@ -98,9 +85,13 @@ export default class EntitySummaryButton extends Vue {
|
|||||||
@Prop({ type: Function, default: () => {} })
|
@Prop({ type: Function, default: () => {} })
|
||||||
onEditRequested!: (data: {
|
onEditRequested!: (data: {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entity: EntityData | Contact | null;
|
entity: GiverReceiverInputInfo | Contact | null;
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
|
|
||||||
|
get entityType(): string {
|
||||||
|
return this.entity && "handleId" in this.entity ? "project" : "person";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the main container
|
* CSS classes for the main container
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ control over updates and validation * * @author Matthew Raymer */
|
|||||||
<!-- Giver Button -->
|
<!-- Giver Button -->
|
||||||
<EntitySummaryButton
|
<EntitySummaryButton
|
||||||
:entity="giver"
|
:entity="giver"
|
||||||
:entity-type="giverEntityType"
|
|
||||||
:label="giverLabel"
|
:label="giverLabel"
|
||||||
:on-edit-requested="handleEditGiver"
|
:on-edit-requested="handleEditGiver"
|
||||||
/>
|
/>
|
||||||
@@ -22,7 +21,6 @@ control over updates and validation * * @author Matthew Raymer */
|
|||||||
<!-- Recipient Button -->
|
<!-- Recipient Button -->
|
||||||
<EntitySummaryButton
|
<EntitySummaryButton
|
||||||
:entity="receiver"
|
:entity="receiver"
|
||||||
:entity-type="recipientEntityType"
|
|
||||||
:label="recipientLabel"
|
:label="recipientLabel"
|
||||||
:on-edit-requested="handleEditRecipient"
|
:on-edit-requested="handleEditRecipient"
|
||||||
/>
|
/>
|
||||||
@@ -104,16 +102,7 @@ import { Component, Prop, Vue, Watch, Emit } from "vue-facing-decorator";
|
|||||||
import EntitySummaryButton from "./EntitySummaryButton.vue";
|
import EntitySummaryButton from "./EntitySummaryButton.vue";
|
||||||
import AmountInput from "./AmountInput.vue";
|
import AmountInput from "./AmountInput.vue";
|
||||||
import { RouteLocationRaw } from "vue-router";
|
import { RouteLocationRaw } from "vue-router";
|
||||||
|
import { GiverReceiverInputInfo } from "@/libs/util";
|
||||||
/**
|
|
||||||
* Entity data interface for giver/receiver
|
|
||||||
*/
|
|
||||||
interface EntityData {
|
|
||||||
did?: string;
|
|
||||||
handleId?: string;
|
|
||||||
name?: string;
|
|
||||||
image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GiftDetailsStep - Complete step 2 gift details form interface
|
* GiftDetailsStep - Complete step 2 gift details form interface
|
||||||
@@ -139,19 +128,11 @@ interface EntityData {
|
|||||||
export default class GiftDetailsStep extends Vue {
|
export default class GiftDetailsStep extends Vue {
|
||||||
/** Giver entity data */
|
/** Giver entity data */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
giver!: EntityData | null;
|
giver!: GiverReceiverInputInfo | null;
|
||||||
|
|
||||||
/** Receiver entity data */
|
/** Receiver entity data */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
receiver!: EntityData | null;
|
receiver!: GiverReceiverInputInfo | null;
|
||||||
|
|
||||||
/** Type of giver entity: 'person' or 'project' */
|
|
||||||
@Prop({ required: true })
|
|
||||||
giverEntityType!: "person" | "project";
|
|
||||||
|
|
||||||
/** Type of recipient entity: 'person' or 'project' */
|
|
||||||
@Prop({ required: true })
|
|
||||||
recipientEntityType!: "person" | "project";
|
|
||||||
|
|
||||||
/** Gift description */
|
/** Gift description */
|
||||||
@Prop({ default: "" })
|
@Prop({ default: "" })
|
||||||
@@ -211,6 +192,14 @@ export default class GiftDetailsStep extends Vue {
|
|||||||
private localAmount: number = 0;
|
private localAmount: number = 0;
|
||||||
private localUnitCode: string = "HUR";
|
private localUnitCode: string = "HUR";
|
||||||
|
|
||||||
|
get giverEntityType(): string {
|
||||||
|
return this.giver?.handleId ? "project" : "person";
|
||||||
|
}
|
||||||
|
|
||||||
|
get recipientEntityType(): string {
|
||||||
|
return this.receiver?.handleId ? "project" : "person";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the photo & more options link
|
* CSS classes for the photo & more options link
|
||||||
*/
|
*/
|
||||||
@@ -332,7 +321,7 @@ export default class GiftDetailsStep extends Vue {
|
|||||||
*/
|
*/
|
||||||
handleEditGiver(_data: {
|
handleEditGiver(_data: {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entity: EntityData | null;
|
entity: GiverReceiverInputInfo | null;
|
||||||
}): void {
|
}): void {
|
||||||
this.emitEditEntity({
|
this.emitEditEntity({
|
||||||
entityType: "giver",
|
entityType: "giver",
|
||||||
@@ -346,7 +335,7 @@ export default class GiftDetailsStep extends Vue {
|
|||||||
*/
|
*/
|
||||||
handleEditRecipient(_data: {
|
handleEditRecipient(_data: {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entity: EntityData | null;
|
entity: GiverReceiverInputInfo | null;
|
||||||
}): void {
|
}): void {
|
||||||
this.emitEditEntity({
|
this.emitEditEntity({
|
||||||
entityType: "recipient",
|
entityType: "recipient",
|
||||||
@@ -386,8 +375,8 @@ export default class GiftDetailsStep extends Vue {
|
|||||||
@Emit("edit-entity")
|
@Emit("edit-entity")
|
||||||
emitEditEntity(data: {
|
emitEditEntity(data: {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
currentEntity: EntityData | null;
|
currentEntity: GiverReceiverInputInfo | null;
|
||||||
}): { entityType: string; currentEntity: EntityData | null } {
|
}): { entityType: string; currentEntity: GiverReceiverInputInfo | null } {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,30 +3,18 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog"
|
class="dialog"
|
||||||
data-testid="gifted-dialog"
|
data-testid="gifted-dialog"
|
||||||
:data-recipient-entity-type="currentRecipientEntityType"
|
:data-recipient-entity-type="recipientEntityType"
|
||||||
>
|
>
|
||||||
<!-- Step 1: Entity Selection -->
|
<!-- Step 1: Entity Selection -->
|
||||||
<EntitySelectionStep
|
<EntitySelectionStep
|
||||||
v-show="firstStep"
|
v-show="firstStep"
|
||||||
:step-type="stepType"
|
:step-type="stepType"
|
||||||
:giver-entity-type="currentGiverEntityType"
|
|
||||||
:recipient-entity-type="currentRecipientEntityType"
|
|
||||||
:show-projects="
|
|
||||||
currentGiverEntityType === 'project' ||
|
|
||||||
currentRecipientEntityType === 'project'
|
|
||||||
"
|
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:conflict-checker="wouldCreateConflict"
|
:conflict-checker="wouldCreateConflict"
|
||||||
:from-project-id="fromProjectId"
|
|
||||||
:to-project-id="toProjectId"
|
|
||||||
:giver="giver"
|
:giver="giver"
|
||||||
:receiver="receiver"
|
:receiver="receiver"
|
||||||
:description="description"
|
|
||||||
:amount-input="amountInput"
|
|
||||||
:unit-code="unitCode"
|
|
||||||
:offer-id="offerId"
|
|
||||||
:notify="$notify"
|
:notify="$notify"
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
@cancel="cancel"
|
@cancel="cancel"
|
||||||
@@ -37,8 +25,8 @@
|
|||||||
v-show="!firstStep"
|
v-show="!firstStep"
|
||||||
:giver="giver"
|
:giver="giver"
|
||||||
:receiver="receiver"
|
:receiver="receiver"
|
||||||
:giver-entity-type="currentGiverEntityType"
|
:giver-entity-type="giverEntityType"
|
||||||
:recipient-entity-type="currentRecipientEntityType"
|
:recipient-entity-type="recipientEntityType"
|
||||||
:description="description"
|
:description="description"
|
||||||
:amount="parseFloat(amountInput) || 0"
|
:amount="parseFloat(amountInput) || 0"
|
||||||
:unit-code="unitCode"
|
:unit-code="unitCode"
|
||||||
@@ -129,8 +117,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
description = "";
|
description = "";
|
||||||
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
|
||||||
currentGiverEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
|
|
||||||
currentRecipientEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
|
|
||||||
offerId = "";
|
offerId = "";
|
||||||
prompt = "";
|
prompt = "";
|
||||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||||
@@ -142,12 +128,20 @@ export default class GiftedDialog extends Vue {
|
|||||||
|
|
||||||
didInfo = didInfo;
|
didInfo = didInfo;
|
||||||
|
|
||||||
|
get giverEntityType(): string {
|
||||||
|
return this.giver && "handleId" in this.giver ? "project" : "person";
|
||||||
|
}
|
||||||
|
|
||||||
|
get recipientEntityType(): string {
|
||||||
|
return this.receiver && "handleId" in this.receiver ? "project" : "person";
|
||||||
|
}
|
||||||
|
|
||||||
// Computed property to check if current selection would create a conflict
|
// Computed property to check if current selection would create a conflict
|
||||||
get hasPersonConflict() {
|
get hasPersonConflict() {
|
||||||
// Only check for conflicts when both entities are persons
|
// Only check for conflicts when both entities are persons
|
||||||
if (
|
if (
|
||||||
this.currentGiverEntityType !== "person" ||
|
this.giverEntityType !== "person" ||
|
||||||
this.currentRecipientEntityType !== "person"
|
this.recipientEntityType !== "person"
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -168,8 +162,8 @@ export default class GiftedDialog extends Vue {
|
|||||||
get hasProjectConflict() {
|
get hasProjectConflict() {
|
||||||
// Only check for conflicts when both entities are projects
|
// Only check for conflicts when both entities are projects
|
||||||
if (
|
if (
|
||||||
this.currentGiverEntityType !== "project" ||
|
this.giverEntityType !== "project" ||
|
||||||
this.currentRecipientEntityType !== "project"
|
this.recipientEntityType !== "project"
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -204,9 +198,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.amountInput = amountInput || "0";
|
this.amountInput = amountInput || "0";
|
||||||
this.unitCode = unitCode || "HUR";
|
this.unitCode = unitCode || "HUR";
|
||||||
this.callbackOnSuccess = callbackOnSuccess;
|
this.callbackOnSuccess = callbackOnSuccess;
|
||||||
// Initialize current entity types from initial prop values
|
|
||||||
this.currentGiverEntityType = this.initialGiverEntityType;
|
|
||||||
this.currentRecipientEntityType = this.initialRecipientEntityType;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await this.$accountSettings();
|
const settings = await this.$accountSettings();
|
||||||
@@ -277,8 +268,8 @@ export default class GiftedDialog extends Vue {
|
|||||||
wouldCreateConflict(identifier: string) {
|
wouldCreateConflict(identifier: string) {
|
||||||
// Check for person conflicts when both entities are persons
|
// Check for person conflicts when both entities are persons
|
||||||
if (
|
if (
|
||||||
this.currentGiverEntityType === "person" &&
|
this.giverEntityType === "person" &&
|
||||||
this.currentRecipientEntityType === "person"
|
this.recipientEntityType === "person"
|
||||||
) {
|
) {
|
||||||
if (this.stepType === "giver") {
|
if (this.stepType === "giver") {
|
||||||
// If selecting as giver, check if it conflicts with current recipient
|
// If selecting as giver, check if it conflicts with current recipient
|
||||||
@@ -291,8 +282,8 @@ export default class GiftedDialog extends Vue {
|
|||||||
|
|
||||||
// Check for project conflicts when both entities are projects
|
// Check for project conflicts when both entities are projects
|
||||||
if (
|
if (
|
||||||
this.currentGiverEntityType === "project" &&
|
this.giverEntityType === "project" &&
|
||||||
this.currentRecipientEntityType === "project"
|
this.recipientEntityType === "project"
|
||||||
) {
|
) {
|
||||||
if (this.stepType === "giver") {
|
if (this.stepType === "giver") {
|
||||||
// If selecting as giver, check if it conflicts with current recipient
|
// If selecting as giver, check if it conflicts with current recipient
|
||||||
@@ -314,9 +305,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.prompt = "";
|
this.prompt = "";
|
||||||
this.unitCode = "HUR";
|
this.unitCode = "HUR";
|
||||||
this.firstStep = true;
|
this.firstStep = true;
|
||||||
// Reset to initial prop values
|
|
||||||
this.currentGiverEntityType = this.initialGiverEntityType;
|
|
||||||
this.currentRecipientEntityType = this.initialRecipientEntityType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirm() {
|
async confirm() {
|
||||||
@@ -404,8 +392,8 @@ export default class GiftedDialog extends Vue {
|
|||||||
let providerPlanHandleId: string | undefined;
|
let providerPlanHandleId: string | undefined;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.currentGiverEntityType === "project" &&
|
this.giverEntityType === "project" &&
|
||||||
this.currentRecipientEntityType === "person"
|
this.recipientEntityType === "person"
|
||||||
) {
|
) {
|
||||||
// Project-to-person gift
|
// Project-to-person gift
|
||||||
fromDid = undefined;
|
fromDid = undefined;
|
||||||
@@ -413,8 +401,8 @@ export default class GiftedDialog extends Vue {
|
|||||||
fulfillsProjectHandleId = undefined;
|
fulfillsProjectHandleId = undefined;
|
||||||
providerPlanHandleId = this.giver?.handleId;
|
providerPlanHandleId = this.giver?.handleId;
|
||||||
} else if (
|
} else if (
|
||||||
this.currentGiverEntityType === "person" &&
|
this.giverEntityType === "person" &&
|
||||||
this.currentRecipientEntityType === "project"
|
this.recipientEntityType === "project"
|
||||||
) {
|
) {
|
||||||
// Person-to-project gift
|
// Person-to-project gift
|
||||||
fromDid = giverDid as string;
|
fromDid = giverDid as string;
|
||||||
@@ -422,8 +410,8 @@ export default class GiftedDialog extends Vue {
|
|||||||
fulfillsProjectHandleId = this.receiver?.handleId;
|
fulfillsProjectHandleId = this.receiver?.handleId;
|
||||||
providerPlanHandleId = undefined;
|
providerPlanHandleId = undefined;
|
||||||
} else if (
|
} else if (
|
||||||
this.currentGiverEntityType === "person" &&
|
this.giverEntityType === "person" &&
|
||||||
this.currentRecipientEntityType === "person"
|
this.recipientEntityType === "person"
|
||||||
) {
|
) {
|
||||||
// Person-to-person gift
|
// Person-to-person gift
|
||||||
fromDid = giverDid as string;
|
fromDid = giverDid as string;
|
||||||
|
|||||||
279
src/components/MeetingExclusionGroups.vue
Normal file
279
src/components/MeetingExclusionGroups.vue
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-for="group in groups" :key="group.id" class="mb-3">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border p-3',
|
||||||
|
colorSet(group.colorIndex).bg,
|
||||||
|
colorSet(group.colorIndex).border,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'w-3 h-3 rounded-full shrink-0',
|
||||||
|
colorSet(group.colorIndex).dot,
|
||||||
|
]"
|
||||||
|
></span>
|
||||||
|
<input
|
||||||
|
:value="group.name"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[
|
||||||
|
'text-sm font-medium bg-transparent border-none',
|
||||||
|
'outline-none flex-1 min-w-0 placeholder-gray-400',
|
||||||
|
{ 'cursor-default': disabled },
|
||||||
|
]"
|
||||||
|
placeholder="Group name…"
|
||||||
|
@input="
|
||||||
|
updateGroupName(
|
||||||
|
group.id,
|
||||||
|
($event.target as HTMLInputElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'transition-colors ml-2 shrink-0',
|
||||||
|
disabled
|
||||||
|
? 'text-slate-300 cursor-not-allowed'
|
||||||
|
: 'text-slate-400 hover:text-red-600',
|
||||||
|
]"
|
||||||
|
title="Delete group"
|
||||||
|
@click="disabled ? notifyLocked() : removeGroup(group.id)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
<span
|
||||||
|
v-for="did in group.memberDids"
|
||||||
|
:key="did"
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs',
|
||||||
|
colorSet(group.colorIndex).chip,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getMemberName(did) }}
|
||||||
|
<button
|
||||||
|
:class="
|
||||||
|
disabled ? 'opacity-40 cursor-not-allowed' : 'hover:opacity-70'
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
disabled ? notifyLocked() : removeMemberFromGroup(group.id, did)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<font-awesome icon="xmark" class="text-xs" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="group.memberDids.length === 0"
|
||||||
|
class="text-xs text-slate-400 italic"
|
||||||
|
>
|
||||||
|
No members yet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!disabled && addingToGroupId === group.id" class="mt-2">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-1.5 p-2 bg-white bg-opacity-60 rounded border border-gray-200"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="member in availableMembersForGroup(group)"
|
||||||
|
:key="member.did"
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-white border border-gray-300 hover:bg-gray-100 transition-colors"
|
||||||
|
@click="addMemberToGroup(group.id, member.did)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="plus" class="text-xs text-green-600" />
|
||||||
|
{{ member.name }}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="availableMembersForGroup(group).length === 0"
|
||||||
|
class="text-xs text-slate-400 italic"
|
||||||
|
>
|
||||||
|
All members already assigned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-xs text-slate-500 mt-1"
|
||||||
|
@click="addingToGroupId = ''"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
:class="[
|
||||||
|
'text-xs transition-colors',
|
||||||
|
disabled
|
||||||
|
? 'text-slate-400 cursor-not-allowed'
|
||||||
|
: 'text-blue-600 hover:text-blue-800',
|
||||||
|
]"
|
||||||
|
@click="disabled ? notifyLocked() : (addingToGroupId = group.id)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="plus" class="text-xs" />
|
||||||
|
Add member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'text-sm transition-colors',
|
||||||
|
disabled
|
||||||
|
? 'text-slate-400 cursor-not-allowed'
|
||||||
|
: 'text-blue-600 hover:text-blue-800',
|
||||||
|
]"
|
||||||
|
@click="disabled ? notifyLocked() : addGroup()"
|
||||||
|
>
|
||||||
|
<font-awesome icon="plus" class="text-sm" />
|
||||||
|
New Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
|
||||||
|
import { DoNotPairGroup } from "@/interfaces";
|
||||||
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
|
import { NotificationIface } from "@/constants/app";
|
||||||
|
|
||||||
|
interface MemberInfo {
|
||||||
|
did: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_COLORS = [
|
||||||
|
{
|
||||||
|
bg: "bg-orange-50",
|
||||||
|
border: "border-orange-200",
|
||||||
|
dot: "bg-orange-400",
|
||||||
|
chip: "bg-orange-200 text-orange-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-purple-50",
|
||||||
|
border: "border-purple-200",
|
||||||
|
dot: "bg-purple-400",
|
||||||
|
chip: "bg-purple-200 text-purple-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-teal-50",
|
||||||
|
border: "border-teal-200",
|
||||||
|
dot: "bg-teal-400",
|
||||||
|
chip: "bg-teal-200 text-teal-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-pink-50",
|
||||||
|
border: "border-pink-200",
|
||||||
|
dot: "bg-pink-400",
|
||||||
|
chip: "bg-pink-200 text-pink-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-indigo-50",
|
||||||
|
border: "border-indigo-200",
|
||||||
|
dot: "bg-indigo-400",
|
||||||
|
chip: "bg-indigo-200 text-indigo-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-yellow-50",
|
||||||
|
border: "border-yellow-200",
|
||||||
|
dot: "bg-yellow-400",
|
||||||
|
chip: "bg-yellow-200 text-yellow-800",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class MeetingExclusionGroups extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
|
@Prop({ required: true }) groups!: DoNotPairGroup[];
|
||||||
|
@Prop({ required: true }) availableMembers!: MemberInfo[];
|
||||||
|
@Prop({ default: false }) disabled!: boolean;
|
||||||
|
|
||||||
|
addingToGroupId = "";
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.notify = createNotifyHelpers(this.$notify);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyLocked(): void {
|
||||||
|
this.notify.warning(
|
||||||
|
"Erase the current matches before changing exclusion groups.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
colorSet(colorIndex: number): (typeof GROUP_COLORS)[0] {
|
||||||
|
return GROUP_COLORS[colorIndex % GROUP_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
getMemberName(did: string): string {
|
||||||
|
const member = this.availableMembers.find((m) => m.did === did);
|
||||||
|
return member?.name || did.substring(0, 16) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
availableMembersForGroup(group: DoNotPairGroup): MemberInfo[] {
|
||||||
|
const allAssignedDids = new Set(this.groups.flatMap((g) => g.memberDids));
|
||||||
|
return this.availableMembers
|
||||||
|
.filter(
|
||||||
|
(m) => !allAssignedDids.has(m.did) || group.memberDids.includes(m.did),
|
||||||
|
)
|
||||||
|
.filter((m) => !group.memberDids.includes(m.did));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("update")
|
||||||
|
emitUpdate(): DoNotPairGroup[] {
|
||||||
|
return [...this.groups];
|
||||||
|
}
|
||||||
|
|
||||||
|
addGroup(): void {
|
||||||
|
const newGroup: DoNotPairGroup = {
|
||||||
|
id: Date.now().toString(36) + Math.random().toString(36).substring(2, 6),
|
||||||
|
name: "",
|
||||||
|
colorIndex: this.groups.length % GROUP_COLORS.length,
|
||||||
|
memberDids: [],
|
||||||
|
};
|
||||||
|
this.groups.push(newGroup);
|
||||||
|
this.addingToGroupId = newGroup.id;
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeGroup(groupId: string): void {
|
||||||
|
const idx = this.groups.findIndex((g) => g.id === groupId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.groups.splice(idx, 1);
|
||||||
|
if (this.addingToGroupId === groupId) {
|
||||||
|
this.addingToGroupId = "";
|
||||||
|
}
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGroupName(groupId: string, name: string): void {
|
||||||
|
const group = this.groups.find((g) => g.id === groupId);
|
||||||
|
if (group) {
|
||||||
|
group.name = name;
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMemberToGroup(groupId: string, did: string): void {
|
||||||
|
const group = this.groups.find((g) => g.id === groupId);
|
||||||
|
if (group && !group.memberDids.includes(did)) {
|
||||||
|
group.memberDids.push(did);
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMemberFromGroup(groupId: string, did: string): void {
|
||||||
|
const group = this.groups.find((g) => g.id === groupId);
|
||||||
|
if (group) {
|
||||||
|
group.memberDids = group.memberDids.filter((d) => d !== did);
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
249
src/components/MeetingMemberMatch.vue
Normal file
249
src/components/MeetingMemberMatch.vue
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<template>
|
||||||
|
<div class="group-onboard-match-display">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="flex items-center justify-center gap-2 py-6 text-slate-600"
|
||||||
|
>
|
||||||
|
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div
|
||||||
|
v-else-if="errorMessage"
|
||||||
|
class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700"
|
||||||
|
>
|
||||||
|
Inform the organizer that there was an error. {{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Matched person -->
|
||||||
|
<div
|
||||||
|
v-else-if="matchedPerson"
|
||||||
|
class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<h2 class="mb-3 font-bold text-slate-700">Your Current Match</h2>
|
||||||
|
<p v-if="myPair != null" class="mb-3 text-sm text-slate-600">
|
||||||
|
You are in Pair #{{ myPair.pairNumber }} with:
|
||||||
|
</p>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<EntityIcon
|
||||||
|
:contact="matchedPersonContact"
|
||||||
|
class="!size-14 shrink-0 overflow-hidden rounded-full border border-slate-300 bg-white"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-medium text-slate-900">
|
||||||
|
{{ matchedPerson.name || "(No name)" }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 truncate text-xs text-slate-500">
|
||||||
|
{{ matchedPerson.did }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="matchedPerson.description"
|
||||||
|
class="mt-2 line-clamp-3 text-sm text-slate-600"
|
||||||
|
>
|
||||||
|
{{ matchedPerson.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue, Prop, Watch } from "vue-facing-decorator";
|
||||||
|
import {
|
||||||
|
errorStringForLog,
|
||||||
|
getHeaders,
|
||||||
|
serverMessageForUser,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
|
import { decryptMessage } from "@/libs/crypto";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import EntityIcon from "./EntityIcon.vue";
|
||||||
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
import { AxiosErrorResponse } from "@/interfaces";
|
||||||
|
|
||||||
|
/** Participant from GET /api/partner/groupOnboardMatch pair */
|
||||||
|
interface MatchPairParticipant {
|
||||||
|
issuerDid: string;
|
||||||
|
content: string;
|
||||||
|
description?: string;
|
||||||
|
decryptedContentObject?: {
|
||||||
|
name: string;
|
||||||
|
did: string;
|
||||||
|
isRegistered: boolean;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchPair {
|
||||||
|
pairNumber: number;
|
||||||
|
similarity: number;
|
||||||
|
participants: MatchPairParticipant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized matched person for display */
|
||||||
|
interface MatchedPersonData {
|
||||||
|
name: string;
|
||||||
|
did: string;
|
||||||
|
isRegistered: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
EntityIcon,
|
||||||
|
},
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
|
})
|
||||||
|
export default class GroupOnboardMatchDisplay extends Vue {
|
||||||
|
@Prop({ required: true })
|
||||||
|
meetingPassword!: string;
|
||||||
|
|
||||||
|
/** When provided, used to determine this person's match instead of calling groupOnboardMatch */
|
||||||
|
@Prop()
|
||||||
|
matchPairs?: MatchPair[] | null;
|
||||||
|
|
||||||
|
activeDid = "";
|
||||||
|
apiServer = "";
|
||||||
|
errorMessage = "";
|
||||||
|
isLoading = true;
|
||||||
|
matchedPerson: MatchedPersonData | null = null;
|
||||||
|
/** Pair that contains the current user (for similarity display if needed) */
|
||||||
|
myPair: MatchPair | null = null;
|
||||||
|
|
||||||
|
/** Contact-like object for EntityIcon from matched person */
|
||||||
|
get matchedPersonContact(): Contact | undefined {
|
||||||
|
if (!this.matchedPerson) return undefined;
|
||||||
|
return {
|
||||||
|
did: this.matchedPerson.did,
|
||||||
|
name: this.matchedPerson.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async created() {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
this.apiServer = settings?.apiServer || "";
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||||
|
this.activeDid = activeIdentity?.activeDid || "";
|
||||||
|
await this.fetchMatches();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch("meetingPassword")
|
||||||
|
async onPasswordChange() {
|
||||||
|
if (
|
||||||
|
this.meetingPassword &&
|
||||||
|
(this.matchPairs != null || (this.apiServer && this.activeDid))
|
||||||
|
) {
|
||||||
|
await this.fetchMatches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch("matchPairs")
|
||||||
|
async onMatchPairsChange() {
|
||||||
|
if (this.activeDid && this.meetingPassword) {
|
||||||
|
await this.fetchMatches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that this is called externally by MeetingMembersList when user triggers a refresh
|
||||||
|
async fetchMatches(): Promise<void> {
|
||||||
|
const usePropPairs =
|
||||||
|
this.matchPairs != null &&
|
||||||
|
Array.isArray(this.matchPairs) &&
|
||||||
|
this.matchPairs.length > 0;
|
||||||
|
const needApi = !usePropPairs;
|
||||||
|
|
||||||
|
if (
|
||||||
|
needApi &&
|
||||||
|
(!this.meetingPassword?.trim() || !this.apiServer || !this.activeDid)
|
||||||
|
) {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.matchedPerson = null;
|
||||||
|
this.myPair = null;
|
||||||
|
this.errorMessage = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (usePropPairs && (!this.meetingPassword?.trim() || !this.activeDid)) {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.matchedPerson = null;
|
||||||
|
this.myPair = null;
|
||||||
|
this.errorMessage = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
this.errorMessage = "";
|
||||||
|
this.matchedPerson = null;
|
||||||
|
this.myPair = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let pairs: MatchPair[] | null = null;
|
||||||
|
if (usePropPairs) {
|
||||||
|
// Shallow-copy so we can set decryptedContentObject without mutating the prop
|
||||||
|
pairs = (this.matchPairs ?? []).map((p) => ({
|
||||||
|
...p,
|
||||||
|
participants: p.participants.map((part) => ({ ...part })),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const response = await this.axios.get(
|
||||||
|
`${this.apiServer}/api/partner/groupOnboardMatch`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
pairs = response?.data?.data?.pairs ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(pairs) || pairs.length === 0) {
|
||||||
|
this.isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt each participant's content and find the pair containing this user
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (!pair.participants || pair.participants.length !== 2) continue;
|
||||||
|
|
||||||
|
for (const participant of pair.participants) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptMessage(
|
||||||
|
participant.content,
|
||||||
|
this.meetingPassword,
|
||||||
|
);
|
||||||
|
participant.decryptedContentObject = JSON.parse(decrypted);
|
||||||
|
} catch {
|
||||||
|
participant.decryptedContentObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const myIndex = pair.participants.findIndex(
|
||||||
|
(p) => p.issuerDid === this.activeDid,
|
||||||
|
);
|
||||||
|
if (myIndex === -1) continue;
|
||||||
|
|
||||||
|
this.myPair = pair;
|
||||||
|
const other = pair.participants[1 - myIndex];
|
||||||
|
const obj = other.decryptedContentObject;
|
||||||
|
this.matchedPerson = {
|
||||||
|
name: obj?.name ?? "",
|
||||||
|
did: obj?.did ?? other.issuerDid,
|
||||||
|
isRegistered: !!obj?.isRegistered,
|
||||||
|
description: other.description,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = false;
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(
|
||||||
|
"Error fetching group onboard match: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.errorMessage =
|
||||||
|
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
|
||||||
|
"Failed to load your match.";
|
||||||
|
this.matchedPerson = null;
|
||||||
|
this.myPair = null;
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
<font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
|
<font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
|
||||||
to add/remove them to/from the meeting.
|
to add/remove them to/from the meeting.
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="
|
||||||
|
membersToShow().length > 0 && showOrganizerTools && isOrganizer
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Click
|
||||||
|
<font-awesome icon="ban" class="text-slate-500 text-sm" />
|
||||||
|
to exclude someone from matching.
|
||||||
|
</li>
|
||||||
<li
|
<li
|
||||||
v-if="
|
v-if="
|
||||||
membersToShow().length > 0 && getNonContactMembers().length > 0
|
membersToShow().length > 0 && getNonContactMembers().length > 0
|
||||||
@@ -47,11 +56,18 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="flex justify-between">
|
<MeetingMemberMatch
|
||||||
|
ref="memberMatch"
|
||||||
|
:match-pairs="matchPairs"
|
||||||
|
:meeting-password="password || ''"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-between mt-4">
|
||||||
<!--
|
<!--
|
||||||
always have at least one refresh button even without members in case the organizer
|
always have at least one refresh button even without members in case the organizer
|
||||||
changes the password
|
changes the password
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||||
title="Refresh members list now"
|
title="Refresh members list now"
|
||||||
@@ -75,6 +91,8 @@
|
|||||||
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
|
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
|
||||||
!member.member.admitted &&
|
!member.member.admitted &&
|
||||||
(isOrganizer || member.did === activeDid),
|
(isOrganizer || member.did === activeDid),
|
||||||
|
'bg-amber-50 opacity-60':
|
||||||
|
member.member.admitted && excludedDids.includes(member.did),
|
||||||
},
|
},
|
||||||
{ 'border-slate-300': member.member.admitted },
|
{ 'border-slate-300': member.member.admitted },
|
||||||
]"
|
]"
|
||||||
@@ -88,6 +106,9 @@
|
|||||||
'text-slate-500':
|
'text-slate-500':
|
||||||
!member.member.admitted &&
|
!member.member.admitted &&
|
||||||
(isOrganizer || member.did === activeDid),
|
(isOrganizer || member.did === activeDid),
|
||||||
|
'line-through text-slate-400':
|
||||||
|
member.member.admitted &&
|
||||||
|
excludedDids.includes(member.did),
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -161,8 +182,31 @@
|
|||||||
v-if="
|
v-if="
|
||||||
showOrganizerTools && isOrganizer && member.did !== activeDid
|
showOrganizerTools && isOrganizer && member.did !== activeDid
|
||||||
"
|
"
|
||||||
class="flex items-center gap-1.5"
|
class="flex items-center gap-6"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
v-if="member.member.admitted"
|
||||||
|
:class="[
|
||||||
|
'btn-exclusion-toggle',
|
||||||
|
exclusionLocked
|
||||||
|
? excludedDids.includes(member.did)
|
||||||
|
? 'text-amber-400 opacity-50'
|
||||||
|
: 'text-slate-300 opacity-50'
|
||||||
|
: excludedDids.includes(member.did)
|
||||||
|
? 'text-amber-600'
|
||||||
|
: 'text-slate-500',
|
||||||
|
]"
|
||||||
|
:title="
|
||||||
|
exclusionLocked
|
||||||
|
? 'Erase matches to change exclusions'
|
||||||
|
: excludedDids.includes(member.did)
|
||||||
|
? 'Include in matching'
|
||||||
|
: 'Exclude from matching'
|
||||||
|
"
|
||||||
|
@click="handleExclusionClick(member.did)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="ban" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="
|
:class="
|
||||||
member.member.admitted
|
member.member.admitted
|
||||||
@@ -198,9 +242,9 @@
|
|||||||
|
|
||||||
<div v-if="membersToShow().length > 0" class="flex justify-between">
|
<div v-if="membersToShow().length > 0" class="flex justify-between">
|
||||||
<!--
|
<!--
|
||||||
always have at least one refresh button even without members in case the organizer
|
always have at least one refresh button even without members in case the organizer
|
||||||
changes the password
|
changes the password
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||||
title="Refresh members list now"
|
title="Refresh members list now"
|
||||||
@@ -246,10 +290,13 @@ import {
|
|||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
import { decryptMessage } from "@/libs/crypto";
|
import { decryptMessage } from "@/libs/crypto";
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
import { MemberData } from "@/interfaces";
|
import { MemberData, MatchPair } from "@/interfaces";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
import BulkMembersDialog from "./BulkMembersDialog.vue";
|
import BulkMembersDialog from "./BulkMembersDialog.vue";
|
||||||
|
import MeetingMemberMatch from "./MeetingMemberMatch.vue";
|
||||||
|
|
||||||
|
const AUTO_REFRESH_INTERVAL = 15;
|
||||||
|
|
||||||
interface Member {
|
interface Member {
|
||||||
admitted: boolean;
|
admitted: boolean;
|
||||||
@@ -257,6 +304,7 @@ interface Member {
|
|||||||
memberId: number;
|
memberId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// there's a similar structure in OnboardMeetingSetupView.vue but without the member
|
||||||
interface DecryptedMember {
|
interface DecryptedMember {
|
||||||
member: Member;
|
member: Member;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -267,16 +315,20 @@ interface DecryptedMember {
|
|||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
BulkMembersDialog,
|
BulkMembersDialog,
|
||||||
|
MeetingMemberMatch,
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
export default class MembersList extends Vue {
|
export default class MeetingMembersList extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
@Prop({ required: true }) password!: string;
|
@Prop({ required: true }) password!: string;
|
||||||
@Prop({ default: false }) showOrganizerTools!: boolean;
|
@Prop({ default: false }) showOrganizerTools!: boolean;
|
||||||
|
@Prop({ default: null }) matchPairs!: MatchPair[] | null;
|
||||||
|
@Prop({ default: () => [] }) excludedDids!: string[];
|
||||||
|
@Prop({ default: false }) exclusionLocked!: boolean;
|
||||||
|
|
||||||
// Emit methods using @Emit decorator
|
// Emit methods using @Emit decorator
|
||||||
@Emit("error")
|
@Emit("error")
|
||||||
@@ -284,6 +336,16 @@ export default class MembersList extends Vue {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Emit("toggle-exclusion")
|
||||||
|
emitToggleExclusion(did: string) {
|
||||||
|
return did;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("members-loaded")
|
||||||
|
emitMembersLoaded() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
contacts: Array<Contact> = [];
|
contacts: Array<Contact> = [];
|
||||||
decryptedMembers: DecryptedMember[] = [];
|
decryptedMembers: DecryptedMember[] = [];
|
||||||
firstName = "";
|
firstName = "";
|
||||||
@@ -296,7 +358,7 @@ export default class MembersList extends Vue {
|
|||||||
apiServer = "";
|
apiServer = "";
|
||||||
|
|
||||||
// Auto-refresh functionality
|
// Auto-refresh functionality
|
||||||
countdownTimer = 10;
|
countdownTimer = AUTO_REFRESH_INTERVAL;
|
||||||
autoRefreshInterval: NodeJS.Timeout | null = null;
|
autoRefreshInterval: NodeJS.Timeout | null = null;
|
||||||
lastRefreshTime = 0;
|
lastRefreshTime = 0;
|
||||||
previousMemberDidsIgnored: string[] = [];
|
previousMemberDidsIgnored: string[] = [];
|
||||||
@@ -345,6 +407,7 @@ export default class MembersList extends Vue {
|
|||||||
this.emitError(serverMessageForUser(error) || "Failed to fetch members.");
|
this.emitError(serverMessageForUser(error) || "Failed to fetch members.");
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
this.emitMembersLoaded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +549,7 @@ export default class MembersList extends Vue {
|
|||||||
|
|
||||||
informAboutAdmission() {
|
informAboutAdmission() {
|
||||||
this.notify.info(
|
this.notify.info(
|
||||||
"This is to register people in Time Safari and to admit them to the meeting. A (+) symbol means they are not yet admitted and you can register and admit them. A (-) symbol means you can remove them, but they will stay registered.",
|
"Click the 'ban' button to exclude from matching. The '+/-' buttons are for admissions: A blue (+) symbol means they are not yet admitted and you can register and admit them. A red (-) symbol means you can remove them, but they will stay registered.",
|
||||||
TIMEOUTS.VERY_LONG,
|
TIMEOUTS.VERY_LONG,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -544,8 +607,17 @@ export default class MembersList extends Vue {
|
|||||||
* (admit pending members for organizers, add to contacts for non-organizers)
|
* (admit pending members for organizers, add to contacts for non-organizers)
|
||||||
*/
|
*/
|
||||||
async refreshData(bypassPromptIfAllWereIgnored = true) {
|
async refreshData(bypassPromptIfAllWereIgnored = true) {
|
||||||
// Force refresh both contacts and members
|
// Force refresh of many things
|
||||||
|
|
||||||
|
// Matches may have been generated or erased
|
||||||
|
(
|
||||||
|
this.$refs.memberMatch as InstanceType<typeof MeetingMemberMatch>
|
||||||
|
)?.fetchMatches();
|
||||||
|
|
||||||
|
// Someone may have been added to their contacts
|
||||||
this.contacts = await this.$getAllContacts();
|
this.contacts = await this.$getAllContacts();
|
||||||
|
|
||||||
|
// The members list may have changed
|
||||||
await this.fetchMembers();
|
await this.fetchMembers();
|
||||||
|
|
||||||
const pendingMembers = this.isOrganizer
|
const pendingMembers = this.isOrganizer
|
||||||
@@ -724,25 +796,42 @@ export default class MembersList extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAdmittedMembers(): Array<{ did: string; name: string }> {
|
||||||
|
return this.decryptedMembers
|
||||||
|
.filter((m) => m.member.admitted)
|
||||||
|
.map((m) => ({ did: m.did, name: m.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleExclusionClick(did: string): void {
|
||||||
|
if (this.exclusionLocked) {
|
||||||
|
this.notify.warning(
|
||||||
|
"Erase the current matches before changing exclusions.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitToggleExclusion(did);
|
||||||
|
}
|
||||||
|
|
||||||
startAutoRefresh() {
|
startAutoRefresh() {
|
||||||
this.stopAutoRefresh();
|
this.stopAutoRefresh();
|
||||||
this.lastRefreshTime = Date.now();
|
this.lastRefreshTime = Date.now();
|
||||||
this.countdownTimer = 10;
|
this.countdownTimer = AUTO_REFRESH_INTERVAL;
|
||||||
|
|
||||||
this.autoRefreshInterval = setInterval(() => {
|
this.autoRefreshInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeSinceLastRefresh = (now - this.lastRefreshTime) / 1000;
|
const timeSinceLastRefresh = (now - this.lastRefreshTime) / 1000;
|
||||||
|
|
||||||
if (timeSinceLastRefresh >= 10) {
|
if (timeSinceLastRefresh >= AUTO_REFRESH_INTERVAL) {
|
||||||
// Time to refresh
|
// Time to refresh
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
this.lastRefreshTime = now;
|
this.lastRefreshTime = now;
|
||||||
this.countdownTimer = 10;
|
this.countdownTimer = AUTO_REFRESH_INTERVAL;
|
||||||
} else {
|
} else {
|
||||||
// Update countdown
|
// Update countdown
|
||||||
this.countdownTimer = Math.max(
|
this.countdownTimer = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.round(10 - timeSinceLastRefresh),
|
Math.round(AUTO_REFRESH_INTERVAL - timeSinceLastRefresh),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 1000); // Update every second
|
}, 1000); // Update every second
|
||||||
@@ -789,6 +878,11 @@ export default class MembersList extends Vue {
|
|||||||
transition-colors;
|
transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-exclusion-toggle {
|
||||||
|
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||||
|
@apply text-lg transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-admission-remove {
|
.btn-admission-remove {
|
||||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||||
@apply text-lg text-rose-500 hover:text-rose-700
|
@apply text-lg text-rose-500 hover:text-rose-700
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div v-if="visible" class="dialog-overlay">
|
<div v-if="visible" class="dialog-overlay">
|
||||||
<div v-if="page === OnboardPage.Home" class="dialog">
|
<div v-if="page === OnboardPage.Home" class="dialog">
|
||||||
<h1 class="text-xl font-bold text-center mb-4 relative">
|
<h1 class="text-xl font-bold text-center mb-4 relative">
|
||||||
Welcome to Time Safari
|
Welcome to {{ AppString.APP_NAME }}
|
||||||
<br />
|
<br />
|
||||||
- Showcase Impact & Magnify Time
|
- Showcase Impact & Magnify Time
|
||||||
<div :class="closeButtonClasses" @click="onClickClose(true)">
|
<div :class="closeButtonClasses" @click="onClickClose(true)">
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { Router } from "vue-router";
|
import { Router } from "vue-router";
|
||||||
|
|
||||||
import { NotificationIface } from "../constants/app";
|
import { AppString, NotificationIface } from "../constants/app";
|
||||||
import { OnboardPage } from "../libs/util";
|
import { OnboardPage } from "../libs/util";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
|
||||||
@@ -226,6 +226,13 @@ export default class OnboardingDialog extends Vue {
|
|||||||
return OnboardPage;
|
return OnboardPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns AppString enum for template access
|
||||||
|
*/
|
||||||
|
get AppString() {
|
||||||
|
return AppString;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for primary action buttons (blue gradient)
|
* CSS classes for primary action buttons (blue gradient)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
export enum AppString {
|
export enum AppString {
|
||||||
// This is used in titles and verbiage inside the app.
|
// This is used in titles and verbiage inside the app.
|
||||||
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
|
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
|
||||||
APP_NAME = "Time Safari",
|
APP_NAME = "Gift Economies",
|
||||||
APP_NAME_NO_SPACES = "TimeSafari",
|
APP_NAME_NO_SPACES = "GiftEconomies",
|
||||||
|
|
||||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||||
@@ -56,6 +56,7 @@ export const IMAGE_TYPE_PROFILE = "profile";
|
|||||||
export function isNotProdServer(apiServer: string): boolean {
|
export function isNotProdServer(apiServer: string): boolean {
|
||||||
return apiServer !== AppString.PROD_ENDORSER_API_SERVER;
|
return apiServer !== AppString.PROD_ENDORSER_API_SERVER;
|
||||||
}
|
}
|
||||||
|
export const SUPPORT_EMAIL = "info@TimeSafari.app";
|
||||||
|
|
||||||
export const PASSKEYS_ENABLED =
|
export const PASSKEYS_ENABLED =
|
||||||
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
||||||
|
|||||||
@@ -448,8 +448,8 @@ export const NOTIFY_UNCONFIRMED_HOURS_DYNAMIC = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Complex modal constants (for raw $notify calls with advanced features)
|
// Complex modal constants (for raw $notify calls with advanced features)
|
||||||
// MembersList.vue complex modals
|
// MeetingMembersList.vue complex modals
|
||||||
// Used in: MembersList.vue (complex modal for adding contacts)
|
// Used in: MeetingMembersList.vue (complex modal for adding contacts)
|
||||||
export const NOTIFY_ADD_CONTACT_FIRST = {
|
export const NOTIFY_ADD_CONTACT_FIRST = {
|
||||||
title: "Add as Contact First?",
|
title: "Add as Contact First?",
|
||||||
text: "This person is not in your contacts. Would you like to add them as a contact first?",
|
text: "This person is not in your contacts. Would you like to add them as a contact first?",
|
||||||
@@ -457,7 +457,7 @@ export const NOTIFY_ADD_CONTACT_FIRST = {
|
|||||||
noText: "Skip Adding Contact",
|
noText: "Skip Adding Contact",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used in: MembersList.vue (complex modal for continuing without adding)
|
// Used in: MeetingMembersList.vue (complex modal for continuing without adding)
|
||||||
export const NOTIFY_CONTINUE_WITHOUT_ADDING = {
|
export const NOTIFY_CONTINUE_WITHOUT_ADDING = {
|
||||||
title: "Continue Without Adding?",
|
title: "Continue Without Adding?",
|
||||||
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",
|
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ const MIGRATIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "002_add_iViewContent_to_contacts",
|
name: "002_add_iViewContent_to_contacts",
|
||||||
|
// Note that many times iViewContent was set to null despite the DEFAULT setting.
|
||||||
sql: `
|
sql: `
|
||||||
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
|
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
|
||||||
`,
|
`,
|
||||||
@@ -214,12 +215,68 @@ const MIGRATIONS = [
|
|||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "007_add_reminderFastRolloverForTesting_to_settings",
|
name: "007_add_hideTheirContent_to_contacts",
|
||||||
|
// Since we have problems where iViewContent is not set, let's default to show content.
|
||||||
|
// Add hideTheirContent: null/absent/false = show content (safe default)
|
||||||
|
sql: `
|
||||||
|
ALTER TABLE contacts ADD COLUMN hideTheirContent BOOLEAN DEFAULT 0;
|
||||||
|
UPDATE contacts SET hideTheirContent = CASE WHEN iViewContent = 0 THEN 1 ELSE 0 END WHERE iViewContent IS NOT NULL;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "008_remove_iViewContent_from_contacts",
|
||||||
|
// Recreate contacts without iViewContent: backup, drop, recreate, restore
|
||||||
|
sql: `
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
CREATE TABLE _contact_labels_backup_008 AS SELECT * FROM contact_labels;
|
||||||
|
DROP TABLE IF EXISTS contact_labels;
|
||||||
|
|
||||||
|
CREATE TABLE _contacts_backup_008 (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
did TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
contactMethods TEXT,
|
||||||
|
nextPubKeyHashB64 TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
profileImageUrl TEXT,
|
||||||
|
publicKeyBase64 TEXT,
|
||||||
|
seesMe BOOLEAN,
|
||||||
|
registered BOOLEAN,
|
||||||
|
hideTheirContent BOOLEAN DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT INTO _contacts_backup_008 (id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, hideTheirContent)
|
||||||
|
SELECT id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, COALESCE(hideTheirContent, 0) FROM contacts;
|
||||||
|
|
||||||
|
DROP TABLE contacts;
|
||||||
|
ALTER TABLE _contacts_backup_008 RENAME TO contacts;
|
||||||
|
|
||||||
|
-- UNIQUE is important on 'did' because the foreign key from contact_labels fails on mobile without it.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
||||||
|
|
||||||
|
CREATE TABLE contact_labels (
|
||||||
|
did TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (did, label),
|
||||||
|
FOREIGN KEY (did) REFERENCES contacts(did) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_contact_labels_label ON contact_labels(label);
|
||||||
|
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
|
||||||
|
INSERT INTO contact_labels SELECT * FROM _contact_labels_backup_008;
|
||||||
|
DROP TABLE _contact_labels_backup_008;
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "009_add_reminderFastRolloverForTesting_to_settings",
|
||||||
sql: `
|
sql: `
|
||||||
-- Dev/test only: 10-minute rollover for daily reminder (plugin rolloverIntervalMinutes)
|
-- Dev/test only: 10-minute rollover for daily reminder (plugin rolloverIntervalMinutes)
|
||||||
ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE;
|
ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export type ContactMethod = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Contact = {
|
export type Contact = {
|
||||||
|
// id is a property in most contacts, but besides sorting from DB we don't need it
|
||||||
|
|
||||||
//
|
//
|
||||||
// When adding a property:
|
// When adding a property:
|
||||||
// - Consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
|
// - Consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
|
||||||
@@ -14,7 +16,8 @@ export type Contact = {
|
|||||||
|
|
||||||
did: string;
|
did: string;
|
||||||
contactMethods?: Array<ContactMethod>;
|
contactMethods?: Array<ContactMethod>;
|
||||||
iViewContent?: boolean;
|
/** When true, hide this contact's activity from the feed. Default (null/undefined) = show. */
|
||||||
|
hideTheirContent?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export interface AxiosErrorResponse {
|
|||||||
response?: {
|
response?: {
|
||||||
data?: {
|
data?: {
|
||||||
error?: {
|
error?: {
|
||||||
|
// This is in responses from endorser-ch server
|
||||||
|
userMessage?: string;
|
||||||
|
// This is the old approach from endorser-ch server; remove when we've removed all "error: { message: ... }"
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
@@ -7,6 +7,25 @@ export interface UserInfo {
|
|||||||
nextPublicEncKeyHash?: string;
|
nextPublicEncKeyHash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MatchPair {
|
||||||
|
pairNumber: number;
|
||||||
|
similarity: number;
|
||||||
|
participants: MatchPairParticipant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pair from GET/POST /api/partner/groupOnboardMatch */
|
||||||
|
export interface MatchPairParticipant {
|
||||||
|
issuerDid: string;
|
||||||
|
content: string;
|
||||||
|
// there's a similar structure in MeetingMembersList.vue with extra Member info
|
||||||
|
decryptedContentObject: {
|
||||||
|
name: string;
|
||||||
|
did: string;
|
||||||
|
isRegistered: boolean;
|
||||||
|
};
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MemberData {
|
export interface MemberData {
|
||||||
did: string;
|
did: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,3 +34,16 @@ export interface MemberData {
|
|||||||
memberId: string;
|
memberId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DoNotPairGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
colorIndex: number;
|
||||||
|
memberDids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeetingExclusionState {
|
||||||
|
meetingGroupId: string;
|
||||||
|
excludedDids: string[];
|
||||||
|
doNotPairGroups: DoNotPairGroup[];
|
||||||
|
}
|
||||||
|
|||||||
155
src/libs/contactImportPayload.ts
Normal file
155
src/libs/contactImportPayload.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import { getContactJwtFromJwtUrl } from "./crypto";
|
||||||
|
import { decodeEndorserJwt } from "./crypto/vc";
|
||||||
|
|
||||||
|
export type ContactImportParseErrorCode =
|
||||||
|
| "not_contact_import_format"
|
||||||
|
| "truncated_data"
|
||||||
|
| "invalid_jwt"
|
||||||
|
| "unsupported_payload";
|
||||||
|
|
||||||
|
export type ContactImportParseResult =
|
||||||
|
| {
|
||||||
|
kind: "single";
|
||||||
|
jwt: string;
|
||||||
|
contact: Contact;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "multi";
|
||||||
|
jwt: string;
|
||||||
|
contacts: Contact[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "error";
|
||||||
|
code: ContactImportParseErrorCode;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapSingleContact(payload: Record<string, unknown>): Contact | null {
|
||||||
|
const own = payload.own as Record<string, unknown> | undefined;
|
||||||
|
if (!own || typeof own !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const didFromOwn = typeof own.did === "string" ? own.did : undefined;
|
||||||
|
const didFromIss = typeof payload.iss === "string" ? payload.iss : undefined;
|
||||||
|
const did = didFromOwn || didFromIss;
|
||||||
|
if (!did) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact: Contact = {
|
||||||
|
did,
|
||||||
|
name: typeof own.name === "string" ? own.name : undefined,
|
||||||
|
registered: Boolean(own.registered),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextPubKeyHashB64 =
|
||||||
|
(typeof own.nextPublicEncKeyHash === "string" &&
|
||||||
|
own.nextPublicEncKeyHash) ||
|
||||||
|
(typeof own.nextPubKeyHashB64 === "string" && own.nextPubKeyHashB64) ||
|
||||||
|
undefined;
|
||||||
|
if (nextPubKeyHashB64) {
|
||||||
|
contact.nextPubKeyHashB64 = nextPubKeyHashB64;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyBase64 =
|
||||||
|
(typeof own.publicEncKey === "string" && own.publicEncKey) ||
|
||||||
|
(typeof own.publicKeyBase64 === "string" && own.publicKeyBase64) ||
|
||||||
|
undefined;
|
||||||
|
if (publicKeyBase64) {
|
||||||
|
contact.publicKeyBase64 = publicKeyBase64;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof own.profileImageUrl === "string" && own.profileImageUrl) {
|
||||||
|
contact.profileImageUrl = own.profileImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseContactImportInput(
|
||||||
|
rawInput: string,
|
||||||
|
): ContactImportParseResult {
|
||||||
|
const input = rawInput.trim();
|
||||||
|
if (!input) {
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
code: "not_contact_import_format",
|
||||||
|
message: "No contact import data was found in that input.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const looksLikeJwt = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/.test(
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
const hasContactHint =
|
||||||
|
input.includes("/contact-import") ||
|
||||||
|
input.includes("contactJwt=") ||
|
||||||
|
input.includes("/contact?jwt=");
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.endsWith("contact-import") ||
|
||||||
|
input.endsWith("contact-import/") ||
|
||||||
|
input.endsWith("/deep-link")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
code: "truncated_data",
|
||||||
|
message:
|
||||||
|
"That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasContactHint && !looksLikeJwt) {
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
code: "not_contact_import_format",
|
||||||
|
message: "No contact import data was found in that input.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwt = getContactJwtFromJwtUrl(input);
|
||||||
|
if (!jwt) {
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
code: "invalid_jwt",
|
||||||
|
message:
|
||||||
|
"That contact-import data could not be decoded. Ask for a fresh link or QR code.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = decodeEndorserJwt(jwt).payload as Record<string, unknown>;
|
||||||
|
if (Array.isArray(payload.contacts)) {
|
||||||
|
return {
|
||||||
|
kind: "multi",
|
||||||
|
jwt,
|
||||||
|
contacts: payload.contacts as Contact[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleContact = mapSingleContact(payload);
|
||||||
|
if (singleContact) {
|
||||||
|
return {
|
||||||
|
kind: "single",
|
||||||
|
jwt,
|
||||||
|
contact: singleContact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
code: "invalid_jwt",
|
||||||
|
message:
|
||||||
|
"That contact-import data could not be decoded. Ask for a fresh link or QR code.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
code: "unsupported_payload",
|
||||||
|
message:
|
||||||
|
"That contact-import format is not supported yet. Ask the sender to share again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -676,15 +676,16 @@ export async function setPlanInCache(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts user-friendly message from server error
|
* Extracts user-friendly message from server error
|
||||||
* @param {any} error - Error thrown from Endorser server call
|
* @param {AxiosErrorResponse} error - Error thrown from Endorser server call
|
||||||
* @returns {string|undefined} User-friendly message or undefined if none found
|
* @returns {string|undefined} User-friendly message or undefined if none found
|
||||||
*/
|
*/
|
||||||
export function serverMessageForUser(error: unknown): string | undefined {
|
export function serverMessageForUser(
|
||||||
if (error && typeof error === "object" && "response" in error) {
|
error: AxiosErrorResponse,
|
||||||
const err = error as AxiosErrorResponse;
|
): string | undefined {
|
||||||
return err.response?.data?.error?.message;
|
return (
|
||||||
}
|
error?.response?.data?.error?.userMessage ||
|
||||||
return undefined;
|
error?.response?.data?.error?.message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1262,7 +1263,7 @@ export async function createAndSubmitClaim(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const errorMessage: string =
|
const errorMessage: string =
|
||||||
serverMessageForUser(error) ||
|
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
|
||||||
(error && typeof error === "object" && "message" in error
|
(error && typeof error === "object" && "message" in error
|
||||||
? String(error.message)
|
? String(error.message)
|
||||||
: undefined) ||
|
: undefined) ||
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
faCircleQuestion,
|
faCircleQuestion,
|
||||||
faCircleRight,
|
faCircleRight,
|
||||||
faCircleUser,
|
faCircleUser,
|
||||||
|
faCircleXmark,
|
||||||
faClock,
|
faClock,
|
||||||
faCoins,
|
faCoins,
|
||||||
faComment,
|
faComment,
|
||||||
@@ -135,6 +136,7 @@ library.add(
|
|||||||
faCircleQuestion,
|
faCircleQuestion,
|
||||||
faCircleRight,
|
faCircleRight,
|
||||||
faCircleUser,
|
faCircleUser,
|
||||||
|
faCircleXmark,
|
||||||
faClock,
|
faClock,
|
||||||
faCoins,
|
faCoins,
|
||||||
faComment,
|
faComment,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
description: string;
|
description: string;
|
||||||
|
generateEmbedding?: boolean;
|
||||||
|
embeddingIsForEmptyString?: boolean;
|
||||||
locLat?: number;
|
locLat?: number;
|
||||||
locLon?: number;
|
locLon?: number;
|
||||||
locLat2?: number;
|
locLat2?: number;
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: "contact-import",
|
name: "contact-import",
|
||||||
component: () => import("../views/ContactImportView.vue"),
|
component: () => import("../views/ContactImportView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/contact-profile-check",
|
||||||
|
name: "contact-profile-check",
|
||||||
|
component: () => import("../views/ContactProfileCheckView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/contact-qr",
|
path: "/contact-qr",
|
||||||
name: "contact-qr",
|
name: "contact-qr",
|
||||||
@@ -407,6 +412,18 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store error details so StartView can display them
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const errorStack = error instanceof Error ? error.stack || "" : "";
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"startupError",
|
||||||
|
JSON.stringify({ message: errorMessage, stack: errorStack }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage may be unavailable
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to start page if identity creation fails
|
// Redirect to start page if identity creation fails
|
||||||
// This allows users to manually create an identity or troubleshoot
|
// This allows users to manually create an identity or troubleshoot
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
*
|
*
|
||||||
* 1. **Single Application**: Each migration runs exactly once per database
|
* 1. **Single Application**: Each migration runs exactly once per database
|
||||||
* 2. **Tracked Execution**: All applied migrations are recorded in a migrations table
|
* 2. **Tracked Execution**: All applied migrations are recorded in a migrations table
|
||||||
* 3. **Schema Validation**: Actual database schema is validated before and after migrations
|
* 3. **Comprehensive Logging**: Detailed logging for debugging and monitoring
|
||||||
* 4. **Graceful Recovery**: Handles cases where schema exists but tracking is missing
|
|
||||||
* 5. **Comprehensive Logging**: Detailed logging for debugging and monitoring
|
|
||||||
*
|
*
|
||||||
* ## Migration Flow
|
* ## Migration Flow
|
||||||
*
|
*
|
||||||
@@ -26,9 +24,7 @@
|
|||||||
* b. Check if schema already exists
|
* b. Check if schema already exists
|
||||||
* c. Skip if already applied
|
* c. Skip if already applied
|
||||||
* d. Apply migration SQL
|
* d. Apply migration SQL
|
||||||
* e. Validate schema was created
|
* e. Record migration as applied
|
||||||
* f. Record migration as applied
|
|
||||||
* 4. Final validation of all migrations
|
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* ## Usage Example
|
* ## Usage Example
|
||||||
@@ -77,25 +73,6 @@ interface Migration {
|
|||||||
statements?: string[];
|
statements?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration validation result
|
|
||||||
*
|
|
||||||
* Contains the results of validating that a migration was successfully
|
|
||||||
* applied by checking the actual database schema.
|
|
||||||
*
|
|
||||||
* @interface MigrationValidation
|
|
||||||
*/
|
|
||||||
interface MigrationValidation {
|
|
||||||
/** Whether the migration validation passed overall */
|
|
||||||
isValid: boolean;
|
|
||||||
/** Whether expected tables exist */
|
|
||||||
tableExists: boolean;
|
|
||||||
/** Whether expected columns exist */
|
|
||||||
hasExpectedColumns: boolean;
|
|
||||||
/** List of validation errors encountered */
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migration registry to store and manage database migrations
|
* Migration registry to store and manage database migrations
|
||||||
*
|
*
|
||||||
@@ -207,354 +184,6 @@ export function registerMigration(migration: Migration): void {
|
|||||||
migrationRegistry.registerMigration(migration);
|
migrationRegistry.registerMigration(migration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a migration was successfully applied by checking schema
|
|
||||||
*
|
|
||||||
* This function performs post-migration validation to ensure that the
|
|
||||||
* expected database schema changes were actually applied. It checks for
|
|
||||||
* the existence of tables, columns, and other schema elements that should
|
|
||||||
* have been created by the migration.
|
|
||||||
*
|
|
||||||
* @param migration - The migration to validate
|
|
||||||
* @param sqlQuery - Function to execute SQL queries
|
|
||||||
* @returns Promise resolving to validation results
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const validation = await validateMigrationApplication(migration, sqlQuery);
|
|
||||||
* if (!validation.isValid) {
|
|
||||||
* console.error('Migration validation failed:', validation.errors);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Helper function to check if a SQLite result indicates a table exists
|
|
||||||
* @param result - The result from a sqlite_master query
|
|
||||||
* @returns true if the table exists
|
|
||||||
*/
|
|
||||||
function checkSqliteTableResult(result: unknown): boolean {
|
|
||||||
return (
|
|
||||||
(result as unknown as { values: unknown[][] })?.values?.length > 0 ||
|
|
||||||
(Array.isArray(result) && result.length > 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to validate that a table exists in the database
|
|
||||||
* @param tableName - Name of the table to check
|
|
||||||
* @param sqlQuery - Function to execute SQL queries
|
|
||||||
* @returns Promise resolving to true if table exists
|
|
||||||
*/
|
|
||||||
async function validateTableExists<T>(
|
|
||||||
tableName: string,
|
|
||||||
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const result = await sqlQuery(
|
|
||||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`,
|
|
||||||
);
|
|
||||||
return checkSqliteTableResult(result);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`❌ [Validation] Error checking table ${tableName}:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to validate that a column exists in a table
|
|
||||||
* @param tableName - Name of the table
|
|
||||||
* @param columnName - Name of the column to check
|
|
||||||
* @param sqlQuery - Function to execute SQL queries
|
|
||||||
* @returns Promise resolving to true if column exists
|
|
||||||
*/
|
|
||||||
async function validateColumnExists<T>(
|
|
||||||
tableName: string,
|
|
||||||
columnName: string,
|
|
||||||
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await sqlQuery(`SELECT ${columnName} FROM ${tableName} LIMIT 1`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`❌ [Validation] Error checking column ${columnName} in ${tableName}:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to validate multiple tables exist
|
|
||||||
* @param tableNames - Array of table names to check
|
|
||||||
* @param sqlQuery - Function to execute SQL queries
|
|
||||||
* @returns Promise resolving to array of validation results
|
|
||||||
*/
|
|
||||||
async function validateMultipleTables<T>(
|
|
||||||
tableNames: string[],
|
|
||||||
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
|
||||||
): Promise<{ exists: boolean; missing: string[] }> {
|
|
||||||
const missing: string[] = [];
|
|
||||||
|
|
||||||
for (const tableName of tableNames) {
|
|
||||||
const exists = await validateTableExists(tableName, sqlQuery);
|
|
||||||
if (!exists) {
|
|
||||||
missing.push(tableName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
exists: missing.length === 0,
|
|
||||||
missing,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to add validation error with consistent logging
|
|
||||||
* @param validation - The validation object to update
|
|
||||||
* @param message - Error message to add
|
|
||||||
* @param error - The error object for logging
|
|
||||||
*/
|
|
||||||
function addValidationError(
|
|
||||||
validation: MigrationValidation,
|
|
||||||
message: string,
|
|
||||||
error: unknown,
|
|
||||||
): void {
|
|
||||||
validation.isValid = false;
|
|
||||||
validation.errors.push(message);
|
|
||||||
logger.error(`❌ [Migration-Validation] ${message}:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateMigrationApplication<T>(
|
|
||||||
migration: Migration,
|
|
||||||
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
|
||||||
): Promise<MigrationValidation> {
|
|
||||||
const validation: MigrationValidation = {
|
|
||||||
isValid: true,
|
|
||||||
tableExists: false,
|
|
||||||
hasExpectedColumns: false,
|
|
||||||
errors: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (migration.name === "001_initial") {
|
|
||||||
// Validate core tables exist for initial migration
|
|
||||||
const tables = [
|
|
||||||
"accounts",
|
|
||||||
"secret",
|
|
||||||
"settings",
|
|
||||||
"contacts",
|
|
||||||
"logs",
|
|
||||||
"temp",
|
|
||||||
];
|
|
||||||
|
|
||||||
const tableValidation = await validateMultipleTables(tables, sqlQuery);
|
|
||||||
if (!tableValidation.exists) {
|
|
||||||
validation.isValid = false;
|
|
||||||
validation.errors.push(
|
|
||||||
`Missing tables: ${tableValidation.missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
logger.error(
|
|
||||||
`❌ [Migration-Validation] Missing tables:`,
|
|
||||||
tableValidation.missing,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
validation.tableExists = tableValidation.exists;
|
|
||||||
} else if (migration.name === "002_add_iViewContent_to_contacts") {
|
|
||||||
// Validate iViewContent column exists in contacts table
|
|
||||||
const columnExists = await validateColumnExists(
|
|
||||||
"contacts",
|
|
||||||
"iViewContent",
|
|
||||||
sqlQuery,
|
|
||||||
);
|
|
||||||
if (!columnExists) {
|
|
||||||
addValidationError(
|
|
||||||
validation,
|
|
||||||
"Column iViewContent missing from contacts table",
|
|
||||||
new Error("Column not found"),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
validation.hasExpectedColumns = true;
|
|
||||||
}
|
|
||||||
} else if (migration.name === "004_active_identity_management") {
|
|
||||||
// Validate active_identity table exists and has correct structure
|
|
||||||
const activeIdentityExists = await validateTableExists(
|
|
||||||
"active_identity",
|
|
||||||
sqlQuery,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!activeIdentityExists) {
|
|
||||||
addValidationError(
|
|
||||||
validation,
|
|
||||||
"Table active_identity missing",
|
|
||||||
new Error("Table not found"),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
validation.tableExists = true;
|
|
||||||
|
|
||||||
// Check that active_identity has the expected structure
|
|
||||||
const hasExpectedColumns = await validateColumnExists(
|
|
||||||
"active_identity",
|
|
||||||
"id, activeDid, lastUpdated",
|
|
||||||
sqlQuery,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasExpectedColumns) {
|
|
||||||
addValidationError(
|
|
||||||
validation,
|
|
||||||
"active_identity table missing expected columns",
|
|
||||||
new Error("Columns not found"),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
validation.hasExpectedColumns = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that hasBackedUpSeed column exists in settings table
|
|
||||||
// Note: This validation is included here because migration 004 is consolidated
|
|
||||||
// and includes the functionality from the original migration 003
|
|
||||||
const hasBackedUpSeedExists = await validateColumnExists(
|
|
||||||
"settings",
|
|
||||||
"hasBackedUpSeed",
|
|
||||||
sqlQuery,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasBackedUpSeedExists) {
|
|
||||||
addValidationError(
|
|
||||||
validation,
|
|
||||||
"Column hasBackedUpSeed missing from settings table",
|
|
||||||
new Error("Column not found"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add validation for future migrations here
|
|
||||||
// } else if (migration.name === "003_future_migration") {
|
|
||||||
// // Validate future migration schema changes
|
|
||||||
// }
|
|
||||||
} catch (error) {
|
|
||||||
validation.isValid = false;
|
|
||||||
validation.errors.push(`Validation error: ${error}`);
|
|
||||||
logger.error(
|
|
||||||
`❌ [Migration-Validation] Validation failed for ${migration.name}:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return validation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if migration is already applied by examining actual schema
|
|
||||||
*
|
|
||||||
* This function performs schema introspection to determine if a migration
|
|
||||||
* has already been applied, even if it's not recorded in the migrations
|
|
||||||
* table. This is useful for handling cases where the database schema exists
|
|
||||||
* but the migration tracking got out of sync.
|
|
||||||
*
|
|
||||||
* @param migration - The migration to check
|
|
||||||
* @param sqlQuery - Function to execute SQL queries
|
|
||||||
* @returns Promise resolving to true if schema already exists
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const schemaExists = await isSchemaAlreadyPresent(migration, sqlQuery);
|
|
||||||
* if (schemaExists) {
|
|
||||||
* console.log('Schema already exists, skipping migration');
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async function isSchemaAlreadyPresent<T>(
|
|
||||||
migration: Migration,
|
|
||||||
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
if (migration.name === "001_initial") {
|
|
||||||
// Check if accounts table exists (primary indicator of initial migration)
|
|
||||||
const result = (await sqlQuery(
|
|
||||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'`,
|
|
||||||
)) as unknown as { values: unknown[][] };
|
|
||||||
const hasTable =
|
|
||||||
result?.values?.length > 0 ||
|
|
||||||
(Array.isArray(result) && result.length > 0);
|
|
||||||
// Reduced logging - only log on error
|
|
||||||
return hasTable;
|
|
||||||
} else if (migration.name === "002_add_iViewContent_to_contacts") {
|
|
||||||
// Check if iViewContent column exists in contacts table
|
|
||||||
try {
|
|
||||||
await sqlQuery(`SELECT iViewContent FROM contacts LIMIT 1`);
|
|
||||||
// Reduced logging - only log on error
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
// Reduced logging - only log on error
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else if (migration.name === "003_add_hasBackedUpSeed_to_settings") {
|
|
||||||
// Check if hasBackedUpSeed column exists in settings table
|
|
||||||
try {
|
|
||||||
await sqlQuery(`SELECT hasBackedUpSeed FROM settings LIMIT 1`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else if (migration.name === "004_active_identity_management") {
|
|
||||||
// Check if active_identity table exists and has correct structure
|
|
||||||
try {
|
|
||||||
// Check that active_identity table exists
|
|
||||||
const activeIdentityResult = await sqlQuery(
|
|
||||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='active_identity'`,
|
|
||||||
);
|
|
||||||
const hasActiveIdentityTable =
|
|
||||||
(activeIdentityResult as unknown as { values: unknown[][] })?.values
|
|
||||||
?.length > 0 ||
|
|
||||||
(Array.isArray(activeIdentityResult) &&
|
|
||||||
activeIdentityResult.length > 0);
|
|
||||||
|
|
||||||
if (!hasActiveIdentityTable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that active_identity has the expected structure
|
|
||||||
try {
|
|
||||||
await sqlQuery(
|
|
||||||
`SELECT id, activeDid, lastUpdated FROM active_identity LIMIT 1`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also check that hasBackedUpSeed column exists in settings
|
|
||||||
// This is included because migration 004 is consolidated
|
|
||||||
try {
|
|
||||||
await sqlQuery(`SELECT hasBackedUpSeed FROM settings LIMIT 1`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`🔍 [Migration-Schema] Schema check failed for ${migration.name}, assuming not present:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add schema checks for future migrations here
|
|
||||||
// } else if (migration.name === "003_future_migration") {
|
|
||||||
// // Check if future migration schema already exists
|
|
||||||
// }
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`🔍 [Migration-Schema] Schema check failed for ${migration.name}, assuming not present:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run all registered migrations against the database
|
* Run all registered migrations against the database
|
||||||
*
|
*
|
||||||
@@ -600,7 +229,7 @@ export async function runMigrations<T>(
|
|||||||
extractMigrationNames: (result: T) => Set<string>,
|
extractMigrationNames: (result: T) => Set<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.debug("📋 [Migration] Starting migration process...");
|
logger.debug("[Migration] Starting migration process...");
|
||||||
|
|
||||||
// Create migrations table if it doesn't exist
|
// Create migrations table if it doesn't exist
|
||||||
// Note: We use IF NOT EXISTS here because this is infrastructure, not a business migration
|
// Note: We use IF NOT EXISTS here because this is infrastructure, not a business migration
|
||||||
@@ -628,7 +257,7 @@ export async function runMigrations<T>(
|
|||||||
|
|
||||||
// Only log migration counts in development
|
// Only log migration counts in development
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`📊 [Migration] Found ${migrations.length} total migrations, ${appliedMigrations.size} already applied`,
|
`[Migration] Found ${migrations.length} total migrations, ${appliedMigrations.size} already applied`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let appliedCount = 0;
|
let appliedCount = 0;
|
||||||
@@ -645,70 +274,35 @@ export async function runMigrations<T>(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 2: Does the schema already exist in the database?
|
|
||||||
const isSchemaPresent = await isSchemaAlreadyPresent(migration, sqlQuery);
|
|
||||||
|
|
||||||
// Handle case where schema exists but isn't recorded
|
|
||||||
if (isSchemaPresent) {
|
|
||||||
try {
|
|
||||||
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
|
|
||||||
migration.name,
|
|
||||||
]);
|
|
||||||
logger.debug(
|
|
||||||
`✅ [Migration] Marked existing schema as applied: ${migration.name}`,
|
|
||||||
);
|
|
||||||
skippedCount++;
|
|
||||||
continue;
|
|
||||||
} catch (insertError) {
|
|
||||||
logger.warn(
|
|
||||||
`⚠️ [Migration] Could not record existing schema ${migration.name}:`,
|
|
||||||
insertError,
|
|
||||||
);
|
|
||||||
// Continue with normal migration process as fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the migration
|
// Apply the migration
|
||||||
logger.debug(`🔄 [Migration] Applying migration: ${migration.name}`);
|
logger.debug(`[Migration] Applying migration: ${migration.name}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Execute the migration SQL as single atomic operation
|
// Execute the migration SQL as single atomic operation
|
||||||
logger.debug(`🔧 [Migration] Executing SQL for: ${migration.name}`);
|
logger.debug(`[Migration] Executing SQL for: ${migration.name}`);
|
||||||
logger.debug(`🔧 [Migration] SQL content: ${migration.sql}`);
|
logger.debug(`[Migration] SQL content: ${migration.sql}`);
|
||||||
|
|
||||||
// Execute the migration SQL directly - it should be atomic
|
// Execute the migration SQL directly - it should be atomic
|
||||||
// The SQL itself should handle any necessary transactions
|
// The SQL itself should handle any necessary transactions
|
||||||
const execResult = await sqlExec(migration.sql);
|
const execResult = await sqlExec(migration.sql);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`🔧 [Migration] SQL execution result: ${JSON.stringify(execResult)}`,
|
`[Migration] SQL execution result: ${JSON.stringify(execResult)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate the migration was applied correctly
|
|
||||||
const validation = await validateMigrationApplication(
|
|
||||||
migration,
|
|
||||||
sqlQuery,
|
|
||||||
);
|
|
||||||
if (!validation.isValid) {
|
|
||||||
logger.warn(
|
|
||||||
`⚠️ [Migration] Validation failed for ${migration.name}:`,
|
|
||||||
validation.errors,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record that the migration was applied
|
// Record that the migration was applied
|
||||||
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
|
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
|
||||||
migration.name,
|
migration.name,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
logger.debug(`🎉 [Migration] Successfully applied: ${migration.name}`);
|
logger.debug(`✅ [Migration] Successfully applied: ${migration.name}`);
|
||||||
appliedCount++;
|
appliedCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ [Migration] Error applying ${migration.name}:`, error);
|
logger.error(`❌ [Migration] Error applying ${migration.name}:`, error);
|
||||||
|
|
||||||
// Provide explicit rollback instructions for migration failures
|
// Provide explicit rollback instructions for migration failures
|
||||||
logger.error(
|
logger.error(
|
||||||
`🔄 [Migration] ROLLBACK INSTRUCTIONS for ${migration.name}:`,
|
`[Migration] ROLLBACK INSTRUCTIONS for ${migration.name}:`,
|
||||||
);
|
);
|
||||||
logger.error(` 1. Stop the application immediately`);
|
logger.error(` 1. Stop the application immediately`);
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -740,41 +334,12 @@ export async function runMigrations<T>(
|
|||||||
errorMessage.includes("already exists"))
|
errorMessage.includes("already exists"))
|
||||||
) {
|
) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`⚠️ [Migration] ${migration.name} appears already applied (${errorMessage}). Validating and marking as complete.`,
|
`⚠️ [Migration] ${migration.name} appears already applied (${errorMessage}).`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate the existing schema
|
|
||||||
const validation = await validateMigrationApplication(
|
|
||||||
migration,
|
|
||||||
sqlQuery,
|
|
||||||
);
|
|
||||||
if (!validation.isValid) {
|
|
||||||
logger.warn(
|
|
||||||
`⚠️ [Migration] Schema validation failed for ${migration.name}:`,
|
|
||||||
validation.errors,
|
|
||||||
);
|
|
||||||
// Don't mark as applied if validation fails
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark the migration as applied since the schema change already exists
|
|
||||||
try {
|
|
||||||
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
|
|
||||||
migration.name,
|
|
||||||
]);
|
|
||||||
logger.debug(`✅ [Migration] Marked as applied: ${migration.name}`);
|
|
||||||
appliedCount++;
|
|
||||||
} catch (insertError) {
|
|
||||||
// If we can't insert the migration record, log it but don't fail
|
|
||||||
logger.warn(
|
|
||||||
`⚠️ [Migration] Could not record ${migration.name} as applied:`,
|
|
||||||
insertError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// For other types of errors, still fail the migration
|
// For other types of errors, still fail the migration
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ [Migration] Failed to apply ${migration.name}:`,
|
`❌ [Migration] Failed to apply ${migration.name}:`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
throw new Error(`Migration ${migration.name} failed: ${error}`);
|
throw new Error(`Migration ${migration.name} failed: ${error}`);
|
||||||
@@ -800,11 +365,10 @@ export async function runMigrations<T>(
|
|||||||
|
|
||||||
// Only show completion message in development
|
// Only show completion message in development
|
||||||
logger.log(
|
logger.log(
|
||||||
`🎉 [Migration] Migration process complete! Summary: ${appliedCount} applied, ${skippedCount} skipped`,
|
`[Migration] Migration process complete. Summary: ${appliedCount} applied, ${skippedCount} skipped`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("\n💥 [Migration] Migration process failed:", error);
|
logger.error("❌ [Migration] Migration process failed:", error);
|
||||||
logger.error("[MigrationService] Migration process failed:", error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -714,7 +714,7 @@ export class CapacitorPlatformService
|
|||||||
*
|
*
|
||||||
* For critical tables like `contacts`, the method validates:
|
* For critical tables like `contacts`, the method validates:
|
||||||
* - Table structure using `PRAGMA table_info`
|
* - Table structure using `PRAGMA table_info`
|
||||||
* - Presence of important columns (e.g., `iViewContent`)
|
* - Presence of important columns (e.g., `hideTheirContent`)
|
||||||
* - Column data types and constraints
|
* - Column data types and constraints
|
||||||
*
|
*
|
||||||
* ## Error Handling:
|
* ## Error Handling:
|
||||||
@@ -784,7 +784,7 @@ export class CapacitorPlatformService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Check contacts table schema (including iViewContent column)
|
// Step 3: Check contacts table schema (including hideTheirContent column)
|
||||||
if (existingTables.includes("contacts")) {
|
if (existingTables.includes("contacts")) {
|
||||||
try {
|
try {
|
||||||
const contactsSchema = await this.db.query(
|
const contactsSchema = await this.db.query(
|
||||||
@@ -795,23 +795,23 @@ export class CapacitorPlatformService
|
|||||||
contactsSchema,
|
contactsSchema,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check for iViewContent column specifically
|
// Check for hideTheirContent column specifically
|
||||||
const hasIViewContent = contactsSchema.values?.some(
|
const hasIHideContent = contactsSchema.values?.some(
|
||||||
(col: unknown) =>
|
(col: unknown) =>
|
||||||
(typeof col === "object" &&
|
(typeof col === "object" &&
|
||||||
col !== null &&
|
col !== null &&
|
||||||
"name" in col &&
|
"name" in col &&
|
||||||
(col as { name: string }).name === "iViewContent") ||
|
(col as { name: string }).name === "hideTheirContent") ||
|
||||||
(Array.isArray(col) && col[1] === "iViewContent"),
|
(Array.isArray(col) && col[1] === "hideTheirContent"),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasIViewContent) {
|
if (hasIHideContent) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`✅ [DB-Integrity] iViewContent column exists in contacts table`,
|
`✅ [DB-Integrity] hideTheirContent column exists in contacts table`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ [DB-Integrity] iViewContent column missing from contacts table`,
|
`❌ [DB-Integrity] hideTheirContent column missing from contacts table`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1055,8 +1055,8 @@ export class CapacitorPlatformService
|
|||||||
// Offer to share the file
|
// Offer to share the file
|
||||||
try {
|
try {
|
||||||
await Share.share({
|
await Share.share({
|
||||||
title: "TimeSafari Backup",
|
title: "Backup",
|
||||||
text: "Here is your TimeSafari backup file.",
|
text: "Here is your backup file.",
|
||||||
url: writeResult.uri,
|
url: writeResult.uri,
|
||||||
dialogTitle: "Share your backup",
|
dialogTitle: "Share your backup",
|
||||||
});
|
});
|
||||||
@@ -1100,8 +1100,8 @@ export class CapacitorPlatformService
|
|||||||
// Then share the file to let user choose where to save it
|
// Then share the file to let user choose where to save it
|
||||||
try {
|
try {
|
||||||
await Share.share({
|
await Share.share({
|
||||||
title: "TimeSafari Backup",
|
title: "Backup",
|
||||||
text: "Here is your TimeSafari backup file.",
|
text: "Here is your backup file.",
|
||||||
url: writeResult.uri,
|
url: writeResult.uri,
|
||||||
dialogTitle: "Save your backup",
|
dialogTitle: "Save your backup",
|
||||||
});
|
});
|
||||||
@@ -1180,7 +1180,7 @@ export class CapacitorPlatformService
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Share.share({
|
await Share.share({
|
||||||
title: "TimeSafari Backup",
|
title: "Backup",
|
||||||
text: "Here is your backup file.",
|
text: "Here is your backup file.",
|
||||||
url: uri,
|
url: uri,
|
||||||
dialogTitle: "Share your backup file",
|
dialogTitle: "Share your backup file",
|
||||||
|
|||||||
@@ -2,15 +2,6 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2>EntityGrid Function Prop Test</h2>
|
<h2>EntityGrid Function Prop Test</h2>
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<button @click="toggleCustomFunction">
|
|
||||||
{{ useCustomFunction ? "Use Default" : "Use Custom Function" }}
|
|
||||||
</button>
|
|
||||||
<span class="ml-2"
|
|
||||||
>Current: {{ useCustomFunction ? "Custom" : "Default" }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h3>
|
<h3>
|
||||||
People Grid ({{ people.length }} total,
|
People Grid ({{ people.length }} total,
|
||||||
@@ -23,9 +14,6 @@
|
|||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="people"
|
:all-contacts="people"
|
||||||
:conflict-checker="conflictChecker"
|
:conflict-checker="conflictChecker"
|
||||||
:display-entities-function="
|
|
||||||
useCustomFunction ? customPeopleFunction : undefined
|
|
||||||
"
|
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,9 +30,6 @@
|
|||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="people"
|
:all-contacts="people"
|
||||||
:conflict-checker="conflictChecker"
|
:conflict-checker="conflictChecker"
|
||||||
:display-entities-function="
|
|
||||||
useCustomFunction ? customProjectsFunction : undefined
|
|
||||||
"
|
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +59,6 @@ import { PlanData } from "../interfaces/records";
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class EntityGridFunctionPropTest extends Vue {
|
export default class EntityGridFunctionPropTest extends Vue {
|
||||||
useCustomFunction = false;
|
|
||||||
selectedEntity: {
|
selectedEntity: {
|
||||||
type: "person" | "project" | "special";
|
type: "person" | "project" | "special";
|
||||||
entityType?: string;
|
entityType?: string;
|
||||||
@@ -144,26 +128,6 @@ export default class EntityGridFunctionPropTest extends Vue {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom function for people: only show those with profile images
|
|
||||||
*/
|
|
||||||
customPeopleFunction = (
|
|
||||||
entities: Contact[],
|
|
||||||
_entityType: string,
|
|
||||||
): Contact[] => {
|
|
||||||
return entities.filter((person) => person.profileImageUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom function for projects: sort by name and limit to 3
|
|
||||||
*/
|
|
||||||
customProjectsFunction = (
|
|
||||||
entities: PlanData[],
|
|
||||||
_entityType: string,
|
|
||||||
): PlanData[] => {
|
|
||||||
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple conflict checker for testing
|
* Simple conflict checker for testing
|
||||||
*/
|
*/
|
||||||
@@ -171,13 +135,6 @@ export default class EntityGridFunctionPropTest extends Vue {
|
|||||||
return did === this.activeDid;
|
return did === this.activeDid;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle between custom and default display functions
|
|
||||||
*/
|
|
||||||
toggleCustomFunction(): void {
|
|
||||||
this.useCustomFunction = !this.useCustomFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle entity selection
|
* Handle entity selection
|
||||||
*/
|
*/
|
||||||
@@ -193,16 +150,10 @@ export default class EntityGridFunctionPropTest extends Vue {
|
|||||||
* Computed properties to show display counts
|
* Computed properties to show display counts
|
||||||
*/
|
*/
|
||||||
get displayedPeopleCount(): number {
|
get displayedPeopleCount(): number {
|
||||||
if (this.useCustomFunction) {
|
|
||||||
return this.customPeopleFunction(this.people, "people").length;
|
|
||||||
}
|
|
||||||
return Math.min(10, this.people.length); // Initial batch size for infinite scroll
|
return Math.min(10, this.people.length); // Initial batch size for infinite scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
get displayedProjectsCount(): number {
|
get displayedProjectsCount(): number {
|
||||||
if (this.useCustomFunction) {
|
|
||||||
return this.customProjectsFunction(this.projects, "projects").length;
|
|
||||||
}
|
|
||||||
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
|
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,8 +114,7 @@ export interface DatabaseExport {
|
|||||||
const _memoryLogs: string[] = [];
|
const _memoryLogs: string[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced mixin that provides cached platform service access and utility methods
|
* Enhanced mixin that provides platform utility methods
|
||||||
* with smart caching layer for ultimate performance optimization
|
|
||||||
*/
|
*/
|
||||||
export const PlatformServiceMixin = {
|
export const PlatformServiceMixin = {
|
||||||
data() {
|
data() {
|
||||||
@@ -298,7 +297,7 @@ export const PlatformServiceMixin = {
|
|||||||
column === "warnIfTestServer" ||
|
column === "warnIfTestServer" ||
|
||||||
column === "reminderFastRolloverForTesting" ||
|
column === "reminderFastRolloverForTesting" ||
|
||||||
// contacts
|
// contacts
|
||||||
column === "iViewContent" ||
|
column === "hideTheirContent" ||
|
||||||
column === "registered" ||
|
column === "registered" ||
|
||||||
column === "seesMe"
|
column === "seesMe"
|
||||||
) {
|
) {
|
||||||
@@ -913,7 +912,7 @@ export const PlatformServiceMixin = {
|
|||||||
// Create a new contact object with proper typing
|
// Create a new contact object with proper typing
|
||||||
const normalizedContact: Contact = {
|
const normalizedContact: Contact = {
|
||||||
did: contact.did,
|
did: contact.did,
|
||||||
iViewContent: contact.iViewContent,
|
hideTheirContent: contact.hideTheirContent,
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
nextPubKeyHashB64: contact.nextPubKeyHashB64,
|
nextPubKeyHashB64: contact.nextPubKeyHashB64,
|
||||||
notes: contact.notes,
|
notes: contact.notes,
|
||||||
@@ -1012,7 +1011,7 @@ export const PlatformServiceMixin = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load settings with optional defaults WITHOUT caching - $settings()
|
* Load settings with optional defaults - $settings()
|
||||||
* Settings are loaded fresh every time for immediate consistency
|
* Settings are loaded fresh every time for immediate consistency
|
||||||
* @param defaults Optional default values
|
* @param defaults Optional default values
|
||||||
* @returns Fresh settings object from database
|
* @returns Fresh settings object from database
|
||||||
@@ -1035,11 +1034,11 @@ export const PlatformServiceMixin = {
|
|||||||
settings.apiServer = DEFAULT_ENDORSER_API_SERVER;
|
settings.apiServer = DEFAULT_ENDORSER_API_SERVER;
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings; // Return fresh data without caching
|
return settings;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load account-specific settings WITHOUT caching - $accountSettings()
|
* Load account-specific settings - $accountSettings()
|
||||||
* Settings are loaded fresh every time for immediate consistency
|
* Settings are loaded fresh every time for immediate consistency
|
||||||
* @param did DID identifier (optional, uses current active DID)
|
* @param did DID identifier (optional, uses current active DID)
|
||||||
* @param defaults Optional default values
|
* @param defaults Optional default values
|
||||||
@@ -1117,8 +1116,8 @@ export const PlatformServiceMixin = {
|
|||||||
// =================================================
|
// =================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save default settings - $saveSettings()
|
* Save settings for currently active DID
|
||||||
* Ultra-concise shortcut for updateDefaultSettings
|
* May be consolidated with $saveUserSettings()
|
||||||
*
|
*
|
||||||
* ✅ KEEP: This method will be the primary settings save method after consolidation
|
* ✅ KEEP: This method will be the primary settings save method after consolidation
|
||||||
*
|
*
|
||||||
@@ -1206,8 +1205,8 @@ export const PlatformServiceMixin = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save user-specific settings - $saveUserSettings()
|
* Save DID-specific settings - $saveUserSettings()
|
||||||
* Ultra-concise shortcut for updateDidSpecificSettings
|
*
|
||||||
* @param did DID identifier
|
* @param did DID identifier
|
||||||
* @param changes Settings changes to save
|
* @param changes Settings changes to save
|
||||||
* @returns Promise<boolean> Success status
|
* @returns Promise<boolean> Success status
|
||||||
@@ -1382,8 +1381,10 @@ export const PlatformServiceMixin = {
|
|||||||
? contact.profileImageUrl
|
? contact.profileImageUrl
|
||||||
: null,
|
: null,
|
||||||
notes: contact.notes !== undefined ? contact.notes : null,
|
notes: contact.notes !== undefined ? contact.notes : null,
|
||||||
iViewContent:
|
hideTheirContent:
|
||||||
contact.iViewContent !== undefined ? contact.iViewContent : null,
|
contact.hideTheirContent !== undefined
|
||||||
|
? contact.hideTheirContent
|
||||||
|
: null,
|
||||||
contactMethods:
|
contactMethods:
|
||||||
contact.contactMethods !== undefined
|
contact.contactMethods !== undefined
|
||||||
? Array.isArray(contact.contactMethods)
|
? Array.isArray(contact.contactMethods)
|
||||||
@@ -1394,7 +1395,7 @@ export const PlatformServiceMixin = {
|
|||||||
|
|
||||||
await this.$dbExec(
|
await this.$dbExec(
|
||||||
`INSERT OR REPLACE INTO contacts
|
`INSERT OR REPLACE INTO contacts
|
||||||
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, iViewContent, contactMethods)
|
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, hideTheirContent, contactMethods)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
safeContact.did,
|
safeContact.did,
|
||||||
@@ -1405,7 +1406,7 @@ export const PlatformServiceMixin = {
|
|||||||
safeContact.nextPubKeyHashB64,
|
safeContact.nextPubKeyHashB64,
|
||||||
safeContact.profileImageUrl,
|
safeContact.profileImageUrl,
|
||||||
safeContact.notes,
|
safeContact.notes,
|
||||||
safeContact.iViewContent,
|
safeContact.hideTheirContent,
|
||||||
safeContact.contactMethods,
|
safeContact.contactMethods,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1732,8 +1733,9 @@ export const PlatformServiceMixin = {
|
|||||||
// =================================================
|
// =================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get temporary data by ID - $getTemp()
|
* Get data from temp table by ID
|
||||||
* Retrieves temporary data from the temp table
|
* Currently set by main.capacitor.ts storeSharedImageInTempDB()
|
||||||
|
*
|
||||||
* @param id Temporary storage ID
|
* @param id Temporary storage ID
|
||||||
* @returns Promise<Temp | null> Temporary data or null if not found
|
* @returns Promise<Temp | null> Temporary data or null if not found
|
||||||
*/
|
*/
|
||||||
@@ -1747,8 +1749,8 @@ export const PlatformServiceMixin = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete temporary data by ID - $deleteTemp()
|
* Delete data from temp table by ID
|
||||||
* Removes temporary data from the temp table
|
*
|
||||||
* @param id Temporary storage ID
|
* @param id Temporary storage ID
|
||||||
* @returns Promise<boolean> Success status
|
* @returns Promise<boolean> Success status
|
||||||
*/
|
*/
|
||||||
@@ -2243,7 +2245,7 @@ export const PlatformServiceMixin = {
|
|||||||
// =================================================
|
// =================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced interface with caching utility methods
|
* Enhanced interface
|
||||||
*/
|
*/
|
||||||
export interface IPlatformServiceMixin {
|
export interface IPlatformServiceMixin {
|
||||||
platformService: PlatformService;
|
platformService: PlatformService;
|
||||||
|
|||||||
@@ -780,7 +780,6 @@ import {
|
|||||||
DEFAULT_PARTNER_API_SERVER,
|
DEFAULT_PARTNER_API_SERVER,
|
||||||
DEFAULT_PUSH_SERVER,
|
DEFAULT_PUSH_SERVER,
|
||||||
IMAGE_TYPE_PROFILE,
|
IMAGE_TYPE_PROFILE,
|
||||||
isNotProdServer as isNotProdServerUtil,
|
|
||||||
NotificationIface,
|
NotificationIface,
|
||||||
PASSKEYS_ENABLED,
|
PASSKEYS_ENABLED,
|
||||||
} from "../constants/app";
|
} from "../constants/app";
|
||||||
@@ -937,14 +936,6 @@ export default class AccountViewView extends Vue {
|
|||||||
return Capacitor.isNativePlatform();
|
return Capacitor.isNativePlatform();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* True when not on prod API server (test/local), so dev-only UI (e.g. 10-minute rollover toggle) is shown.
|
|
||||||
* Matches logic used in ImportAccountView and TestView.
|
|
||||||
*/
|
|
||||||
private get isNotProdServer(): boolean {
|
|
||||||
return isNotProdServerUtil(this.apiServer);
|
|
||||||
}
|
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
this.notify = createNotifyHelpers(this.$notify);
|
this.notify = createNotifyHelpers(this.$notify);
|
||||||
|
|
||||||
@@ -1804,7 +1795,7 @@ export default class AccountViewView extends Vue {
|
|||||||
} else {
|
} else {
|
||||||
logger.error("Non-success deleting image:", response);
|
logger.error("Non-success deleting image:", response);
|
||||||
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_PROBLEM);
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_PROBLEM);
|
||||||
// keep the imageUrl in localStorage so the user can try again if they want
|
// keep the profileImageUrl so the user can try again if they want
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isApiError(error) && error.response?.status === 404) {
|
if (isApiError(error) && error.response?.status === 404) {
|
||||||
|
|||||||
@@ -55,6 +55,9 @@
|
|||||||
|
|
||||||
<!-- Contact Methods -->
|
<!-- Contact Methods -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700"
|
||||||
|
>Contact Methods</label
|
||||||
|
>
|
||||||
<div v-for="(method, index) in contactMethods" :key="index" class="mt-4">
|
<div v-for="(method, index) in contactMethods" :key="index" class="mt-4">
|
||||||
<!-- Type and Value Row -->
|
<!-- Type and Value Row -->
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|||||||
@@ -197,16 +197,30 @@
|
|||||||
Import Contacts
|
Import Contacts
|
||||||
</button>
|
</button>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else-if="contactsImporting.length > 0">
|
<div v-else-if="contactsImporting.length > 0">
|
||||||
All those contacts are already in your list with the same information.
|
<p>
|
||||||
<button
|
All those contacts are already in your list with the same information.
|
||||||
v-if="applyLabelsToExisting"
|
</p>
|
||||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
|
<div class="mt-3 flex flex-col items-center gap-2">
|
||||||
@click="importContacts"
|
<button
|
||||||
>
|
data-testId="copyUnsignedImportLinkButton"
|
||||||
Apply Labels to Existing Contacts
|
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white px-2 py-1.5 rounded w-fit"
|
||||||
</button>
|
@click="copyUnsignedImportLink"
|
||||||
</p>
|
>
|
||||||
|
Copy Unsigned Link for These Contacts
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white px-2 py-1.5 rounded w-fit"
|
||||||
|
:class="{
|
||||||
|
'opacity-50 cursor-not-allowed': !canApplyLabelsToExisting,
|
||||||
|
}"
|
||||||
|
@click="handleApplyLabelsToExistingClick"
|
||||||
|
>
|
||||||
|
Apply Labels to Existing Contacts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
There are no contacts in that import. If some were sent, try again to
|
There are no contacts in that import. If some were sent, try again to
|
||||||
get the full text and paste it. (Note that iOS cuts off data in text
|
get the full text and paste it. (Note that iOS cuts off data in text
|
||||||
@@ -292,6 +306,7 @@ import QuickNav from "../components/QuickNav.vue";
|
|||||||
import EntityIcon from "../components/EntityIcon.vue";
|
import EntityIcon from "../components/EntityIcon.vue";
|
||||||
import OfferDialog from "../components/OfferDialog.vue";
|
import OfferDialog from "../components/OfferDialog.vue";
|
||||||
import { APP_SERVER, AppString, NotificationIface } from "../constants/app";
|
import { APP_SERVER, AppString, NotificationIface } from "../constants/app";
|
||||||
|
import { copyToClipboard } from "../services/ClipboardService";
|
||||||
import {
|
import {
|
||||||
Contact,
|
Contact,
|
||||||
ContactWithLabels,
|
ContactWithLabels,
|
||||||
@@ -303,8 +318,7 @@ import {
|
|||||||
errorStringForLog,
|
errorStringForLog,
|
||||||
setVisibilityUtil,
|
setVisibilityUtil,
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
import { parseContactImportInput } from "../libs/contactImportPayload";
|
||||||
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
import { ContactLabel } from "@/db/tables/contactLabels";
|
import { ContactLabel } from "@/db/tables/contactLabels";
|
||||||
@@ -495,27 +509,29 @@ export default class ContactImportView extends Vue {
|
|||||||
* Processes JWT from URL path and handles different JWT formats
|
* Processes JWT from URL path and handles different JWT formats
|
||||||
*/
|
*/
|
||||||
private async processJwtFromPath() {
|
private async processJwtFromPath() {
|
||||||
// JWT tokens always start with 'ey' (base64url encoded header)
|
const parsedImport = parseContactImportInput(window.location.pathname);
|
||||||
const JWT_PATTERN = /\/contact-import\/(ey.+)$/;
|
if (parsedImport.kind === "error") {
|
||||||
const jwt = window.location.pathname.match(JWT_PATTERN)?.[1];
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (jwt) {
|
if (parsedImport.kind === "single") {
|
||||||
const parsedJwt = decodeEndorserJwt(jwt);
|
this.$router.push({
|
||||||
const contacts: Array<Contact> =
|
name: "contacts",
|
||||||
parsedJwt.payload.contacts ||
|
query: { contactJwt: parsedImport.jwt },
|
||||||
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!contacts && parsedJwt.payload.own) {
|
if (parsedImport.contacts.length === 1) {
|
||||||
this.$router.push({
|
this.$router.push({
|
||||||
name: "contacts",
|
name: "contacts",
|
||||||
query: { contactJwt: jwt },
|
query: { contactJwt: parsedImport.jwt },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contacts) {
|
if (parsedImport.contacts.length > 0) {
|
||||||
await this.setContactsSelected(contacts);
|
await this.setContactsSelected(parsedImport.contacts);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,16 +611,12 @@ export default class ContactImportView extends Vue {
|
|||||||
* @param jwtInput JWT string to validate
|
* @param jwtInput JWT string to validate
|
||||||
*/
|
*/
|
||||||
async checkContactJwt(jwtInput: string) {
|
async checkContactJwt(jwtInput: string) {
|
||||||
|
const parsedImport = parseContactImportInput(jwtInput);
|
||||||
if (
|
if (
|
||||||
jwtInput.endsWith(APP_SERVER) ||
|
parsedImport.kind === "error" &&
|
||||||
jwtInput.endsWith(APP_SERVER + "/") ||
|
parsedImport.code === "truncated_data"
|
||||||
jwtInput.endsWith("contact-import") ||
|
|
||||||
jwtInput.endsWith("contact-import/")
|
|
||||||
) {
|
) {
|
||||||
this.notify.error(
|
this.notify.error(parsedImport.message, TIMEOUTS.LONG);
|
||||||
"That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.",
|
|
||||||
TIMEOUTS.LONG,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,14 +628,29 @@ export default class ContactImportView extends Vue {
|
|||||||
this.checkingImports = true;
|
this.checkingImports = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jwt: string = getContactJwtFromJwtUrl(jwtInput) || "";
|
const parsedImport = parseContactImportInput(jwtInput);
|
||||||
const payload = decodeEndorserJwt(jwt).payload;
|
if (parsedImport.kind === "error") {
|
||||||
|
this.notify.error(parsedImport.message, TIMEOUTS.STANDARD);
|
||||||
if (Array.isArray(payload.contacts)) {
|
return;
|
||||||
await this.setContactsSelected(payload.contacts);
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid contact-import JWT or URL: " + jwtInput);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsedImport.kind === "single") {
|
||||||
|
await this.$router.push({
|
||||||
|
name: "contacts",
|
||||||
|
query: { contactJwt: parsedImport.jwt },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedImport.contacts.length === 1) {
|
||||||
|
await this.$router.push({
|
||||||
|
name: "contacts",
|
||||||
|
query: { contactJwt: parsedImport.jwt },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.setContactsSelected(parsedImport.contacts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fullError = "Error importing contacts: " + errorStringForLog(error);
|
const fullError = "Error importing contacts: " + errorStringForLog(error);
|
||||||
this.$logAndConsole(fullError, true);
|
this.$logAndConsole(fullError, true);
|
||||||
@@ -635,6 +662,52 @@ export default class ContactImportView extends Vue {
|
|||||||
this.checkingImports = false;
|
this.checkingImports = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildUnsignedImportLink(): string {
|
||||||
|
const contactsForLink: Array<Contact> = this.contactsImporting.map((c) => {
|
||||||
|
const contact: Contact = {
|
||||||
|
did: c.did,
|
||||||
|
};
|
||||||
|
if (c.name) {
|
||||||
|
contact.name = c.name;
|
||||||
|
}
|
||||||
|
if (c.nextPubKeyHashB64) {
|
||||||
|
contact.nextPubKeyHashB64 = c.nextPubKeyHashB64;
|
||||||
|
}
|
||||||
|
if (c.profileImageUrl) {
|
||||||
|
contact.profileImageUrl = c.profileImageUrl;
|
||||||
|
}
|
||||||
|
if (c.publicKeyBase64) {
|
||||||
|
contact.publicKeyBase64 = c.publicKeyBase64;
|
||||||
|
}
|
||||||
|
if (typeof c.registered === "boolean") {
|
||||||
|
contact.registered = c.registered;
|
||||||
|
}
|
||||||
|
return contact;
|
||||||
|
});
|
||||||
|
const contactsParam = encodeURIComponent(JSON.stringify(contactsForLink));
|
||||||
|
return `${APP_SERVER}/deep-link/contact-import?contacts=${contactsParam}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyUnsignedImportLink() {
|
||||||
|
if (this.contactsImporting.length === 0) {
|
||||||
|
this.notify.error(
|
||||||
|
"No contacts are loaded to build a link.",
|
||||||
|
TIMEOUTS.SHORT,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const link = this.buildUnsignedImportLink();
|
||||||
|
await copyToClipboard(link);
|
||||||
|
this.notify.copied("unsigned contact import link", TIMEOUTS.STANDARD);
|
||||||
|
} catch (error) {
|
||||||
|
const fullError =
|
||||||
|
"Error copying unsigned import link: " + errorStringForLog(error);
|
||||||
|
this.$logAndConsole(fullError, true);
|
||||||
|
this.notify.error("Failed to copy link to clipboard.", TIMEOUTS.STANDARD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new label to the selected labels list
|
* Adds a new label to the selected labels list
|
||||||
*/
|
*/
|
||||||
@@ -791,5 +864,20 @@ export default class ContactImportView extends Vue {
|
|||||||
);
|
);
|
||||||
this.$router.push({ name: "contacts" });
|
this.$router.push({ name: "contacts" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get canApplyLabelsToExisting(): boolean {
|
||||||
|
return this.applyLabelsToExisting && this.selectedLabels.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleApplyLabelsToExistingClick() {
|
||||||
|
if (!this.canApplyLabelsToExisting) {
|
||||||
|
this.notify.warning(
|
||||||
|
`You must choose some labels and check the "Apply" checkbox to use this.`,
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.importContacts();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
589
src/views/ContactProfileCheckView.vue
Normal file
589
src/views/ContactProfileCheckView.vue
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
<template>
|
||||||
|
<QuickNav selected="Contacts" />
|
||||||
|
|
||||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
|
<TopMessage />
|
||||||
|
|
||||||
|
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||||
|
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||||
|
Profile Check
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="order-first text-lg text-center leading-none p-1"
|
||||||
|
@click="goBack()"
|
||||||
|
>
|
||||||
|
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="contactResults.length === 0"
|
||||||
|
class="text-center text-slate-500 mt-8"
|
||||||
|
>
|
||||||
|
No contacts to check. Go back and select contacts first.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div class="mb-4 text-sm text-slate-600">
|
||||||
|
Checked {{ completedCount }} of {{ contactResults.length }}
|
||||||
|
{{ contactResults.length === 1 ? "contact" : "contacts" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2 mb-6">
|
||||||
|
<div
|
||||||
|
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: progressPercent + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isComplete" class="mb-6 p-4 bg-slate-100 rounded-lg">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-slate-800">
|
||||||
|
<span class="text-green-600">{{ fullyReadyCount }}</span>
|
||||||
|
of {{ contactResults.length }}
|
||||||
|
{{ contactResults.length === 1 ? "contact" : "contacts" }}
|
||||||
|
fully ready
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
v-if="fullyReadyCount < contactResults.length"
|
||||||
|
class="mt-2 text-sm text-slate-600 list-disc list-inside space-y-0.5"
|
||||||
|
>
|
||||||
|
<li v-if="blankProfileCount > 0">
|
||||||
|
<span class="text-amber-600 font-medium">{{
|
||||||
|
blankProfileCount
|
||||||
|
}}</span>
|
||||||
|
{{ blankProfileCount === 1 ? "profile" : "profiles" }} with
|
||||||
|
blank description
|
||||||
|
</li>
|
||||||
|
<li v-if="noProfileOrHiddenCount > 0">
|
||||||
|
<span class="text-amber-600 font-medium">{{
|
||||||
|
noProfileOrHiddenCount
|
||||||
|
}}</span>
|
||||||
|
no profile or not visible
|
||||||
|
</li>
|
||||||
|
<li v-if="noEmbeddingOrHiddenCount > 0">
|
||||||
|
<span class="text-amber-600 font-medium">{{
|
||||||
|
noEmbeddingOrHiddenCount
|
||||||
|
}}</span>
|
||||||
|
no embedding or not visible
|
||||||
|
</li>
|
||||||
|
<li v-if="errorCount > 0">
|
||||||
|
<span class="text-red-600 font-medium">{{ errorCount }}</span>
|
||||||
|
{{ errorCount === 1 ? "check" : "checks" }} failed
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex-shrink-0 text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isRefreshing"
|
||||||
|
@click="refreshChecks"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
icon="rotate"
|
||||||
|
class="fa-fw mr-1"
|
||||||
|
:class="{ 'fa-spin': isRefreshing }"
|
||||||
|
/>
|
||||||
|
{{ isRefreshing ? "Refreshing…" : "Refresh" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li
|
||||||
|
v-for="result in contactResults"
|
||||||
|
:key="result.did"
|
||||||
|
class="bg-slate-50 rounded-lg p-4 border border-slate-200"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span v-if="result.status === 'pending'" class="text-slate-400">
|
||||||
|
<font-awesome icon="clock" class="fa-fw" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="result.status === 'checking'"
|
||||||
|
class="text-blue-500"
|
||||||
|
>
|
||||||
|
<font-awesome icon="spinner" class="fa-spin-pulse fa-fw" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="result.status === 'has-profile'"
|
||||||
|
:class="
|
||||||
|
result.embeddingMetadata?.generateEmbedding &&
|
||||||
|
!result.embeddingMetadata?.isForEmptyString
|
||||||
|
? 'text-green-600'
|
||||||
|
: 'text-amber-500'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
:icon="
|
||||||
|
result.embeddingMetadata?.generateEmbedding &&
|
||||||
|
!result.embeddingMetadata?.isForEmptyString
|
||||||
|
? 'circle-check'
|
||||||
|
: 'circle-xmark'
|
||||||
|
"
|
||||||
|
class="fa-fw"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="result.status === 'blank-profile'"
|
||||||
|
class="text-amber-500"
|
||||||
|
>
|
||||||
|
<font-awesome icon="circle-xmark" class="fa-fw" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="result.status === 'no-profile'"
|
||||||
|
class="text-amber-500"
|
||||||
|
>
|
||||||
|
<font-awesome icon="circle-xmark" class="fa-fw" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="result.status === 'error'"
|
||||||
|
class="text-red-500"
|
||||||
|
>
|
||||||
|
<font-awesome icon="triangle-exclamation" class="fa-fw" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="font-medium text-slate-800 truncate">
|
||||||
|
{{ result.name || "(no name)" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-sm">
|
||||||
|
<span v-if="result.status === 'pending'" class="text-slate-400">
|
||||||
|
Waiting…
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="result.status === 'checking'"
|
||||||
|
class="text-blue-500"
|
||||||
|
>
|
||||||
|
Checking profile…
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="result.status === 'has-profile'"
|
||||||
|
class="text-green-700"
|
||||||
|
>
|
||||||
|
Has profile
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="result.status === 'blank-profile'"
|
||||||
|
class="text-amber-600"
|
||||||
|
>
|
||||||
|
Has profile with blank description
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="result.status === 'no-profile'"
|
||||||
|
class="text-amber-600"
|
||||||
|
>
|
||||||
|
No profile or not visible
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="result.status === 'error'"
|
||||||
|
class="text-red-600"
|
||||||
|
>
|
||||||
|
Error: {{ result.error }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
result.status === 'has-profile' && result.profileDescription
|
||||||
|
"
|
||||||
|
class="mt-2 text-sm text-slate-600 line-clamp-2"
|
||||||
|
>
|
||||||
|
{{ result.profileDescription }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
result.status !== 'pending' && result.status !== 'checking'
|
||||||
|
"
|
||||||
|
class="mt-2 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="
|
||||||
|
result.embeddingMetadata?.generateEmbedding ?? false
|
||||||
|
"
|
||||||
|
:disabled="embeddingSavingDids.has(result.did)"
|
||||||
|
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:class="
|
||||||
|
(result.embeddingMetadata?.generateEmbedding ?? false)
|
||||||
|
? 'bg-blue-600'
|
||||||
|
: 'bg-gray-200'
|
||||||
|
"
|
||||||
|
@click="toggleEmbedding(result.did)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition"
|
||||||
|
:class="
|
||||||
|
(result.embeddingMetadata?.generateEmbedding ?? false)
|
||||||
|
? 'translate-x-4'
|
||||||
|
: 'translate-x-0'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-600">
|
||||||
|
Embedding:
|
||||||
|
{{
|
||||||
|
(result.embeddingMetadata?.generateEmbedding ?? false)
|
||||||
|
? "On"
|
||||||
|
: "Off"
|
||||||
|
}}
|
||||||
|
<span v-if="embeddingSavingDids.has(result.did)" class="ml-1"
|
||||||
|
>(saving…)</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'did', params: { did: result.did } }"
|
||||||
|
class="flex-shrink-0 text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||||
|
|
||||||
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
|
import {
|
||||||
|
DEFAULT_PARTNER_API_SERVER,
|
||||||
|
NotificationIface,
|
||||||
|
} from "../constants/app";
|
||||||
|
import { getHeaders } from "../libs/endorserServer";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
|
|
||||||
|
interface EmbeddingMetadata {
|
||||||
|
generateEmbedding: boolean;
|
||||||
|
isForEmptyString: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactCheckResult {
|
||||||
|
did: string;
|
||||||
|
name: string;
|
||||||
|
status:
|
||||||
|
| "pending"
|
||||||
|
| "checking"
|
||||||
|
| "has-profile"
|
||||||
|
| "blank-profile"
|
||||||
|
| "no-profile"
|
||||||
|
| "error";
|
||||||
|
profileDescription?: string;
|
||||||
|
embeddingMetadata?: EmbeddingMetadata | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
name: "ContactProfileCheckView",
|
||||||
|
components: {
|
||||||
|
QuickNav,
|
||||||
|
TopMessage,
|
||||||
|
},
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
|
})
|
||||||
|
export default class ContactProfileCheckView extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
$route!: RouteLocationNormalizedLoaded;
|
||||||
|
$router!: Router;
|
||||||
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
|
activeDid = "";
|
||||||
|
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
||||||
|
contactResults: ContactCheckResult[] = [];
|
||||||
|
embeddingSavingDids: Set<string> = new Set();
|
||||||
|
isRefreshing = false;
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.notify = createNotifyHelpers(this.$notify);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||||
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
|
this.partnerApiServer =
|
||||||
|
(settings.partnerApiServer as string) || DEFAULT_PARTNER_API_SERVER;
|
||||||
|
|
||||||
|
const didsParam = this.$route.query.dids as string;
|
||||||
|
if (!didsParam) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFresh = sessionStorage.getItem("profileCheckFresh") === "true";
|
||||||
|
sessionStorage.removeItem("profileCheckFresh");
|
||||||
|
|
||||||
|
if (!isFresh) {
|
||||||
|
const cachedDids = sessionStorage.getItem("profileCheckDids");
|
||||||
|
const cachedResults = sessionStorage.getItem("profileCheckResults");
|
||||||
|
if (cachedDids === didsParam && cachedResults) {
|
||||||
|
try {
|
||||||
|
this.contactResults = JSON.parse(cachedResults);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// fall through to fresh load
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.removeItem("profileCheckResults");
|
||||||
|
sessionStorage.removeItem("profileCheckDids");
|
||||||
|
|
||||||
|
let dids: string[];
|
||||||
|
try {
|
||||||
|
dids = JSON.parse(didsParam);
|
||||||
|
} catch {
|
||||||
|
this.notify.error("Invalid contact data.", TIMEOUTS.LONG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const did of dids) {
|
||||||
|
const contact = await this.$getContact(did);
|
||||||
|
this.contactResults.push({
|
||||||
|
did,
|
||||||
|
name: contact?.name || "",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runChecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
get completedCount(): number {
|
||||||
|
return this.contactResults.filter(
|
||||||
|
(r) => r.status !== "pending" && r.status !== "checking",
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get progressPercent(): number {
|
||||||
|
if (this.contactResults.length === 0) return 0;
|
||||||
|
return Math.round((this.completedCount / this.contactResults.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isComplete(): boolean {
|
||||||
|
return (
|
||||||
|
this.contactResults.length > 0 &&
|
||||||
|
this.completedCount === this.contactResults.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get profileCount(): number {
|
||||||
|
return this.contactResults.filter((r) => r.status === "has-profile").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get blankProfileCount(): number {
|
||||||
|
return this.contactResults.filter((r) => r.status === "blank-profile")
|
||||||
|
.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get noProfileOrHiddenCount(): number {
|
||||||
|
return this.contactResults.filter((r) => r.status === "no-profile").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get errorCount(): number {
|
||||||
|
return this.contactResults.filter((r) => r.status === "error").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get noEmbeddingOrHiddenCount(): number {
|
||||||
|
return this.contactResults.filter(
|
||||||
|
(r) =>
|
||||||
|
r.status !== "pending" &&
|
||||||
|
r.status !== "checking" &&
|
||||||
|
!r.embeddingMetadata?.generateEmbedding,
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get fullyReadyCount(): number {
|
||||||
|
return this.contactResults.filter(
|
||||||
|
(r) =>
|
||||||
|
r.status === "has-profile" &&
|
||||||
|
r.embeddingMetadata?.generateEmbedding &&
|
||||||
|
!r.embeddingMetadata?.isForEmptyString,
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runChecks() {
|
||||||
|
if (!this.activeDid) {
|
||||||
|
this.notify.error("No active identity.", TIMEOUTS.LONG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < this.contactResults.length; i++) {
|
||||||
|
this.contactResults[i].status = "checking";
|
||||||
|
this.contactResults = [...this.contactResults];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const url =
|
||||||
|
`${this.partnerApiServer}/api/partner/userProfileForIssuer/` +
|
||||||
|
encodeURIComponent(this.contactResults[i].did);
|
||||||
|
const response = await this.axios.get(url, { headers });
|
||||||
|
const data = response.data?.data;
|
||||||
|
|
||||||
|
if (data && data.description) {
|
||||||
|
this.contactResults[i].status = "has-profile";
|
||||||
|
this.contactResults[i].profileDescription = data.description;
|
||||||
|
} else if (data && typeof data.description === "string") {
|
||||||
|
this.contactResults[i].status = "blank-profile";
|
||||||
|
} else {
|
||||||
|
this.contactResults[i].status = "no-profile";
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const axiosErr = err as {
|
||||||
|
response?: { status?: number; data?: { error?: string } };
|
||||||
|
};
|
||||||
|
if (axiosErr.response?.status === 404) {
|
||||||
|
this.contactResults[i].status = "no-profile";
|
||||||
|
} else {
|
||||||
|
this.contactResults[i].status = "error";
|
||||||
|
this.contactResults[i].error =
|
||||||
|
axiosErr.response?.data?.error || "Could not check profile";
|
||||||
|
logger.error(
|
||||||
|
`Failed to check profile for ${this.contactResults[i].did}:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const embUrl =
|
||||||
|
`${this.partnerApiServer}/api/partner/userProfileEmbeddingMetadata/` +
|
||||||
|
encodeURIComponent(this.contactResults[i].did);
|
||||||
|
const embResponse = await this.axios.get(embUrl, { headers });
|
||||||
|
const embData = embResponse.data?.data;
|
||||||
|
if (embData && typeof embData.generateEmbedding === "boolean") {
|
||||||
|
this.contactResults[i].embeddingMetadata = {
|
||||||
|
generateEmbedding: embData.generateEmbedding,
|
||||||
|
isForEmptyString: !!embData.isForEmptyString,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.contactResults[i].embeddingMetadata = null;
|
||||||
|
}
|
||||||
|
} catch (embErr: unknown) {
|
||||||
|
const axiosErr = embErr as { response?: { status?: number } };
|
||||||
|
if (axiosErr.response?.status !== 404) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to load embedding metadata for ${this.contactResults[i].did}:`,
|
||||||
|
embErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.contactResults[i].embeddingMetadata = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.contactResults = [...this.contactResults];
|
||||||
|
}
|
||||||
|
|
||||||
|
const didsParam = this.$route.query.dids as string;
|
||||||
|
if (didsParam) {
|
||||||
|
sessionStorage.setItem("profileCheckDids", didsParam);
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"profileCheckResults",
|
||||||
|
JSON.stringify(this.contactResults),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshChecks() {
|
||||||
|
this.isRefreshing = true;
|
||||||
|
for (let i = 0; i < this.contactResults.length; i++) {
|
||||||
|
this.contactResults[i].status = "pending";
|
||||||
|
this.contactResults[i].profileDescription = undefined;
|
||||||
|
this.contactResults[i].embeddingMetadata = undefined;
|
||||||
|
this.contactResults[i].error = undefined;
|
||||||
|
}
|
||||||
|
this.contactResults = [...this.contactResults];
|
||||||
|
await this.runChecks();
|
||||||
|
this.isRefreshing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleEmbedding(did: string) {
|
||||||
|
const idx = this.contactResults.findIndex((r) => r.did === did);
|
||||||
|
if (idx === -1 || !this.activeDid) return;
|
||||||
|
|
||||||
|
const current =
|
||||||
|
this.contactResults[idx].embeddingMetadata?.generateEmbedding ?? false;
|
||||||
|
const newValue = !current;
|
||||||
|
|
||||||
|
this.embeddingSavingDids = new Set(this.embeddingSavingDids).add(did);
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const url =
|
||||||
|
`${this.partnerApiServer}/api/partner/userProfileGenerateEmbedding/` +
|
||||||
|
encodeURIComponent(did);
|
||||||
|
await this.axios.put(url, { generateEmbedding: newValue }, { headers });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const embUrl =
|
||||||
|
`${this.partnerApiServer}/api/partner/userProfileEmbeddingMetadata/` +
|
||||||
|
encodeURIComponent(did);
|
||||||
|
const embResponse = await this.axios.get(embUrl, { headers });
|
||||||
|
const embData = embResponse.data?.data;
|
||||||
|
if (embData && typeof embData.generateEmbedding === "boolean") {
|
||||||
|
this.contactResults[idx].embeddingMetadata = {
|
||||||
|
generateEmbedding: embData.generateEmbedding,
|
||||||
|
isForEmptyString: !!embData.isForEmptyString,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (refreshErr) {
|
||||||
|
logger.error(
|
||||||
|
"Failed to refresh embedding metadata",
|
||||||
|
!newValue
|
||||||
|
? "- where a 404 is expected when embedding is turned off:"
|
||||||
|
: ":",
|
||||||
|
refreshErr,
|
||||||
|
);
|
||||||
|
this.contactResults[idx].embeddingMetadata = {
|
||||||
|
generateEmbedding: newValue,
|
||||||
|
isForEmptyString:
|
||||||
|
this.contactResults[idx].embeddingMetadata?.isForEmptyString ??
|
||||||
|
true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.contactResults = [...this.contactResults];
|
||||||
|
|
||||||
|
this.notify.success(
|
||||||
|
newValue
|
||||||
|
? "Embedding generation enabled."
|
||||||
|
: "Embedding generation disabled.",
|
||||||
|
TIMEOUTS.STANDARD,
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = (err as { response?: { data?: { error?: string } } })
|
||||||
|
?.response?.data?.error;
|
||||||
|
if (error) {
|
||||||
|
this.notify.error(error, TIMEOUTS.LONG);
|
||||||
|
} else {
|
||||||
|
logger.error("Failed to update generate-embedding flag:", err);
|
||||||
|
this.notify.error(
|
||||||
|
"Failed to update embedding flag. Try again.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const updated = new Set(this.embeddingSavingDids);
|
||||||
|
updated.delete(did);
|
||||||
|
this.embeddingSavingDids = updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
this.$router.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
|
|
||||||
<!-- Label Filter -->
|
<!-- Label Filter -->
|
||||||
<div v-if="allLabels.length > 0" class="mt-4 mb-2">
|
<div v-if="allLabels.length > 0" class="mt-4 mb-2">
|
||||||
<div class="flex items-center justify-between pl-[16.666%] pr-[16.666%]">
|
<div class="flex items-center justify-between pl-[10%] pr-[10%]">
|
||||||
<button
|
<button
|
||||||
class="text-sm font-medium text-blue-600 flex items-center gap-1"
|
class="text-sm font-medium text-blue-600 flex items-center gap-1"
|
||||||
@click="showLabelFilter = !showLabelFilter"
|
@click="showLabelFilter = !showLabelFilter"
|
||||||
@@ -167,6 +167,23 @@
|
|||||||
@copy-selected="copySelectedContacts"
|
@copy-selected="copySelectedContacts"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Check for Profile (admin/organizer feature) -->
|
||||||
|
<div
|
||||||
|
v-if="showGeneralAdvanced && contactsSelected.length > 0"
|
||||||
|
class="my-3 flex justify-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="text-sm bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||||
|
@click="checkSelectedProfiles"
|
||||||
|
>
|
||||||
|
<font-awesome icon="circle-user" class="fa-fw mr-1" />
|
||||||
|
Check for Public Profile
|
||||||
|
</button>
|
||||||
|
<button class="ml-4 text-2xl text-blue-500" @click="showProfileCheckInfo">
|
||||||
|
<font-awesome icon="circle-info" class="fa-fw" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<GiftedDialog
|
<GiftedDialog
|
||||||
ref="customGivenDialog"
|
ref="customGivenDialog"
|
||||||
:initial-giver-entity-type="'person'"
|
:initial-giver-entity-type="'person'"
|
||||||
@@ -206,8 +223,11 @@ import LargeIdenticonModal from "../components/LargeIdenticonModal.vue";
|
|||||||
import { AppString, NotificationIface } from "../constants/app";
|
import { AppString, NotificationIface } from "../constants/app";
|
||||||
// Legacy logging import removed - using PlatformServiceMixin methods
|
// Legacy logging import removed - using PlatformServiceMixin methods
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
|
||||||
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
||||||
|
import {
|
||||||
|
parseContactImportInput,
|
||||||
|
ContactImportParseResult,
|
||||||
|
} from "../libs/contactImportPayload";
|
||||||
import {
|
import {
|
||||||
CONTACT_CSV_HEADER,
|
CONTACT_CSV_HEADER,
|
||||||
createEndorserJwtForDid,
|
createEndorserJwtForDid,
|
||||||
@@ -216,15 +236,8 @@ import {
|
|||||||
isDid,
|
isDid,
|
||||||
register,
|
register,
|
||||||
setVisibilityUtil,
|
setVisibilityUtil,
|
||||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
|
||||||
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
|
|
||||||
CONTACT_URL_PATH_ENDORSER_CH_OLD,
|
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import {
|
import { GiveSummaryRecord, VerifiableCredential } from "@/interfaces";
|
||||||
GiveSummaryRecord,
|
|
||||||
UserInfo,
|
|
||||||
VerifiableCredential,
|
|
||||||
} from "@/interfaces";
|
|
||||||
import * as libsUtil from "../libs/util";
|
import * as libsUtil from "../libs/util";
|
||||||
import { generateSaveAndActivateIdentity } from "../libs/util";
|
import { generateSaveAndActivateIdentity } from "../libs/util";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
@@ -334,6 +347,7 @@ export default class ContactsView extends Vue {
|
|||||||
hideRegisterPromptOnNewContact = false;
|
hideRegisterPromptOnNewContact = false;
|
||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
showDidCopy = false;
|
showDidCopy = false;
|
||||||
|
showGeneralAdvanced = false;
|
||||||
showPubKeyCopy = false;
|
showPubKeyCopy = false;
|
||||||
showPubKeyHashCopy = false;
|
showPubKeyHashCopy = false;
|
||||||
showGiveNumbers = false;
|
showGiveNumbers = false;
|
||||||
@@ -377,6 +391,7 @@ export default class ContactsView extends Vue {
|
|||||||
await this.processContactJwt();
|
await this.processContactJwt();
|
||||||
await this.processInviteJwt();
|
await this.processInviteJwt();
|
||||||
|
|
||||||
|
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
||||||
this.showGiveNumbers = !!settings.showContactGivesInline;
|
this.showGiveNumbers = !!settings.showContactGivesInline;
|
||||||
this.hideRegisterPromptOnNewContact =
|
this.hideRegisterPromptOnNewContact =
|
||||||
!!settings.hideRegisterPromptOnNewContact;
|
!!settings.hideRegisterPromptOnNewContact;
|
||||||
@@ -397,20 +412,8 @@ export default class ContactsView extends Vue {
|
|||||||
// because that will do better error checking for things like missing data on iOS platforms.
|
// because that will do better error checking for things like missing data on iOS platforms.
|
||||||
const importedContactJwt = this.$route.query["contactJwt"] as string;
|
const importedContactJwt = this.$route.query["contactJwt"] as string;
|
||||||
if (importedContactJwt) {
|
if (importedContactJwt) {
|
||||||
// really should fully verify contents
|
const parsedImport = parseContactImportInput(importedContactJwt);
|
||||||
const { payload } = decodeEndorserJwt(importedContactJwt);
|
await this.handleParsedContactImport(parsedImport);
|
||||||
const userInfo = payload["own"] as UserInfo;
|
|
||||||
const newContact = {
|
|
||||||
did: userInfo.did || payload["iss"], // ".did" is reliable as of v 0.3.49
|
|
||||||
name: userInfo.name,
|
|
||||||
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
|
|
||||||
profileImageUrl: userInfo.profileImageUrl,
|
|
||||||
publicKeyBase64: userInfo.publicEncKey,
|
|
||||||
registered: userInfo.registered,
|
|
||||||
} as Contact;
|
|
||||||
await this.addContact(newContact);
|
|
||||||
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
|
|
||||||
this.$router.push({ path: "/contacts" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +601,24 @@ export default class ContactsView extends Vue {
|
|||||||
this.contactsFiltered = await this.filteredContacts();
|
this.contactsFiltered = await this.filteredContacts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to profile check view with selected contacts
|
||||||
|
*/
|
||||||
|
showProfileCheckInfo() {
|
||||||
|
this.notify.info(
|
||||||
|
"This User Profile visibility check is a useful report for meeting organizers when running meetings that match people based on common interests.",
|
||||||
|
TIMEOUTS.VERY_LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSelectedProfiles() {
|
||||||
|
sessionStorage.setItem("profileCheckFresh", "true");
|
||||||
|
this.$router.push({
|
||||||
|
name: "contact-profile-check",
|
||||||
|
query: { dids: JSON.stringify(this.contactsSelected) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
get copyButtonClass() {
|
get copyButtonClass() {
|
||||||
return this.contactsSelected.length > 0
|
return this.contactsSelected.length > 0
|
||||||
? "text-md bg-gradient-to-b from-blue-400 to-blue-700 " +
|
? "text-md bg-gradient-to-b from-blue-400 to-blue-700 " +
|
||||||
@@ -750,7 +771,7 @@ export default class ContactsView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try different parsing methods in order
|
// Try different parsing methods in order
|
||||||
if (await this.tryParseJwtContact(contactInput)) return;
|
if (await this.tryParseContactImport(contactInput)) return;
|
||||||
if (await this.tryParseCsvContacts(contactInput)) return;
|
if (await this.tryParseCsvContacts(contactInput)) return;
|
||||||
if (await this.tryParseDidContact(contactInput)) return;
|
if (await this.tryParseDidContact(contactInput)) return;
|
||||||
if (await this.tryParseJsonContacts(contactInput)) return;
|
if (await this.tryParseJsonContacts(contactInput)) return;
|
||||||
@@ -762,29 +783,51 @@ export default class ContactsView extends Vue {
|
|||||||
/**
|
/**
|
||||||
* Parse contact from JWT URL format
|
* Parse contact from JWT URL format
|
||||||
*/
|
*/
|
||||||
private async tryParseJwtContact(contactInput: string): Promise<boolean> {
|
private async tryParseContactImport(contactInput: string): Promise<boolean> {
|
||||||
|
const parsedImport = parseContactImportInput(contactInput);
|
||||||
if (
|
if (
|
||||||
contactInput.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI) ||
|
parsedImport.kind === "error" &&
|
||||||
contactInput.includes(CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI) ||
|
parsedImport.code === "not_contact_import_format"
|
||||||
contactInput.includes(CONTACT_URL_PATH_ENDORSER_CH_OLD)
|
|
||||||
) {
|
) {
|
||||||
const jwt = getContactJwtFromJwtUrl(contactInput);
|
return false;
|
||||||
if (jwt) {
|
|
||||||
const { payload } = decodeEndorserJwt(jwt);
|
|
||||||
const userInfo = payload["own"] as UserInfo;
|
|
||||||
const newContact = {
|
|
||||||
did: userInfo.did || payload["iss"],
|
|
||||||
name: userInfo.name,
|
|
||||||
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
|
|
||||||
profileImageUrl: userInfo.profileImageUrl,
|
|
||||||
publicKeyBase64: userInfo.publicEncKey,
|
|
||||||
registered: userInfo.registered,
|
|
||||||
} as Contact;
|
|
||||||
await this.addContact(newContact);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
await this.handleParsedContactImport(parsedImport);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleParsedContactImport(
|
||||||
|
parsedImport: ContactImportParseResult,
|
||||||
|
): Promise<void> {
|
||||||
|
if (parsedImport.kind === "error") {
|
||||||
|
this.notify.error(parsedImport.message, TIMEOUTS.LONG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedImport.kind === "single") {
|
||||||
|
await this.addOrEditImportedContact(parsedImport.contact);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedImport.contacts.length === 1) {
|
||||||
|
await this.addOrEditImportedContact(parsedImport.contacts[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$router.push({
|
||||||
|
name: "contact-import",
|
||||||
|
params: { jwt: parsedImport.jwt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addOrEditImportedContact(contact: Contact): Promise<void> {
|
||||||
|
const existingContact = await this.$getContact(contact.did);
|
||||||
|
if (!existingContact) {
|
||||||
|
await this.addContact(contact);
|
||||||
|
}
|
||||||
|
await this.$router.push({
|
||||||
|
name: "contact-edit",
|
||||||
|
params: { did: contact.did },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -136,95 +136,148 @@
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button class="ml-2 mr-2 mt-4" @click="toggleUserProfile">
|
||||||
|
User Profile
|
||||||
|
<font-awesome
|
||||||
|
v-if="showUserProfile"
|
||||||
|
icon="chevron-down"
|
||||||
|
class="text-blue-400"
|
||||||
|
/>
|
||||||
|
<font-awesome v-else icon="chevron-right" class="text-blue-400" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showUserProfile"
|
||||||
|
class="mt-2 px-4 py-3 bg-slate-100 rounded-md"
|
||||||
|
>
|
||||||
|
<div v-if="userProfileLoading" class="text-sm text-slate-600">
|
||||||
|
<font-awesome icon="spinner" class="fa-spin-pulse mr-2" />
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="userProfileError" class="text-sm text-slate-600">
|
||||||
|
{{ userProfileError }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="userProfileData" class="text-sm">
|
||||||
|
<p
|
||||||
|
v-if="userProfileData.description"
|
||||||
|
class="text-slate-700 whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
{{ userProfileData.description }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-slate-500 italic">
|
||||||
|
This person has no profile description or it's not visible to you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between mt-4">
|
<div class="flex justify-between mt-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div v-if="activeDid" class="flex justify-between">
|
<div v-if="activeDid" class="flex justify-between items-end">
|
||||||
<div>
|
<div class="flex items-end gap-1">
|
||||||
<button
|
<div
|
||||||
v-if="
|
|
||||||
contactFromDid?.seesMe && contactFromDid.did !== activeDid
|
|
||||||
"
|
|
||||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
|
||||||
title="They can see your activity"
|
|
||||||
@click="confirmSetVisibility(contactFromDid, false)"
|
|
||||||
>
|
|
||||||
<font-awesome icon="arrow-up" class="fa-fw" />
|
|
||||||
<font-awesome icon="eye" class="fa-fw" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else-if="
|
|
||||||
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid
|
|
||||||
"
|
|
||||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
|
||||||
title="They cannot see your activity"
|
|
||||||
@click="confirmSetVisibility(contactFromDid, true)"
|
|
||||||
>
|
|
||||||
<font-awesome icon="arrow-up" class="fa-fw" />
|
|
||||||
<font-awesome icon="eye-slash" class="fa-fw" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="
|
|
||||||
contactFromDid?.iViewContent &&
|
|
||||||
contactFromDid.did !== activeDid
|
|
||||||
"
|
|
||||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
|
||||||
title="You watch their activity"
|
|
||||||
@click="confirmViewContent(contactFromDid, false)"
|
|
||||||
>
|
|
||||||
<font-awesome icon="arrow-down" class="fa-fw" />
|
|
||||||
<font-awesome icon="eye" class="fa-fw" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else-if="
|
|
||||||
!contactFromDid?.iViewContent &&
|
|
||||||
contactFromDid?.did !== activeDid
|
|
||||||
"
|
|
||||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
|
||||||
title="You do not watch their activity"
|
|
||||||
@click="confirmViewContent(contactFromDid, true)"
|
|
||||||
>
|
|
||||||
<font-awesome icon="arrow-down" class="fa-fw" />
|
|
||||||
<font-awesome icon="eye-slash" class="fa-fw" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="contactFromDid?.did !== activeDid"
|
v-if="contactFromDid?.did !== activeDid"
|
||||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
class="flex flex-col items-center"
|
||||||
title="Check Visibility"
|
|
||||||
@click="checkVisibility(contactFromDid)"
|
|
||||||
>
|
>
|
||||||
<font-awesome icon="rotate" class="fa-fw" />
|
<button
|
||||||
</button>
|
v-if="contactFromDid?.seesMe"
|
||||||
|
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||||
|
title="They can see your activity"
|
||||||
|
@click="confirmSetVisibility(contactFromDid, false)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="arrow-up" class="fa-fw" />
|
||||||
|
<font-awesome icon="eye" class="fa-fw" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="!contactFromDid?.seesMe"
|
||||||
|
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||||
|
title="They cannot see your activity"
|
||||||
|
@click="confirmSetVisibility(contactFromDid, true)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="arrow-up" class="fa-fw" />
|
||||||
|
<font-awesome icon="eye-slash" class="fa-fw" />
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-slate-600">See You</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="contactFromDid?.did !== activeDid"
|
||||||
|
class="flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="!contactFromDid?.hideTheirContent"
|
||||||
|
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||||
|
title="You watch their activity"
|
||||||
|
@click="confirmViewContent(contactFromDid, false)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="arrow-down" class="fa-fw" />
|
||||||
|
<font-awesome icon="eye" class="fa-fw" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="contactFromDid?.hideTheirContent"
|
||||||
|
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||||
|
title="You do not watch their activity"
|
||||||
|
@click="confirmViewContent(contactFromDid, true)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="arrow-down" class="fa-fw" />
|
||||||
|
<font-awesome icon="eye-slash" class="fa-fw" />
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-slate-600">Watch Them</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="contactFromDid?.did !== activeDid"
|
||||||
|
class="flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||||
|
title="Check Visibility"
|
||||||
|
@click="checkVisibility(contactFromDid)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="rotate" class="fa-fw" />
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-slate-600">Check</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div
|
||||||
v-if="contactFromDid?.did !== activeDid"
|
v-if="contactFromDid?.did !== activeDid"
|
||||||
class="text-sm 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 ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
class="flex flex-col items-center ml-6"
|
||||||
title="Registration"
|
|
||||||
@click="confirmRegister(contactFromDid)"
|
|
||||||
>
|
>
|
||||||
<font-awesome
|
<button
|
||||||
v-if="contactFromDid?.registered"
|
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||||
icon="person-circle-check"
|
title="Registration"
|
||||||
class="fa-fw"
|
@click="confirmRegister(contactFromDid)"
|
||||||
/>
|
>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
v-else
|
v-if="contactFromDid?.registered"
|
||||||
icon="person-circle-question"
|
icon="person-circle-check"
|
||||||
class="fa-fw"
|
class="fa-fw"
|
||||||
/>
|
/>
|
||||||
</button>
|
<font-awesome
|
||||||
|
v-else
|
||||||
|
icon="person-circle-question"
|
||||||
|
class="fa-fw"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-slate-600">Register</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div
|
||||||
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
v-if="contactFromDid?.did !== activeDid"
|
||||||
title="Delete"
|
class="flex flex-col items-center ml-6"
|
||||||
@click="confirmDeleteContact(contactFromDid)"
|
|
||||||
>
|
>
|
||||||
<font-awesome icon="trash-can" class="fa-fw" />
|
<button
|
||||||
</button>
|
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||||
|
title="Delete"
|
||||||
|
@click="confirmDeleteContact(contactFromDid)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" class="fa-fw" />
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-slate-600">Delete</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!contactFromDid?.profileImageUrl">
|
<div v-if="!contactFromDid?.profileImageUrl">
|
||||||
<div>Auto-Generated Icon</div>
|
<div>Auto-Generated Icon</div>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
@@ -253,6 +306,57 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Only admins can set the generate-embedding flag -->
|
||||||
|
<div
|
||||||
|
v-if="showGeneralAdvanced && viewingDid"
|
||||||
|
class="mt-2 pt-2"
|
||||||
|
data-testid="generateEmbeddingSection"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2 mt-2">
|
||||||
|
Always generate embedding
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="embeddingMetadata?.generateEmbedding ?? false"
|
||||||
|
:disabled="embeddingMetadataSaving || embeddingMetadataLoading"
|
||||||
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:class="
|
||||||
|
(embeddingMetadata?.generateEmbedding ?? false)
|
||||||
|
? 'bg-blue-600'
|
||||||
|
: 'bg-gray-200'
|
||||||
|
"
|
||||||
|
@click="toggleGenerateEmbedding"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition"
|
||||||
|
:class="
|
||||||
|
(embeddingMetadata?.generateEmbedding ?? false)
|
||||||
|
? 'translate-x-5'
|
||||||
|
: 'translate-x-1'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-gray-600">
|
||||||
|
{{ (embeddingMetadata?.generateEmbedding ?? false) ? "On" : "Off" }}
|
||||||
|
<span v-if="embeddingMetadataLoading" class="ml-1">(loading…)</span>
|
||||||
|
<span v-else-if="embeddingMetadataSaving" class="ml-1"
|
||||||
|
>(saving…)</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600">
|
||||||
|
{{
|
||||||
|
embeddingMetadata?.isForEmptyString == null
|
||||||
|
? ""
|
||||||
|
: embeddingMetadata?.isForEmptyString
|
||||||
|
? "- Embedding is for blank description"
|
||||||
|
: "- Embedding is for non-blank description"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
<div v-else class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||||
<!-- !contactFromDid -->
|
<!-- !contactFromDid -->
|
||||||
@@ -332,7 +436,10 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import InfiniteScroll from "../components/InfiniteScroll.vue";
|
import InfiniteScroll from "../components/InfiniteScroll.vue";
|
||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
import { NotificationIface } from "../constants/app";
|
import {
|
||||||
|
DEFAULT_PARTNER_API_SERVER,
|
||||||
|
NotificationIface,
|
||||||
|
} from "../constants/app";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import { BoundingBox } from "../db/tables/settings";
|
import { BoundingBox } from "../db/tables/settings";
|
||||||
|
|
||||||
@@ -367,6 +474,7 @@ import {
|
|||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
|
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
|
||||||
import { getContactMethodLabel } from "@/constants/contacts";
|
import { getContactMethodLabel } from "@/constants/contacts";
|
||||||
|
import type { UserProfile } from "@/libs/partnerServer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DIDView Component
|
* DIDView Component
|
||||||
@@ -406,13 +514,25 @@ export default class DIDView extends Vue {
|
|||||||
contactLabels: string[] = [];
|
contactLabels: string[] = [];
|
||||||
|
|
||||||
contactYaml = "";
|
contactYaml = "";
|
||||||
|
embeddingMetadata: {
|
||||||
|
generateEmbedding: boolean;
|
||||||
|
isForEmptyString: boolean;
|
||||||
|
} | null = null;
|
||||||
|
embeddingMetadataLoading = false;
|
||||||
|
embeddingMetadataSaving = false;
|
||||||
hitEnd = false;
|
hitEnd = false;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
isMyDid = false;
|
isMyDid = false;
|
||||||
|
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
||||||
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||||
showDidDetails = false;
|
showDidDetails = false;
|
||||||
|
showGeneralAdvanced = false;
|
||||||
showLargeIdenticonId?: string;
|
showLargeIdenticonId?: string;
|
||||||
showLargeIdenticonUrl?: string;
|
showLargeIdenticonUrl?: string;
|
||||||
|
showUserProfile = false;
|
||||||
|
userProfileData: UserProfile | null = null;
|
||||||
|
userProfileError: string | null = null;
|
||||||
|
userProfileLoading = false;
|
||||||
viewingDid?: string;
|
viewingDid?: string;
|
||||||
|
|
||||||
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
||||||
@@ -444,6 +564,9 @@ export default class DIDView extends Vue {
|
|||||||
await this.loadContactInformation();
|
await this.loadContactInformation();
|
||||||
await this.loadClaimsAbout();
|
await this.loadClaimsAbout();
|
||||||
await this.checkIfOwnDID();
|
await this.checkIfOwnDID();
|
||||||
|
if (this.showGeneralAdvanced && this.activeDid) {
|
||||||
|
await this.loadUserProfileEmbeddingMetadata();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +582,9 @@ export default class DIDView extends Vue {
|
|||||||
this.activeDid = activeIdentity.activeDid || "";
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
|
|
||||||
this.apiServer = settings.apiServer || "";
|
this.apiServer = settings.apiServer || "";
|
||||||
|
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
||||||
|
this.partnerApiServer =
|
||||||
|
(settings.partnerApiServer as string) || DEFAULT_PARTNER_API_SERVER;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -519,6 +645,48 @@ export default class DIDView extends Vue {
|
|||||||
this.isMyDid = allAccountDids.includes(this.viewingDid);
|
this.isMyDid = allAccountDids.includes(this.viewingDid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the "always generate embedding" flag for the viewed DID on the partner API.
|
||||||
|
* Only permissioned (admin) users can change this; API returns 403 otherwise.
|
||||||
|
*/
|
||||||
|
async toggleGenerateEmbedding() {
|
||||||
|
if (!this.viewingDid || !this.activeDid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentValue = this.embeddingMetadata?.generateEmbedding ?? false;
|
||||||
|
const newValue = !currentValue;
|
||||||
|
this.embeddingMetadataSaving = true;
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const url = `${this.partnerApiServer}/api/partner/userProfileGenerateEmbedding/${encodeURIComponent(this.viewingDid)}`;
|
||||||
|
await this.axios.put(url, { generateEmbedding: newValue }, { headers });
|
||||||
|
|
||||||
|
// Refresh embedding metadata from the dedicated endpoint
|
||||||
|
await this.loadUserProfileEmbeddingMetadata();
|
||||||
|
|
||||||
|
this.notify.success(
|
||||||
|
newValue
|
||||||
|
? "Contact tagged to always generate embedding."
|
||||||
|
: "Contact untagged from always generating embedding.",
|
||||||
|
TIMEOUTS.STANDARD,
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = (err as { response?: { data?: { error?: string } } })
|
||||||
|
?.response?.data?.error;
|
||||||
|
if (error) {
|
||||||
|
this.notify.error(error, TIMEOUTS.LONG);
|
||||||
|
} else {
|
||||||
|
logger.error("Failed to update generate-embedding flag:", err);
|
||||||
|
this.notify.error(
|
||||||
|
"Failed to update generate-embedding flag. Try again.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.embeddingMetadataSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads additional claims when user scrolls to bottom
|
* Loads additional claims when user scrolls to bottom
|
||||||
* Used by infinite scroll component to implement pagination
|
* Used by infinite scroll component to implement pagination
|
||||||
@@ -545,6 +713,83 @@ export default class DIDView extends Vue {
|
|||||||
this.showDidDetails = !this.showDidDetails;
|
this.showDidDetails = !this.showDidDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the User Profile section and loads profile from server on first expand.
|
||||||
|
*/
|
||||||
|
toggleUserProfile() {
|
||||||
|
this.showUserProfile = !this.showUserProfile;
|
||||||
|
if (this.showUserProfile && !this.userProfileData) {
|
||||||
|
this.loadUserProfile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads embedding metadata (generateEmbedding, isForEmptyString) for the viewing DID
|
||||||
|
* from the partner API userProfileEmbeddingMetadata endpoint.
|
||||||
|
*/
|
||||||
|
async loadUserProfileEmbeddingMetadata() {
|
||||||
|
if (!this.viewingDid || !this.activeDid) return;
|
||||||
|
this.embeddingMetadataLoading = true;
|
||||||
|
this.embeddingMetadata = null;
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const url = `${this.partnerApiServer}/api/partner/userProfileEmbeddingMetadata/${encodeURIComponent(this.viewingDid)}`;
|
||||||
|
const response = await this.axios.get(url, { headers });
|
||||||
|
const data = response.data?.data;
|
||||||
|
if (data && typeof data.generateEmbedding === "boolean") {
|
||||||
|
this.embeddingMetadata = {
|
||||||
|
generateEmbedding: data.generateEmbedding,
|
||||||
|
isForEmptyString: !!data.isForEmptyString,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.embeddingMetadata = null;
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const axiosErr = err as { response?: { status?: number } };
|
||||||
|
if (axiosErr.response?.status === 404) {
|
||||||
|
this.embeddingMetadata = null;
|
||||||
|
} else {
|
||||||
|
logger.error("Failed to load user profile embedding metadata:", err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.embeddingMetadataLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the user profile for the viewing DID from the partner API.
|
||||||
|
* Shows profile content or a message if the DID has no profile or it is not visible.
|
||||||
|
*/
|
||||||
|
async loadUserProfile() {
|
||||||
|
if (!this.viewingDid || !this.activeDid) return;
|
||||||
|
this.userProfileLoading = true;
|
||||||
|
this.userProfileError = null;
|
||||||
|
this.userProfileData = null;
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const url = `${this.partnerApiServer}/api/partner/userProfileForIssuer/${encodeURIComponent(this.viewingDid)}`;
|
||||||
|
const response = await this.axios.get(url, { headers });
|
||||||
|
const data = response.data?.data;
|
||||||
|
if (data) {
|
||||||
|
this.userProfileData = data;
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const axiosErr = err as {
|
||||||
|
response?: { status?: number; data?: { error?: string } };
|
||||||
|
};
|
||||||
|
const status = axiosErr.response?.status;
|
||||||
|
const message = axiosErr.response?.data?.error;
|
||||||
|
if (status === 404 && message) {
|
||||||
|
this.userProfileError = message;
|
||||||
|
} else {
|
||||||
|
this.userProfileError =
|
||||||
|
"This DID does not have a profile or doesn't make it visible to you.";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.userProfileLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showLargeProfileImage() {
|
showLargeProfileImage() {
|
||||||
this.showLargeIdenticonUrl = this.contactFromDid?.profileImageUrl;
|
this.showLargeIdenticonUrl = this.contactFromDid?.profileImageUrl;
|
||||||
}
|
}
|
||||||
@@ -897,7 +1142,7 @@ export default class DIDView extends Vue {
|
|||||||
this.notify.confirm(contentVisibilityPrompt, async () => {
|
this.notify.confirm(contentVisibilityPrompt, async () => {
|
||||||
const success = await this.setViewContent(contact, view);
|
const success = await this.setViewContent(contact, view);
|
||||||
if (success) {
|
if (success) {
|
||||||
contact.iViewContent = view; // see visibility note about not working inside setVisibility
|
contact.hideTheirContent = !view; // inverted: view=true -> hideTheirContent=false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -910,7 +1155,7 @@ export default class DIDView extends Vue {
|
|||||||
* @returns Boolean indicating success
|
* @returns Boolean indicating success
|
||||||
*/
|
*/
|
||||||
async setViewContent(contact: Contact, visibility: boolean) {
|
async setViewContent(contact: Contact, visibility: boolean) {
|
||||||
await this.$updateContact(contact.did, { iViewContent: visibility });
|
await this.$updateContact(contact.did, { hideTheirContent: !visibility });
|
||||||
const message =
|
const message =
|
||||||
"You will" +
|
"You will" +
|
||||||
(visibility ? "" : " not") +
|
(visibility ? "" : " not") +
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
} from "../interfaces/deepLinks";
|
} from "../interfaces/deepLinks";
|
||||||
import { logConsoleAndDb } from "../db/databaseUtil";
|
import { logConsoleAndDb } from "../db/databaseUtil";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { SUPPORT_EMAIL } from "../constants/app";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -105,7 +106,7 @@ const goHome = () => router.replace({ name: "home" });
|
|||||||
const reportIssue = () => {
|
const reportIssue = () => {
|
||||||
// Open a support form or email
|
// Open a support form or email
|
||||||
window.open(
|
window.open(
|
||||||
"mailto:support@timesafari.app?subject=Invalid Deep Link&body=" +
|
`mailto:${SUPPORT_EMAIL}?subject=Invalid Deep Link&body=` +
|
||||||
encodeURIComponent(
|
encodeURIComponent(
|
||||||
`I encountered an error with a deep link: timesafari://${originalPath.value}\nError: ${errorMessage.value}`,
|
`I encountered an error with a deep link: timesafari://${originalPath.value}\nError: ${errorMessage.value}`,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<!-- Sub View Heading -->
|
<!-- Sub View Heading -->
|
||||||
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||||
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||||
Redirecting to Time Safari
|
Redirecting to app
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
<p v-if="isMobile">
|
<p v-if="isMobile">
|
||||||
{{
|
{{
|
||||||
isIOS
|
isIOS
|
||||||
? "Opening Time Safari app on your iPhone..."
|
? "Opening on your iPhone..."
|
||||||
: "Opening Time Safari app on your Android device..."
|
: "Opening on your Android device..."
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p v-else>Opening Time Safari app...</p>
|
<p v-else>Opening the app...</p>
|
||||||
<p class="text-sm mt-2">
|
<p class="text-sm mt-2">
|
||||||
<span v-if="isMobile"
|
<span v-if="isMobile"
|
||||||
>If the app doesn't open automatically, use one of these
|
>If the app doesn't open automatically, use one of these
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||||
@click="handleDeepLinkClick"
|
@click="handleDeepLinkClick"
|
||||||
>
|
>
|
||||||
<span v-if="isMobile">Open in Time Safari App</span>
|
<span v-if="isMobile">Open in App</span>
|
||||||
<span v-else>Try Opening in Time Safari App</span>
|
<span v-else>Try Opening in App</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,8 +61,7 @@
|
|||||||
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
|
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
|
||||||
</p>
|
</p>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
If you have the Time Safari app installed, you can also copy this
|
If you have the app installed, you can also copy this link:
|
||||||
link:
|
|
||||||
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
|
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,13 +176,13 @@ export default class DeepLinkRedirectView extends Vue {
|
|||||||
"Fallback deep link failed: " + errorStringForLog(error),
|
"Fallback deep link failed: " + errorStringForLog(error),
|
||||||
);
|
);
|
||||||
this.pageError =
|
this.pageError =
|
||||||
"Redirecting to the Time Safari app failed. Please use a manual option below.";
|
"Redirecting to the app failed. Please use a manual option below.";
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Deep link redirect failed: " + errorStringForLog(error));
|
logger.error("Deep link redirect failed: " + errorStringForLog(error));
|
||||||
this.pageError =
|
this.pageError =
|
||||||
"Unable to open the Time Safari app. Please use a manual option below.";
|
"Unable to open the app. Please use a manual option below.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,10 @@
|
|||||||
<div class="truncate">
|
<div class="truncate">
|
||||||
From
|
From
|
||||||
{{
|
{{
|
||||||
givenByProjectFunction()
|
givenByProject()
|
||||||
? providerProjectName
|
? providerProjectName
|
||||||
: // check for DID because name could be "Unnamed"
|
: // check for DID because name could be "Unnamed"
|
||||||
givenByPersonFunction() && giverDid
|
givenByPerson() && giverDid
|
||||||
? giverName
|
? giverName
|
||||||
: "someone not named"
|
: "someone not named"
|
||||||
}}
|
}}
|
||||||
@@ -42,9 +42,10 @@
|
|||||||
<div class="truncate">
|
<div class="truncate">
|
||||||
to
|
to
|
||||||
{{
|
{{
|
||||||
givenToProject
|
givenToProject()
|
||||||
? fulfillsProjectName
|
? fulfillsProjectName
|
||||||
: givenToRecipient
|
: // check for DID because name could be "Unnamed"
|
||||||
|
givenToPerson() && recipientDid
|
||||||
? recipientName
|
? recipientName
|
||||||
: "someone not named"
|
: "someone not named"
|
||||||
}}
|
}}
|
||||||
@@ -114,9 +115,9 @@
|
|||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<label class="text-sm flex-1">
|
<label class="text-sm flex-1">
|
||||||
{{
|
{{
|
||||||
givenByProjectFunction() && providerProjectName
|
givenByProject() && providerProjectName
|
||||||
? "From " + providerProjectName
|
? "From " + providerProjectName
|
||||||
: givenByPersonFunction() && giverName
|
: givenByPerson() && giverName
|
||||||
? "From " + giverName
|
? "From " + giverName
|
||||||
: "Unnamed giver"
|
: "Unnamed giver"
|
||||||
}}
|
}}
|
||||||
@@ -133,15 +134,9 @@
|
|||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:conflict-checker="wouldCreateConflict"
|
:conflict-checker="wouldCreateConflictWithRecipient"
|
||||||
:from-project-id="providerProjectId"
|
|
||||||
:to-project-id="fulfillsProjectId"
|
|
||||||
:giver="currentGiver"
|
:giver="currentGiver"
|
||||||
:receiver="currentReceiver"
|
:receiver="currentReceiver"
|
||||||
:description="description"
|
|
||||||
:amount-input="amountInput"
|
|
||||||
:unit-code="unitCode"
|
|
||||||
:offer-id="offerId"
|
|
||||||
:notify="$notify"
|
:notify="$notify"
|
||||||
@entity-selected="handleGiverEntitySelected"
|
@entity-selected="handleGiverEntitySelected"
|
||||||
@cancel="closeGiverSelection"
|
@cancel="closeGiverSelection"
|
||||||
@@ -169,9 +164,9 @@
|
|||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<label class="text-sm flex-1">
|
<label class="text-sm flex-1">
|
||||||
{{
|
{{
|
||||||
givenToProjectFunction() && fulfillsProjectName
|
givenToProject() && fulfillsProjectName
|
||||||
? "To " + fulfillsProjectName
|
? "To " + fulfillsProjectName
|
||||||
: givenToPersonFunction() && recipientName
|
: givenToPerson() && recipientName
|
||||||
? "To " + recipientName
|
? "To " + recipientName
|
||||||
: "Unnamed recipient"
|
: "Unnamed recipient"
|
||||||
}}
|
}}
|
||||||
@@ -188,15 +183,9 @@
|
|||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:conflict-checker="wouldCreateConflict"
|
:conflict-checker="wouldCreateConflictWithGiver"
|
||||||
:from-project-id="providerProjectId"
|
|
||||||
:to-project-id="fulfillsProjectId"
|
|
||||||
:giver="currentGiver"
|
:giver="currentGiver"
|
||||||
:receiver="currentReceiver"
|
:receiver="currentReceiver"
|
||||||
:description="description"
|
|
||||||
:amount-input="amountInput"
|
|
||||||
:unit-code="unitCode"
|
|
||||||
:offer-id="offerId"
|
|
||||||
:notify="$notify"
|
:notify="$notify"
|
||||||
@entity-selected="handleRecipientEntitySelected"
|
@entity-selected="handleRecipientEntitySelected"
|
||||||
@cancel="closeRecipientSelection"
|
@cancel="closeRecipientSelection"
|
||||||
@@ -309,8 +298,6 @@ export default class GiftedDetails extends Vue {
|
|||||||
destinationPathAfter = "";
|
destinationPathAfter = "";
|
||||||
fulfillsProjectId = "";
|
fulfillsProjectId = "";
|
||||||
fulfillsProjectName = "a project";
|
fulfillsProjectName = "a project";
|
||||||
givenToProject = false; // basically static, based on input; if we allow changing then let's fix things (see below)
|
|
||||||
givenToRecipient = false; // basically static, based on input; if we allow changing then let's fix things (see below)
|
|
||||||
giverDid = "";
|
giverDid = "";
|
||||||
giverName = "";
|
giverName = "";
|
||||||
hideBackButton = false;
|
hideBackButton = false;
|
||||||
@@ -414,7 +401,6 @@ export default class GiftedDetails extends Vue {
|
|||||||
|
|
||||||
this.imageUrl = ((this.$route.query["imageUrl"] as string) ||
|
this.imageUrl = ((this.$route.query["imageUrl"] as string) ||
|
||||||
this.prevCredToEdit?.claim?.image ||
|
this.prevCredToEdit?.claim?.image ||
|
||||||
localStorage.getItem("imageUrl") ||
|
|
||||||
this.imageUrl) as string;
|
this.imageUrl) as string;
|
||||||
|
|
||||||
// this is an endpoint for sharing project info to highlight something given
|
// this is an endpoint for sharing project info to highlight something given
|
||||||
@@ -441,10 +427,8 @@ export default class GiftedDetails extends Vue {
|
|||||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||||
this.activeDid = activeIdentity.activeDid || "";
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
|
|
||||||
const dbContacts = await this.$dbQuery("SELECT * FROM contacts");
|
this.allContacts = await this.$contactsByDateAdded();
|
||||||
this.allContacts = this.$mapQueryResultToValues(
|
|
||||||
dbContacts,
|
|
||||||
) as unknown as Contact[];
|
|
||||||
this.allMyDids = await retrieveAccountDids();
|
this.allMyDids = await retrieveAccountDids();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -468,9 +452,6 @@ export default class GiftedDetails extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// these should be functions but something's wrong with the syntax in the <> conditional
|
|
||||||
this.givenToProject = !!this.fulfillsProjectId;
|
|
||||||
this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
|
|
||||||
|
|
||||||
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
||||||
|
|
||||||
@@ -498,19 +479,19 @@ export default class GiftedDetails extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
givenByPersonFunction() {
|
givenByPerson() {
|
||||||
return !!this.giverDid;
|
return !!this.giverDid;
|
||||||
}
|
}
|
||||||
|
|
||||||
givenByProjectFunction() {
|
givenByProject() {
|
||||||
return !!this.providerProjectId;
|
return !!this.providerProjectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
givenToPersonFunction() {
|
givenToPerson() {
|
||||||
return !!this.recipientDid;
|
return !!this.recipientDid;
|
||||||
}
|
}
|
||||||
|
|
||||||
givenToProjectFunction() {
|
givenToProject() {
|
||||||
return !!this.fulfillsProjectId;
|
return !!this.fulfillsProjectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +576,6 @@ export default class GiftedDetails extends Vue {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.removeItem("imageUrl");
|
|
||||||
this.imageUrl = "";
|
this.imageUrl = "";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error deleting image:", error);
|
logger.error("Error deleting image:", error);
|
||||||
@@ -603,7 +583,6 @@ export default class GiftedDetails extends Vue {
|
|||||||
if ((error as any)?.response?.status === 404) {
|
if ((error as any)?.response?.status === 404) {
|
||||||
logger.log("Weird: the image was already deleted.", error);
|
logger.log("Weird: the image was already deleted.", error);
|
||||||
|
|
||||||
localStorage.removeItem("imageUrl");
|
|
||||||
this.imageUrl = "";
|
this.imageUrl = "";
|
||||||
|
|
||||||
// it already doesn't exist so we won't say anything to the user
|
// it already doesn't exist so we won't say anything to the user
|
||||||
@@ -660,7 +639,7 @@ export default class GiftedDetails extends Vue {
|
|||||||
TIMEOUTS.SHORT,
|
TIMEOUTS.SHORT,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// must be because givenToProject is true
|
// must be because givenToProject() is true
|
||||||
this.notify.warning(
|
this.notify.warning(
|
||||||
"You cannot assign both to a recipient and to a project.",
|
"You cannot assign both to a recipient and to a project.",
|
||||||
TIMEOUTS.SHORT,
|
TIMEOUTS.SHORT,
|
||||||
@@ -767,7 +746,6 @@ export default class GiftedDetails extends Vue {
|
|||||||
logger.error("Error checking seed backup status:", error);
|
logger.error("Error checking seed backup status:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.removeItem("imageUrl");
|
|
||||||
if (this.destinationPathAfter) {
|
if (this.destinationPathAfter) {
|
||||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||||
} else {
|
} else {
|
||||||
@@ -837,12 +815,12 @@ export default class GiftedDetails extends Vue {
|
|||||||
* Computed property for current receiver entity data
|
* Computed property for current receiver entity data
|
||||||
*/
|
*/
|
||||||
get currentReceiver() {
|
get currentReceiver() {
|
||||||
if (this.givenToProject && this.fulfillsProjectId) {
|
if (this.givenToProject() && this.fulfillsProjectId) {
|
||||||
return {
|
return {
|
||||||
handleId: this.fulfillsProjectId,
|
handleId: this.fulfillsProjectId,
|
||||||
name: this.fulfillsProjectName,
|
name: this.fulfillsProjectName,
|
||||||
};
|
};
|
||||||
} else if (this.givenToRecipient && this.recipientDid) {
|
} else if (this.givenToPerson() && this.recipientDid) {
|
||||||
return {
|
return {
|
||||||
did: this.recipientDid,
|
did: this.recipientDid,
|
||||||
name: this.recipientName,
|
name: this.recipientName,
|
||||||
@@ -930,7 +908,7 @@ export default class GiftedDetails extends Vue {
|
|||||||
/**
|
/**
|
||||||
* Check if selecting an entity would create a conflict
|
* Check if selecting an entity would create a conflict
|
||||||
*/
|
*/
|
||||||
wouldCreateConflict(identifier: string): boolean {
|
wouldCreateConflictWithGiver(identifier: string): boolean {
|
||||||
// Check if it would conflict with giver
|
// Check if it would conflict with giver
|
||||||
if (this.giverDid === identifier) {
|
if (this.giverDid === identifier) {
|
||||||
return true;
|
return true;
|
||||||
@@ -938,6 +916,9 @@ export default class GiftedDetails extends Vue {
|
|||||||
if (this.providerProjectId === identifier) {
|
if (this.providerProjectId === identifier) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
wouldCreateConflictWithRecipient(identifier: string): boolean {
|
||||||
// Check if it would conflict with recipient
|
// Check if it would conflict with recipient
|
||||||
if (this.recipientDid === identifier) {
|
if (this.recipientDid === identifier) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<!-- Sub View Heading -->
|
<!-- Sub View Heading -->
|
||||||
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||||
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||||
Time Safari Onboarding Instructions
|
Onboarding Instructions
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,27 +80,6 @@
|
|||||||
4) Add yourself to their Contacts <font-awesome icon="users" />
|
4) Add yourself to their Contacts <font-awesome icon="users" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="font-bold text-xl">Install</h1>
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
Have them visit TimeSafari.app in a browser, preferably Chrome or Safari,
|
|
||||||
and then look for the "Install" selection which adds this app to their desktop.
|
|
||||||
This enables other things, like the ability to "share" a photo from their
|
|
||||||
device directly to Time Safari, and it makes notifications more reliable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="font-bold text-xl">Enable Notifications</h1>
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
Enable notifications from the Account page <font-awesome icon="circle-user" />.
|
|
||||||
Those notifications might show up on the device depending on your settings.
|
|
||||||
For the most reliable habits, set an alarm or do some other ritual to record gratitude
|
|
||||||
every day.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<!-- eslint enable -->
|
<!-- eslint enable -->
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -442,9 +442,9 @@
|
|||||||
browser window and look at the version there.
|
browser window and look at the version there.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Close all tabs that have Time Safari open; it can be difficult to find them all,
|
Close all tabs that have this site open; it can be difficult to find them all,
|
||||||
and you may have to close all your tabs. In addition, it may be running as an
|
and you may have to close all your tabs. In addition, it may be running as an
|
||||||
installed app, so look for any Time Safari app that may be running outside a browser.
|
installed app, so look for any app that may be running outside a browser.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
There may be a problem with your identity. Go to the Identity
|
There may be a problem with your identity. Go to the Identity
|
||||||
@@ -467,7 +467,7 @@
|
|||||||
<a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a>
|
<a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a>
|
||||||
for instructions for other browsers.</li>
|
for instructions for other browsers.</li>
|
||||||
</ul>
|
</ul>
|
||||||
Then reload Time Safari.
|
Then reload the page.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
@@ -552,8 +552,8 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Contact us at
|
Contact us at
|
||||||
<a href="mailto:info@TimeSafari.app" class="text-blue-500"
|
<a :href="`mailto:${SUPPORT_EMAIL}`" class="text-blue-500"
|
||||||
>info@TimeSafari.app</a
|
>{{ SUPPORT_EMAIL }}</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -591,7 +591,7 @@ import { copyToClipboard } from "../services/ClipboardService";
|
|||||||
|
|
||||||
import * as Package from "../../package.json";
|
import * as Package from "../../package.json";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import { APP_SERVER, NotificationIface } from "../constants/app";
|
import { APP_SERVER, NotificationIface, SUPPORT_EMAIL } from "../constants/app";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { QRNavigationService } from "@/services/QRNavigationService";
|
import { QRNavigationService } from "@/services/QRNavigationService";
|
||||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||||
@@ -643,7 +643,7 @@ export default class HelpView extends Vue {
|
|||||||
showVerifiable = false;
|
showVerifiable = false;
|
||||||
|
|
||||||
APP_SERVER = APP_SERVER;
|
APP_SERVER = APP_SERVER;
|
||||||
// Capacitor reference removed - using QRNavigationService instead
|
SUPPORT_EMAIL = SUPPORT_EMAIL;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize notification helpers
|
* Initialize notification helpers
|
||||||
|
|||||||
@@ -167,18 +167,12 @@ Raymer * @version 1.0.0 */
|
|||||||
<div class="flex gap-2 items-center mb-3">
|
<div class="flex gap-2 items-center mb-3">
|
||||||
<h2 class="font-bold">Latest Activity</h2>
|
<h2 class="font-bold">Latest Activity</h2>
|
||||||
<button
|
<button
|
||||||
v-if="resultsAreFiltered()"
|
class="block ms-auto text-sm text-center text-white p-1.5 rounded-full shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)]"
|
||||||
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
:class="
|
||||||
@click="openFeedFilters()"
|
isAnyFeedFilterOn
|
||||||
>
|
? 'bg-gradient-to-b from-blue-400 to-blue-700'
|
||||||
<font-awesome
|
: 'bg-gradient-to-b from-slate-400 to-slate-700'
|
||||||
icon="filter"
|
"
|
||||||
class="block text-center w-[1em] translate-y-[0.05em]"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
|
||||||
@click="openFeedFilters()"
|
@click="openFeedFilters()"
|
||||||
>
|
>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
@@ -779,7 +773,7 @@ export default class HomeView extends Vue {
|
|||||||
private async loadContacts() {
|
private async loadContacts() {
|
||||||
this.allContacts = await this.$contacts();
|
this.allContacts = await this.$contacts();
|
||||||
this.blockedContactDids = this.allContacts
|
this.blockedContactDids = this.allContacts
|
||||||
.filter((c) => !c.iViewContent)
|
.filter((c) => c.hideTheirContent)
|
||||||
.map((c) => c.did);
|
.map((c) => c.did);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,17 +965,6 @@ export default class HomeView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if feed results are being filtered
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
* Used in template for filter button display
|
|
||||||
* @returns true if visible or nearby filters are active
|
|
||||||
*/
|
|
||||||
resultsAreFiltered() {
|
|
||||||
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if browser notifications are supported
|
* Checks if browser notifications are supported
|
||||||
*
|
*
|
||||||
@@ -1003,12 +986,12 @@ export default class HomeView extends Vue {
|
|||||||
* Called by FeedFilters component when filters change
|
* Called by FeedFilters component when filters change
|
||||||
*/
|
*/
|
||||||
async reloadFeedOnChange() {
|
async reloadFeedOnChange() {
|
||||||
logger.debug("[HomeView] 🔄 reloadFeedOnChange() called - refreshing feed");
|
logger.debug("[HomeView] reloadFeedOnChange() called - refreshing feed");
|
||||||
|
|
||||||
// Get current settings without overriding with defaults
|
// Get current settings without overriding with defaults
|
||||||
const settings = await this.$accountSettings(this.activeDid);
|
const settings = await this.$accountSettings(this.activeDid);
|
||||||
|
|
||||||
logger.debug("[HomeView] 📊 Current filter settings:", {
|
logger.debug("[HomeView] Current filter settings:", {
|
||||||
filterFeedByVisible: settings.filterFeedByVisible,
|
filterFeedByVisible: settings.filterFeedByVisible,
|
||||||
filterFeedByNearby: settings.filterFeedByNearby,
|
filterFeedByNearby: settings.filterFeedByNearby,
|
||||||
searchBoxes: settings.searchBoxes?.length || 0,
|
searchBoxes: settings.searchBoxes?.length || 0,
|
||||||
@@ -1018,7 +1001,7 @@ export default class HomeView extends Vue {
|
|||||||
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
||||||
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
||||||
|
|
||||||
logger.debug("[HomeView] 🎯 Updated filter states:", {
|
logger.debug("[HomeView] Updated filter states:", {
|
||||||
isFeedFilteredByVisible: this.isFeedFilteredByVisible,
|
isFeedFilteredByVisible: this.isFeedFilteredByVisible,
|
||||||
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
|
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
|
||||||
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
||||||
@@ -1052,17 +1035,9 @@ export default class HomeView extends Vue {
|
|||||||
/**
|
/**
|
||||||
* Checks if coordinates fall within any search box
|
* Checks if coordinates fall within any search box
|
||||||
*
|
*
|
||||||
* @internal
|
|
||||||
* @callGraph
|
|
||||||
* Called by: shouldIncludeRecord()
|
|
||||||
* Calls: None
|
|
||||||
*
|
|
||||||
* @chain
|
* @chain
|
||||||
* shouldIncludeRecord() -> latLongInAnySearchBox()
|
* shouldIncludeRecord() -> latLongInAnySearchBox()
|
||||||
*
|
*
|
||||||
* @requires
|
|
||||||
* - this.searchBoxes
|
|
||||||
*
|
|
||||||
* @param lat Latitude to check
|
* @param lat Latitude to check
|
||||||
* @param long Longitude to check
|
* @param long Longitude to check
|
||||||
* @returns true if coordinates are within any search box
|
* @returns true if coordinates are within any search box
|
||||||
@@ -1087,29 +1062,11 @@ export default class HomeView extends Vue {
|
|||||||
* - Updates last viewed claim ID
|
* - Updates last viewed claim ID
|
||||||
* - Handles paging if needed
|
* - Handles paging if needed
|
||||||
*
|
*
|
||||||
* @internal
|
|
||||||
* @callGraph
|
|
||||||
* Called by: loadFeedData(), manual refresh
|
|
||||||
* Calls:
|
|
||||||
* - retrieveGives()
|
|
||||||
* - processFeedResults()
|
|
||||||
* - updateFeedLastViewedId()
|
|
||||||
* - handleFeedError()
|
|
||||||
*
|
|
||||||
* @chain
|
* @chain
|
||||||
* loadFeedData() -> updateAllFeed() -> retrieveGives()
|
* loadFeedData() -> updateAllFeed() -> retrieveGives()
|
||||||
*
|
|
||||||
* @requires
|
|
||||||
* - this.apiServer
|
|
||||||
* - this.feedPreviousOldestId
|
|
||||||
*
|
|
||||||
* @modifies
|
|
||||||
* - this.isFeedLoading
|
|
||||||
* - this.feedData (via processFeedResults)
|
|
||||||
* - this.feedLastViewedClaimId (via updateFeedLastViewedId)
|
|
||||||
*/
|
*/
|
||||||
async updateAllFeed() {
|
async updateAllFeed() {
|
||||||
logger.debug("[HomeView] 🚀 updateAllFeed() called", {
|
logger.debug("[HomeView] updateAllFeed() called", {
|
||||||
isFeedLoading: this.isFeedLoading,
|
isFeedLoading: this.isFeedLoading,
|
||||||
currentFeedDataLength: this.feedData.length,
|
currentFeedDataLength: this.feedData.length,
|
||||||
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
||||||
@@ -1126,7 +1083,7 @@ export default class HomeView extends Vue {
|
|||||||
this.feedPreviousOldestId,
|
this.feedPreviousOldestId,
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug("[HomeView] 📡 Retrieved gives from API", {
|
logger.debug("[HomeView] Retrieved gives from API", {
|
||||||
resultsCount: results.data.length,
|
resultsCount: results.data.length,
|
||||||
endOfResults,
|
endOfResults,
|
||||||
});
|
});
|
||||||
@@ -1137,7 +1094,7 @@ export default class HomeView extends Vue {
|
|||||||
await this.processFeedResults(results.data);
|
await this.processFeedResults(results.data);
|
||||||
await this.updateFeedLastViewedId(results.data);
|
await this.updateFeedLastViewedId(results.data);
|
||||||
|
|
||||||
logger.debug("[HomeView] 📝 Processed feed results", {
|
logger.debug("[HomeView] Processed feed results", {
|
||||||
processedCount: this.feedData.length,
|
processedCount: this.feedData.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1161,26 +1118,13 @@ export default class HomeView extends Vue {
|
|||||||
/**
|
/**
|
||||||
* Processes feed results and adds them to feedData
|
* Processes feed results and adds them to feedData
|
||||||
*
|
*
|
||||||
* @internal
|
|
||||||
* @callGraph
|
|
||||||
* Called by: updateAllFeed()
|
|
||||||
* Calls: processRecord()
|
|
||||||
*
|
|
||||||
* @chain
|
* @chain
|
||||||
* updateAllFeed() -> processFeedResults()
|
* updateAllFeed() -> processFeedResults()
|
||||||
*
|
*
|
||||||
* @requires
|
|
||||||
* - this.feedData
|
|
||||||
* - this.feedPreviousOldestId
|
|
||||||
*
|
|
||||||
* @modifies
|
|
||||||
* - this.feedData
|
|
||||||
* - this.feedPreviousOldestId
|
|
||||||
*
|
|
||||||
* @param records Array of feed records to process
|
* @param records Array of feed records to process
|
||||||
*/
|
*/
|
||||||
private async processFeedResults(records: GiveSummaryRecord[]) {
|
private async processFeedResults(records: GiveSummaryRecord[]) {
|
||||||
logger.debug("[HomeView] 📝 Processing feed results:", {
|
logger.debug("[HomeView] Processing feed results:", {
|
||||||
inputRecords: records.length,
|
inputRecords: records.length,
|
||||||
currentFilters: {
|
currentFilters: {
|
||||||
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
||||||
@@ -1202,7 +1146,7 @@ export default class HomeView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("[HomeView] 📊 Feed processing results:", {
|
logger.debug("[HomeView] Feed processing results:", {
|
||||||
processed: processedCount,
|
processed: processedCount,
|
||||||
filtered: filteredCount,
|
filtered: filteredCount,
|
||||||
total: records.length,
|
total: records.length,
|
||||||
@@ -1215,32 +1159,9 @@ export default class HomeView extends Vue {
|
|||||||
/**
|
/**
|
||||||
* Processes a single record and returns it if it passes filters
|
* Processes a single record and returns it if it passes filters
|
||||||
*
|
*
|
||||||
* @internal
|
|
||||||
* @callGraph
|
|
||||||
* Called by: processFeedResults()
|
|
||||||
* Calls:
|
|
||||||
* - extractClaim()
|
|
||||||
* - extractGiverDid()
|
|
||||||
* - extractRecipientDid()
|
|
||||||
* - getFulfillsPlan()
|
|
||||||
* - shouldIncludeRecord()
|
|
||||||
* - extractProvider()
|
|
||||||
* - getProvidedByPlan()
|
|
||||||
* - createFeedRecord()
|
|
||||||
*
|
|
||||||
* @chain
|
* @chain
|
||||||
* updateAllFeed() -> processFeedResults() -> processRecord()
|
* updateAllFeed() -> processFeedResults() -> processRecord()
|
||||||
*
|
*
|
||||||
* @requires
|
|
||||||
* - this.isAnyFeedFilterOn
|
|
||||||
* - this.isFeedFilteredByVisible
|
|
||||||
* - this.isFeedFilteredByNearby
|
|
||||||
* - this.activeDid
|
|
||||||
* - this.allContacts
|
|
||||||
*
|
|
||||||
* @modifies
|
|
||||||
* - this.feedData (via createFeedRecord)
|
|
||||||
*
|
|
||||||
* @param record The record to process
|
* @param record The record to process
|
||||||
* @returns Processed record if it passes filters, null otherwise
|
* @returns Processed record if it passes filters, null otherwise
|
||||||
*/
|
*/
|
||||||
@@ -1254,7 +1175,7 @@ export default class HomeView extends Vue {
|
|||||||
const fulfillsPlan = await this.getFulfillsPlan(record);
|
const fulfillsPlan = await this.getFulfillsPlan(record);
|
||||||
|
|
||||||
// Log record details for debugging
|
// Log record details for debugging
|
||||||
logger.debug("[HomeView] 🔍 Processing record:", {
|
logger.debug("[HomeView] Processing record:", {
|
||||||
recordId: record.jwtId,
|
recordId: record.jwtId,
|
||||||
hasFulfillsPlan: !!fulfillsPlan,
|
hasFulfillsPlan: !!fulfillsPlan,
|
||||||
fulfillsPlanHandleId: record.fulfillsPlanHandleId,
|
fulfillsPlanHandleId: record.fulfillsPlanHandleId,
|
||||||
@@ -1266,14 +1187,12 @@ export default class HomeView extends Vue {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!this.shouldIncludeRecord(record, fulfillsPlan)) {
|
if (!this.shouldIncludeRecord(record, fulfillsPlan)) {
|
||||||
logger.debug("[HomeView] ❌ Record filtered out:", record.jwtId);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = this.extractProvider(claim);
|
const provider = this.extractProvider(claim);
|
||||||
const providedByPlan = await this.getProvidedByPlan(provider);
|
const providedByPlan = await this.getProvidedByPlan(provider);
|
||||||
|
|
||||||
logger.debug("[HomeView] ✅ Record included:", record.jwtId);
|
|
||||||
return this.createFeedRecord(
|
return this.createFeedRecord(
|
||||||
record,
|
record,
|
||||||
claim,
|
claim,
|
||||||
@@ -1397,6 +1316,11 @@ export default class HomeView extends Vue {
|
|||||||
fulfillsPlan?: FulfillsPlan,
|
fulfillsPlan?: FulfillsPlan,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (this.blockedContactDids.includes(record.issuerDid)) {
|
if (this.blockedContactDids.includes(record.issuerDid)) {
|
||||||
|
logger.debug("[HomeView] Record filtered (blocked contact):", {
|
||||||
|
recordId: record.jwtId,
|
||||||
|
issuerDid: record.issuerDid,
|
||||||
|
hint: "This issuer is in your contacts with 'don't watch their activity'. Visit their DID page and tap the eye button to show their content.",
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1423,24 +1347,26 @@ export default class HomeView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add debug logging for nearby filter
|
// Add debug logging for any other filter that causes hiding
|
||||||
if (this.isFeedFilteredByNearby && record.fulfillsPlanHandleId) {
|
if (this.isFeedFilteredByNearby && record.fulfillsPlanHandleId) {
|
||||||
logger.debug("[HomeView] 🔍 Nearby filter check:", {
|
if (!anyMatch) {
|
||||||
recordId: record.jwtId,
|
logger.debug("[HomeView] Nearby filter check:", {
|
||||||
hasFulfillsPlan: !!fulfillsPlan,
|
recordId: record.jwtId,
|
||||||
hasLocation: !!(fulfillsPlan?.locLat && fulfillsPlan?.locLon),
|
hasFulfillsPlan: !!fulfillsPlan,
|
||||||
location: fulfillsPlan
|
hasLocation: !!(fulfillsPlan?.locLat && fulfillsPlan?.locLon),
|
||||||
? { lat: fulfillsPlan.locLat, lon: fulfillsPlan.locLon }
|
location: fulfillsPlan
|
||||||
: null,
|
? { lat: fulfillsPlan.locLat, lon: fulfillsPlan.locLon }
|
||||||
inSearchBox:
|
|
||||||
fulfillsPlan?.locLat && fulfillsPlan?.locLon
|
|
||||||
? this.latLongInAnySearchBox(
|
|
||||||
fulfillsPlan.locLat,
|
|
||||||
fulfillsPlan.locLon,
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
finalResult: anyMatch,
|
inSearchBox:
|
||||||
});
|
fulfillsPlan?.locLat && fulfillsPlan?.locLon
|
||||||
|
? this.latLongInAnySearchBox(
|
||||||
|
fulfillsPlan.locLat,
|
||||||
|
fulfillsPlan.locLon,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
finalResult: anyMatch,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return anyMatch;
|
return anyMatch;
|
||||||
|
|||||||
@@ -699,7 +699,6 @@ export default class OfferDetailsView extends Vue {
|
|||||||
NOTIFY_OFFER_SUCCESS_RECORDED.message,
|
NOTIFY_OFFER_SUCCESS_RECORDED.message,
|
||||||
TIMEOUTS.LONG,
|
TIMEOUTS.LONG,
|
||||||
);
|
);
|
||||||
localStorage.removeItem("imageUrl");
|
|
||||||
if (this.destinationPathAfter) {
|
if (this.destinationPathAfter) {
|
||||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ import {
|
|||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { NotificationIface } from "@/constants/app";
|
||||||
|
import { AxiosErrorResponse } from "@/interfaces";
|
||||||
|
|
||||||
interface Meeting {
|
interface Meeting {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -209,11 +210,6 @@ export default class OnboardMeetingListView extends Vue {
|
|||||||
* 3. If not attending: Fetch all available meetings (groupsOnboarding endpoint)
|
* 3. If not attending: Fetch all available meetings (groupsOnboarding endpoint)
|
||||||
* 4. Handle loading states and error conditions
|
* 4. Handle loading states and error conditions
|
||||||
*
|
*
|
||||||
* API Endpoints Used:
|
|
||||||
* - GET /api/partner/groupOnboardMember - Check current attendance
|
|
||||||
* - GET /api/partner/groupOnboard/{id} - Get meeting details
|
|
||||||
* - GET /api/partner/groupsOnboarding - Get all available meetings
|
|
||||||
*
|
|
||||||
* State Management:
|
* State Management:
|
||||||
* - Sets isLoading flag during API calls
|
* - Sets isLoading flag during API calls
|
||||||
* - Updates attendingMeeting or meetings array
|
* - Updates attendingMeeting or meetings array
|
||||||
@@ -271,7 +267,8 @@ export default class OnboardMeetingListView extends Vue {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
this.notify.error(
|
this.notify.error(
|
||||||
serverMessageForUser(error) || "There was a problem fetching meetings.",
|
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
|
||||||
|
"There was a problem fetching meetings.",
|
||||||
TIMEOUTS.LONG,
|
TIMEOUTS.LONG,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -40,8 +40,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Members List -->
|
<div v-else>
|
||||||
<MembersList v-else :password="password" @error="handleError" />
|
<!-- Any Match + Members List -->
|
||||||
|
<MeetingMembersList :password="password" @error="handleError" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Project Link Section -->
|
<!-- Project Link Section -->
|
||||||
<div v-if="projectLink" class="mt-8 p-4 border rounded-lg bg-white shadow">
|
<div v-if="projectLink" class="mt-8 p-4 border rounded-lg bg-white shadow">
|
||||||
@@ -67,7 +69,8 @@ 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 MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
|
||||||
|
import MeetingMembersList from "../components/MeetingMembersList.vue";
|
||||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||||
import { encryptMessage } from "../libs/crypto";
|
import { encryptMessage } from "../libs/crypto";
|
||||||
import {
|
import {
|
||||||
@@ -78,12 +81,14 @@ import {
|
|||||||
import { generateSaveAndActivateIdentity } from "../libs/util";
|
import { generateSaveAndActivateIdentity } from "../libs/util";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { NotificationIface } from "../constants/app";
|
import { NotificationIface } from "../constants/app";
|
||||||
|
import { AxiosErrorResponse } from "@/interfaces";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
QuickNav,
|
QuickNav,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
MembersList,
|
MeetingMemberMatch,
|
||||||
|
MeetingMembersList,
|
||||||
UserNameDialog,
|
UserNameDialog,
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
@@ -178,7 +183,7 @@ export default class OnboardMeetingMembersView extends Vue {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.errorMessage =
|
this.errorMessage =
|
||||||
serverMessageForUser(error) ||
|
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
|
||||||
"There was an error checking for that meeting. Reload or go back and try again.";
|
"There was an error checking for that meeting. Reload or go back and try again.";
|
||||||
this.$logAndConsole(
|
this.$logAndConsole(
|
||||||
"Error checking meeting: " + errorStringForLog(error),
|
"Error checking meeting: " + errorStringForLog(error),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<!-- Sub View Heading -->
|
<!-- Sub View Heading -->
|
||||||
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||||
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||||
Onboarding Meeting
|
Onboarding Meeting Admin
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Spacer (no Back button) -->
|
<!-- Spacer (no Back button) -->
|
||||||
@@ -63,8 +63,8 @@
|
|||||||
|
|
||||||
<div v-if="currentMeeting.password" class="mt-4">
|
<div v-if="currentMeeting.password" class="mt-4">
|
||||||
<p class="text-gray-600">
|
<p class="text-gray-600">
|
||||||
Share the password with the members. You can also send them the
|
Share the meeting name & password with the members, or send them the
|
||||||
"shortcut page for members" link below.
|
"Page for Members" link below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-red-600">
|
<div v-else class="text-red-600">
|
||||||
@@ -307,15 +307,251 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<MembersList
|
<MeetingMembersList
|
||||||
ref="membersList"
|
ref="membersList"
|
||||||
|
:match-pairs="matchPairs"
|
||||||
:password="currentMeeting.password || ''"
|
:password="currentMeeting.password || ''"
|
||||||
:show-organizer-tools="true"
|
:show-organizer-tools="true"
|
||||||
|
:excluded-dids="excludedDids"
|
||||||
|
:exclusion-locked="hasActiveMatches"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
@error="handleMembersError"
|
@error="handleMembersError"
|
||||||
|
@toggle-exclusion="toggleExclusion"
|
||||||
|
@members-loaded="refreshAdmittedMembers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
||||||
|
class="mt-8 p-4 border rounded-lg bg-white shadow"
|
||||||
|
>
|
||||||
|
<!-- Pairwise matches (organizer only: this page is organizer's meeting) -->
|
||||||
|
<div class="border-gray-200">
|
||||||
|
<h3 class="font-semibold mb-2">Matching Pairs</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isPostingMatch"
|
||||||
|
@click="promptPreMatchConfirm()"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
v-if="isPostingMatch"
|
||||||
|
icon="spinner"
|
||||||
|
class="fa-spin fa-fw"
|
||||||
|
/>
|
||||||
|
Make New Matches
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-2 text-sm rounded',
|
||||||
|
isPostingMatch || !matchPairs?.length
|
||||||
|
? 'bg-red-400 text-white opacity-50 cursor-not-allowed'
|
||||||
|
: 'bg-red-600 text-white hover:bg-red-700',
|
||||||
|
]"
|
||||||
|
@click="handleEraseClick()"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
v-if="isPostingMatch"
|
||||||
|
icon="spinner"
|
||||||
|
class="fa-spin fa-fw"
|
||||||
|
/>
|
||||||
|
Erase to Start Over
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Do Not Pair Groups -->
|
||||||
|
<div class="mb-4 pt-2 border-t border-gray-100">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Do Not Pair Together
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500 mb-2">
|
||||||
|
People in the same group will not be matched with each other.
|
||||||
|
</p>
|
||||||
|
<p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2">
|
||||||
|
Erase matches to change restrictions.
|
||||||
|
</p>
|
||||||
|
<MeetingExclusionGroups
|
||||||
|
:groups="doNotPairGroups"
|
||||||
|
:available-members="admittedMembers"
|
||||||
|
:disabled="hasActiveMatches"
|
||||||
|
@update="handleDoNotPairGroupsUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoadingMatches" class="text-sm text-gray-500 py-2">
|
||||||
|
<font-awesome icon="spinner" class="fa-spin fa-fw" />
|
||||||
|
Loading matches…
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
v-else-if="matchPairs && matchPairs.length > 0"
|
||||||
|
class="list-none space-y-3 text-sm"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="pair in matchPairs"
|
||||||
|
:key="pair.pairNumber"
|
||||||
|
class="p-3 rounded border border-gray-200 bg-gray-50"
|
||||||
|
>
|
||||||
|
<span class="font-medium">Pair {{ pair.pairNumber }}</span>
|
||||||
|
<span class="text-gray-600">
|
||||||
|
(similarity {{ pair.similarity.toFixed(2) }})</span
|
||||||
|
>
|
||||||
|
<ul class="mt-2 ml-2 space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="p in pair.participants"
|
||||||
|
:key="p.issuerDid"
|
||||||
|
class="text-gray-700"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
p.decryptedContentObject?.name
|
||||||
|
? p.decryptedContentObject.name
|
||||||
|
: "(No Name)"
|
||||||
|
}}
|
||||||
|
-
|
||||||
|
{{
|
||||||
|
p.description
|
||||||
|
? p.description.substring(0, 80) +
|
||||||
|
(p.description.length > 80 ? "…" : "")
|
||||||
|
: "(No Profile)"
|
||||||
|
}}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
v-else-if="matchPairs && matchPairs.length === 0"
|
||||||
|
class="text-sm text-gray-500 py-2"
|
||||||
|
>
|
||||||
|
No matches yet. Click "Make New Matches" to pair members by profile
|
||||||
|
similarity.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="unmatchedMembers.length > 0"
|
||||||
|
class="mt-3 p-3 rounded border border-amber-200 bg-amber-50 text-sm"
|
||||||
|
>
|
||||||
|
<h4 class="font-medium text-amber-800 mb-1">
|
||||||
|
Not Paired ({{ unmatchedMembers.length }})
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
<li
|
||||||
|
v-for="m in unmatchedMembers"
|
||||||
|
:key="m.did"
|
||||||
|
class="text-amber-700"
|
||||||
|
>
|
||||||
|
{{ m.name }} —
|
||||||
|
<span class="text-amber-600 italic">{{ m.reason }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pre-Match Confirmation Dialog -->
|
||||||
|
<div
|
||||||
|
v-if="showPreMatchConfirm"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg p-6 max-w-md w-full max-h-[80vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-medium mb-4">Confirm Matching</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Will be matched ({{ includedMembers.length }})
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="member in includedMembers"
|
||||||
|
:key="member.did"
|
||||||
|
class="inline-block px-2 py-0.5 bg-green-100 text-green-800 rounded-full text-xs"
|
||||||
|
>
|
||||||
|
{{ member.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="includedMembers.length === 0"
|
||||||
|
class="text-xs text-gray-400 italic"
|
||||||
|
>
|
||||||
|
No participants
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="excludedDids.length > 0" class="mb-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Excluded ({{ excludedDids.length }})
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="did in excludedDids"
|
||||||
|
:key="did"
|
||||||
|
class="inline-block px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs line-through"
|
||||||
|
>
|
||||||
|
{{ getMemberNameByDid(did) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="doNotPairGroups.some((g) => g.memberDids.length >= 2)"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Do Not Pair Groups
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="group in doNotPairGroups.filter(
|
||||||
|
(g) => g.memberDids.length >= 2,
|
||||||
|
)"
|
||||||
|
:key="group.id"
|
||||||
|
class="text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{
|
||||||
|
group.name || "Unnamed group"
|
||||||
|
}}</span
|
||||||
|
>:
|
||||||
|
{{
|
||||||
|
group.memberDids.map((d) => getMemberNameByDid(d)).join(", ")
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="previousMatchedPairs.length > 0"
|
||||||
|
class="mb-4 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
{{ previousMatchedPairs.length }} previous pair(s) will be avoided.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="includedMembers.length % 2 !== 0"
|
||||||
|
class="mb-4 p-2 rounded border border-amber-300 bg-amber-50 text-xs text-amber-800"
|
||||||
|
>
|
||||||
|
<font-awesome icon="triangle-exclamation" class="mr-1" />
|
||||||
|
Odd number of participants ({{ includedMembers.length }}). Matching
|
||||||
|
requires an even number — exclude one more person or add another
|
||||||
|
member.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between space-x-4 pt-2">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-slate-500 text-white rounded hover:bg-slate-700"
|
||||||
|
@click="cancelPreMatchConfirm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
|
@click="confirmAndMatch"
|
||||||
|
>
|
||||||
|
Run Matching
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="currentMeeting?.projectLink"
|
v-if="currentMeeting?.projectLink"
|
||||||
class="mt-8 p-4 border rounded-lg bg-white shadow"
|
class="mt-8 p-4 border rounded-lg bg-white shadow"
|
||||||
@@ -346,7 +582,9 @@ 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 MeetingMembersList from "../components/MeetingMembersList.vue";
|
||||||
|
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
|
||||||
|
import MeetingExclusionGroups from "../components/MeetingExclusionGroups.vue";
|
||||||
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
||||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||||
import {
|
import {
|
||||||
@@ -355,7 +593,7 @@ import {
|
|||||||
serverMessageForUser,
|
serverMessageForUser,
|
||||||
didInfo,
|
didInfo,
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import { encryptMessage } from "../libs/crypto";
|
import { encryptMessage, decryptMessage } from "../libs/crypto";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
import { APP_SERVER } from "@/constants/app";
|
import { APP_SERVER } from "@/constants/app";
|
||||||
@@ -369,6 +607,12 @@ import {
|
|||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
import { PlanData } from "../interfaces/records";
|
import { PlanData } from "../interfaces/records";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import {
|
||||||
|
AxiosErrorResponse,
|
||||||
|
DoNotPairGroup,
|
||||||
|
MatchPair,
|
||||||
|
MeetingExclusionState,
|
||||||
|
} from "@/interfaces";
|
||||||
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
|
||||||
@@ -390,7 +634,9 @@ interface MeetingSetupInputs {
|
|||||||
components: {
|
components: {
|
||||||
QuickNav,
|
QuickNav,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
MembersList,
|
MeetingMembersList,
|
||||||
|
MeetingMemberMatch,
|
||||||
|
MeetingExclusionGroups,
|
||||||
MeetingProjectDialog,
|
MeetingProjectDialog,
|
||||||
ProjectIcon,
|
ProjectIcon,
|
||||||
},
|
},
|
||||||
@@ -409,22 +655,114 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
|
|
||||||
currentMeeting: ServerMeeting | null = null;
|
currentMeeting: ServerMeeting | null = null;
|
||||||
newOrUpdatedMeetingInputs: MeetingSetupInputs | null = null;
|
newOrUpdatedMeetingInputs: MeetingSetupInputs | null = null;
|
||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
|
allContacts: Contact[] = [];
|
||||||
|
allMyDids: string[] = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
isDeleting = false;
|
isDeleting = false;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
isLoadingMatches = false;
|
||||||
|
isPostingMatch = false;
|
||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
showDeleteConfirm = false;
|
|
||||||
fullName = "";
|
fullName = "";
|
||||||
allContacts: Contact[] = [];
|
matchPairs: MatchPair[] | null = null;
|
||||||
allMyDids: string[] = [];
|
/** Accumulated pair DIDs from every match run; sent as previousPairDids on future posts. */
|
||||||
|
previousMatchedPairs: [string, string][] = [];
|
||||||
|
|
||||||
|
excludedDids: string[] = [];
|
||||||
|
doNotPairGroups: DoNotPairGroup[] = [];
|
||||||
|
showPreMatchConfirm = false;
|
||||||
|
|
||||||
selectedProjectData: PlanData | null = null;
|
selectedProjectData: PlanData | null = null;
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
|
||||||
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
|
||||||
return this.formatDateForInput(now);
|
return this.formatDateForInput(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly EXCLUSION_STORAGE_KEY = "meeting-exclusion-state";
|
||||||
|
|
||||||
|
get meetingGroupIdStr(): string {
|
||||||
|
return this.currentMeeting?.groupId?.toString() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasActiveMatches(): boolean {
|
||||||
|
return Array.isArray(this.matchPairs) && this.matchPairs.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
admittedMembers: Array<{ did: string; name: string }> = [];
|
||||||
|
|
||||||
|
get includedMembers(): Array<{ did: string; name: string }> {
|
||||||
|
return this.admittedMembers.filter(
|
||||||
|
(m) => !this.excludedDids.includes(m.did),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get unmatchedMembers(): Array<{ did: string; name: string; reason: string }> {
|
||||||
|
if (!this.matchPairs?.length || !this.admittedMembers.length) return [];
|
||||||
|
const matchedDids = new Set<string>();
|
||||||
|
for (const pair of this.matchPairs) {
|
||||||
|
for (const p of pair.participants) {
|
||||||
|
matchedDids.add(p.issuerDid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.admittedMembers
|
||||||
|
.filter((m) => !matchedDids.has(m.did))
|
||||||
|
.map((m) => {
|
||||||
|
let reason = "not paired (odd number of participants)";
|
||||||
|
if (this.excludedDids.includes(m.did)) {
|
||||||
|
reason = "individually excluded";
|
||||||
|
} else {
|
||||||
|
const inGroup = this.doNotPairGroups.find((g) =>
|
||||||
|
g.memberDids.includes(m.did),
|
||||||
|
);
|
||||||
|
if (inGroup) {
|
||||||
|
reason = `in do-not-pair group "${inGroup.name || "Unnamed"}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { did: m.did, name: m.name, reason };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get excludedPairDids(): [string, string][] {
|
||||||
|
const pairs: [string, string][] = [];
|
||||||
|
for (const group of this.doNotPairGroups) {
|
||||||
|
for (let i = 0; i < group.memberDids.length; i++) {
|
||||||
|
for (let j = i + 1; j < group.memberDids.length; j++) {
|
||||||
|
pairs.push([group.memberDids[i], group.memberDids[j]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async created() {
|
async created() {
|
||||||
this.notify = createNotifyHelpers(
|
this.notify = createNotifyHelpers(
|
||||||
this.$notify as Parameters<typeof createNotifyHelpers>[0],
|
this.$notify as Parameters<typeof createNotifyHelpers>[0],
|
||||||
@@ -446,6 +784,12 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
// Ensure selected project is loaded if projectLink exists
|
// Ensure selected project is loaded if projectLink exists
|
||||||
await this.ensureSelectedProjectLoaded();
|
await this.ensureSelectedProjectLoaded();
|
||||||
|
|
||||||
|
// Load pairwise matches when organizer has a meeting
|
||||||
|
if (this.currentMeeting?.password) {
|
||||||
|
await this.fetchMatchPairs();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadExclusionState();
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,6 +933,12 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!this.fullName) {
|
||||||
|
this.$saveSettings({
|
||||||
|
firstName: this.newOrUpdatedMeetingInputs.userFullName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.newOrUpdatedMeetingInputs.password) {
|
if (!this.newOrUpdatedMeetingInputs.password) {
|
||||||
this.notify.warning(
|
this.notify.warning(
|
||||||
NOTIFY_MEETING_PASSWORD_REQUIRED.message,
|
NOTIFY_MEETING_PASSWORD_REQUIRED.message,
|
||||||
@@ -793,7 +1143,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
this.newOrUpdatedMeetingInputs = null;
|
this.newOrUpdatedMeetingInputs = null;
|
||||||
|
|
||||||
if (this.currentMeeting?.password) {
|
if (this.currentMeeting?.password) {
|
||||||
this.$router.push({
|
await this.$router.push({
|
||||||
name: "onboard-meeting-setup",
|
name: "onboard-meeting-setup",
|
||||||
query: { password: this.currentMeeting?.password },
|
query: { password: this.currentMeeting?.password },
|
||||||
});
|
});
|
||||||
@@ -827,6 +1177,230 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current pairwise matches (GET /api/partner/groupOnboardMatch).
|
||||||
|
* Any member of the meeting can fetch; organizer is the one who can POST.
|
||||||
|
*/
|
||||||
|
async fetchMatchPairs(): Promise<void> {
|
||||||
|
if (!this.currentMeeting?.password) return;
|
||||||
|
this.isLoadingMatches = true;
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const response = await this.axios.get(
|
||||||
|
this.apiServer + "/api/partner/groupOnboardMatch",
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
const pairs = response?.data?.data?.pairs ?? null;
|
||||||
|
let tempMatchPairs: MatchPair[] | null = null;
|
||||||
|
if (Array.isArray(pairs)) {
|
||||||
|
tempMatchPairs = [];
|
||||||
|
// walk through pairs and decrypt the content for each participant
|
||||||
|
for (const pair of pairs) {
|
||||||
|
for (const participant of pair.participants) {
|
||||||
|
try {
|
||||||
|
const decryptedContent = await decryptMessage(
|
||||||
|
participant.content,
|
||||||
|
this.currentMeeting?.password || "",
|
||||||
|
);
|
||||||
|
participant.decryptedContentObject = JSON.parse(decryptedContent);
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(
|
||||||
|
"Error decrypting participant content: " +
|
||||||
|
errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
participant.decryptedContentObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tempMatchPairs.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.matchPairs = tempMatchPairs;
|
||||||
|
if (tempMatchPairs?.length) {
|
||||||
|
this.mergePairsIntoPrevious(tempMatchPairs);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(
|
||||||
|
"Error fetching match pairs: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// Don't overwrite matchPairs on fetch error - preserve existing data so we don't wipe
|
||||||
|
// good matches on transient failures (e.g. after navigating away and back).
|
||||||
|
this.notify.error(
|
||||||
|
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
|
||||||
|
"Failed to load matches.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.isLoadingMatches = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a pair of DIDs to a sorted tuple for deduplication.
|
||||||
|
*/
|
||||||
|
private normalizedPair(did1: string, did2: string): [string, string] {
|
||||||
|
return did1 <= did2 ? [did1, did2] : [did2, did1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append pairs from a match result into previousMatchedPairs (no duplicates).
|
||||||
|
*/
|
||||||
|
private mergePairsIntoPrevious(pairs: MatchPair[]): void {
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (pair.participants?.length !== 2) continue;
|
||||||
|
const [a, b] = pair.participants.map((p) => p.issuerDid);
|
||||||
|
const norm = this.normalizedPair(a, b);
|
||||||
|
const exists = this.previousMatchedPairs.some(
|
||||||
|
([x, y]) => x === norm[0] && y === norm[1],
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
this.previousMatchedPairs.push(norm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST to groupOnboardMatch with optional body (excludedDids, excludedPairDids, previousPairDids).
|
||||||
|
* Organizer only; uses meeting for current user.
|
||||||
|
*/
|
||||||
|
async postMatch(body?: {
|
||||||
|
excludedDids?: string[];
|
||||||
|
excludedPairDids?: [string, string][];
|
||||||
|
previousPairDids?: [string, string][];
|
||||||
|
}): Promise<MatchPair[] | null> {
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const response = await this.axios.post(
|
||||||
|
this.apiServer + "/api/partner/groupOnboardMatch",
|
||||||
|
body ?? {},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
const pairs = response?.data?.data?.pairs ?? null;
|
||||||
|
return Array.isArray(pairs) ? pairs : null;
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(
|
||||||
|
"Error posting group onboard match: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const errorMessage = serverMessageForUser(
|
||||||
|
error as unknown as AxiosErrorResponse,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
errorMessage || "Failed to run matching.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
promptPreMatchConfirm(): void {
|
||||||
|
this.refreshAdmittedMembers();
|
||||||
|
this.showPreMatchConfirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAdmittedMembers(): void {
|
||||||
|
const membersList = this.$refs.membersList as MeetingMembersList;
|
||||||
|
this.admittedMembers = membersList?.getAdmittedMembers() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPreMatchConfirm(): void {
|
||||||
|
this.showPreMatchConfirm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMemberNameByDid(did: string): string {
|
||||||
|
const member = this.admittedMembers.find((m) => m.did === did);
|
||||||
|
return member?.name || did.substring(0, 16) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmAndMatch(): Promise<void> {
|
||||||
|
this.showPreMatchConfirm = false;
|
||||||
|
await this.postNewMatchesThenRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async postNewMatchesThenRefresh(): Promise<void> {
|
||||||
|
this.isPostingMatch = true;
|
||||||
|
try {
|
||||||
|
const body: {
|
||||||
|
excludedDids?: string[];
|
||||||
|
excludedPairDids?: [string, string][];
|
||||||
|
previousPairDids?: [string, string][];
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (this.excludedDids.length > 0) {
|
||||||
|
body.excludedDids = this.excludedDids;
|
||||||
|
}
|
||||||
|
if (this.excludedPairDids.length > 0) {
|
||||||
|
body.excludedPairDids = this.excludedPairDids;
|
||||||
|
}
|
||||||
|
if (this.previousMatchedPairs.length > 0) {
|
||||||
|
body.previousPairDids = this.previousMatchedPairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairs = await this.postMatch(
|
||||||
|
Object.keys(body).length > 0 ? body : undefined,
|
||||||
|
);
|
||||||
|
if (Array.isArray(pairs) && pairs.length > 0) {
|
||||||
|
const tempMatchPairs: MatchPair[] = [];
|
||||||
|
for (const pair of pairs) {
|
||||||
|
for (const participant of pair.participants) {
|
||||||
|
const decryptedContent = await decryptMessage(
|
||||||
|
participant.content,
|
||||||
|
this.currentMeeting?.password || "",
|
||||||
|
);
|
||||||
|
participant.decryptedContentObject = JSON.parse(decryptedContent);
|
||||||
|
}
|
||||||
|
tempMatchPairs.push(pair);
|
||||||
|
}
|
||||||
|
this.matchPairs = tempMatchPairs;
|
||||||
|
this.mergePairsIntoPrevious(tempMatchPairs);
|
||||||
|
this.notify.success("New matches generated.", TIMEOUTS.STANDARD);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.isPostingMatch = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEraseClick(): void {
|
||||||
|
if (this.isPostingMatch) {
|
||||||
|
this.notify.warning(
|
||||||
|
"Matching is currently in progress. Please wait for it to finish.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.matchPairs?.length) {
|
||||||
|
this.notify.warning(
|
||||||
|
"There are no matches to erase. Run matching first to create pairs.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clearMatchesThenRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearMatchesThenRefresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
await this.axios.delete(
|
||||||
|
this.apiServer + "/api/partner/groupOnboardMatch",
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
this.matchPairs = null;
|
||||||
|
this.previousMatchedPairs = [];
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(
|
||||||
|
"Error clearing matches: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
|
||||||
|
"Failed to clear matches.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleMembersError(message: string) {
|
handleMembersError(message: string) {
|
||||||
this.notify.error(message, TIMEOUTS.LONG);
|
this.notify.error(message, TIMEOUTS.LONG);
|
||||||
}
|
}
|
||||||
@@ -844,30 +1418,52 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
loadExclusionState(): void {
|
||||||
* Computed property for selected project
|
if (!this.meetingGroupIdStr) return;
|
||||||
* Returns the separately stored selected project data
|
try {
|
||||||
*/
|
const raw = localStorage.getItem(
|
||||||
get selectedProject(): PlanData | null {
|
OnboardMeetingView.EXCLUSION_STORAGE_KEY,
|
||||||
return this.selectedProjectData;
|
);
|
||||||
|
if (!raw) return;
|
||||||
|
const state: MeetingExclusionState = JSON.parse(raw);
|
||||||
|
if (state.meetingGroupId !== this.meetingGroupIdStr) {
|
||||||
|
localStorage.removeItem(OnboardMeetingView.EXCLUSION_STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.excludedDids = state.excludedDids || [];
|
||||||
|
this.doNotPairGroups = state.doNotPairGroups || [];
|
||||||
|
} catch {
|
||||||
|
this.excludedDids = [];
|
||||||
|
this.doNotPairGroups = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
saveExclusionState(): void {
|
||||||
* Computed property for selected project issuer display name
|
if (!this.meetingGroupIdStr) return;
|
||||||
* Uses didInfo to format the issuer name similar to ProjectCard
|
const state: MeetingExclusionState = {
|
||||||
*/
|
meetingGroupId: this.meetingGroupIdStr,
|
||||||
get selectedProjectIssuerName(): string {
|
excludedDids: this.excludedDids,
|
||||||
if (!this.selectedProject) {
|
doNotPairGroups: this.doNotPairGroups,
|
||||||
return "";
|
};
|
||||||
}
|
localStorage.setItem(
|
||||||
return didInfo(
|
OnboardMeetingView.EXCLUSION_STORAGE_KEY,
|
||||||
this.selectedProject.issuerDid,
|
JSON.stringify(state),
|
||||||
this.activeDid,
|
|
||||||
this.allMyDids,
|
|
||||||
this.allContacts,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleExclusion(did: string): void {
|
||||||
|
if (this.excludedDids.includes(did)) {
|
||||||
|
this.excludedDids = this.excludedDids.filter((d) => d !== did);
|
||||||
|
} else {
|
||||||
|
this.excludedDids = [...this.excludedDids, did];
|
||||||
|
}
|
||||||
|
this.saveExclusionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDoNotPairGroupsUpdate(): void {
|
||||||
|
this.saveExclusionState();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the project link selection dialog
|
* Open the project link selection dialog
|
||||||
*/
|
*/
|
||||||
@@ -898,20 +1494,20 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle dialog open event - stop auto-refresh in MembersList
|
* Handle dialog open event - stop auto-refresh in MeetingMembersList
|
||||||
*/
|
*/
|
||||||
handleDialogOpen(): void {
|
handleDialogOpen(): void {
|
||||||
const membersList = this.$refs.membersList as MembersList;
|
const membersList = this.$refs.membersList as MeetingMembersList;
|
||||||
if (membersList) {
|
if (membersList) {
|
||||||
membersList.stopAutoRefresh();
|
membersList.stopAutoRefresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle dialog close event - start auto-refresh in MembersList
|
* Handle dialog close event - start auto-refresh in MeetingMembersList
|
||||||
*/
|
*/
|
||||||
handleDialogClose(): void {
|
handleDialogClose(): void {
|
||||||
const membersList = this.$refs.membersList as MembersList;
|
const membersList = this.$refs.membersList as MeetingMembersList;
|
||||||
if (membersList) {
|
if (membersList) {
|
||||||
membersList.startAutoRefresh();
|
membersList.startAutoRefresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,31 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Startup error banner -->
|
||||||
|
<div
|
||||||
|
v-if="startupError"
|
||||||
|
class="max-w-3xl mx-auto mb-6 p-4 bg-red-50 border border-red-300 rounded-lg"
|
||||||
|
>
|
||||||
|
<h2 class="text-red-800 font-semibold text-lg mb-2">Startup Error</h2>
|
||||||
|
<p class="text-red-700 mb-3">
|
||||||
|
The app encountered a critical error during startup. This is often
|
||||||
|
caused by a database problem. Please send the details below to
|
||||||
|
<a :href="`mailto:${SUPPORT_EMAIL}`" class="underline font-semibold">{{
|
||||||
|
SUPPORT_EMAIL
|
||||||
|
}}</a>
|
||||||
|
so we can help resolve it.
|
||||||
|
</p>
|
||||||
|
<details class="bg-white border border-red-200 rounded p-3">
|
||||||
|
<summary class="cursor-pointer text-red-700 font-medium">
|
||||||
|
Error details
|
||||||
|
</summary>
|
||||||
|
<pre
|
||||||
|
class="mt-2 text-xs text-red-900 whitespace-pre-wrap break-words"
|
||||||
|
>{{ startupError }}</pre
|
||||||
|
>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- id used by puppeteer test script -->
|
<!-- id used by puppeteer test script -->
|
||||||
<div id="start-question">
|
<div id="start-question">
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
@@ -35,10 +60,7 @@
|
|||||||
<p v-if="PASSKEYS_ENABLED" class="text-center font-light mt-6">
|
<p v-if="PASSKEYS_ENABLED" class="text-center font-light mt-6">
|
||||||
A <strong>passkey</strong> is easy to manage, though it is less
|
A <strong>passkey</strong> is easy to manage, though it is less
|
||||||
interoperable with other systems for advanced uses.
|
interoperable with other systems for advanced uses.
|
||||||
<a
|
<a href="https://duckduckgo.com/?q=what+is+a+passkey" target="_blank">
|
||||||
href="https://www.perplexity.ai/search/what-are-passkeys-v2SHV3yLQlyA2CYH6.Nvhg"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<font-awesome icon="info-circle" class="fa-fw text-blue-500" />
|
<font-awesome icon="info-circle" class="fa-fw text-blue-500" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -46,7 +68,7 @@
|
|||||||
A <strong>new seed</strong> allows you full control over the keys,
|
A <strong>new seed</strong> allows you full control over the keys,
|
||||||
though you are responsible for backups.
|
though you are responsible for backups.
|
||||||
<a
|
<a
|
||||||
href="https://www.perplexity.ai/search/what-is-a-seed-phrase-OqiP9foVRXidr_2le5OFKA"
|
href="https://duckduckgo.com/?q=what+is+a+seed+phrase"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<font-awesome icon="info-circle" class="fa-fw text-blue-500" />
|
<font-awesome icon="info-circle" class="fa-fw text-blue-500" />
|
||||||
@@ -139,7 +161,7 @@
|
|||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { Router } from "vue-router";
|
import { Router } from "vue-router";
|
||||||
|
|
||||||
import { AppString, PASSKEYS_ENABLED } from "../constants/app";
|
import { AppString, PASSKEYS_ENABLED, SUPPORT_EMAIL } from "../constants/app";
|
||||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
@@ -157,10 +179,12 @@ export default class StartView extends Vue {
|
|||||||
|
|
||||||
// Feature flags and application constants
|
// Feature flags and application constants
|
||||||
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
|
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
|
||||||
|
SUPPORT_EMAIL = SUPPORT_EMAIL;
|
||||||
|
|
||||||
// Component state for identity generation
|
// Component state for identity generation
|
||||||
givenName = "";
|
givenName = "";
|
||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
|
startupError = "";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed property for primary action button styling
|
* Computed property for primary action button styling
|
||||||
@@ -201,11 +225,26 @@ export default class StartView extends Vue {
|
|||||||
*/
|
*/
|
||||||
async mounted() {
|
async mounted() {
|
||||||
try {
|
try {
|
||||||
// Load user settings using platform service
|
const raw = sessionStorage.getItem("startupError");
|
||||||
|
if (raw) {
|
||||||
|
sessionStorage.removeItem("startupError");
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const parts = [parsed.message, parsed.stack].filter(Boolean);
|
||||||
|
this.startupError = parts.length > 0 ? parts.join("\n\n") : raw;
|
||||||
|
logger.error("[StartView] Displaying startup error to user", parsed);
|
||||||
|
} catch {
|
||||||
|
this.startupError = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessionStorage or JSON parse may fail; non-critical
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const settings = await this.$accountSettings();
|
const settings = await this.$accountSettings();
|
||||||
this.givenName = settings.firstName || "";
|
this.givenName = settings.firstName || "";
|
||||||
|
|
||||||
// Load account count for display logic
|
|
||||||
this.numAccounts = await retrieveAccountCount();
|
this.numAccounts = await retrieveAccountCount();
|
||||||
|
|
||||||
logger.debug("[StartView] Component mounted", {
|
logger.debug("[StartView] Component mounted", {
|
||||||
@@ -215,7 +254,6 @@ export default class StartView extends Vue {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[StartView] Failed to load initialization data", error);
|
logger.error("[StartView] Failed to load initialization data", error);
|
||||||
// Continue with default behavior if settings load fails
|
|
||||||
this.givenName = "";
|
this.givenName = "";
|
||||||
this.numAccounts = 0;
|
this.numAccounts = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,9 +106,9 @@ test('Record something given', async ({ page }) => {
|
|||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
return !document.querySelector('.dialog-overlay');
|
return !document.querySelector('.dialog-overlay');
|
||||||
}, { timeout: 5000 });
|
}, { timeout: 5000 });
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Thank' }).click();
|
await page.getByRole('button', { name: 'Thank' }).click();
|
||||||
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
|
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
|
||||||
await page.getByPlaceholder('What was given').fill(finalTitle);
|
await page.getByPlaceholder('What was given').fill(finalTitle);
|
||||||
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
|
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
|
||||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||||
@@ -130,8 +130,9 @@ test('Record something given', async ({ page }) => {
|
|||||||
// Verify the gift we just recorded appears in the activity feed
|
// Verify the gift we just recorded appears in the activity feed
|
||||||
await expect(page.getByText(finalTitle, { exact: false })).toBeVisible();
|
await expect(page.getByText(finalTitle, { exact: false })).toBeVisible();
|
||||||
|
|
||||||
// Click the specific gift item
|
// Click the specific gift item (find by title - don't assume first-child,
|
||||||
const item = page.locator('li:first-child').filter({ hasText: finalTitle });
|
// since parallel tests or shared DB can add newer items above ours)
|
||||||
|
const item = page.locator('ul#listLatestActivity li').filter({ hasText: finalTitle });
|
||||||
await retryClick(page, item.locator('[data-testid="circle-info-link"]'));
|
await retryClick(page, item.locator('[data-testid="circle-info-link"]'));
|
||||||
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
||||||
// Verify we're viewing the specific gift we recorded
|
// Verify we're viewing the specific gift we recorded
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
|
|||||||
// Go to home view and look for gift
|
// Go to home view and look for gift
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||||
const giftLink = page.locator('li:first-child').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
|
const giftLink = page.locator('li').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
|
||||||
await expect(giftLink).toBeVisible();
|
await expect(giftLink).toBeVisible();
|
||||||
await giftLink.click();
|
await giftLink.click();
|
||||||
|
|
||||||
@@ -290,8 +290,8 @@ test('Copy contact to clipboard, then import ', async ({ page, context }, testIn
|
|||||||
await page.locator('button', { hasText: 'Import' }).click();
|
await page.locator('button', { hasText: 'Import' }).click();
|
||||||
|
|
||||||
await page.goto('./contacts');
|
await page.goto('./contacts');
|
||||||
// Copy contact details
|
// Select and copy exactly one contact (single-contact deep link flow)
|
||||||
await page.getByTestId('contactCheckAllTop').click();
|
await page.getByTestId('contactCheckOne').first().click();
|
||||||
|
|
||||||
const isChromium = await page.evaluate(() => {
|
const isChromium = await page.evaluate(() => {
|
||||||
return navigator.userAgent.includes('Chrome') || navigator.userAgent.includes('Chromium');
|
return navigator.userAgent.includes('Chrome') || navigator.userAgent.includes('Chromium');
|
||||||
@@ -333,8 +333,89 @@ test('Copy contact to clipboard, then import ', async ({ page, context }, testIn
|
|||||||
await expect(page.locator('div[role="alert"]')).toBeHidden({ timeout: 7000 });
|
await expect(page.locator('div[role="alert"]')).toBeHidden({ timeout: 7000 });
|
||||||
|
|
||||||
await page.goto(clipboardText);
|
await page.goto(clipboardText);
|
||||||
// we're on the contact-import page
|
// single-contact payload now auto-adds and routes to contact-edit
|
||||||
await expect(page.getByRole('heading', { name: "Contact Import" })).toBeVisible();
|
await expect(page).toHaveURL(/\/contact-edit\//);
|
||||||
// For some reason, Chromium shows 1 contact the same but Firefox shows 4.
|
await expect(page.getByTestId('contactName').locator('input')).toBeVisible();
|
||||||
await expect(page.locator('span', { hasText: 'the same as' })).toBeVisible();
|
});
|
||||||
|
|
||||||
|
test('Copied deep link with multiple contacts opens Contact Import', async ({ page, context }, testInfo) => {
|
||||||
|
await importUser(page, '00');
|
||||||
|
|
||||||
|
await page.goto('./contacts');
|
||||||
|
|
||||||
|
// Add contact #111
|
||||||
|
await page
|
||||||
|
.getByPlaceholder('URL or DID, Name, Public Key')
|
||||||
|
.fill('did:ethr:0x111d15564f824D56C7a07b913aA7aDd03382aA39, User #111');
|
||||||
|
await page.locator('button > svg.fa-plus').click();
|
||||||
|
await expect(page.getByRole('alert').filter({ hasText: 'Success' })).toBeVisible();
|
||||||
|
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||||
|
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'No', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'No, Not Yet', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'No, Not Yet', exact: true }).click();
|
||||||
|
await expect(page.locator('div[role="alert"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Add contact #222
|
||||||
|
await page
|
||||||
|
.getByPlaceholder('URL or DID, Name, Public Key')
|
||||||
|
.fill('did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b, User #222');
|
||||||
|
await page.locator('button > svg.fa-plus').click();
|
||||||
|
await expect(page.getByRole('alert').filter({ hasText: 'Success' })).toBeVisible();
|
||||||
|
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||||
|
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'No', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'No, Not Yet', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'No, Not Yet', exact: true }).click();
|
||||||
|
await expect(page.locator('div[role="alert"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
const isWebkit = await page.evaluate(() => {
|
||||||
|
return navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('iPhone');
|
||||||
|
});
|
||||||
|
if (isWebkit) {
|
||||||
|
console.log("Haven't found a way to access clipboard text in Webkit. Skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChromium = await page.evaluate(() => {
|
||||||
|
return navigator.userAgent.includes('Chrome') || navigator.userAgent.includes('Chromium');
|
||||||
|
});
|
||||||
|
if (isChromium) {
|
||||||
|
await context.grantPermissions(['clipboard-read']);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByTestId('contactListItem')).toHaveCount(2);
|
||||||
|
await page.getByTestId('contactCheckAllTop').click();
|
||||||
|
await page.getByTestId('copySelectedContactsButtonTop').click();
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
const clipboardText = await page.evaluate(async () => {
|
||||||
|
try {
|
||||||
|
return await navigator.clipboard.readText();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clipboard read failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const webServer = testInfo.config.webServer;
|
||||||
|
const clientServerUrl = webServer?.url;
|
||||||
|
const PATH_PART = clientServerUrl + '/deep-link/contact-import/';
|
||||||
|
await expect(clipboardText).toContain(PATH_PART);
|
||||||
|
|
||||||
|
// Delete one contact so import has at least one new contact
|
||||||
|
await page.getByTestId('contactListItem').nth(1).locator('h2 > a').click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Identifier Details' })).toBeVisible();
|
||||||
|
await page.locator('button > svg.fa-trash-can').click();
|
||||||
|
await page.locator('div[role="alert"] button:has-text("Yes")').click();
|
||||||
|
await expect(page.locator('div[role="alert"] button:has-text("Yes")')).toBeHidden();
|
||||||
|
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||||
|
await page.goto('./contacts');
|
||||||
|
await expect(page.getByTestId('contactListItem')).toHaveCount(1);
|
||||||
|
|
||||||
|
await page.goto(clipboardText as string);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Contact Import' })).toBeVisible();
|
||||||
|
await expect(page.locator('button', { hasText: 'Import Contacts' })).toBeVisible();
|
||||||
|
await page.locator('button', { hasText: 'Import Contacts' }).click();
|
||||||
|
await expect(page.getByTestId('contactListItem')).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
|||||||
server: {
|
server: {
|
||||||
port: parseInt(process.env.VITE_PORT || "8080"),
|
port: parseInt(process.env.VITE_PORT || "8080"),
|
||||||
fs: { strict: false },
|
fs: { strict: false },
|
||||||
|
//allowedHosts: ['bab3-68-69-173-46.ngrok-free.app'],
|
||||||
|
//allowedHosts: ['*'],
|
||||||
|
|
||||||
// CORS headers disabled to allow images from any domain
|
// CORS headers disabled to allow images from any domain
|
||||||
// This means SharedArrayBuffer is unavailable, but absurd-sql
|
// This means SharedArrayBuffer is unavailable, but absurd-sql
|
||||||
// will automatically fall back to IndexedDB mode which still works
|
// will automatically fall back to IndexedDB mode which still works
|
||||||
|
|||||||
Reference in New Issue
Block a user