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.
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
|
||||
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
|
||||
# production server
|
||||
#VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
|
||||
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
||||
# 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
|
||||
|
||||
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).
|
||||
|
||||
|
||||
## [1.3.6] - 2026
|
||||
## [1.3.8] - 2026
|
||||
### Added
|
||||
- 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
|
||||
### Fixed
|
||||
- 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
|
||||
├── PhotoDialog.vue (669 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%)
|
||||
├── GiftDetailsStep.vue (450 lines)
|
||||
|
||||
@@ -15,7 +15,7 @@ Quick start:
|
||||
|
||||
```bash
|
||||
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".
|
||||
|
||||
@@ -38,7 +38,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 64
|
||||
versionName "1.3.6"
|
||||
versionName "1.3.8"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -534,7 +534,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.6;
|
||||
MARKETING_VERSION = 1.3.8;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -562,7 +562,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.6;
|
||||
MARKETING_VERSION = 1.3.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -594,7 +594,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.3.6;
|
||||
MARKETING_VERSION = 1.3.8;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
@@ -632,7 +632,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.3.6;
|
||||
MARKETING_VERSION = 1.3.8;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
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",
|
||||
"version": "1.3.6",
|
||||
"description": "Time Safari Application",
|
||||
"version": "1.3.8-beta",
|
||||
"description": "Gift Economies Application",
|
||||
"author": {
|
||||
"name": "Time Safari Team"
|
||||
"name": "Gift Economies Team"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||
|
||||
@@ -116,7 +116,7 @@ echo "=============================="
|
||||
|
||||
# Analyze critical files identified in the assessment
|
||||
critical_files=(
|
||||
src/components/MembersList.vue"
|
||||
src/components/MeetingMembersList.vue"
|
||||
"src/views/ContactsView.vue"
|
||||
src/views/OnboardMeetingSetupView.vue"
|
||||
src/db/databaseUtil.ts"
|
||||
|
||||
@@ -93,7 +93,7 @@ echo "=========================="
|
||||
|
||||
# Critical files from our assessment
|
||||
files=(
|
||||
src/components/MembersList.vue"
|
||||
src/components/MeetingMembersList.vue"
|
||||
"src/views/ContactsView.vue"
|
||||
src/views/OnboardMeetingSetupView.vue"
|
||||
src/db/databaseUtil.ts"
|
||||
|
||||
@@ -93,7 +93,7 @@ echo "=========================="
|
||||
|
||||
# Critical files from our assessment
|
||||
files=(
|
||||
src/components/MembersList.vue"
|
||||
src/components/MeetingMembersList.vue"
|
||||
"src/views/ContactsView.vue"
|
||||
src/views/OnboardMeetingSetupView.vue"
|
||||
src/db/databaseUtil.ts"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</button>
|
||||
<font-awesome
|
||||
icon="circle-info"
|
||||
class="text-2xl text-blue-500 ml-2"
|
||||
class="text-2xl text-blue-500 ml-4"
|
||||
@click="emitShowCopyInfo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -57,8 +57,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
:selectable="youSelectable"
|
||||
:conflicted="youConflicted"
|
||||
:entity-data="youEntityData"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:notify="notify"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
|
||||
@@ -69,8 +69,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
:label="unnamedEntityName"
|
||||
icon="circle-question"
|
||||
:entity-data="unnamedEntityData"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:notify="notify"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
</template>
|
||||
@@ -97,8 +97,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
:person="person"
|
||||
:conflicted="isPersonConflicted(person.did)"
|
||||
:show-time-icon="true"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:notify="notify"
|
||||
@person-selected="handlePersonSelected"
|
||||
/>
|
||||
</template>
|
||||
@@ -116,8 +116,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
:person="person"
|
||||
:conflicted="isPersonConflicted(person.did)"
|
||||
:show-time-icon="true"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:notify="notify"
|
||||
@person-selected="handlePersonSelected"
|
||||
/>
|
||||
</template>
|
||||
@@ -131,40 +131,40 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
:person="person"
|
||||
:conflicted="isPersonConflicted(person.did)"
|
||||
:show-time-icon="true"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:notify="notify"
|
||||
@person-selected="handlePersonSelected"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<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()">
|
||||
<!-- Recently Bookmarked Section -->
|
||||
<template v-if="recentBookmarkedProjects.length > 0">
|
||||
<!-- Recently Starred Section -->
|
||||
<template v-if="recentStarredProjectsToShow.length > 0">
|
||||
<li
|
||||
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||
>
|
||||
Recently Bookmarked
|
||||
Recently Starred
|
||||
</li>
|
||||
<ProjectCard
|
||||
v-for="project in recentBookmarkedProjects"
|
||||
v-for="project in recentStarredProjectsToShow"
|
||||
:key="project.handleId"
|
||||
:project="project"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
:conflicted="isProjectConflicted(project.handleId)"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:notify="notify"
|
||||
@project-selected="handleProjectSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Rest of Projects Section -->
|
||||
<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"
|
||||
>
|
||||
All Projects
|
||||
@@ -177,8 +177,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
:conflicted="isProjectConflicted(project.handleId)"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:notify="notify"
|
||||
@project-selected="handleProjectSelected"
|
||||
/>
|
||||
</template>
|
||||
@@ -193,8 +193,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
:conflicted="isProjectConflicted(project.handleId)"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:notify="notify"
|
||||
@project-selected="handleProjectSelected"
|
||||
/>
|
||||
</template>
|
||||
@@ -223,7 +223,7 @@ import { TIMEOUTS } from "@/utils/notify";
|
||||
const INITIAL_BATCH_SIZE = 20;
|
||||
const INCREMENT_SIZE = 20;
|
||||
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
|
||||
@@ -251,30 +251,6 @@ export default class EntityGrid extends Vue {
|
||||
@Prop({ required: true })
|
||||
entityType!: "people" | "projects";
|
||||
|
||||
// Search state
|
||||
searchTerm = "";
|
||||
isSearching = false;
|
||||
searchTimeout: NodeJS.Timeout | null = null;
|
||||
filteredEntities: Contact[] | PlanData[] = [];
|
||||
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
|
||||
*
|
||||
@@ -326,32 +302,30 @@ export default class EntityGrid extends Vue {
|
||||
@Prop({ default: "other party" })
|
||||
conflictContext!: string;
|
||||
|
||||
/**
|
||||
* Function to determine which entities to display (allows parent control)
|
||||
*
|
||||
* This function prop allows parent components to customize which entities
|
||||
* are displayed in the grid, enabling advanced filtering and sorting.
|
||||
* Note: Infinite scroll is disabled when this prop is provided.
|
||||
*
|
||||
* @param entities - The full array of entities (Contact[] or PlanData[])
|
||||
* @param entityType - The type of entities being displayed ("people" or "projects")
|
||||
* @returns Filtered/sorted array of entities to display
|
||||
*
|
||||
* @example
|
||||
* // Custom filtering: only show contacts with profile images
|
||||
* :display-entities-function="(entities, type) =>
|
||||
* entities.filter(e => e.profileImageUrl)"
|
||||
*
|
||||
* @example
|
||||
* // Custom sorting: sort projects by name
|
||||
* :display-entities-function="(entities, type) =>
|
||||
* entities.sort((a, b) => a.name.localeCompare(b.name))"
|
||||
*/
|
||||
@Prop({ default: null })
|
||||
displayEntitiesFunction?: (
|
||||
entities: Contact[] | PlanData[],
|
||||
entityType: "people" | "projects",
|
||||
) => Contact[] | PlanData[];
|
||||
// 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 starred projects)
|
||||
starredPlanHandleIds: string[] = [];
|
||||
recentStarredProjects: PlanData[] = [];
|
||||
|
||||
/**
|
||||
* CSS classes for the empty state message
|
||||
@@ -397,11 +371,6 @@ export default class EntityGrid extends Vue {
|
||||
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
|
||||
if (this.entityType === "projects") {
|
||||
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)
|
||||
* The starredPlanHandleIds array order represents bookmark order (newest at the end)
|
||||
* Get the 3 most recently starred projects (when showing projects and not searching)
|
||||
* Returns the cached member field
|
||||
*/
|
||||
get recentBookmarkedProjects(): PlanData[] {
|
||||
if (
|
||||
this.entityType !== "projects" ||
|
||||
this.searchTerm.trim() ||
|
||||
this.starredPlanHandleIds.length === 0
|
||||
) {
|
||||
get recentStarredProjectsToShow(): PlanData[] {
|
||||
if (this.entityType !== "projects" || this.searchTerm.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
return this.recentStarredProjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
get remainingProjects(): PlanData[] {
|
||||
@@ -552,6 +500,115 @@ export default class EntityGrid extends Vue {
|
||||
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
|
||||
*/
|
||||
@@ -636,7 +693,7 @@ export default class EntityGrid extends Vue {
|
||||
if (this.entityType === "projects") {
|
||||
// Server-side search for projects (initial load, no beforeId)
|
||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||
await this.fetchProjects(undefined, searchLower);
|
||||
await this.loadProjects(undefined, searchLower);
|
||||
} else {
|
||||
// Client-side filtering for contacts (complete list)
|
||||
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 claimContents - Optional search term (if provided, performs search; if not, loads all)
|
||||
*/
|
||||
async fetchProjects(
|
||||
beforeId?: string,
|
||||
claimContents?: string,
|
||||
): Promise<void> {
|
||||
async loadProjects(beforeId?: string, claimContents?: string): Promise<void> {
|
||||
if (!this.apiServer) {
|
||||
if (claimContents) {
|
||||
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
|
||||
* 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
|
||||
*/
|
||||
canLoadMore(): boolean {
|
||||
if (this.displayEntitiesFunction) {
|
||||
// Custom function disables infinite scroll
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.searchTerm.trim()) {
|
||||
// Search mode: check if more results available
|
||||
if (this.entityType === "projects") {
|
||||
@@ -911,129 +1011,6 @@ export default class EntityGrid extends Vue {
|
||||
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("entity-selected")
|
||||
@@ -1061,26 +1038,15 @@ export default class EntityGrid extends Vue {
|
||||
|
||||
// When switching to projects, load them if not provided via entities prop
|
||||
if (newType === "projects" && !this.entities) {
|
||||
// Ensure apiServer is loaded
|
||||
if (!this.apiServer) {
|
||||
const settings = await this.$accountSettings();
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
||||
}
|
||||
const settings = await this.$accountSettings();
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
||||
|
||||
// Load projects if we have an API server
|
||||
if (this.apiServer && this.allProjects.length === 0) {
|
||||
if (this.allProjects.length === 0) {
|
||||
this.isLoadingProjects = true;
|
||||
try {
|
||||
await this.fetchProjects();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error loading projects when switching to projects:",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
this.isLoadingProjects = false;
|
||||
}
|
||||
await this.loadProjects();
|
||||
await this.loadRecentStarredProjects();
|
||||
this.isLoadingProjects = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,14 @@ properties * * @author Matthew Raymer */
|
||||
|
||||
<EntityGrid
|
||||
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
||||
:entities="shouldShowProjects ? projects || undefined : allContacts"
|
||||
:entities="shouldShowProjects ? undefined : allContacts"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
:conflict-checker="conflictChecker"
|
||||
:you-selectable="youSelectable"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:notify="notify"
|
||||
:you-selectable="youSelectable"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
|
||||
@@ -45,16 +45,7 @@ import EntityGrid from "./EntityGrid.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
|
||||
/**
|
||||
* Entity data interface for giver/receiver
|
||||
*/
|
||||
interface EntityData {
|
||||
did?: string;
|
||||
handleId?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
}
|
||||
import { GiverReceiverInputInfo } from "@/libs/util";
|
||||
|
||||
/**
|
||||
* Entity selection event data structure
|
||||
@@ -87,10 +78,6 @@ export default class EntitySelectionStep extends Vue {
|
||||
@Prop({ required: true })
|
||||
stepType!: "giver" | "recipient";
|
||||
|
||||
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
|
||||
@Prop({ required: false })
|
||||
projects?: PlanData[];
|
||||
|
||||
/** Array of available contacts */
|
||||
@Prop({ required: true })
|
||||
allContacts!: Contact[];
|
||||
@@ -107,35 +94,13 @@ export default class EntitySelectionStep extends Vue {
|
||||
@Prop({ required: true })
|
||||
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 */
|
||||
@Prop()
|
||||
giver?: EntityData | null;
|
||||
giver?: GiverReceiverInputInfo | null;
|
||||
|
||||
/** Current receiver entity for context */
|
||||
@Prop()
|
||||
receiver?: EntityData | 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;
|
||||
receiver?: GiverReceiverInputInfo | null;
|
||||
|
||||
/** Notification function from parent component */
|
||||
@Prop()
|
||||
|
||||
@@ -50,16 +50,7 @@ import EntityIcon from "./EntityIcon.vue";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
|
||||
/**
|
||||
* Entity interface for both person and project entities
|
||||
*/
|
||||
interface EntityData {
|
||||
did?: string;
|
||||
handleId?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
}
|
||||
import { GiverReceiverInputInfo } from "@/libs/util";
|
||||
|
||||
/**
|
||||
* EntitySummaryButton - Displays selected entity with edit capability
|
||||
@@ -81,11 +72,7 @@ interface EntityData {
|
||||
export default class EntitySummaryButton extends Vue {
|
||||
/** Entity data to display */
|
||||
@Prop({ required: true })
|
||||
entity!: EntityData | Contact | null;
|
||||
|
||||
/** Type of entity: 'person' or 'project' */
|
||||
@Prop({ required: true })
|
||||
entityType!: "person" | "project";
|
||||
entity!: GiverReceiverInputInfo | Contact | null;
|
||||
|
||||
/** Display label for the entity role */
|
||||
@Prop({ required: true })
|
||||
@@ -98,9 +85,13 @@ export default class EntitySummaryButton extends Vue {
|
||||
@Prop({ type: Function, default: () => {} })
|
||||
onEditRequested!: (data: {
|
||||
entityType: string;
|
||||
entity: EntityData | Contact | null;
|
||||
entity: GiverReceiverInputInfo | Contact | null;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
get entityType(): string {
|
||||
return this.entity && "handleId" in this.entity ? "project" : "person";
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS classes for the main container
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,6 @@ control over updates and validation * * @author Matthew Raymer */
|
||||
<!-- Giver Button -->
|
||||
<EntitySummaryButton
|
||||
:entity="giver"
|
||||
:entity-type="giverEntityType"
|
||||
:label="giverLabel"
|
||||
:on-edit-requested="handleEditGiver"
|
||||
/>
|
||||
@@ -22,7 +21,6 @@ control over updates and validation * * @author Matthew Raymer */
|
||||
<!-- Recipient Button -->
|
||||
<EntitySummaryButton
|
||||
:entity="receiver"
|
||||
:entity-type="recipientEntityType"
|
||||
:label="recipientLabel"
|
||||
:on-edit-requested="handleEditRecipient"
|
||||
/>
|
||||
@@ -104,16 +102,7 @@ import { Component, Prop, Vue, Watch, Emit } from "vue-facing-decorator";
|
||||
import EntitySummaryButton from "./EntitySummaryButton.vue";
|
||||
import AmountInput from "./AmountInput.vue";
|
||||
import { RouteLocationRaw } from "vue-router";
|
||||
|
||||
/**
|
||||
* Entity data interface for giver/receiver
|
||||
*/
|
||||
interface EntityData {
|
||||
did?: string;
|
||||
handleId?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
}
|
||||
import { GiverReceiverInputInfo } from "@/libs/util";
|
||||
|
||||
/**
|
||||
* GiftDetailsStep - Complete step 2 gift details form interface
|
||||
@@ -139,19 +128,11 @@ interface EntityData {
|
||||
export default class GiftDetailsStep extends Vue {
|
||||
/** Giver entity data */
|
||||
@Prop({ required: true })
|
||||
giver!: EntityData | null;
|
||||
giver!: GiverReceiverInputInfo | null;
|
||||
|
||||
/** Receiver entity data */
|
||||
@Prop({ required: true })
|
||||
receiver!: EntityData | 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";
|
||||
receiver!: GiverReceiverInputInfo | null;
|
||||
|
||||
/** Gift description */
|
||||
@Prop({ default: "" })
|
||||
@@ -211,6 +192,14 @@ export default class GiftDetailsStep extends Vue {
|
||||
private localAmount: number = 0;
|
||||
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
|
||||
*/
|
||||
@@ -332,7 +321,7 @@ export default class GiftDetailsStep extends Vue {
|
||||
*/
|
||||
handleEditGiver(_data: {
|
||||
entityType: string;
|
||||
entity: EntityData | null;
|
||||
entity: GiverReceiverInputInfo | null;
|
||||
}): void {
|
||||
this.emitEditEntity({
|
||||
entityType: "giver",
|
||||
@@ -346,7 +335,7 @@ export default class GiftDetailsStep extends Vue {
|
||||
*/
|
||||
handleEditRecipient(_data: {
|
||||
entityType: string;
|
||||
entity: EntityData | null;
|
||||
entity: GiverReceiverInputInfo | null;
|
||||
}): void {
|
||||
this.emitEditEntity({
|
||||
entityType: "recipient",
|
||||
@@ -386,8 +375,8 @@ export default class GiftDetailsStep extends Vue {
|
||||
@Emit("edit-entity")
|
||||
emitEditEntity(data: {
|
||||
entityType: string;
|
||||
currentEntity: EntityData | null;
|
||||
}): { entityType: string; currentEntity: EntityData | null } {
|
||||
currentEntity: GiverReceiverInputInfo | null;
|
||||
}): { entityType: string; currentEntity: GiverReceiverInputInfo | null } {
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,30 +3,18 @@
|
||||
<div
|
||||
class="dialog"
|
||||
data-testid="gifted-dialog"
|
||||
:data-recipient-entity-type="currentRecipientEntityType"
|
||||
:data-recipient-entity-type="recipientEntityType"
|
||||
>
|
||||
<!-- Step 1: Entity Selection -->
|
||||
<EntitySelectionStep
|
||||
v-show="firstStep"
|
||||
:step-type="stepType"
|
||||
:giver-entity-type="currentGiverEntityType"
|
||||
:recipient-entity-type="currentRecipientEntityType"
|
||||
:show-projects="
|
||||
currentGiverEntityType === 'project' ||
|
||||
currentRecipientEntityType === 'project'
|
||||
"
|
||||
:all-contacts="allContacts"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:conflict-checker="wouldCreateConflict"
|
||||
:from-project-id="fromProjectId"
|
||||
:to-project-id="toProjectId"
|
||||
:giver="giver"
|
||||
:receiver="receiver"
|
||||
:description="description"
|
||||
:amount-input="amountInput"
|
||||
:unit-code="unitCode"
|
||||
:offer-id="offerId"
|
||||
:notify="$notify"
|
||||
@entity-selected="handleEntitySelected"
|
||||
@cancel="cancel"
|
||||
@@ -37,8 +25,8 @@
|
||||
v-show="!firstStep"
|
||||
:giver="giver"
|
||||
:receiver="receiver"
|
||||
:giver-entity-type="currentGiverEntityType"
|
||||
:recipient-entity-type="currentRecipientEntityType"
|
||||
:giver-entity-type="giverEntityType"
|
||||
:recipient-entity-type="recipientEntityType"
|
||||
:description="description"
|
||||
:amount="parseFloat(amountInput) || 0"
|
||||
:unit-code="unitCode"
|
||||
@@ -129,8 +117,6 @@ export default class GiftedDialog extends Vue {
|
||||
description = "";
|
||||
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
||||
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 = "";
|
||||
prompt = "";
|
||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||
@@ -142,12 +128,20 @@ export default class GiftedDialog extends Vue {
|
||||
|
||||
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
|
||||
get hasPersonConflict() {
|
||||
// Only check for conflicts when both entities are persons
|
||||
if (
|
||||
this.currentGiverEntityType !== "person" ||
|
||||
this.currentRecipientEntityType !== "person"
|
||||
this.giverEntityType !== "person" ||
|
||||
this.recipientEntityType !== "person"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -168,8 +162,8 @@ export default class GiftedDialog extends Vue {
|
||||
get hasProjectConflict() {
|
||||
// Only check for conflicts when both entities are projects
|
||||
if (
|
||||
this.currentGiverEntityType !== "project" ||
|
||||
this.currentRecipientEntityType !== "project"
|
||||
this.giverEntityType !== "project" ||
|
||||
this.recipientEntityType !== "project"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -204,9 +198,6 @@ export default class GiftedDialog extends Vue {
|
||||
this.amountInput = amountInput || "0";
|
||||
this.unitCode = unitCode || "HUR";
|
||||
this.callbackOnSuccess = callbackOnSuccess;
|
||||
// Initialize current entity types from initial prop values
|
||||
this.currentGiverEntityType = this.initialGiverEntityType;
|
||||
this.currentRecipientEntityType = this.initialRecipientEntityType;
|
||||
|
||||
try {
|
||||
const settings = await this.$accountSettings();
|
||||
@@ -277,8 +268,8 @@ export default class GiftedDialog extends Vue {
|
||||
wouldCreateConflict(identifier: string) {
|
||||
// Check for person conflicts when both entities are persons
|
||||
if (
|
||||
this.currentGiverEntityType === "person" &&
|
||||
this.currentRecipientEntityType === "person"
|
||||
this.giverEntityType === "person" &&
|
||||
this.recipientEntityType === "person"
|
||||
) {
|
||||
if (this.stepType === "giver") {
|
||||
// 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
|
||||
if (
|
||||
this.currentGiverEntityType === "project" &&
|
||||
this.currentRecipientEntityType === "project"
|
||||
this.giverEntityType === "project" &&
|
||||
this.recipientEntityType === "project"
|
||||
) {
|
||||
if (this.stepType === "giver") {
|
||||
// If selecting as giver, check if it conflicts with current recipient
|
||||
@@ -314,9 +305,6 @@ export default class GiftedDialog extends Vue {
|
||||
this.prompt = "";
|
||||
this.unitCode = "HUR";
|
||||
this.firstStep = true;
|
||||
// Reset to initial prop values
|
||||
this.currentGiverEntityType = this.initialGiverEntityType;
|
||||
this.currentRecipientEntityType = this.initialRecipientEntityType;
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
@@ -404,8 +392,8 @@ export default class GiftedDialog extends Vue {
|
||||
let providerPlanHandleId: string | undefined;
|
||||
|
||||
if (
|
||||
this.currentGiverEntityType === "project" &&
|
||||
this.currentRecipientEntityType === "person"
|
||||
this.giverEntityType === "project" &&
|
||||
this.recipientEntityType === "person"
|
||||
) {
|
||||
// Project-to-person gift
|
||||
fromDid = undefined;
|
||||
@@ -413,8 +401,8 @@ export default class GiftedDialog extends Vue {
|
||||
fulfillsProjectHandleId = undefined;
|
||||
providerPlanHandleId = this.giver?.handleId;
|
||||
} else if (
|
||||
this.currentGiverEntityType === "person" &&
|
||||
this.currentRecipientEntityType === "project"
|
||||
this.giverEntityType === "person" &&
|
||||
this.recipientEntityType === "project"
|
||||
) {
|
||||
// Person-to-project gift
|
||||
fromDid = giverDid as string;
|
||||
@@ -422,8 +410,8 @@ export default class GiftedDialog extends Vue {
|
||||
fulfillsProjectHandleId = this.receiver?.handleId;
|
||||
providerPlanHandleId = undefined;
|
||||
} else if (
|
||||
this.currentGiverEntityType === "person" &&
|
||||
this.currentRecipientEntityType === "person"
|
||||
this.giverEntityType === "person" &&
|
||||
this.recipientEntityType === "person"
|
||||
) {
|
||||
// Person-to-person gift
|
||||
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" />
|
||||
to add/remove them to/from the meeting.
|
||||
</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
|
||||
v-if="
|
||||
membersToShow().length > 0 && getNonContactMembers().length > 0
|
||||
@@ -47,11 +56,18 @@
|
||||
</li>
|
||||
</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
|
||||
changes the password
|
||||
-->
|
||||
always have at least one refresh button even without members in case the organizer
|
||||
changes the password
|
||||
-->
|
||||
<button
|
||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
title="Refresh members list now"
|
||||
@@ -75,6 +91,8 @@
|
||||
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
|
||||
!member.member.admitted &&
|
||||
(isOrganizer || member.did === activeDid),
|
||||
'bg-amber-50 opacity-60':
|
||||
member.member.admitted && excludedDids.includes(member.did),
|
||||
},
|
||||
{ 'border-slate-300': member.member.admitted },
|
||||
]"
|
||||
@@ -88,6 +106,9 @@
|
||||
'text-slate-500':
|
||||
!member.member.admitted &&
|
||||
(isOrganizer || member.did === activeDid),
|
||||
'line-through text-slate-400':
|
||||
member.member.admitted &&
|
||||
excludedDids.includes(member.did),
|
||||
},
|
||||
]"
|
||||
>
|
||||
@@ -161,8 +182,31 @@
|
||||
v-if="
|
||||
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
|
||||
:class="
|
||||
member.member.admitted
|
||||
@@ -198,9 +242,9 @@
|
||||
|
||||
<div v-if="membersToShow().length > 0" class="flex justify-between">
|
||||
<!--
|
||||
always have at least one refresh button even without members in case the organizer
|
||||
changes the password
|
||||
-->
|
||||
always have at least one refresh button even without members in case the organizer
|
||||
changes the password
|
||||
-->
|
||||
<button
|
||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
title="Refresh members list now"
|
||||
@@ -246,10 +290,13 @@ import {
|
||||
} from "@/libs/endorserServer";
|
||||
import { decryptMessage } from "@/libs/crypto";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MemberData } from "@/interfaces";
|
||||
import { MemberData, MatchPair } from "@/interfaces";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import BulkMembersDialog from "./BulkMembersDialog.vue";
|
||||
import MeetingMemberMatch from "./MeetingMemberMatch.vue";
|
||||
|
||||
const AUTO_REFRESH_INTERVAL = 15;
|
||||
|
||||
interface Member {
|
||||
admitted: boolean;
|
||||
@@ -257,6 +304,7 @@ interface Member {
|
||||
memberId: number;
|
||||
}
|
||||
|
||||
// there's a similar structure in OnboardMeetingSetupView.vue but without the member
|
||||
interface DecryptedMember {
|
||||
member: Member;
|
||||
name: string;
|
||||
@@ -267,16 +315,20 @@ interface DecryptedMember {
|
||||
@Component({
|
||||
components: {
|
||||
BulkMembersDialog,
|
||||
MeetingMemberMatch,
|
||||
},
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
export default class MembersList extends Vue {
|
||||
export default class MeetingMembersList extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
|
||||
@Prop({ required: true }) password!: string;
|
||||
@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("error")
|
||||
@@ -284,6 +336,16 @@ export default class MembersList extends Vue {
|
||||
return message;
|
||||
}
|
||||
|
||||
@Emit("toggle-exclusion")
|
||||
emitToggleExclusion(did: string) {
|
||||
return did;
|
||||
}
|
||||
|
||||
@Emit("members-loaded")
|
||||
emitMembersLoaded() {
|
||||
return;
|
||||
}
|
||||
|
||||
contacts: Array<Contact> = [];
|
||||
decryptedMembers: DecryptedMember[] = [];
|
||||
firstName = "";
|
||||
@@ -296,7 +358,7 @@ export default class MembersList extends Vue {
|
||||
apiServer = "";
|
||||
|
||||
// Auto-refresh functionality
|
||||
countdownTimer = 10;
|
||||
countdownTimer = AUTO_REFRESH_INTERVAL;
|
||||
autoRefreshInterval: NodeJS.Timeout | null = null;
|
||||
lastRefreshTime = 0;
|
||||
previousMemberDidsIgnored: string[] = [];
|
||||
@@ -345,6 +407,7 @@ export default class MembersList extends Vue {
|
||||
this.emitError(serverMessageForUser(error) || "Failed to fetch members.");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.emitMembersLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,7 +549,7 @@ export default class MembersList extends Vue {
|
||||
|
||||
informAboutAdmission() {
|
||||
this.notify.info(
|
||||
"This is to register people in Time Safari and to admit them to the meeting. A (+) symbol means they are not yet admitted and you can register and admit them. A (-) 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,
|
||||
);
|
||||
}
|
||||
@@ -544,8 +607,17 @@ export default class MembersList extends Vue {
|
||||
* (admit pending members for organizers, add to contacts for non-organizers)
|
||||
*/
|
||||
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();
|
||||
|
||||
// The members list may have changed
|
||||
await this.fetchMembers();
|
||||
|
||||
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() {
|
||||
this.stopAutoRefresh();
|
||||
this.lastRefreshTime = Date.now();
|
||||
this.countdownTimer = 10;
|
||||
this.countdownTimer = AUTO_REFRESH_INTERVAL;
|
||||
|
||||
this.autoRefreshInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastRefresh = (now - this.lastRefreshTime) / 1000;
|
||||
|
||||
if (timeSinceLastRefresh >= 10) {
|
||||
if (timeSinceLastRefresh >= AUTO_REFRESH_INTERVAL) {
|
||||
// Time to refresh
|
||||
this.refreshData();
|
||||
this.lastRefreshTime = now;
|
||||
this.countdownTimer = 10;
|
||||
this.countdownTimer = AUTO_REFRESH_INTERVAL;
|
||||
} else {
|
||||
// Update countdown
|
||||
this.countdownTimer = Math.max(
|
||||
0,
|
||||
Math.round(10 - timeSinceLastRefresh),
|
||||
Math.round(AUTO_REFRESH_INTERVAL - timeSinceLastRefresh),
|
||||
);
|
||||
}
|
||||
}, 1000); // Update every second
|
||||
@@ -789,6 +878,11 @@ export default class MembersList extends Vue {
|
||||
transition-colors;
|
||||
}
|
||||
|
||||
.btn-exclusion-toggle {
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
@apply text-lg transition-colors;
|
||||
}
|
||||
|
||||
.btn-admission-remove {
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
@apply text-lg text-rose-500 hover:text-rose-700
|
||||
@@ -24,7 +24,7 @@
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div v-if="page === OnboardPage.Home" class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4 relative">
|
||||
Welcome to Time Safari
|
||||
Welcome to {{ AppString.APP_NAME }}
|
||||
<br />
|
||||
- Showcase Impact & Magnify Time
|
||||
<div :class="closeButtonClasses" @click="onClickClose(true)">
|
||||
@@ -199,7 +199,7 @@
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { OnboardPage } from "../libs/util";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
|
||||
@@ -226,6 +226,13 @@ export default class OnboardingDialog extends Vue {
|
||||
return OnboardPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns AppString enum for template access
|
||||
*/
|
||||
get AppString() {
|
||||
return AppString;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS classes for primary action buttons (blue gradient)
|
||||
*/
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
export enum AppString {
|
||||
// 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.
|
||||
APP_NAME = "Time Safari",
|
||||
APP_NAME_NO_SPACES = "TimeSafari",
|
||||
APP_NAME = "Gift Economies",
|
||||
APP_NAME_NO_SPACES = "GiftEconomies",
|
||||
|
||||
PROD_ENDORSER_API_SERVER = "https://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 {
|
||||
return apiServer !== AppString.PROD_ENDORSER_API_SERVER;
|
||||
}
|
||||
export const SUPPORT_EMAIL = "info@TimeSafari.app";
|
||||
|
||||
export const PASSKEYS_ENABLED =
|
||||
!!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)
|
||||
// MembersList.vue complex modals
|
||||
// Used in: MembersList.vue (complex modal for adding contacts)
|
||||
// MeetingMembersList.vue complex modals
|
||||
// Used in: MeetingMembersList.vue (complex modal for adding contacts)
|
||||
export const NOTIFY_ADD_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?",
|
||||
@@ -457,7 +457,7 @@ export const NOTIFY_ADD_CONTACT_FIRST = {
|
||||
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 = {
|
||||
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.",
|
||||
|
||||
@@ -175,6 +175,7 @@ const MIGRATIONS = [
|
||||
},
|
||||
{
|
||||
name: "002_add_iViewContent_to_contacts",
|
||||
// Note that many times iViewContent was set to null despite the DEFAULT setting.
|
||||
sql: `
|
||||
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: `
|
||||
-- Dev/test only: 10-minute rollover for daily reminder (plugin rolloverIntervalMinutes)
|
||||
ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE;
|
||||
`,
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,8 @@ export type ContactMethod = {
|
||||
};
|
||||
|
||||
export type Contact = {
|
||||
// id is a property in most contacts, but besides sorting from DB we don't need it
|
||||
|
||||
//
|
||||
// When adding a property:
|
||||
// - Consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
|
||||
@@ -14,7 +16,8 @@ export type Contact = {
|
||||
|
||||
did: string;
|
||||
contactMethods?: Array<ContactMethod>;
|
||||
iViewContent?: boolean;
|
||||
/** When true, hide this contact's activity from the feed. Default (null/undefined) = show. */
|
||||
hideTheirContent?: boolean;
|
||||
name?: string;
|
||||
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
||||
notes?: string;
|
||||
|
||||
@@ -55,6 +55,9 @@ export interface AxiosErrorResponse {
|
||||
response?: {
|
||||
data?: {
|
||||
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;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -7,6 +7,25 @@ export interface UserInfo {
|
||||
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 {
|
||||
did: string;
|
||||
name: string;
|
||||
@@ -15,3 +34,16 @@ export interface MemberData {
|
||||
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
|
||||
* @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
|
||||
*/
|
||||
export function serverMessageForUser(error: unknown): string | undefined {
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const err = error as AxiosErrorResponse;
|
||||
return err.response?.data?.error?.message;
|
||||
}
|
||||
return undefined;
|
||||
export function serverMessageForUser(
|
||||
error: AxiosErrorResponse,
|
||||
): string | undefined {
|
||||
return (
|
||||
error?.response?.data?.error?.userMessage ||
|
||||
error?.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1262,7 +1263,7 @@ export async function createAndSubmitClaim(
|
||||
});
|
||||
|
||||
const errorMessage: string =
|
||||
serverMessageForUser(error) ||
|
||||
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
|
||||
(error && typeof error === "object" && "message" in error
|
||||
? String(error.message)
|
||||
: undefined) ||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
faCircleUser,
|
||||
faCircleXmark,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
@@ -135,6 +136,7 @@ library.add(
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
faCircleUser,
|
||||
faCircleXmark,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface UserProfile {
|
||||
description: string;
|
||||
generateEmbedding?: boolean;
|
||||
embeddingIsForEmptyString?: boolean;
|
||||
locLat?: number;
|
||||
locLon?: number;
|
||||
locLat2?: number;
|
||||
|
||||
@@ -62,6 +62,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "contact-import",
|
||||
component: () => import("../views/ContactImportView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/contact-profile-check",
|
||||
name: "contact-profile-check",
|
||||
component: () => import("../views/ContactProfileCheckView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/contact-qr",
|
||||
name: "contact-qr",
|
||||
@@ -407,6 +412,18 @@ router.beforeEach(async (to, _from, next) => {
|
||||
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
|
||||
// This allows users to manually create an identity or troubleshoot
|
||||
logger.info(
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
*
|
||||
* 1. **Single Application**: Each migration runs exactly once per database
|
||||
* 2. **Tracked Execution**: All applied migrations are recorded in a migrations table
|
||||
* 3. **Schema Validation**: Actual database schema is validated before and after migrations
|
||||
* 4. **Graceful Recovery**: Handles cases where schema exists but tracking is missing
|
||||
* 5. **Comprehensive Logging**: Detailed logging for debugging and monitoring
|
||||
* 3. **Comprehensive Logging**: Detailed logging for debugging and monitoring
|
||||
*
|
||||
* ## Migration Flow
|
||||
*
|
||||
@@ -26,9 +24,7 @@
|
||||
* b. Check if schema already exists
|
||||
* c. Skip if already applied
|
||||
* d. Apply migration SQL
|
||||
* e. Validate schema was created
|
||||
* f. Record migration as applied
|
||||
* 4. Final validation of all migrations
|
||||
* e. Record migration as applied
|
||||
* ```
|
||||
*
|
||||
* ## Usage Example
|
||||
@@ -77,25 +73,6 @@ interface Migration {
|
||||
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
|
||||
*
|
||||
@@ -207,354 +184,6 @@ export function registerMigration(migration: Migration): void {
|
||||
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
|
||||
*
|
||||
@@ -600,7 +229,7 @@ export async function runMigrations<T>(
|
||||
extractMigrationNames: (result: T) => Set<string>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.debug("📋 [Migration] Starting migration process...");
|
||||
logger.debug("[Migration] Starting migration process...");
|
||||
|
||||
// Create migrations table if it doesn't exist
|
||||
// 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
|
||||
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;
|
||||
@@ -645,70 +274,35 @@ export async function runMigrations<T>(
|
||||
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
|
||||
logger.debug(`🔄 [Migration] Applying migration: ${migration.name}`);
|
||||
logger.debug(`[Migration] Applying migration: ${migration.name}`);
|
||||
|
||||
try {
|
||||
// Execute the migration SQL as single atomic operation
|
||||
logger.debug(`🔧 [Migration] Executing SQL for: ${migration.name}`);
|
||||
logger.debug(`🔧 [Migration] SQL content: ${migration.sql}`);
|
||||
logger.debug(`[Migration] Executing SQL for: ${migration.name}`);
|
||||
logger.debug(`[Migration] SQL content: ${migration.sql}`);
|
||||
|
||||
// Execute the migration SQL directly - it should be atomic
|
||||
// The SQL itself should handle any necessary transactions
|
||||
const execResult = await sqlExec(migration.sql);
|
||||
|
||||
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
|
||||
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
|
||||
migration.name,
|
||||
]);
|
||||
|
||||
logger.debug(`🎉 [Migration] Successfully applied: ${migration.name}`);
|
||||
logger.debug(`✅ [Migration] Successfully applied: ${migration.name}`);
|
||||
appliedCount++;
|
||||
} catch (error) {
|
||||
logger.error(`❌ [Migration] Error applying ${migration.name}:`, error);
|
||||
|
||||
// Provide explicit rollback instructions for migration failures
|
||||
logger.error(
|
||||
`🔄 [Migration] ROLLBACK INSTRUCTIONS for ${migration.name}:`,
|
||||
`[Migration] ROLLBACK INSTRUCTIONS for ${migration.name}:`,
|
||||
);
|
||||
logger.error(` 1. Stop the application immediately`);
|
||||
logger.error(
|
||||
@@ -740,41 +334,12 @@ export async function runMigrations<T>(
|
||||
errorMessage.includes("already exists"))
|
||||
) {
|
||||
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 {
|
||||
// For other types of errors, still fail the migration
|
||||
logger.error(
|
||||
`❌ [Migration] Failed to apply ${migration.name}:`,
|
||||
`❌ [Migration] Failed to apply ${migration.name}:`,
|
||||
error,
|
||||
);
|
||||
throw new Error(`Migration ${migration.name} failed: ${error}`);
|
||||
@@ -800,11 +365,10 @@ export async function runMigrations<T>(
|
||||
|
||||
// Only show completion message in development
|
||||
logger.log(
|
||||
`🎉 [Migration] Migration process complete! Summary: ${appliedCount} applied, ${skippedCount} skipped`,
|
||||
`[Migration] Migration process complete. Summary: ${appliedCount} applied, ${skippedCount} skipped`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("\n💥 [Migration] Migration process failed:", error);
|
||||
logger.error("[MigrationService] Migration process failed:", error);
|
||||
logger.error("❌ [Migration] Migration process failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,7 +714,7 @@ export class CapacitorPlatformService
|
||||
*
|
||||
* For critical tables like `contacts`, the method validates:
|
||||
* - 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
|
||||
*
|
||||
* ## 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")) {
|
||||
try {
|
||||
const contactsSchema = await this.db.query(
|
||||
@@ -795,23 +795,23 @@ export class CapacitorPlatformService
|
||||
contactsSchema,
|
||||
);
|
||||
|
||||
// Check for iViewContent column specifically
|
||||
const hasIViewContent = contactsSchema.values?.some(
|
||||
// Check for hideTheirContent column specifically
|
||||
const hasIHideContent = contactsSchema.values?.some(
|
||||
(col: unknown) =>
|
||||
(typeof col === "object" &&
|
||||
col !== null &&
|
||||
"name" in col &&
|
||||
(col as { name: string }).name === "iViewContent") ||
|
||||
(Array.isArray(col) && col[1] === "iViewContent"),
|
||||
(col as { name: string }).name === "hideTheirContent") ||
|
||||
(Array.isArray(col) && col[1] === "hideTheirContent"),
|
||||
);
|
||||
|
||||
if (hasIViewContent) {
|
||||
if (hasIHideContent) {
|
||||
logger.debug(
|
||||
`✅ [DB-Integrity] iViewContent column exists in contacts table`,
|
||||
`✅ [DB-Integrity] hideTheirContent column exists in contacts table`,
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`❌ [DB-Integrity] iViewContent column missing from contacts table`,
|
||||
`❌ [DB-Integrity] hideTheirContent column missing from contacts table`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1055,8 +1055,8 @@ export class CapacitorPlatformService
|
||||
// Offer to share the file
|
||||
try {
|
||||
await Share.share({
|
||||
title: "TimeSafari Backup",
|
||||
text: "Here is your TimeSafari backup file.",
|
||||
title: "Backup",
|
||||
text: "Here is your backup file.",
|
||||
url: writeResult.uri,
|
||||
dialogTitle: "Share your backup",
|
||||
});
|
||||
@@ -1100,8 +1100,8 @@ export class CapacitorPlatformService
|
||||
// Then share the file to let user choose where to save it
|
||||
try {
|
||||
await Share.share({
|
||||
title: "TimeSafari Backup",
|
||||
text: "Here is your TimeSafari backup file.",
|
||||
title: "Backup",
|
||||
text: "Here is your backup file.",
|
||||
url: writeResult.uri,
|
||||
dialogTitle: "Save your backup",
|
||||
});
|
||||
@@ -1180,7 +1180,7 @@ export class CapacitorPlatformService
|
||||
});
|
||||
|
||||
await Share.share({
|
||||
title: "TimeSafari Backup",
|
||||
title: "Backup",
|
||||
text: "Here is your backup file.",
|
||||
url: uri,
|
||||
dialogTitle: "Share your backup file",
|
||||
|
||||
@@ -2,15 +2,6 @@
|
||||
<div>
|
||||
<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">
|
||||
<h3>
|
||||
People Grid ({{ people.length }} total,
|
||||
@@ -23,9 +14,6 @@
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="people"
|
||||
:conflict-checker="conflictChecker"
|
||||
:display-entities-function="
|
||||
useCustomFunction ? customPeopleFunction : undefined
|
||||
"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
</div>
|
||||
@@ -42,9 +30,6 @@
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="people"
|
||||
:conflict-checker="conflictChecker"
|
||||
:display-entities-function="
|
||||
useCustomFunction ? customProjectsFunction : undefined
|
||||
"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
</div>
|
||||
@@ -74,7 +59,6 @@ import { PlanData } from "../interfaces/records";
|
||||
},
|
||||
})
|
||||
export default class EntityGridFunctionPropTest extends Vue {
|
||||
useCustomFunction = false;
|
||||
selectedEntity: {
|
||||
type: "person" | "project" | "special";
|
||||
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
|
||||
*/
|
||||
@@ -171,13 +135,6 @@ export default class EntityGridFunctionPropTest extends Vue {
|
||||
return did === this.activeDid;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle between custom and default display functions
|
||||
*/
|
||||
toggleCustomFunction(): void {
|
||||
this.useCustomFunction = !this.useCustomFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle entity selection
|
||||
*/
|
||||
@@ -193,16 +150,10 @@ export default class EntityGridFunctionPropTest extends Vue {
|
||||
* Computed properties to show display counts
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,8 +114,7 @@ export interface DatabaseExport {
|
||||
const _memoryLogs: string[] = [];
|
||||
|
||||
/**
|
||||
* Enhanced mixin that provides cached platform service access and utility methods
|
||||
* with smart caching layer for ultimate performance optimization
|
||||
* Enhanced mixin that provides platform utility methods
|
||||
*/
|
||||
export const PlatformServiceMixin = {
|
||||
data() {
|
||||
@@ -298,7 +297,7 @@ export const PlatformServiceMixin = {
|
||||
column === "warnIfTestServer" ||
|
||||
column === "reminderFastRolloverForTesting" ||
|
||||
// contacts
|
||||
column === "iViewContent" ||
|
||||
column === "hideTheirContent" ||
|
||||
column === "registered" ||
|
||||
column === "seesMe"
|
||||
) {
|
||||
@@ -913,7 +912,7 @@ export const PlatformServiceMixin = {
|
||||
// Create a new contact object with proper typing
|
||||
const normalizedContact: Contact = {
|
||||
did: contact.did,
|
||||
iViewContent: contact.iViewContent,
|
||||
hideTheirContent: contact.hideTheirContent,
|
||||
name: contact.name,
|
||||
nextPubKeyHashB64: contact.nextPubKeyHashB64,
|
||||
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
|
||||
* @param defaults Optional default values
|
||||
* @returns Fresh settings object from database
|
||||
@@ -1035,11 +1034,11 @@ export const PlatformServiceMixin = {
|
||||
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
|
||||
* @param did DID identifier (optional, uses current active DID)
|
||||
* @param defaults Optional default values
|
||||
@@ -1117,8 +1116,8 @@ export const PlatformServiceMixin = {
|
||||
// =================================================
|
||||
|
||||
/**
|
||||
* Save default settings - $saveSettings()
|
||||
* Ultra-concise shortcut for updateDefaultSettings
|
||||
* Save settings for currently active DID
|
||||
* May be consolidated with $saveUserSettings()
|
||||
*
|
||||
* ✅ KEEP: This method will be the primary settings save method after consolidation
|
||||
*
|
||||
@@ -1206,8 +1205,8 @@ export const PlatformServiceMixin = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Save user-specific settings - $saveUserSettings()
|
||||
* Ultra-concise shortcut for updateDidSpecificSettings
|
||||
* Save DID-specific settings - $saveUserSettings()
|
||||
*
|
||||
* @param did DID identifier
|
||||
* @param changes Settings changes to save
|
||||
* @returns Promise<boolean> Success status
|
||||
@@ -1382,8 +1381,10 @@ export const PlatformServiceMixin = {
|
||||
? contact.profileImageUrl
|
||||
: null,
|
||||
notes: contact.notes !== undefined ? contact.notes : null,
|
||||
iViewContent:
|
||||
contact.iViewContent !== undefined ? contact.iViewContent : null,
|
||||
hideTheirContent:
|
||||
contact.hideTheirContent !== undefined
|
||||
? contact.hideTheirContent
|
||||
: null,
|
||||
contactMethods:
|
||||
contact.contactMethods !== undefined
|
||||
? Array.isArray(contact.contactMethods)
|
||||
@@ -1394,7 +1395,7 @@ export const PlatformServiceMixin = {
|
||||
|
||||
await this.$dbExec(
|
||||
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
safeContact.did,
|
||||
@@ -1405,7 +1406,7 @@ export const PlatformServiceMixin = {
|
||||
safeContact.nextPubKeyHashB64,
|
||||
safeContact.profileImageUrl,
|
||||
safeContact.notes,
|
||||
safeContact.iViewContent,
|
||||
safeContact.hideTheirContent,
|
||||
safeContact.contactMethods,
|
||||
],
|
||||
);
|
||||
@@ -1732,8 +1733,9 @@ export const PlatformServiceMixin = {
|
||||
// =================================================
|
||||
|
||||
/**
|
||||
* Get temporary data by ID - $getTemp()
|
||||
* Retrieves temporary data from the temp table
|
||||
* Get data from temp table by ID
|
||||
* Currently set by main.capacitor.ts storeSharedImageInTempDB()
|
||||
*
|
||||
* @param id Temporary storage ID
|
||||
* @returns Promise<Temp | null> Temporary data or null if not found
|
||||
*/
|
||||
@@ -1747,8 +1749,8 @@ export const PlatformServiceMixin = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete temporary data by ID - $deleteTemp()
|
||||
* Removes temporary data from the temp table
|
||||
* Delete data from temp table by ID
|
||||
*
|
||||
* @param id Temporary storage ID
|
||||
* @returns Promise<boolean> Success status
|
||||
*/
|
||||
@@ -2243,7 +2245,7 @@ export const PlatformServiceMixin = {
|
||||
// =================================================
|
||||
|
||||
/**
|
||||
* Enhanced interface with caching utility methods
|
||||
* Enhanced interface
|
||||
*/
|
||||
export interface IPlatformServiceMixin {
|
||||
platformService: PlatformService;
|
||||
|
||||
@@ -780,7 +780,6 @@ import {
|
||||
DEFAULT_PARTNER_API_SERVER,
|
||||
DEFAULT_PUSH_SERVER,
|
||||
IMAGE_TYPE_PROFILE,
|
||||
isNotProdServer as isNotProdServerUtil,
|
||||
NotificationIface,
|
||||
PASSKEYS_ENABLED,
|
||||
} from "../constants/app";
|
||||
@@ -937,14 +936,6 @@ export default class AccountViewView extends Vue {
|
||||
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() {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
|
||||
@@ -1804,7 +1795,7 @@ export default class AccountViewView extends Vue {
|
||||
} else {
|
||||
logger.error("Non-success deleting image:", response);
|
||||
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) {
|
||||
if (isApiError(error) && error.response?.status === 404) {
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
|
||||
<!-- Contact Methods -->
|
||||
<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">
|
||||
<!-- Type and Value Row -->
|
||||
<div class="flex gap-2">
|
||||
|
||||
@@ -197,16 +197,30 @@
|
||||
Import Contacts
|
||||
</button>
|
||||
</ul>
|
||||
<p v-else-if="contactsImporting.length > 0">
|
||||
All those contacts are already in your list with the same information.
|
||||
<button
|
||||
v-if="applyLabelsToExisting"
|
||||
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"
|
||||
@click="importContacts"
|
||||
>
|
||||
Apply Labels to Existing Contacts
|
||||
</button>
|
||||
</p>
|
||||
<div v-else-if="contactsImporting.length > 0">
|
||||
<p>
|
||||
All those contacts are already in your list with the same information.
|
||||
</p>
|
||||
<div class="mt-3 flex flex-col items-center gap-2">
|
||||
<button
|
||||
data-testId="copyUnsignedImportLinkButton"
|
||||
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"
|
||||
@click="copyUnsignedImportLink"
|
||||
>
|
||||
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>
|
||||
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
|
||||
@@ -292,6 +306,7 @@ import QuickNav from "../components/QuickNav.vue";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
import OfferDialog from "../components/OfferDialog.vue";
|
||||
import { APP_SERVER, AppString, NotificationIface } from "../constants/app";
|
||||
import { copyToClipboard } from "../services/ClipboardService";
|
||||
import {
|
||||
Contact,
|
||||
ContactWithLabels,
|
||||
@@ -303,8 +318,7 @@ import {
|
||||
errorStringForLog,
|
||||
setVisibilityUtil,
|
||||
} from "../libs/endorserServer";
|
||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
||||
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
||||
import { parseContactImportInput } from "../libs/contactImportPayload";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
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
|
||||
*/
|
||||
private async processJwtFromPath() {
|
||||
// JWT tokens always start with 'ey' (base64url encoded header)
|
||||
const JWT_PATTERN = /\/contact-import\/(ey.+)$/;
|
||||
const jwt = window.location.pathname.match(JWT_PATTERN)?.[1];
|
||||
const parsedImport = parseContactImportInput(window.location.pathname);
|
||||
if (parsedImport.kind === "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (jwt) {
|
||||
const parsedJwt = decodeEndorserJwt(jwt);
|
||||
const contacts: Array<Contact> =
|
||||
parsedJwt.payload.contacts ||
|
||||
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
|
||||
if (parsedImport.kind === "single") {
|
||||
this.$router.push({
|
||||
name: "contacts",
|
||||
query: { contactJwt: parsedImport.jwt },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contacts && parsedJwt.payload.own) {
|
||||
this.$router.push({
|
||||
name: "contacts",
|
||||
query: { contactJwt: jwt },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (parsedImport.contacts.length === 1) {
|
||||
this.$router.push({
|
||||
name: "contacts",
|
||||
query: { contactJwt: parsedImport.jwt },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (contacts) {
|
||||
await this.setContactsSelected(contacts);
|
||||
}
|
||||
if (parsedImport.contacts.length > 0) {
|
||||
await this.setContactsSelected(parsedImport.contacts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,16 +611,12 @@ export default class ContactImportView extends Vue {
|
||||
* @param jwtInput JWT string to validate
|
||||
*/
|
||||
async checkContactJwt(jwtInput: string) {
|
||||
const parsedImport = parseContactImportInput(jwtInput);
|
||||
if (
|
||||
jwtInput.endsWith(APP_SERVER) ||
|
||||
jwtInput.endsWith(APP_SERVER + "/") ||
|
||||
jwtInput.endsWith("contact-import") ||
|
||||
jwtInput.endsWith("contact-import/")
|
||||
parsedImport.kind === "error" &&
|
||||
parsedImport.code === "truncated_data"
|
||||
) {
|
||||
this.notify.error(
|
||||
"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,
|
||||
);
|
||||
this.notify.error(parsedImport.message, TIMEOUTS.LONG);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,14 +628,29 @@ export default class ContactImportView extends Vue {
|
||||
this.checkingImports = true;
|
||||
|
||||
try {
|
||||
const jwt: string = getContactJwtFromJwtUrl(jwtInput) || "";
|
||||
const payload = decodeEndorserJwt(jwt).payload;
|
||||
|
||||
if (Array.isArray(payload.contacts)) {
|
||||
await this.setContactsSelected(payload.contacts);
|
||||
} else {
|
||||
throw new Error("Invalid contact-import JWT or URL: " + jwtInput);
|
||||
const parsedImport = parseContactImportInput(jwtInput);
|
||||
if (parsedImport.kind === "error") {
|
||||
this.notify.error(parsedImport.message, TIMEOUTS.STANDARD);
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
const fullError = "Error importing contacts: " + errorStringForLog(error);
|
||||
this.$logAndConsole(fullError, true);
|
||||
@@ -635,6 +662,52 @@ export default class ContactImportView extends Vue {
|
||||
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
|
||||
*/
|
||||
@@ -791,5 +864,20 @@ export default class ContactImportView extends Vue {
|
||||
);
|
||||
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>
|
||||
|
||||
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 -->
|
||||
<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
|
||||
class="text-sm font-medium text-blue-600 flex items-center gap-1"
|
||||
@click="showLabelFilter = !showLabelFilter"
|
||||
@@ -167,6 +167,23 @@
|
||||
@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
|
||||
ref="customGivenDialog"
|
||||
:initial-giver-entity-type="'person'"
|
||||
@@ -206,8 +223,11 @@ import LargeIdenticonModal from "../components/LargeIdenticonModal.vue";
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
// Legacy logging import removed - using PlatformServiceMixin methods
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
||||
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
||||
import {
|
||||
parseContactImportInput,
|
||||
ContactImportParseResult,
|
||||
} from "../libs/contactImportPayload";
|
||||
import {
|
||||
CONTACT_CSV_HEADER,
|
||||
createEndorserJwtForDid,
|
||||
@@ -216,15 +236,8 @@ import {
|
||||
isDid,
|
||||
register,
|
||||
setVisibilityUtil,
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
|
||||
CONTACT_URL_PATH_ENDORSER_CH_OLD,
|
||||
} from "../libs/endorserServer";
|
||||
import {
|
||||
GiveSummaryRecord,
|
||||
UserInfo,
|
||||
VerifiableCredential,
|
||||
} from "@/interfaces";
|
||||
import { GiveSummaryRecord, VerifiableCredential } from "@/interfaces";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { generateSaveAndActivateIdentity } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
@@ -334,6 +347,7 @@ export default class ContactsView extends Vue {
|
||||
hideRegisterPromptOnNewContact = false;
|
||||
isRegistered = false;
|
||||
showDidCopy = false;
|
||||
showGeneralAdvanced = false;
|
||||
showPubKeyCopy = false;
|
||||
showPubKeyHashCopy = false;
|
||||
showGiveNumbers = false;
|
||||
@@ -377,6 +391,7 @@ export default class ContactsView extends Vue {
|
||||
await this.processContactJwt();
|
||||
await this.processInviteJwt();
|
||||
|
||||
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
||||
this.showGiveNumbers = !!settings.showContactGivesInline;
|
||||
this.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.
|
||||
const importedContactJwt = this.$route.query["contactJwt"] as string;
|
||||
if (importedContactJwt) {
|
||||
// really should fully verify contents
|
||||
const { payload } = decodeEndorserJwt(importedContactJwt);
|
||||
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" });
|
||||
const parsedImport = parseContactImportInput(importedContactJwt);
|
||||
await this.handleParsedContactImport(parsedImport);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +601,24 @@ export default class ContactsView extends Vue {
|
||||
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() {
|
||||
return this.contactsSelected.length > 0
|
||||
? "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
|
||||
if (await this.tryParseJwtContact(contactInput)) return;
|
||||
if (await this.tryParseContactImport(contactInput)) return;
|
||||
if (await this.tryParseCsvContacts(contactInput)) return;
|
||||
if (await this.tryParseDidContact(contactInput)) return;
|
||||
if (await this.tryParseJsonContacts(contactInput)) return;
|
||||
@@ -762,29 +783,51 @@ export default class ContactsView extends Vue {
|
||||
/**
|
||||
* 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 (
|
||||
contactInput.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI) ||
|
||||
contactInput.includes(CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI) ||
|
||||
contactInput.includes(CONTACT_URL_PATH_ENDORSER_CH_OLD)
|
||||
parsedImport.kind === "error" &&
|
||||
parsedImport.code === "not_contact_import_format"
|
||||
) {
|
||||
const jwt = getContactJwtFromJwtUrl(contactInput);
|
||||
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;
|
||||
}
|
||||
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>
|
||||
</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 items-center">
|
||||
<div v-if="activeDid" class="flex justify-between">
|
||||
<div>
|
||||
<button
|
||||
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
|
||||
<div v-if="activeDid" class="flex justify-between items-end">
|
||||
<div class="flex items-end gap-1">
|
||||
<div
|
||||
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"
|
||||
title="Check Visibility"
|
||||
@click="checkVisibility(contactFromDid)"
|
||||
class="flex flex-col items-center"
|
||||
>
|
||||
<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>
|
||||
|
||||
<button
|
||||
<div
|
||||
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"
|
||||
title="Registration"
|
||||
@click="confirmRegister(contactFromDid)"
|
||||
class="flex flex-col items-center ml-6"
|
||||
>
|
||||
<font-awesome
|
||||
v-if="contactFromDid?.registered"
|
||||
icon="person-circle-check"
|
||||
class="fa-fw"
|
||||
/>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="person-circle-question"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</button>
|
||||
<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="Registration"
|
||||
@click="confirmRegister(contactFromDid)"
|
||||
>
|
||||
<font-awesome
|
||||
v-if="contactFromDid?.registered"
|
||||
icon="person-circle-check"
|
||||
class="fa-fw"
|
||||
/>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="person-circle-question"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-xs text-slate-600">Register</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
title="Delete"
|
||||
@click="confirmDeleteContact(contactFromDid)"
|
||||
<div
|
||||
v-if="contactFromDid?.did !== activeDid"
|
||||
class="flex flex-col items-center ml-6"
|
||||
>
|
||||
<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 v-if="!contactFromDid?.profileImageUrl">
|
||||
<div>Auto-Generated Icon</div>
|
||||
<div class="flex justify-center">
|
||||
@@ -253,6 +306,57 @@
|
||||
/>
|
||||
</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 v-else class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<!-- !contactFromDid -->
|
||||
@@ -332,7 +436,10 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import InfiniteScroll from "../components/InfiniteScroll.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 { BoundingBox } from "../db/tables/settings";
|
||||
|
||||
@@ -367,6 +474,7 @@ import {
|
||||
} from "@/constants/notifications";
|
||||
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
|
||||
import { getContactMethodLabel } from "@/constants/contacts";
|
||||
import type { UserProfile } from "@/libs/partnerServer";
|
||||
|
||||
/**
|
||||
* DIDView Component
|
||||
@@ -406,13 +514,25 @@ export default class DIDView extends Vue {
|
||||
contactLabels: string[] = [];
|
||||
|
||||
contactYaml = "";
|
||||
embeddingMetadata: {
|
||||
generateEmbedding: boolean;
|
||||
isForEmptyString: boolean;
|
||||
} | null = null;
|
||||
embeddingMetadataLoading = false;
|
||||
embeddingMetadataSaving = false;
|
||||
hitEnd = false;
|
||||
isLoading = false;
|
||||
isMyDid = false;
|
||||
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
||||
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||
showDidDetails = false;
|
||||
showGeneralAdvanced = false;
|
||||
showLargeIdenticonId?: string;
|
||||
showLargeIdenticonUrl?: string;
|
||||
showUserProfile = false;
|
||||
userProfileData: UserProfile | null = null;
|
||||
userProfileError: string | null = null;
|
||||
userProfileLoading = false;
|
||||
viewingDid?: string;
|
||||
|
||||
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
||||
@@ -444,6 +564,9 @@ export default class DIDView extends Vue {
|
||||
await this.loadContactInformation();
|
||||
await this.loadClaimsAbout();
|
||||
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.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Used by infinite scroll component to implement pagination
|
||||
@@ -545,6 +713,83 @@ export default class DIDView extends Vue {
|
||||
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() {
|
||||
this.showLargeIdenticonUrl = this.contactFromDid?.profileImageUrl;
|
||||
}
|
||||
@@ -897,7 +1142,7 @@ export default class DIDView extends Vue {
|
||||
this.notify.confirm(contentVisibilityPrompt, async () => {
|
||||
const success = await this.setViewContent(contact, view);
|
||||
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
|
||||
*/
|
||||
async setViewContent(contact: Contact, visibility: boolean) {
|
||||
await this.$updateContact(contact.did, { iViewContent: visibility });
|
||||
await this.$updateContact(contact.did, { hideTheirContent: !visibility });
|
||||
const message =
|
||||
"You will" +
|
||||
(visibility ? "" : " not") +
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from "../interfaces/deepLinks";
|
||||
import { logConsoleAndDb } from "../db/databaseUtil";
|
||||
import { logger } from "../utils/logger";
|
||||
import { SUPPORT_EMAIL } from "../constants/app";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -105,7 +106,7 @@ const goHome = () => router.replace({ name: "home" });
|
||||
const reportIssue = () => {
|
||||
// Open a support form or email
|
||||
window.open(
|
||||
"mailto:support@timesafari.app?subject=Invalid Deep Link&body=" +
|
||||
`mailto:${SUPPORT_EMAIL}?subject=Invalid Deep Link&body=` +
|
||||
encodeURIComponent(
|
||||
`I encountered an error with a deep link: timesafari://${originalPath.value}\nError: ${errorMessage.value}`,
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- Sub View Heading -->
|
||||
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||
Redirecting to Time Safari
|
||||
Redirecting to app
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
<p v-if="isMobile">
|
||||
{{
|
||||
isIOS
|
||||
? "Opening Time Safari app on your iPhone..."
|
||||
: "Opening Time Safari app on your Android device..."
|
||||
? "Opening on your iPhone..."
|
||||
: "Opening on your Android device..."
|
||||
}}
|
||||
</p>
|
||||
<p v-else>Opening Time Safari app...</p>
|
||||
<p v-else>Opening the app...</p>
|
||||
<p class="text-sm mt-2">
|
||||
<span v-if="isMobile"
|
||||
>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"
|
||||
@click="handleDeepLinkClick"
|
||||
>
|
||||
<span v-if="isMobile">Open in Time Safari App</span>
|
||||
<span v-else>Try Opening in Time Safari App</span>
|
||||
<span v-if="isMobile">Open in App</span>
|
||||
<span v-else>Try Opening in App</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -61,8 +61,7 @@
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
|
||||
</p>
|
||||
<p v-else>
|
||||
If you have the Time Safari app installed, you can also copy this
|
||||
link:
|
||||
If you have the app installed, you can also copy this link:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
|
||||
</p>
|
||||
</div>
|
||||
@@ -177,13 +176,13 @@ export default class DeepLinkRedirectView extends Vue {
|
||||
"Fallback deep link failed: " + errorStringForLog(error),
|
||||
);
|
||||
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);
|
||||
} catch (error) {
|
||||
logger.error("Deep link redirect failed: " + errorStringForLog(error));
|
||||
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">
|
||||
From
|
||||
{{
|
||||
givenByProjectFunction()
|
||||
givenByProject()
|
||||
? providerProjectName
|
||||
: // check for DID because name could be "Unnamed"
|
||||
givenByPersonFunction() && giverDid
|
||||
givenByPerson() && giverDid
|
||||
? giverName
|
||||
: "someone not named"
|
||||
}}
|
||||
@@ -42,9 +42,10 @@
|
||||
<div class="truncate">
|
||||
to
|
||||
{{
|
||||
givenToProject
|
||||
givenToProject()
|
||||
? fulfillsProjectName
|
||||
: givenToRecipient
|
||||
: // check for DID because name could be "Unnamed"
|
||||
givenToPerson() && recipientDid
|
||||
? recipientName
|
||||
: "someone not named"
|
||||
}}
|
||||
@@ -114,9 +115,9 @@
|
||||
<div class="flex items-center">
|
||||
<label class="text-sm flex-1">
|
||||
{{
|
||||
givenByProjectFunction() && providerProjectName
|
||||
givenByProject() && providerProjectName
|
||||
? "From " + providerProjectName
|
||||
: givenByPersonFunction() && giverName
|
||||
: givenByPerson() && giverName
|
||||
? "From " + giverName
|
||||
: "Unnamed giver"
|
||||
}}
|
||||
@@ -133,15 +134,9 @@
|
||||
:all-contacts="allContacts"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:conflict-checker="wouldCreateConflict"
|
||||
:from-project-id="providerProjectId"
|
||||
:to-project-id="fulfillsProjectId"
|
||||
:conflict-checker="wouldCreateConflictWithRecipient"
|
||||
:giver="currentGiver"
|
||||
:receiver="currentReceiver"
|
||||
:description="description"
|
||||
:amount-input="amountInput"
|
||||
:unit-code="unitCode"
|
||||
:offer-id="offerId"
|
||||
:notify="$notify"
|
||||
@entity-selected="handleGiverEntitySelected"
|
||||
@cancel="closeGiverSelection"
|
||||
@@ -169,9 +164,9 @@
|
||||
<div class="flex items-center">
|
||||
<label class="text-sm flex-1">
|
||||
{{
|
||||
givenToProjectFunction() && fulfillsProjectName
|
||||
givenToProject() && fulfillsProjectName
|
||||
? "To " + fulfillsProjectName
|
||||
: givenToPersonFunction() && recipientName
|
||||
: givenToPerson() && recipientName
|
||||
? "To " + recipientName
|
||||
: "Unnamed recipient"
|
||||
}}
|
||||
@@ -188,15 +183,9 @@
|
||||
:all-contacts="allContacts"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:conflict-checker="wouldCreateConflict"
|
||||
:from-project-id="providerProjectId"
|
||||
:to-project-id="fulfillsProjectId"
|
||||
:conflict-checker="wouldCreateConflictWithGiver"
|
||||
:giver="currentGiver"
|
||||
:receiver="currentReceiver"
|
||||
:description="description"
|
||||
:amount-input="amountInput"
|
||||
:unit-code="unitCode"
|
||||
:offer-id="offerId"
|
||||
:notify="$notify"
|
||||
@entity-selected="handleRecipientEntitySelected"
|
||||
@cancel="closeRecipientSelection"
|
||||
@@ -309,8 +298,6 @@ export default class GiftedDetails extends Vue {
|
||||
destinationPathAfter = "";
|
||||
fulfillsProjectId = "";
|
||||
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 = "";
|
||||
giverName = "";
|
||||
hideBackButton = false;
|
||||
@@ -414,7 +401,6 @@ export default class GiftedDetails extends Vue {
|
||||
|
||||
this.imageUrl = ((this.$route.query["imageUrl"] as string) ||
|
||||
this.prevCredToEdit?.claim?.image ||
|
||||
localStorage.getItem("imageUrl") ||
|
||||
this.imageUrl) as string;
|
||||
|
||||
// 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();
|
||||
this.activeDid = activeIdentity.activeDid || "";
|
||||
|
||||
const dbContacts = await this.$dbQuery("SELECT * FROM contacts");
|
||||
this.allContacts = this.$mapQueryResultToValues(
|
||||
dbContacts,
|
||||
) as unknown as Contact[];
|
||||
this.allContacts = await this.$contactsByDateAdded();
|
||||
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
|
||||
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;
|
||||
|
||||
@@ -498,19 +479,19 @@ export default class GiftedDetails extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
givenByPersonFunction() {
|
||||
givenByPerson() {
|
||||
return !!this.giverDid;
|
||||
}
|
||||
|
||||
givenByProjectFunction() {
|
||||
givenByProject() {
|
||||
return !!this.providerProjectId;
|
||||
}
|
||||
|
||||
givenToPersonFunction() {
|
||||
givenToPerson() {
|
||||
return !!this.recipientDid;
|
||||
}
|
||||
|
||||
givenToProjectFunction() {
|
||||
givenToProject() {
|
||||
return !!this.fulfillsProjectId;
|
||||
}
|
||||
|
||||
@@ -595,7 +576,6 @@ export default class GiftedDetails extends Vue {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem("imageUrl");
|
||||
this.imageUrl = "";
|
||||
} catch (error) {
|
||||
logger.error("Error deleting image:", error);
|
||||
@@ -603,7 +583,6 @@ export default class GiftedDetails extends Vue {
|
||||
if ((error as any)?.response?.status === 404) {
|
||||
logger.log("Weird: the image was already deleted.", error);
|
||||
|
||||
localStorage.removeItem("imageUrl");
|
||||
this.imageUrl = "";
|
||||
|
||||
// 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,
|
||||
);
|
||||
} else {
|
||||
// must be because givenToProject is true
|
||||
// must be because givenToProject() is true
|
||||
this.notify.warning(
|
||||
"You cannot assign both to a recipient and to a project.",
|
||||
TIMEOUTS.SHORT,
|
||||
@@ -767,7 +746,6 @@ export default class GiftedDetails extends Vue {
|
||||
logger.error("Error checking seed backup status:", error);
|
||||
}
|
||||
|
||||
localStorage.removeItem("imageUrl");
|
||||
if (this.destinationPathAfter) {
|
||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||
} else {
|
||||
@@ -837,12 +815,12 @@ export default class GiftedDetails extends Vue {
|
||||
* Computed property for current receiver entity data
|
||||
*/
|
||||
get currentReceiver() {
|
||||
if (this.givenToProject && this.fulfillsProjectId) {
|
||||
if (this.givenToProject() && this.fulfillsProjectId) {
|
||||
return {
|
||||
handleId: this.fulfillsProjectId,
|
||||
name: this.fulfillsProjectName,
|
||||
};
|
||||
} else if (this.givenToRecipient && this.recipientDid) {
|
||||
} else if (this.givenToPerson() && this.recipientDid) {
|
||||
return {
|
||||
did: this.recipientDid,
|
||||
name: this.recipientName,
|
||||
@@ -930,7 +908,7 @@ export default class GiftedDetails extends Vue {
|
||||
/**
|
||||
* Check if selecting an entity would create a conflict
|
||||
*/
|
||||
wouldCreateConflict(identifier: string): boolean {
|
||||
wouldCreateConflictWithGiver(identifier: string): boolean {
|
||||
// Check if it would conflict with giver
|
||||
if (this.giverDid === identifier) {
|
||||
return true;
|
||||
@@ -938,6 +916,9 @@ export default class GiftedDetails extends Vue {
|
||||
if (this.providerProjectId === identifier) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
wouldCreateConflictWithRecipient(identifier: string): boolean {
|
||||
// Check if it would conflict with recipient
|
||||
if (this.recipientDid === identifier) {
|
||||
return true;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<!-- Sub View Heading -->
|
||||
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||
Time Safari Onboarding Instructions
|
||||
Onboarding Instructions
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -80,27 +80,6 @@
|
||||
4) Add yourself to their Contacts <font-awesome icon="users" />
|
||||
</p>
|
||||
</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>
|
||||
<!-- eslint enable -->
|
||||
</section>
|
||||
|
||||
@@ -442,9 +442,9 @@
|
||||
browser window and look at the version there.
|
||||
</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
|
||||
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>
|
||||
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>
|
||||
for instructions for other browsers.</li>
|
||||
</ul>
|
||||
Then reload Time Safari.
|
||||
Then reload the page.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
@@ -552,8 +552,8 @@
|
||||
</h2>
|
||||
<p>
|
||||
Contact us at
|
||||
<a href="mailto:info@TimeSafari.app" class="text-blue-500"
|
||||
>info@TimeSafari.app</a
|
||||
<a :href="`mailto:${SUPPORT_EMAIL}`" class="text-blue-500"
|
||||
>{{ SUPPORT_EMAIL }}</a
|
||||
>
|
||||
</p>
|
||||
|
||||
@@ -591,7 +591,7 @@ import { copyToClipboard } from "../services/ClipboardService";
|
||||
|
||||
import * as Package from "../../package.json";
|
||||
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 { QRNavigationService } from "@/services/QRNavigationService";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
@@ -643,7 +643,7 @@ export default class HelpView extends Vue {
|
||||
showVerifiable = false;
|
||||
|
||||
APP_SERVER = APP_SERVER;
|
||||
// Capacitor reference removed - using QRNavigationService instead
|
||||
SUPPORT_EMAIL = SUPPORT_EMAIL;
|
||||
|
||||
/**
|
||||
* Initialize notification helpers
|
||||
|
||||
@@ -167,18 +167,12 @@ Raymer * @version 1.0.0 */
|
||||
<div class="flex gap-2 items-center mb-3">
|
||||
<h2 class="font-bold">Latest Activity</h2>
|
||||
<button
|
||||
v-if="resultsAreFiltered()"
|
||||
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"
|
||||
@click="openFeedFilters()"
|
||||
>
|
||||
<font-awesome
|
||||
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"
|
||||
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="
|
||||
isAnyFeedFilterOn
|
||||
? 'bg-gradient-to-b from-blue-400 to-blue-700'
|
||||
: 'bg-gradient-to-b from-slate-400 to-slate-700'
|
||||
"
|
||||
@click="openFeedFilters()"
|
||||
>
|
||||
<font-awesome
|
||||
@@ -779,7 +773,7 @@ export default class HomeView extends Vue {
|
||||
private async loadContacts() {
|
||||
this.allContacts = await this.$contacts();
|
||||
this.blockedContactDids = this.allContacts
|
||||
.filter((c) => !c.iViewContent)
|
||||
.filter((c) => c.hideTheirContent)
|
||||
.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
|
||||
*
|
||||
@@ -1003,12 +986,12 @@ export default class HomeView extends Vue {
|
||||
* Called by FeedFilters component when filters change
|
||||
*/
|
||||
async reloadFeedOnChange() {
|
||||
logger.debug("[HomeView] 🔄 reloadFeedOnChange() called - refreshing feed");
|
||||
logger.debug("[HomeView] reloadFeedOnChange() called - refreshing feed");
|
||||
|
||||
// Get current settings without overriding with defaults
|
||||
const settings = await this.$accountSettings(this.activeDid);
|
||||
|
||||
logger.debug("[HomeView] 📊 Current filter settings:", {
|
||||
logger.debug("[HomeView] Current filter settings:", {
|
||||
filterFeedByVisible: settings.filterFeedByVisible,
|
||||
filterFeedByNearby: settings.filterFeedByNearby,
|
||||
searchBoxes: settings.searchBoxes?.length || 0,
|
||||
@@ -1018,7 +1001,7 @@ export default class HomeView extends Vue {
|
||||
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
||||
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
||||
|
||||
logger.debug("[HomeView] 🎯 Updated filter states:", {
|
||||
logger.debug("[HomeView] Updated filter states:", {
|
||||
isFeedFilteredByVisible: this.isFeedFilteredByVisible,
|
||||
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
|
||||
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
||||
@@ -1052,17 +1035,9 @@ export default class HomeView extends Vue {
|
||||
/**
|
||||
* Checks if coordinates fall within any search box
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: shouldIncludeRecord()
|
||||
* Calls: None
|
||||
*
|
||||
* @chain
|
||||
* shouldIncludeRecord() -> latLongInAnySearchBox()
|
||||
*
|
||||
* @requires
|
||||
* - this.searchBoxes
|
||||
*
|
||||
* @param lat Latitude to check
|
||||
* @param long Longitude to check
|
||||
* @returns true if coordinates are within any search box
|
||||
@@ -1087,29 +1062,11 @@ export default class HomeView extends Vue {
|
||||
* - Updates last viewed claim ID
|
||||
* - Handles paging if needed
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: loadFeedData(), manual refresh
|
||||
* Calls:
|
||||
* - retrieveGives()
|
||||
* - processFeedResults()
|
||||
* - updateFeedLastViewedId()
|
||||
* - handleFeedError()
|
||||
*
|
||||
* @chain
|
||||
* loadFeedData() -> updateAllFeed() -> retrieveGives()
|
||||
*
|
||||
* @requires
|
||||
* - this.apiServer
|
||||
* - this.feedPreviousOldestId
|
||||
*
|
||||
* @modifies
|
||||
* - this.isFeedLoading
|
||||
* - this.feedData (via processFeedResults)
|
||||
* - this.feedLastViewedClaimId (via updateFeedLastViewedId)
|
||||
*/
|
||||
async updateAllFeed() {
|
||||
logger.debug("[HomeView] 🚀 updateAllFeed() called", {
|
||||
logger.debug("[HomeView] updateAllFeed() called", {
|
||||
isFeedLoading: this.isFeedLoading,
|
||||
currentFeedDataLength: this.feedData.length,
|
||||
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
||||
@@ -1126,7 +1083,7 @@ export default class HomeView extends Vue {
|
||||
this.feedPreviousOldestId,
|
||||
);
|
||||
|
||||
logger.debug("[HomeView] 📡 Retrieved gives from API", {
|
||||
logger.debug("[HomeView] Retrieved gives from API", {
|
||||
resultsCount: results.data.length,
|
||||
endOfResults,
|
||||
});
|
||||
@@ -1137,7 +1094,7 @@ export default class HomeView extends Vue {
|
||||
await this.processFeedResults(results.data);
|
||||
await this.updateFeedLastViewedId(results.data);
|
||||
|
||||
logger.debug("[HomeView] 📝 Processed feed results", {
|
||||
logger.debug("[HomeView] Processed feed results", {
|
||||
processedCount: this.feedData.length,
|
||||
});
|
||||
}
|
||||
@@ -1161,26 +1118,13 @@ export default class HomeView extends Vue {
|
||||
/**
|
||||
* Processes feed results and adds them to feedData
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: updateAllFeed()
|
||||
* Calls: processRecord()
|
||||
*
|
||||
* @chain
|
||||
* updateAllFeed() -> processFeedResults()
|
||||
*
|
||||
* @requires
|
||||
* - this.feedData
|
||||
* - this.feedPreviousOldestId
|
||||
*
|
||||
* @modifies
|
||||
* - this.feedData
|
||||
* - this.feedPreviousOldestId
|
||||
*
|
||||
* @param records Array of feed records to process
|
||||
*/
|
||||
private async processFeedResults(records: GiveSummaryRecord[]) {
|
||||
logger.debug("[HomeView] 📝 Processing feed results:", {
|
||||
logger.debug("[HomeView] Processing feed results:", {
|
||||
inputRecords: records.length,
|
||||
currentFilters: {
|
||||
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,
|
||||
filtered: filteredCount,
|
||||
total: records.length,
|
||||
@@ -1215,32 +1159,9 @@ export default class HomeView extends Vue {
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* @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);
|
||||
|
||||
// Log record details for debugging
|
||||
logger.debug("[HomeView] 🔍 Processing record:", {
|
||||
logger.debug("[HomeView] Processing record:", {
|
||||
recordId: record.jwtId,
|
||||
hasFulfillsPlan: !!fulfillsPlan,
|
||||
fulfillsPlanHandleId: record.fulfillsPlanHandleId,
|
||||
@@ -1266,14 +1187,12 @@ export default class HomeView extends Vue {
|
||||
});
|
||||
|
||||
if (!this.shouldIncludeRecord(record, fulfillsPlan)) {
|
||||
logger.debug("[HomeView] ❌ Record filtered out:", record.jwtId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = this.extractProvider(claim);
|
||||
const providedByPlan = await this.getProvidedByPlan(provider);
|
||||
|
||||
logger.debug("[HomeView] ✅ Record included:", record.jwtId);
|
||||
return this.createFeedRecord(
|
||||
record,
|
||||
claim,
|
||||
@@ -1397,6 +1316,11 @@ export default class HomeView extends Vue {
|
||||
fulfillsPlan?: FulfillsPlan,
|
||||
): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
logger.debug("[HomeView] 🔍 Nearby filter check:", {
|
||||
recordId: record.jwtId,
|
||||
hasFulfillsPlan: !!fulfillsPlan,
|
||||
hasLocation: !!(fulfillsPlan?.locLat && fulfillsPlan?.locLon),
|
||||
location: fulfillsPlan
|
||||
? { lat: fulfillsPlan.locLat, lon: fulfillsPlan.locLon }
|
||||
: null,
|
||||
inSearchBox:
|
||||
fulfillsPlan?.locLat && fulfillsPlan?.locLon
|
||||
? this.latLongInAnySearchBox(
|
||||
fulfillsPlan.locLat,
|
||||
fulfillsPlan.locLon,
|
||||
)
|
||||
if (!anyMatch) {
|
||||
logger.debug("[HomeView] Nearby filter check:", {
|
||||
recordId: record.jwtId,
|
||||
hasFulfillsPlan: !!fulfillsPlan,
|
||||
hasLocation: !!(fulfillsPlan?.locLat && fulfillsPlan?.locLon),
|
||||
location: fulfillsPlan
|
||||
? { lat: fulfillsPlan.locLat, lon: fulfillsPlan.locLon }
|
||||
: null,
|
||||
finalResult: anyMatch,
|
||||
});
|
||||
inSearchBox:
|
||||
fulfillsPlan?.locLat && fulfillsPlan?.locLon
|
||||
? this.latLongInAnySearchBox(
|
||||
fulfillsPlan.locLat,
|
||||
fulfillsPlan.locLon,
|
||||
)
|
||||
: null,
|
||||
finalResult: anyMatch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return anyMatch;
|
||||
|
||||
@@ -699,7 +699,6 @@ export default class OfferDetailsView extends Vue {
|
||||
NOTIFY_OFFER_SUCCESS_RECORDED.message,
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
localStorage.removeItem("imageUrl");
|
||||
if (this.destinationPathAfter) {
|
||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||
} else {
|
||||
|
||||
@@ -131,6 +131,7 @@ import {
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { AxiosErrorResponse } from "@/interfaces";
|
||||
|
||||
interface Meeting {
|
||||
name: string;
|
||||
@@ -209,11 +210,6 @@ export default class OnboardMeetingListView extends Vue {
|
||||
* 3. If not attending: Fetch all available meetings (groupsOnboarding endpoint)
|
||||
* 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:
|
||||
* - Sets isLoading flag during API calls
|
||||
* - Updates attendingMeeting or meetings array
|
||||
@@ -271,7 +267,8 @@ export default class OnboardMeetingListView extends Vue {
|
||||
true,
|
||||
);
|
||||
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,
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -40,8 +40,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<MembersList v-else :password="password" @error="handleError" />
|
||||
<div v-else>
|
||||
<!-- Any Match + Members List -->
|
||||
<MeetingMembersList :password="password" @error="handleError" />
|
||||
</div>
|
||||
|
||||
<!-- Project Link Section -->
|
||||
<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 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 { encryptMessage } from "../libs/crypto";
|
||||
import {
|
||||
@@ -78,12 +81,14 @@ import {
|
||||
import { generateSaveAndActivateIdentity } from "../libs/util";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { AxiosErrorResponse } from "@/interfaces";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
MembersList,
|
||||
MeetingMemberMatch,
|
||||
MeetingMembersList,
|
||||
UserNameDialog,
|
||||
},
|
||||
mixins: [PlatformServiceMixin],
|
||||
@@ -178,7 +183,7 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
}
|
||||
} catch (error) {
|
||||
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.";
|
||||
this.$logAndConsole(
|
||||
"Error checking meeting: " + errorStringForLog(error),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Sub View Heading -->
|
||||
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||
Onboarding Meeting
|
||||
Onboarding Meeting Admin
|
||||
</h1>
|
||||
|
||||
<!-- Spacer (no Back button) -->
|
||||
@@ -63,8 +63,8 @@
|
||||
|
||||
<div v-if="currentMeeting.password" class="mt-4">
|
||||
<p class="text-gray-600">
|
||||
Share the password with the members. You can also send them the
|
||||
"shortcut page for members" link below.
|
||||
Share the meeting name & password with the members, or send them the
|
||||
"Page for Members" link below.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-red-600">
|
||||
@@ -307,15 +307,251 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<MembersList
|
||||
<MeetingMembersList
|
||||
ref="membersList"
|
||||
:match-pairs="matchPairs"
|
||||
:password="currentMeeting.password || ''"
|
||||
:show-organizer-tools="true"
|
||||
:excluded-dids="excludedDids"
|
||||
:exclusion-locked="hasActiveMatches"
|
||||
class="mt-4"
|
||||
@error="handleMembersError"
|
||||
@toggle-exclusion="toggleExclusion"
|
||||
@members-loaded="refreshAdmittedMembers"
|
||||
/>
|
||||
</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
|
||||
v-if="currentMeeting?.projectLink"
|
||||
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 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 ProjectIcon from "../components/ProjectIcon.vue";
|
||||
import {
|
||||
@@ -355,7 +593,7 @@ import {
|
||||
serverMessageForUser,
|
||||
didInfo,
|
||||
} from "../libs/endorserServer";
|
||||
import { encryptMessage } from "../libs/crypto";
|
||||
import { encryptMessage, decryptMessage } from "../libs/crypto";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { APP_SERVER } from "@/constants/app";
|
||||
@@ -369,6 +607,12 @@ import {
|
||||
} from "@/constants/notifications";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import {
|
||||
AxiosErrorResponse,
|
||||
DoNotPairGroup,
|
||||
MatchPair,
|
||||
MeetingExclusionState,
|
||||
} from "@/interfaces";
|
||||
interface ServerMeeting {
|
||||
groupId: number; // from the server
|
||||
name: string; // to & from the server
|
||||
@@ -390,7 +634,9 @@ interface MeetingSetupInputs {
|
||||
components: {
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
MembersList,
|
||||
MeetingMembersList,
|
||||
MeetingMemberMatch,
|
||||
MeetingExclusionGroups,
|
||||
MeetingProjectDialog,
|
||||
ProjectIcon,
|
||||
},
|
||||
@@ -409,22 +655,114 @@ export default class OnboardMeetingView extends Vue {
|
||||
|
||||
currentMeeting: ServerMeeting | null = null;
|
||||
newOrUpdatedMeetingInputs: MeetingSetupInputs | null = null;
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Contact[] = [];
|
||||
allMyDids: string[] = [];
|
||||
apiServer = "";
|
||||
isDeleting = false;
|
||||
isLoading = true;
|
||||
isLoadingMatches = false;
|
||||
isPostingMatch = false;
|
||||
isRegistered = false;
|
||||
showDeleteConfirm = false;
|
||||
fullName = "";
|
||||
allContacts: Contact[] = [];
|
||||
allMyDids: string[] = [];
|
||||
matchPairs: MatchPair[] | null = null;
|
||||
/** 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;
|
||||
showDeleteConfirm = false;
|
||||
|
||||
get minDateTime() {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
|
||||
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() {
|
||||
this.notify = createNotifyHelpers(
|
||||
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
|
||||
await this.ensureSelectedProjectLoaded();
|
||||
|
||||
// Load pairwise matches when organizer has a meeting
|
||||
if (this.currentMeeting?.password) {
|
||||
await this.fetchMatchPairs();
|
||||
}
|
||||
|
||||
this.loadExclusionState();
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
@@ -589,6 +933,12 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.fullName) {
|
||||
this.$saveSettings({
|
||||
firstName: this.newOrUpdatedMeetingInputs.userFullName,
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.newOrUpdatedMeetingInputs.password) {
|
||||
this.notify.warning(
|
||||
NOTIFY_MEETING_PASSWORD_REQUIRED.message,
|
||||
@@ -793,7 +1143,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
this.newOrUpdatedMeetingInputs = null;
|
||||
|
||||
if (this.currentMeeting?.password) {
|
||||
this.$router.push({
|
||||
await this.$router.push({
|
||||
name: "onboard-meeting-setup",
|
||||
query: { password: this.currentMeeting?.password },
|
||||
});
|
||||
@@ -827,6 +1177,230 @@ export default class OnboardMeetingView extends Vue {
|
||||
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) {
|
||||
this.notify.error(message, TIMEOUTS.LONG);
|
||||
}
|
||||
@@ -844,30 +1418,52 @@ export default class OnboardMeetingView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property for selected project
|
||||
* Returns the separately stored selected project data
|
||||
*/
|
||||
get selectedProject(): PlanData | null {
|
||||
return this.selectedProjectData;
|
||||
loadExclusionState(): void {
|
||||
if (!this.meetingGroupIdStr) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(
|
||||
OnboardMeetingView.EXCLUSION_STORAGE_KEY,
|
||||
);
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
saveExclusionState(): void {
|
||||
if (!this.meetingGroupIdStr) return;
|
||||
const state: MeetingExclusionState = {
|
||||
meetingGroupId: this.meetingGroupIdStr,
|
||||
excludedDids: this.excludedDids,
|
||||
doNotPairGroups: this.doNotPairGroups,
|
||||
};
|
||||
localStorage.setItem(
|
||||
OnboardMeetingView.EXCLUSION_STORAGE_KEY,
|
||||
JSON.stringify(state),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -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 {
|
||||
const membersList = this.$refs.membersList as MembersList;
|
||||
const membersList = this.$refs.membersList as MeetingMembersList;
|
||||
if (membersList) {
|
||||
membersList.stopAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dialog close event - start auto-refresh in MembersList
|
||||
* Handle dialog close event - start auto-refresh in MeetingMembersList
|
||||
*/
|
||||
handleDialogClose(): void {
|
||||
const membersList = this.$refs.membersList as MembersList;
|
||||
const membersList = this.$refs.membersList as MeetingMembersList;
|
||||
if (membersList) {
|
||||
membersList.startAutoRefresh();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,31 @@
|
||||
</router-link>
|
||||
</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 -->
|
||||
<div id="start-question">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
@@ -35,10 +60,7 @@
|
||||
<p v-if="PASSKEYS_ENABLED" class="text-center font-light mt-6">
|
||||
A <strong>passkey</strong> is easy to manage, though it is less
|
||||
interoperable with other systems for advanced uses.
|
||||
<a
|
||||
href="https://www.perplexity.ai/search/what-are-passkeys-v2SHV3yLQlyA2CYH6.Nvhg"
|
||||
target="_blank"
|
||||
>
|
||||
<a href="https://duckduckgo.com/?q=what+is+a+passkey" target="_blank">
|
||||
<font-awesome icon="info-circle" class="fa-fw text-blue-500" />
|
||||
</a>
|
||||
</p>
|
||||
@@ -46,7 +68,7 @@
|
||||
A <strong>new seed</strong> allows you full control over the keys,
|
||||
though you are responsible for backups.
|
||||
<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"
|
||||
>
|
||||
<font-awesome icon="info-circle" class="fa-fw text-blue-500" />
|
||||
@@ -139,7 +161,7 @@
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
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 { logger } from "../utils/logger";
|
||||
|
||||
@@ -157,10 +179,12 @@ export default class StartView extends Vue {
|
||||
|
||||
// Feature flags and application constants
|
||||
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
|
||||
SUPPORT_EMAIL = SUPPORT_EMAIL;
|
||||
|
||||
// Component state for identity generation
|
||||
givenName = "";
|
||||
numAccounts = 0;
|
||||
startupError = "";
|
||||
|
||||
/**
|
||||
* Computed property for primary action button styling
|
||||
@@ -201,11 +225,26 @@ export default class StartView extends Vue {
|
||||
*/
|
||||
async mounted() {
|
||||
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();
|
||||
this.givenName = settings.firstName || "";
|
||||
|
||||
// Load account count for display logic
|
||||
this.numAccounts = await retrieveAccountCount();
|
||||
|
||||
logger.debug("[StartView] Component mounted", {
|
||||
@@ -215,7 +254,6 @@ export default class StartView extends Vue {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[StartView] Failed to load initialization data", error);
|
||||
// Continue with default behavior if settings load fails
|
||||
this.givenName = "";
|
||||
this.numAccounts = 0;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ test('Record something given', async ({ page }) => {
|
||||
}, { timeout: 5000 });
|
||||
|
||||
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.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
|
||||
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
|
||||
await expect(page.getByText(finalTitle, { exact: false })).toBeVisible();
|
||||
|
||||
// Click the specific gift item
|
||||
const item = page.locator('li:first-child').filter({ hasText: finalTitle });
|
||||
// Click the specific gift item (find by title - don't assume first-child,
|
||||
// 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 expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
||||
// 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
|
||||
await page.goto('./');
|
||||
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 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.goto('./contacts');
|
||||
// Copy contact details
|
||||
await page.getByTestId('contactCheckAllTop').click();
|
||||
// Select and copy exactly one contact (single-contact deep link flow)
|
||||
await page.getByTestId('contactCheckOne').first().click();
|
||||
|
||||
const isChromium = await page.evaluate(() => {
|
||||
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 page.goto(clipboardText);
|
||||
// we're on the contact-import page
|
||||
await expect(page.getByRole('heading', { name: "Contact Import" })).toBeVisible();
|
||||
// For some reason, Chromium shows 1 contact the same but Firefox shows 4.
|
||||
await expect(page.locator('span', { hasText: 'the same as' })).toBeVisible();
|
||||
// single-contact payload now auto-adds and routes to contact-edit
|
||||
await expect(page).toHaveURL(/\/contact-edit\//);
|
||||
await expect(page.getByTestId('contactName').locator('input')).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: {
|
||||
port: parseInt(process.env.VITE_PORT || "8080"),
|
||||
fs: { strict: false },
|
||||
//allowedHosts: ['bab3-68-69-173-46.ngrok-free.app'],
|
||||
//allowedHosts: ['*'],
|
||||
|
||||
// CORS headers disabled to allow images from any domain
|
||||
// This means SharedArrayBuffer is unavailable, but absurd-sql
|
||||
// will automatically fall back to IndexedDB mode which still works
|
||||
|
||||
Reference in New Issue
Block a user