Compare commits

..

23 Commits

Author SHA1 Message Date
Jose Olarte III
ce54b0b435 mockup: change wording 2025-12-03 09:34:37 +08:00
Jose Olarte III
54351dd67c mockup: HomeView gifting buttons + GiftedDialog photo button 2025-12-02 23:32:28 +08:00
6ec2002cb0 Merge pull request 'refactor: move Import Contacts section to DataExportSection component' (#226) from accountview-contact-management-bundling into master
Reviewed-on: #226
2025-11-21 11:02:59 +00:00
Jose Olarte III
36eb9a16b0 fix: preserve contact methods and notes in export/import workflow
- Fix contactMethods not being exported (was checking for string instead of array)
- Add missing notes and iViewContent fields to $insertContact method
- Normalize empty strings to null when saving contacts in ContactEditView

This ensures contact data integrity is maintained during export/import operations.
2025-11-20 18:11:27 +08:00
7d295dd062 feat: make the contact methods more presentable, and clarify exact types 2025-11-19 20:00:42 -07:00
5f1b4dcc21 chore: bump version and add "-beta" 2025-11-19 20:00:09 -07:00
11f122552d chore: bump to version 1.1.3 number 48 2025-11-19 19:58:48 -07:00
c84a3b6705 add instructions to connect to any user profile (#224)
See https://app.clickup.com/t/86b76734v

Reviewed-on: #224
Co-authored-by: Trent Larson <trent@trentlarson.com>
Co-committed-by: Trent Larson <trent@trentlarson.com>
2025-11-19 18:58:49 +00:00
Jose Olarte III
203cf6b078 refactor(DataExportSection): rename section title to "Data Management"
Update the section title from "Data Export" to "Data Management" to better reflect that the component handles both data export and import functionality.
2025-11-19 21:32:35 +08:00
Jose Olarte III
9b84b28a78 refactor: move Import Contacts section to DataExportSection component
- Move Import Contacts UI section from AccountViewView to DataExportSection
- Consolidate import/export functionality in a single component
- Move related methods: uploadImportFile, showContactImport, checkContactImports
- Convert module-level ref to component property (inputImportFileName)
- Remove unused imports (ref, ImportContent) from AccountViewView
- Rename "Download Contacts" button to "Export Contacts"
- Improve import UI styling with full-width file input and button
2025-11-19 19:26:36 +08:00
e64902321f Merge pull request 'fix(GiftedDialog): preserve recipient when changing giver project' (#225) from gifted-dialog-recipient-fix into master
Reviewed-on: #225
2025-11-19 09:56:55 +00:00
7abce8f95c fix: don't count any changed projects on the front page that had blank differences 2025-11-18 19:53:52 -07:00
88dce4d100 fix: show a "project changed" entry if the server reports something 2025-11-18 19:49:40 -07:00
Jose Olarte III
c4eb6f2d1d fix(GiftedDialog): preserve recipient when changing giver project
Modified selectProject() to only set receiver to "You" if no receiver
has been selected yet, preventing recipient from being reset when
changing giver project in Project-to-Person context.
2025-11-18 15:50:11 +08:00
06fdaff879 Merge pull request 'entitygrid-infinite-scroll-improvements' (#223) from entitygrid-infinite-scroll-improvements into master
Reviewed-on: #223
2025-11-18 06:56:55 +00:00
8024a3d02a Merge pull request 'meeting-project-dialog' (#222) from meeting-project-dialog into master
Reviewed-on: #222
2025-11-18 06:56:23 +00:00
83b470e28a fix: link from DID page to Help 2025-11-16 15:35:19 -07:00
1739567b18 Merge pull request 'feat: replace authorized representative input with contact selection dialog' (#219) from project-representative-dialog into master
Reviewed-on: #219
2025-11-12 01:42:48 -05:00
5050156beb fix: a type, plus add the type-check to the mobile build scripts 2025-11-08 08:31:42 -07:00
d265a9f78c chore: bump version and add "-beta" 2025-11-06 08:56:33 -07:00
f848de15f1 chore: bump version to 1.1.2 build 47 (for fix to seed backup) 2025-11-06 08:54:11 -07:00
ebaf2dedf0 Merge pull request 'fix: database connection error causing navigation redirect on iOS/Android' (#220) from fix-sqlite-connection-error-mobile into master
Reviewed-on: #220
2025-11-06 09:52:31 -05:00
Jose Olarte III
749204f96b fix: database connection error causing navigation redirect on iOS/Android
Handle "Connection already exists" error when initializing SQLite database
on Capacitor platforms. The native connection can persist across app
restarts while the JavaScript connection Map is empty, causing a mismatch.

When createConnection fails with "already exists":
- Check if connection exists in JavaScript Map and retrieve it if present
- If not in Map, close the native connection and recreate to sync both sides
- Handle "already open" errors gracefully when opening existing connections

This fixes the issue where clicking "Backup Identifier Seed" would redirect
to StartView instead of SeedBackupView due to database initialization
failures in the router navigation guard.

Fixes navigation issue on both iOS and Android platforms.
2025-11-06 21:38:51 +08:00
24 changed files with 861 additions and 324 deletions

View File

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

View File

@@ -6,6 +6,21 @@ 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.1.3] - 2025.11.19
### Changed
- Project selection in dialogs now reaches out to server when filtering
- Project selection during onboarding meeting is a search (not an input box)
- Improve the switching of agent when agent edits a project
### Fixed
- Reassignment of "you" as recipient when changing giver project
- Bad counts for project-change notification on front page
## [1.1.2] - 2025.11.06
### Fixed
- Bad page when user follows prompt to backup seed
## [1.1.1] - 2025.11.03
### Added

View File

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

View File

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

24
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.4-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.4-beta",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",
@@ -27,6 +27,7 @@
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
@@ -6789,6 +6790,25 @@
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.1.0.tgz",
"integrity": "sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.4-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"
@@ -156,6 +156,7 @@
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
<template>
<div id="sectionDataExport" :class="containerClasses">
<div :class="titleClasses">Data Export</div>
<div :class="titleClasses">Data Management</div>
<router-link
v-if="activeDid"
:to="{ name: 'seed-backup' }"
@@ -30,7 +30,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
:class="exportButtonClasses"
@click="exportDatabase()"
>
{{ isExporting ? "Exporting..." : "Download Contacts" }}
{{ isExporting ? "Exporting..." : "Export Contacts" }}
</button>
<div
@@ -55,11 +55,54 @@ messages * - Conditional UI based on platform capabilities * * @component *
</li>
</ul>
</div>
<!-- Import Contacts -->
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="mt-2">
<input
type="file"
class="w-full bg-white rounded-md pe-2 file:border-0 file:bg-gradient-to-b file:from-blue-400 file:to-blue-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:me-2 file:rounded-s-md"
@change="uploadImportFile"
/>
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-2">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md 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-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<button
class="block w-full text-center text-md 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-1.5 py-2 rounded-md"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</transition>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as R from "ramda";
import { AppString, NotificationIface } from "../constants/app";
@@ -67,8 +110,10 @@ import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger";
import { contactsToExportJson } from "../libs/util";
import { createNotifyHelpers } from "@/utils/notify";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { ImportContent } from "@/interfaces/accountView";
/**
* @vue-component
@@ -91,6 +136,12 @@ export default class DataExportSection extends Vue {
*/
$notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Router instance injected by Vue
* Used for navigation
*/
$router!: Router;
/**
* Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option
@@ -110,6 +161,12 @@ export default class DataExportSection extends Vue {
*/
showRedNotificationDot = false;
/**
* Reference to the selected import file
* Used to store the file selected by the user for import
*/
private inputImportFileName: Blob | undefined;
/**
* Notification helper for consistent notification patterns
* Created as a getter to ensure $notify is available when called
@@ -200,12 +257,30 @@ export default class DataExportSection extends Vue {
// first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects)
const exContact: Contact = R.omit(["contactMethods"], contact);
// now add contactMethods as a true array of ContactMethod objects
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
// $contacts() returns normalized contacts where contactMethods is already an array,
// but we handle both array and string cases for robustness
if (contact.contactMethods) {
if (Array.isArray(contact.contactMethods)) {
// Already an array, use it directly
exContact.contactMethods = contact.contactMethods;
} else {
// Check if it's a string that needs parsing (shouldn't happen with normalized contacts, but handle for robustness)
const contactMethodsValue = contact.contactMethods as unknown;
if (
typeof contactMethodsValue === "string" &&
contactMethodsValue.trim() !== ""
) {
// String that needs parsing
exContact.contactMethods = JSON.parse(contactMethodsValue);
} else {
// Invalid data, use empty array
exContact.contactMethods = [];
}
}
} else {
// No contactMethods, use empty array
exContact.contactMethods = [];
}
return exContact;
});
@@ -248,5 +323,58 @@ export default class DataExportSection extends Vue {
this.showRedNotificationDot = false;
}
}
/**
* Handles file selection for contact import
* Stores the selected file for later processing
*/
async uploadImportFile(event: Event): Promise<void> {
this.inputImportFileName = (event.target as HTMLInputElement).files?.[0];
}
/**
* Checks if a contact import file has been selected
* Used to conditionally show the import button
*/
showContactImport(): boolean {
return !!this.inputImportFileName;
}
/**
* Processes the selected import file and navigates to the contact import view
* Parses the JSON file and extracts contact data for import
*/
async checkContactImports(): Promise<void> {
if (!this.inputImportFileName) {
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
this.$router.push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(this.inputImportFileName);
}
}
</script>

View File

@@ -61,10 +61,21 @@ control over updates and validation * * @author Matthew Raymer */
</select>
</div>
<!-- Photo & More Options Link -->
<router-link :to="photoOptionsRoute" :class="photoOptionsClasses">
Photo &amp; more options&hellip;
</router-link>
<div class="grid grid-cols-2 gap-2 mb-4">
<button
class="text-center text-md 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-1.5 py-2 rounded-lg"
>
<font-awesome icon="camera" />
Add Photo
</button>
<!-- More Options Link -->
<router-link
:to="optionsRoute"
class="block w-full text-center text-md 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-1.5 py-2 rounded-lg"
>
More Options
</router-link>
</div>
<!-- Sign & Send Info -->
<p class="text-center text-sm mb-4">
@@ -218,13 +229,6 @@ export default class GiftDetailsStep extends Vue {
private localAmount: number = 0;
private localUnitCode: string = "HUR";
/**
* CSS classes for the photo & more options link
*/
get photoOptionsClasses(): string {
return "block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg mb-4";
}
/**
* CSS classes for the cancel button
*/
@@ -304,7 +308,7 @@ export default class GiftDetailsStep extends Vue {
/**
* Computed route for photo & more options
*/
get photoOptionsRoute(): RouteLocationRaw {
get optionsRoute(): RouteLocationRaw {
return {
name: "gifted-details",
query: {

View File

@@ -483,10 +483,13 @@ export default class GiftedDialog extends Vue {
image: project.image,
handleId: project.handleId,
};
this.receiver = {
did: this.activeDid,
name: "You",
};
// Only set receiver to "You" if no receiver has been selected yet
if (!this.receiver || !this.receiver.did) {
this.receiver = {
did: this.activeDid,
name: "You",
};
}
this.firstStep = false;
}

29
src/constants/contacts.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Constants for contact-related functionality
* Created: 2025-11-16
*/
/**
* Contact method types with user-friendly labels
* Used in: ContactEditView.vue, DIDView.vue
*/
export const CONTACT_METHOD_TYPES = [
{ value: "CELL", label: "Mobile" },
{ value: "EMAIL", label: "Email" },
{ value: "WHATSAPP", label: "WhatsApp" },
] as const;
/**
* Type for contact method type values
*/
export type ContactMethodType = (typeof CONTACT_METHOD_TYPES)[number]["value"];
/**
* Helper function to get label for a contact method type
* @param type - The contact method type value (e.g., "CELL", "EMAIL")
* @returns The user-friendly label or the original type if not found
*/
export function getContactMethodLabel(type: string): string {
const methodType = CONTACT_METHOD_TYPES.find((m) => m.value === type);
return methodType ? methodType.label : type;
}

View File

@@ -57,7 +57,12 @@ export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName: string;
}
// a summary record; the VC is not currently part of this record
/**
* A summary record
* The VC is not currently part of this record.
*
* If you change this, you may want to update NewActivityView.vue to handle differences correctly.
*/
export interface PlanSummaryRecord {
agentDid?: string;
description: string;
@@ -76,7 +81,9 @@ export interface PlanSummaryRecord {
export interface PlanSummaryAndPreviousClaim {
plan: PlanSummaryRecord;
wrappedClaimBefore: GenericCredWrapper<PlanActionClaim>;
// This can be undefined, eg. if a project is starred after the stored last-seen-change-jwt ID.
// The endorser-ch test code shows some cases.
wrappedClaimBefore?: GenericCredWrapper<PlanActionClaim>;
}
/**

View File

@@ -1686,7 +1686,10 @@ export async function register(
"Registration thrown error:",
errorMessage || JSON.stringify(err),
);
return { error: errorMessage || "Got a server error when registering." };
return {
error:
(errorMessage as string) || "Got a server error when registering.",
};
}
return { error: "Got a server error when registering." };
}

View File

@@ -43,6 +43,7 @@ import {
faDownload,
faEllipsis,
faEllipsisVertical,
faEnvelope,
faEnvelopeOpenText,
faEraser,
faEye,
@@ -101,6 +102,9 @@ import {
// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue
import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons";
// Brand icons
import { faWhatsapp } from "@fortawesome/free-brands-svg-icons";
// Initialize Font Awesome library with all required icons
library.add(
faArrowDown,
@@ -140,6 +144,7 @@ library.add(
faDownload,
faEllipsis,
faEllipsisVertical,
faEnvelope,
faEnvelopeOpenText,
faEraser,
faEye,
@@ -193,6 +198,7 @@ library.add(
faTriangleExclamation,
faUser,
faUsers,
faWhatsapp,
faXmark,
);

View File

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

View File

@@ -1367,6 +1367,9 @@ export const PlatformServiceMixin = {
contact.profileImageUrl !== undefined
? contact.profileImageUrl
: null,
notes: contact.notes !== undefined ? contact.notes : null,
iViewContent:
contact.iViewContent !== undefined ? contact.iViewContent : null,
contactMethods:
contact.contactMethods !== undefined
? Array.isArray(contact.contactMethods)
@@ -1377,8 +1380,8 @@ export const PlatformServiceMixin = {
await this.$dbExec(
`INSERT OR REPLACE INTO contacts
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, iViewContent, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
safeContact.did,
safeContact.name,
@@ -1387,6 +1390,8 @@ export const PlatformServiceMixin = {
safeContact.registered,
safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl,
safeContact.notes,
safeContact.iViewContent,
safeContact.contactMethods,
],
);

View File

@@ -375,45 +375,6 @@
Switch Identifier
</router-link>
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="ml-4 mt-2">
<input type="file" class="ml-2" @change="uploadImportFile" />
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-4">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md 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-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<div class="flex justify-center">
<button
class="block text-center text-md 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-1.5 py-2 rounded-md mb-6"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</div>
</transition>
</div>
</div>
<label
for="toggleShowAmounts"
class="flex items-center justify-between cursor-pointer my-4"
@@ -770,9 +731,7 @@ import "dexie-export-import";
import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet";
import * as L from "leaflet";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
@@ -799,7 +758,6 @@ import {
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
import { Contact } from "../db/tables/contacts";
import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
BoundingBox,
@@ -823,11 +781,7 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
AccountSettings,
isApiError,
ImportContent,
} from "@/interfaces/accountView";
import { AccountSettings, isApiError } from "@/interfaces/accountView";
// Profile data interface (inlined from ProfileService)
interface ProfileData {
description: string;
@@ -836,8 +790,6 @@ interface ProfileData {
includeLocation: boolean;
}
const inputImportFileNameRef = ref<Blob>();
interface UserNameDialogRef {
open: (cb: (name?: string) => void) => void;
}
@@ -1369,65 +1321,6 @@ export default class AccountViewView extends Vue {
);
}
async uploadImportFile(event: Event): Promise<void> {
inputImportFileNameRef.value = (
event.target as HTMLInputElement
).files?.[0];
}
showContactImport(): boolean {
return !!inputImportFileNameRef.value;
}
confirmSubmitImportFile(): void {
if (inputImportFileNameRef.value != null) {
this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMPORT_REPLACE_WARNING,
this.submitImportFile,
);
}
}
/**
* Asynchronously imports the database from a downloadable JSON file.
*
* @throws Will notify the user if there is an export error.
*/
async submitImportFile(): Promise<void> {
if (inputImportFileNameRef.value != null) {
// TODO: implement this for SQLite
}
}
async checkContactImports(): Promise<void> {
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
(this.$router as Router).push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(inputImportFileNameRef.value as Blob);
}
private progressCallback(progress: ImportProgress): boolean {
logger.log(
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,

View File

@@ -55,66 +55,70 @@
<!-- Contact Methods -->
<div class="mt-4">
<h2 class="text-lg font-medium text-gray-700">Contact Methods</h2>
<div
v-for="(method, index) in contactMethods"
:key="index"
class="flex mt-2"
>
<input
v-model="method.label"
type="text"
class="block w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label"
/>
<input
v-model="method.type"
type="text"
class="block ml-2 w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Type"
/>
<div class="relative">
<button
class="px-2 py-1 bg-gray-200 rounded-md"
@click="toggleDropdown(index)"
>
<font-awesome icon="caret-down" class="fa-fw" />
</button>
<div
v-if="dropdownIndex === index"
class="absolute bg-white border border-gray-300 rounded-md mt-1"
>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'CELL')"
<div v-for="(method, index) in contactMethods" :key="index" class="mt-4">
<!-- Type and Value Row -->
<div class="flex gap-2">
<div class="flex-none w-32">
<label class="block text-xs font-medium text-gray-700 mb-1">
Type
</label>
<select
v-model="method.type"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
>
CELL
</div>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'EMAIL')"
>
EMAIL
</div>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'WHATSAPP')"
>
WHATSAPP
</div>
<option value=""></option>
<option
v-for="methodType in contactMethodTypes"
:key="methodType.value"
:value="methodType.value"
>
{{ methodType.label }}
</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-700 mb-1">
Value
</label>
<input
v-model="method.value"
type="text"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
</div>
<button
class="self-end pb-0.5 text-red-500"
@click="removeContactMethod(index)"
>
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<!-- WhatsApp Help Text -->
<div
v-if="method.type === 'WHATSAPP'"
class="mt-1 ml-[calc(8rem+0.5rem)] text-xs text-gray-600 italic"
>
Must include country code and only numbers (e.g., 12225551234)
</div>
<!-- Label Row -->
<div class="mt-2 flex justify-end">
<div class="flex-1 ml-[calc(8rem+0.5rem)]">
<label class="block text-xs font-medium text-gray-700 mb-1">
</label>
<input
v-model="method.label"
type="text"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label / Note"
/>
</div>
<div class="w-[2.5rem]"></div>
</div>
<input
v-model="method.value"
type="text"
class="block ml-2 w-1/2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
<button class="ml-2 text-red-500" @click="removeContactMethod(index)">
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<button class="mt-2" @click="addContactMethod">
<button class="mt-4" @click="addContactMethod">
<font-awesome
icon="plus"
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
@@ -157,6 +161,7 @@ import {
} from "../constants/notifications";
import { Contact, ContactMethod } from "../db/tables/contacts";
import { AppString } from "../constants/app";
import { CONTACT_METHOD_TYPES } from "../constants/contacts";
/**
* Contact Edit View Component
@@ -219,11 +224,11 @@ export default class ContactEditView extends Vue {
contactNotes = "";
/** Array of editable contact methods */
contactMethods: Array<ContactMethod> = [];
/** Currently open dropdown index, null if none open */
dropdownIndex: number | null = null;
/** App string constants */
AppString = AppString;
/** Contact method types for datalist suggestions */
contactMethodTypes = CONTACT_METHOD_TYPES;
/**
* Component lifecycle hook that initializes the contact edit form
@@ -280,29 +285,6 @@ export default class ContactEditView extends Vue {
this.contactMethods.splice(index, 1);
}
/**
* Toggles the type selection dropdown for a contact method
*
* If the clicked dropdown is already open, closes it.
* If another dropdown is open, closes it and opens the clicked one.
*
* @param index The array index of the method whose dropdown to toggle
*/
toggleDropdown(index: number) {
this.dropdownIndex = this.dropdownIndex === index ? null : index;
}
/**
* Sets the type for a contact method and closes the dropdown
*
* @param index The array index of the method to update
* @param type The new type value (CELL, EMAIL, WHATSAPP)
*/
setMethodType(index: number, type: string) {
this.contactMethods[index].type = type;
this.dropdownIndex = null;
}
/**
* Saves the edited contact information to the database
*
@@ -338,9 +320,10 @@ export default class ContactEditView extends Vue {
}
// Save to database via PlatformServiceMixin
// Normalize empty strings to null to preserve database consistency
await this.$updateContact(this.contact?.did || "", {
name: this.contactName,
notes: this.contactNotes,
name: this.contactName?.trim() || null,
notes: this.contactNotes?.trim() || null,
contactMethods: contactMethods,
});

View File

@@ -20,12 +20,12 @@
</button>
<!-- Help button -->
<button
<router-link
:to="{ name: 'help' }"
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="goToHelp()"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</button>
</router-link>
</div>
<!-- Identity Details -->
@@ -42,6 +42,58 @@
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</router-link>
</h2>
<!-- Notes -->
<div v-if="contactFromDid.notes" class="mt-3">
<p class="text-sm text-slate-700 whitespace-pre-wrap">
{{ contactFromDid.notes }}
</p>
</div>
<!-- Contact Methods -->
<div v-if="contactFromDid.contactMethods?.length" class="mt-3">
<div class="flex flex-col gap-2">
<div
v-for="(method, index) in contactFromDid.contactMethods"
:key="index"
class="flex items-center gap-2 text-sm"
>
<span class="font-semibold text-slate-600"
>{{
getContactMethodLabel(method.type) || "(unspecified)"
}}:</span
>
<span class="text-slate-700">{{ method.label }}</span>
<span class="text-slate-600">{{ method.value }}</span>
<a
v-if="method.type === 'CELL'"
:href="`sms:${method.value}`"
class="ml-2 text-blue-500 hover:text-blue-700"
title="Send text message"
>
<font-awesome icon="message" class="text-base" />
</a>
<a
v-if="method.type === 'EMAIL'"
:href="`mailto:${method.value}`"
class="ml-2 text-blue-500 hover:text-blue-700"
title="Send email"
>
<font-awesome icon="envelope" class="text-base" />
</a>
<a
v-if="method.type === 'WHATSAPP'"
:href="`https://wa.me/${method.value.replace(/\D/g, '')}`"
target="_blank"
class="ml-2 text-blue-700"
title="Send WhatsApp message"
>
<font-awesome :icon="['fab', 'whatsapp']" class="text-base" />
</a>
</div>
</div>
</div>
<button class="ml-2 mr-2 mt-4" @click="toggleDidDetails">
Details
<font-awesome
@@ -302,6 +354,7 @@ import {
NOTIFY_CONTACT_INVALID_DID,
} from "@/constants/notifications";
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
import { getContactMethodLabel } from "@/constants/contacts";
/**
* DIDView Component
@@ -352,6 +405,7 @@ export default class DIDView extends Vue {
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
didInfoForContact = didInfoForContact;
displayAmount = displayAmount;
getContactMethodLabel = getContactMethodLabel;
/**
* Initializes notification helpers

View File

@@ -108,37 +108,60 @@ Raymer * @version 1.0.0 */
/>
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
<!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
<h2 class="font-bold">Record something given by:</h2>
<button
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="openGiftedPrompts()"
>
<font-awesome
icon="lightbulb"
class="block text-center w-[1em]"
/>
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<!-- Record Quick-Action - variant A -->
<div v-if="false" class="mb-6">
<div class="flex justify-end gap-1">
<button
type="button"
class="text-center text-base uppercase 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-2 rounded-lg"
class="grow text-center text-xl font-bold 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-3 py-2 rounded-lg"
@click="openPersonDialog()"
>
<font-awesome icon="user" />
Person
<font-awesome icon="circle-plus" />
Record a Give
</button>
<button
type="button"
class="text-center text-base uppercase 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-2 rounded-lg"
class="text-center text-lg uppercase 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-2 rounded-lg"
@click="openProjectDialog()"
>
<font-awesome icon="folder-open" class="fa-fw" />
</button>
<button
type="button"
class="text-center text-lg uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
@click="openGiftedPrompts()"
>
<font-awesome icon="lightbulb" class="fa-fw" />
</button>
</div>
</div>
<!-- Record Quick-Action - variant B -->
<div class="mb-6">
<button
type="button"
class="w-full text-center text-2xl font-bold 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-3 py-2 rounded-lg mb-2"
@click="openPersonDialog()"
>
<font-awesome icon="circle-plus" />
Record a Give
</button>
<div class="grid grid-cols-2 gap-1">
<button
type="button"
class="text-center text-base 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-2 rounded-lg"
@click="openProjectDialog()"
>
<font-awesome icon="folder-open" />
Project
Project Give
</button>
<button
type="button"
class="text-center text-base 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-2 rounded-lg"
@click="openGiftedPrompts()"
>
<font-awesome icon="lightbulb" />
Give Ideas
</button>
</div>
</div>
@@ -898,7 +921,13 @@ export default class HomeView extends Vue {
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
// filter out any data elements where there is no wrappedClaimBefore
const filteredNewStarredProjectChanges =
starredProjectChanges.data.filter(
(change) => change.wrappedClaimBefore !== undefined,
);
this.numNewStarredProjectChanges =
filteredNewStarredProjectChanges.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) {
// Don't show errors for starred project changes as it's a secondary feature

View File

@@ -284,7 +284,10 @@
</table>
</div>
</div>
<div v-else>The changes did not affect essential project data.</div>
<div v-else>
The changes are not important, like it was saved by accident or
you've seen it all before.
</div>
<!-- New line that appears on hover -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@@ -589,13 +592,13 @@ export default class NewActivityView extends Vue {
for (const planChange of planChanges) {
const currentPlan: PlanSummaryRecord = planChange.plan;
const wrappedClaim: GenericCredWrapper<PlanActionClaim> =
const wrappedClaim: GenericCredWrapper<PlanActionClaim> | undefined =
planChange.wrappedClaimBefore;
// Extract the actual claim from the wrapped claim
let previousClaim: PlanActionClaim;
let previousClaim: PlanActionClaim | undefined;
const embeddedClaim: PlanActionClaim = wrappedClaim.claim;
const embeddedClaim: PlanActionClaim | undefined = wrappedClaim?.claim;
if (
embeddedClaim &&
typeof embeddedClaim === "object" &&
@@ -609,7 +612,9 @@ export default class NewActivityView extends Vue {
previousClaim = embeddedClaim;
}
if (!previousClaim || !currentPlan.handleId) {
if (!previousClaim) {
// Can happen when a project is starred after the stored last-seen-change-jwt ID
// so we'll just leave the message saying there are no important differences.
continue;
}

View File

@@ -57,6 +57,9 @@
<button :class="sqlLinkClasses" @click="setAccountsQuery">
Accounts
</button>
<button :class="sqlLinkClasses" @click="setActiveIdentityQuery">
Active DID
</button>
<button :class="sqlLinkClasses" @click="setContactsQuery">
Contacts
</button>
@@ -525,6 +528,11 @@ export default class Help extends Vue {
this.executeSql();
}
setActiveIdentityQuery() {
this.sqlQuery = "SELECT * FROM active_identity;";
this.executeSql();
}
setContactsQuery() {
this.sqlQuery = "SELECT * FROM contacts;";
this.executeSql();

View File

@@ -54,6 +54,108 @@
</p>
</div>
<!-- Nearest Neighbors Section -->
<div
v-if="
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
profile.issuerDid !== neighbors?.[0]?.did // and they're not directly connected (since there's no in-between)
"
class="mt-6"
>
<h2 class="text-lg font-semibold mb-3">Network Connections</h2>
<div v-if="loadingNeighbors">
<div class="flex justify-center items-center py-8">
<font-awesome
icon="spinner"
class="fa-spin-pulse text-2xl text-slate-400"
/>
</div>
</div>
<div
v-else-if="neighborsError"
class="bg-red-50 border border-red-300 rounded-md p-4"
>
<div class="flex items-start gap-2">
<font-awesome
icon="exclamation-triangle"
class="text-red-500 mt-0.5"
/>
<p class="text-red-700">{{ neighborsError }}</p>
</div>
</div>
<div v-else>
<div class="space-y-2">
<div
v-for="neighbor in neighbors"
:key="neighbor.did"
class="bg-slate-50 border border-slate-300 rounded-md"
>
<div class="flex items-center justify-between gap-3 p-3">
<div class="flex items-center gap-2 flex-1 min-w-0">
<button
title="Copy profile link and expand"
class="text-blue-600 flex-shrink-0"
@click="onNeighborExpandClick(neighbor.did)"
>
<font-awesome
:icon="
expandedNeighborDid === neighbor.did
? 'chevron-down'
: 'chevron-right'
"
class="text-sm"
/>
{{ getNeighborDisplayName(neighbor.did) }}
</button>
<span :class="getRelationBadgeClass(neighbor.relation)">
{{ getRelationLabel(neighbor.relation) }}
</span>
</div>
</div>
<div
v-if="expandedNeighborDid === neighbor.did"
class="border-t border-slate-300 p-3 bg-white"
>
<router-link
:to="{ name: 'did', params: { did: neighbor.did } }"
class="text-blue-600 hover:text-blue-800 font-medium underline"
>
Go to contact info
</router-link>
and send them the link in your clipboard and ask for an
introduction to this person.
<div
v-if="
getNeighborDisplayName(neighbor.did) === '' ||
neighborIsNotInContacts(neighbor.did)
"
class="flex flex-col gap-1 mt-2"
>
<p class="text-xs text-slate-600">
This person is connected to you, but they are not in this
device's contacts. Copy this DID link and check on another
device or check with different people.
</p>
<span class="flex items-center gap-1 min-w-0">
<span class="text-xs truncate text-slate-600 min-w-0">
{{ neighbor.did }}
</span>
<button
title="Copy DID Link"
class="text-blue-600 hover:text-blue-800 underline cursor-pointer flex-shrink-0"
@click.prevent="onCopyDidClick(neighbor.did)"
>
<font-awesome icon="copy" class="text-sm" />
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Map for first coordinates -->
<div v-if="hasFirstLocation" class="mt-4">
<h2 class="text-lg font-semibold">Location</h2>
@@ -159,7 +261,11 @@ export default class UserProfileView extends Vue {
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
expandedNeighborDid: string | null = null;
isLoading = true;
loadingNeighbors = false;
neighbors: Array<{ did: string; relation: string }> = [];
neighborsError = "";
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
profile: UserProfile | null = null;
@@ -183,8 +289,8 @@ export default class UserProfileView extends Vue {
*/
async mounted() {
await this.initializeSettings();
await this.loadContacts();
await this.loadProfile();
await this.loadNeighbors();
}
/**
@@ -199,12 +305,7 @@ export default class UserProfileView extends Vue {
this.activeDid = activeIdentity.activeDid || "";
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
}
/**
* Loads all contacts from database
*/
private async loadContacts() {
this.allContacts = await this.$getAllContacts();
this.allMyDids = await retrieveAccountDids();
}
@@ -249,23 +350,100 @@ export default class UserProfileView extends Vue {
}
/**
* Copies profile link to clipboard
* Loads nearest neighbors from partner API
*
* Creates a deep link to the profile and copies it to the clipboard
* Shows success notification when completed
* Fetches network connections for the profile and displays them
* with appropriate relation labels
*/
async loadNeighbors() {
const profileId: string = this.$route.params.id as string;
if (!profileId) {
return;
}
this.loadingNeighbors = true;
this.neighborsError = "";
try {
const response = await fetch(
`${this.partnerApiServer}/api/partner/userProfileNearestNeighbors/${encodeURIComponent(profileId)}`,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
if (response.status === 200) {
const result = await response.json();
this.neighbors = result.data;
this.neighborsError = "";
} else {
logger.warn("Failed to load neighbors:", response.status);
this.neighbors = [];
this.neighborsError = "Failed to load network connections.";
}
} catch (error) {
logger.error("Error loading neighbors:", error);
this.neighbors = [];
this.neighborsError =
"An error occurred while loading network connections.";
} finally {
this.loadingNeighbors = false;
}
}
/**
* Copies a deep link to the profile to the clipboard
*/
async onCopyLinkClick() {
// Use production URL for sharing to avoid localhost issues in development
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("profile link", TIMEOUTS.STANDARD);
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying profile link: ${error}`, true);
this.notify.error("Failed to copy profile link.");
}
}
/**
* Copies a deep link to the provided DID to the clipboard
*/
async onCopyDidClick(did: string) {
const deepLink = `${APP_SERVER}/deep-link/did/${encodeURIComponent(did)}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("DID link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying DID link: ${error}`, true);
this.notify.error("Failed to copy DID link.");
}
}
/**
* Handles clicking the expand button next to a neighbor's name
* Copies the profile link to clipboard and toggles the expanded section
*/
async onNeighborExpandClick(did: string) {
if (this.expandedNeighborDid === did) {
this.expandedNeighborDid = null;
// don't copy the link
return;
}
// Copy the profile link
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying profile link: ${error}`, true);
this.notify.error("Failed to copy profile link.");
}
// Toggle the expanded section
this.expandedNeighborDid = did;
}
/**
* Computed properties for template logic streamlining
*/
@@ -330,5 +508,64 @@ export default class UserProfileView extends Vue {
get tileLayerUrl() {
return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
}
/**
* Gets display name for a neighbor's DID
* Uses didInfo utility to show contact name if available, otherwise DID
* @param did - The DID to get display name for
* @returns Formatted display name
*/
getNeighborDisplayName(did: string): string {
return this.didInfo(did, this.activeDid, this.allMyDids, this.allContacts);
}
neighborIsNotInContacts(did: string) {
return !this.allContacts.some((contact) => contact.did === did);
}
noNeighborsAreInContacts() {
return this.neighbors.every(
(neighbor) =>
!this.allContacts.some((contact) => contact.did === neighbor.did),
);
}
/**
* Gets human-readable label for relation type
* @param relation - The relation type from API
* @returns Display label for the relation
*/
getRelationLabel(relation: string): string {
switch (relation) {
case "REGISTERED_BY_YOU":
return "Registered by You";
case "REGISTERED_YOU":
return "Registered You";
case "TARGET":
return "Yourself";
default:
return relation;
}
}
/**
* Gets CSS classes for relation badge styling
* @param relation - The relation type from API
* @returns CSS class string for badge
*/
getRelationBadgeClass(relation: string): string {
const baseClasses =
"text-xs font-semibold px-2 py-1 rounded whitespace-nowrap";
switch (relation) {
case "REGISTERED_BY_YOU":
return `${baseClasses} bg-blue-100 text-blue-700`;
case "REGISTERED_YOU":
return `${baseClasses} bg-green-100 text-green-700`;
case "TARGET":
return `${baseClasses} bg-purple-100 text-purple-700`;
default:
return `${baseClasses} bg-slate-100 text-slate-700`;
}
}
}
</script>