Compare commits

...

45 Commits

Author SHA1 Message Date
3118f71320 fix linting (whitespace only) 2025-06-18 21:44:11 -06:00
d12f23aa81 Merge pull request 'Make all external URLs go to the /deep-link/ endpoint to redirect to mobile vs web' (#139) from deep-link-redirect into master
Reviewed-on: #139
2025-06-18 23:33:12 -04:00
e9a8a3c1e7 add support for deep-link query parameters 2025-06-18 19:31:16 -06:00
1e0efe6011 lengthen the error timeout when the message may be complicated, eg. with details from the server 2025-06-18 18:32:55 -06:00
16557f1e4b update build instruction & package-lock.json 2025-06-18 17:32:41 -06:00
c4a54967bc fix linting 2025-06-18 16:33:55 -06:00
20ade415dc bump to version 0.5.8 build 34 2025-06-18 16:31:31 -06:00
6689520270 fix all copies for externally-shared links to redirected deep links 2025-06-18 15:53:16 -06:00
3fd6c2b80d add first cut at deep-link redirecting, with one example contact-import that works on mobile 2025-06-18 13:16:17 -06:00
a5c5c2b9dd bump to build 33 and version 0.5.7 2025-06-18 02:34:18 -06:00
cf33a39fbc fix the invite-one setup, fix adeep link, and tweak other verbiage & error info 2025-06-18 02:32:47 -06:00
8629cefa13 bump to build 32 & version 0.5.6 2025-06-17 05:25:45 -06:00
5e851e442f shrink the contents of the QR code so people can scan it 2025-06-16 15:38:11 -06:00
4a43bc9c6c bump build to 31 and version to 0.5.5 2025-06-16 07:38:16 -06:00
60de8cee62 reword some of the help-page introduction (no code changes) 2025-06-16 07:24:37 -06:00
Jose Olarte III
bb2a4ab76e URL scheme config for iOS
- Registers the timesafari:// URL scheme
- Sets the bundle URL name to app.timesafari
2025-06-16 16:09:08 +08:00
Matthew Raymer
048dded278 fix: resolve deep link route mismatch for project links
- Fix schema validation mismatch between "project-details" and "project"
- Update VALID_DEEP_LINK_ROUTES to include "project" instead of "project-details"
- Update deepLinkSchemas to use "project" route name
- Update documentation to reflect correct route name
- Resolves "Invalid route path: project" errors in deep link handling

The deep link timesafari://project/01JWH0YAB3MAGBD751VAJAXQ17 now works correctly
and routes to the ProjectViewView component as expected.

Fixes: Deep link validation errors for project routes
2025-06-16 05:48:13 +00:00
e240c2940a remove unused deep links and add another 2025-06-15 13:54:12 -06:00
54dca9e745 fix project deep-link (and reorder alphabetically) 2025-06-15 11:02:16 -06:00
9f0fed0a60 update ios check to work, and add links to app stores 2025-06-14 22:10:49 -06:00
0d152adbf2 remove the deep-link autoVerify because it caused a build failure 2025-06-14 22:06:12 -06:00
cead308800 incorporate one of the BUILDING steps directly into the file 2025-06-13 22:37:03 -06:00
676a301331 bump to build 30 version 0.5.4 2025-06-13 22:36:28 -06:00
d6db81cc36 fix some result types and refactor types themselves 2025-06-13 21:58:57 -06:00
Matthew Raymer
f2ddcd2541 feat: add conditional rendering for claim certificate link and update gitignore
- Add v-if directive to show claim certificate link only when veriClaim.id exists
- Update .gitignore to exclude android app resource directory
- Prevents broken links when claim data is not fully loaded
- Improves build process by ignoring generated Android resources

This change ensures the certificate link is only displayed when there's
valid claim data available, preventing navigation errors and improving
user experience. The gitignore update helps keep the repository clean
by excluding Android-specific generated files.
2025-06-14 03:31:12 +00:00
fb81f7b96e fix problems with :href links causing the app to reload for DB errors on mobile 2025-06-13 20:39:12 -06:00
a23416ead1 fix optional message at top to not overflow 2025-06-12 20:10:31 -06:00
530c7c1a13 fix problem with user-profile page, and bump to build 29 & version 0.5.3 2025-06-12 19:16:02 -06:00
f255ea389b bump to build 26 and version 0.5.1 2025-06-11 00:46:46 -06:00
0d343b9877 Merge pull request 'fix creation of did-specific settings (with a rename)' (#138) from fix-did-specifics into master
Reviewed-on: #138
2025-06-11 02:14:41 -04:00
df06100c32 remove more debugging 2025-06-10 23:49:14 -06:00
Matthew Raymer
ac5ddfc6f2 style: fix line length in ContactsView ternary operator
- Break long CSS class strings into multiple concatenated lines
- Ensure all lines are under 100 characters for better readability
- Maintain same functionality and styling behavior
- Improve code maintainability and readability

Fixes: Long lines in conditional CSS class assignment
2025-06-11 05:45:58 +00:00
Matthew Raymer
89b3f30466 fix: debug and clean up GiftedPrompts contact retrieval logic
- Add comprehensive debug logging to identify contact list population issues
- Fix array indexing bug in contact mapping (someContactDbIndex -> 0)
- Clean up all console.log statements for production readiness
- Improve contact retrieval debugging for SQLite and Dexie databases
- Maintain core functionality while adding diagnostic capabilities

Debugging: Contact list population issues in GiftedPrompts component
Cleanup: Remove debug console.log statements
2025-06-11 05:40:05 +00:00
Matthew Raymer
3cb5cc096b refactor: use databaseUtil.updateDefaultSettings for feed filter settings
- Replace direct platform service calls with databaseUtil.updateDefaultSettings
- Remove manual SQL query construction in favor of centralized utility
- Improve code consistency and maintainability
- Add proper error handling through databaseUtil's built-in mechanisms
- Remove unused PlatformServiceFactory import
- Fix SQL syntax errors in clearAll and setAll methods (AND -> comma)
- Ensure both SQLite and Dexie databases are updated consistently

Improves: FeedFilters component architecture and error handling
Fixes: isNearby and filterFeedByVisible settings not being saved properly
2025-06-11 05:19:15 +00:00
Matthew Raymer
5df560154f fix: resolve cross-platform contactMethods JSON parsing inconsistencies
- Add platform-agnostic parseJsonField utility for contactMethods handling
- Update contact export functions (contactsToExportJson, contactToCsvLine)
- Fix contact storage in QR scan views (ContactQRScanShowView, ContactQRScanFullView)
- Ensure consistent JSON string storage across web SQLite and Capacitor SQLite
- Prevents "[object Object] is not valid JSON" errors when switching platforms
- Maintains compatibility between auto-parsing web SQLite and raw string Capacitor SQLite

Fixes: contactMethods parsing errors in export and QR scan functionality
Related: searchBoxes field had similar issue (already fixed)
2025-06-11 04:17:38 +00:00
Matthew Raymer
c1aa522e6c fix: resolve cross-platform SQLite JSON parsing inconsistencies
- Add platform-agnostic parseJsonField utility to handle different SQLite implementations
- Web SQLite (wa-sqlite/absurd-sql) auto-parses JSON strings to objects
- Capacitor SQLite returns raw strings requiring manual parsing
- Update searchBoxes parsing to use new utility for consistent behavior
- Fixes "[object Object] is not valid JSON" error when switching platforms
- Ensures compatibility between web and mobile SQLite implementations

Fixes: searchBoxes parsing errors in databaseUtil.ts
Related: contactMethods field has similar issue (needs same treatment)
2025-06-11 03:44:28 +00:00
a082469a01 fix creation of did-specific settings (with a rename) 2025-06-10 20:51:22 -06:00
Jose Olarte III
3544d7278d Optimized item actions
- Edited button labels for brevity
- Repositioned Totals toggle
- Restyled note about recent hours
- Various text size and spacing changes
2025-06-10 19:54:05 +08:00
Jose Olarte III
d3110506ea Optimized per-item layout
- Stacked contact name and DID
- Text truncates to leave room for action buttons when visible
- Separated "from / to" heading from buttons to minimize width
- Various spacing and alignment adjustments
2025-06-10 18:42:49 +08:00
8609f8458d bump to build 25 & version 0.5.0 2025-06-09 09:26:21 -06:00
8f5c34bc5f fix linting 2025-06-09 09:09:54 -06:00
b0d61b95ea Merge branch 'ui-fixes-2025-06-w2' 2025-06-09 08:44:42 -06:00
af7bd236a3 fix check for successful gift submission 2025-06-09 08:41:47 -06:00
d719338bcc fix problem setting 'loading' flag 2025-06-09 08:37:42 -06:00
6ddf2d1012 fix problem switching IDs (creating too many settings) 2025-06-09 08:33:33 -06:00
57 changed files with 2037 additions and 1269 deletions

1
.gitignore vendored
View File

@@ -55,3 +55,4 @@ build_logs/
icons icons
android/app/src/main/res/

View File

@@ -9,19 +9,6 @@ For a quick dev environment setup, use [pkgx](https://pkgx.dev).
- Node.js (LTS version recommended) - Node.js (LTS version recommended)
- npm (comes with Node.js) - npm (comes with Node.js)
- Git - Git
- For Android builds: Android Studio with SDK installed
- For iOS builds: macOS with Xcode and ruby gems & bundle
- `pkgx +rubygems.org sh`
- ... 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
```
- For desktop builds: Additional build tools based on your OS - For desktop builds: Additional build tools based on your OS
## Forks ## Forks
@@ -54,6 +41,7 @@ Install dependencies:
1. Run the production build: 1. Run the production build:
```bash ```bash
rm -rf dist
npm run build:web npm run build:web
``` ```
@@ -77,6 +65,8 @@ Install dependencies:
* Commit everything (since the commit hash is used the app). * Commit everything (since the commit hash is used the app).
* Run a build to make sure package-lock version is updated, linting works, etc: `npm install && npm run build`
* Put the commit hash in the changelog (which will help you remember to bump the version later). * Put the commit hash in the changelog (which will help you remember to bump the version later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`. * Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`.
@@ -84,7 +74,7 @@ Install dependencies:
* For test, build the app (because test server is not yet set up to build): * For test, build the app (because test server is not yet set up to build):
```bash ```bash
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build:web
``` ```
... and transfer to the test server: ... and transfer to the test server:
@@ -326,17 +316,33 @@ npm run build:electron-prod && npm run electron:start
Prerequisites: macOS with Xcode installed Prerequisites: macOS with Xcode installed
1. Build the web assets: #### First-time iOS Configuration
- Generate certificates inside XCode.
- Right-click on App and under Signing & Capabilities set the Team.
#### Each Release
0. First time (or if dependencies change):
- `pkgx +rubygems.org sh`
- ... 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
```
1. Build the web assets & update ios:
```bash ```bash
rm -rf dist rm -rf dist
npm run build:web npm run build:web
npm run build:capacitor npm run build:capacitor
```
2. Update iOS project with latest build:
```bash
npx cap sync ios npx cap sync ios
``` ```
@@ -353,15 +359,14 @@ Prerequisites: macOS with Xcode installed
npx capacitor-assets generate --ios npx capacitor-assets generate --ios
``` ```
4. Bump the version to match Android: 4. Bump the version to match Android & package.json:
``` ```
cd ios/App cd ios/App
xcrun agvtool new-version 21 xcrun agvtool new-version 34
# Unfortunately this edits Info.plist directly. # Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5 #xcrun agvtool new-marketing-version 0.4.5
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.4.7;/g" > temp cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.8;/g" > temp && mv temp App.xcodeproj/project.pbxproj
mv temp App.xcodeproj/project.pbxproj
cd - cd -
``` ```
@@ -377,7 +382,7 @@ Prerequisites: macOS with Xcode installed
7. Release 7. Release
* Under "General" we want to rename a bunch of things to "Time Safari" * Someday: Under "General" we want to rename a bunch of things to "Time Safari"
* Choose Product -> Destination -> Any iOS Device * Choose Product -> Destination -> Any iOS Device
* Choose Product -> Archive * Choose Product -> Archive
* This will trigger a build and take time, needing user's "login" keychain password (user's login password), repeatedly. * This will trigger a build and take time, needing user's "login" keychain password (user's login password), repeatedly.
@@ -389,15 +394,9 @@ Prerequisites: macOS with Xcode installed
* You'll probably have to "Manage" something about encryption, disallowed in France. * You'll probably have to "Manage" something about encryption, disallowed in France.
* Then "Save" and "Add to Review" and "Resubmit to App Review". * Then "Save" and "Add to Review" and "Resubmit to App Review".
#### First-time iOS Configuration
- Generate certificates inside XCode.
- Right-click on App and under Signing & Capabilities set the Team.
### Android Build ### Android Build
Prerequisites: Android Studio with SDK installed Prerequisites: Android Studio with Java SDK installed
1. Build the web assets: 1. Build the web assets:
@@ -419,7 +418,7 @@ Prerequisites: Android Studio with SDK installed
npx capacitor-assets generate --android npx capacitor-assets generate --android
``` ```
4. Bump version to match iOS: android/app/build.gradle 4. Bump version to match iOS & package.json: android/app/build.gradle
5. Open the project in Android Studio: 5. Open the project in Android Studio:
@@ -436,7 +435,6 @@ Prerequisites: Android Studio with SDK installed
./gradlew clean ./gradlew clean
./gradlew build -Dlint.baselines.continue=true ./gradlew build -Dlint.baselines.continue=true
cd - cd -
npx cap run android
``` ```
... or, to create the `aab` file, `bundle` instead of `build`: ... or, to create the `aab` file, `bundle` instead of `build`:
@@ -452,7 +450,9 @@ Prerequisites: Android Studio with SDK installed
* Then `bundleRelease`: * Then `bundleRelease`:
```bash ```bash
cd android
./gradlew bundleRelease -Dlint.baselines.continue=true ./gradlew bundleRelease -Dlint.baselines.continue=true
cd -
``` ```
... and find your `aab` file at app/build/outputs/bundle/release ... and find your `aab` file at app/build/outputs/bundle/release
@@ -468,7 +468,7 @@ 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. - 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.
## First-time Android Configuration for deep links ## Android Configuration for deep links
You must add the following intent filter to the `android/app/src/main/AndroidManifest.xml` file: You must add the following intent filter to the `android/app/src/main/AndroidManifest.xml` file:
@@ -480,3 +480,5 @@ You must add the following intent filter to the `android/app/src/main/AndroidMan
<data android:scheme="timesafari" /> <data android:scheme="timesafari" />
</intent-filter> </intent-filter>
``` ```
... though when we tried that most recently it failed to 'build' the APK with: http(s) scheme and host attribute are missing, but are required for Android App Links [AppLinkUrlError]

View File

@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.8]
### Added
- /deep-link/ path for URLs that are shared with people
### Changed
- External links now go to /deep-link/...
- Feed visuals now have arrow imagery from giver to receiver
## [0.4.7] ## [0.4.7]
### Fixed ### Fixed

View File

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

View File

@@ -32,7 +32,7 @@
} }
}, },
"ios": { "ios": {
"contentInset": "always", "contentInset": "never",
"allowsLinkPreview": true, "allowsLinkPreview": true,
"scrollEnabled": true, "scrollEnabled": true,
"limitsNavigationsToAppBoundDomains": true, "limitsNavigationsToAppBoundDomains": true,

View File

@@ -100,6 +100,7 @@ try {
- `src/interfaces/deepLinks.ts`: Type definitions and validation schemas - `src/interfaces/deepLinks.ts`: Type definitions and validation schemas
- `src/services/deepLinks.ts`: Deep link processing service - `src/services/deepLinks.ts`: Deep link processing service
- `src/main.capacitor.ts`: Capacitor integration - `src/main.capacitor.ts`: Capacitor integration
- `src/views/DeepLinkRedirectView.vue`: Page to handle links to both mobile and web
## Type Safety Examples ## Type Safety Examples

View File

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

View File

@@ -49,5 +49,16 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>

1217
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "0.4.8", "version": "0.5.8",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"

View File

@@ -49,7 +49,11 @@
</div> </div>
</div> </div>
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)" data-testid="circle-info-link"> <a
class="cursor-pointer"
data-testid="circle-info-link"
@click="$emit('loadClaim', record.jwtId)"
>
<font-awesome icon="circle-info" class="fa-fw text-slate-500" /> <font-awesome icon="circle-info" class="fa-fw text-slate-500" />
</a> </a>
</div> </div>

View File

@@ -104,7 +104,6 @@ import { USE_DEXIE_DB } from "@/constants/app";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({ @Component({
components: { components: {
@@ -143,19 +142,23 @@ export default class FeedFilters extends Vue {
async toggleHasVisibleDid() { async toggleHasVisibleDid() {
this.settingChanged = true; this.settingChanged = true;
this.hasVisibleDid = !this.hasVisibleDid; this.hasVisibleDid = !this.hasVisibleDid;
await databaseUtil.updateDefaultSettings({
filterFeedByVisible: this.hasVisibleDid,
});
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByVisible: this.hasVisibleDid, filterFeedByVisible: this.hasVisibleDid,
}); });
} }
}
async toggleNearby() { async toggleNearby() {
this.settingChanged = true; this.settingChanged = true;
this.isNearby = !this.isNearby; this.isNearby = !this.isNearby;
const platformService = PlatformServiceFactory.getInstance(); await databaseUtil.updateDefaultSettings({
await platformService.dbExec( filterFeedByNearby: this.isNearby,
`UPDATE settings SET filterFeedByNearby = ? WHERE id = ?`, });
[this.isNearby, MASTER_SETTINGS_KEY],
);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
@@ -169,11 +172,10 @@ export default class FeedFilters extends Vue {
this.settingChanged = true; this.settingChanged = true;
} }
const platformService = PlatformServiceFactory.getInstance(); await databaseUtil.updateDefaultSettings({
await platformService.dbExec( filterFeedByNearby: false,
`UPDATE settings SET filterFeedByNearby = ? AND filterFeedByVisible = ? WHERE id = ?`, filterFeedByVisible: false,
[false, false, MASTER_SETTINGS_KEY], });
);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
@@ -191,11 +193,10 @@ export default class FeedFilters extends Vue {
this.settingChanged = true; this.settingChanged = true;
} }
const platformService = PlatformServiceFactory.getInstance(); await databaseUtil.updateDefaultSettings({
await platformService.dbExec( filterFeedByNearby: true,
`UPDATE settings SET filterFeedByNearby = ? AND filterFeedByVisible = ? WHERE id = ?`, filterFeedByVisible: true,
[true, true, MASTER_SETTINGS_KEY], });
);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {

View File

@@ -321,7 +321,7 @@ export default class GiftedDialog extends Vue {
); );
if (!result.success) { if (!result.success) {
const errorMessage = this.getGiveCreationErrorMessage(result); const errorMessage = result.error;
logger.error("Error with give creation result:", result); logger.error("Error with give creation result:", result);
this.$notify( this.$notify(
{ {
@@ -367,19 +367,6 @@ export default class GiftedDialog extends Vue {
// Helper functions for readability // Helper functions for readability
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() { explainData() {
this.$notify( this.$notify(
{ {

View File

@@ -227,6 +227,7 @@ export default class GivenPrompts extends Vue {
let someContactDbIndex = Math.floor(Math.random() * this.numContacts); let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
let count = 0; let count = 0;
// as long as the index has an entry, loop // as long as the index has an entry, loop
while ( while (
this.shownContactDbIndices[someContactDbIndex] != null && this.shownContactDbIndices[someContactDbIndex] != null &&
@@ -245,9 +246,8 @@ export default class GivenPrompts extends Vue {
[someContactDbIndex], [someContactDbIndex],
); );
if (result) { if (result) {
this.currentContact = databaseUtil.mapQueryResultToValues(result)[ const mappedContacts = databaseUtil.mapQueryResultToValues(result);
someContactDbIndex this.currentContact = mappedContacts[0] as unknown as Contact;
] as unknown as Contact;
} }
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.open(); await db.open();

View File

@@ -48,15 +48,15 @@
<span> <span>
{{ didInfo(visDid) }} {{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)"> <span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<a <router-link
:href="`/did/${visDid}`" :to="{ path: '/did/' + encodeURIComponent(visDid) }"
class="text-blue-500" class="text-blue-500"
> >
<font-awesome <font-awesome
icon="arrow-up-right-from-square" icon="arrow-up-right-from-square"
class="fa-fw" class="fa-fw"
/> />
</a> </router-link>
</span> </span>
</span> </span>
</div> </div>
@@ -77,7 +77,7 @@
If you'd like an introduction, If you'd like an introduction,
<a <a
class="text-blue-500" class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)" @click="copyToClipboard('A link to this page', deepLinkUrl)"
>click here to copy this page, paste it into a message, and ask if >click here to copy this page, paste it into a message, and ask if
they'll tell you more about the {{ roleName }}.</a they'll tell you more about the {{ roleName }}.</a
> >
@@ -104,7 +104,7 @@ import * as R from "ramda";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import { NotificationIface } from "../constants/app"; import { APP_SERVER, NotificationIface } from "../constants/app";
@Component @Component
export default class HiddenDidDialog extends Vue { export default class HiddenDidDialog extends Vue {
@@ -117,7 +117,8 @@ export default class HiddenDidDialog extends Vue {
activeDid = ""; activeDid = "";
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
canShare = false; canShare = false;
windowLocation = window.location.href; deepLinkPathSuffix = "";
deepLinkUrl = window.location.href; // this is changed to a deep link in the setup
R = R; R = R;
serverUtil = serverUtil; serverUtil = serverUtil;
@@ -129,17 +130,21 @@ export default class HiddenDidDialog extends Vue {
} }
open( open(
deepLinkPathSuffix: string,
roleName: string, roleName: string,
visibleToDids: string[], visibleToDids: string[],
allContacts: Array<Contact>, allContacts: Array<Contact>,
activeDid: string, activeDid: string,
allMyDids: Array<string>, allMyDids: Array<string>,
) { ) {
this.deepLinkPathSuffix = deepLinkPathSuffix;
this.roleName = roleName; this.roleName = roleName;
this.visibleToDids = visibleToDids; this.visibleToDids = visibleToDids;
this.allContacts = allContacts; this.allContacts = allContacts;
this.activeDid = activeDid; this.activeDid = activeDid;
this.allMyDids = allMyDids; this.allMyDids = allMyDids;
this.deepLinkUrl = APP_SERVER + "/deep-link/" + this.deepLinkPathSuffix;
this.isOpen = true; this.isOpen = true;
} }
@@ -173,11 +178,11 @@ export default class HiddenDidDialog extends Vue {
} }
onClickShareClaim() { onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation); this.copyToClipboard("A link to this page", this.deepLinkUrl);
window.navigator.share({ window.navigator.share({
title: "Help Connect Me", title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?", text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation, url: this.deepLinkUrl,
}); });
} }
} }

View File

@@ -83,10 +83,7 @@
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { import { createAndSubmitOffer } from "../libs/endorserServer";
createAndSubmitOffer,
serverMessageForUser,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
@@ -250,7 +247,7 @@ export default class OfferDialog extends Vue {
); );
if (!result.success) { if (!result.success) {
const errorMessage = this.getOfferCreationErrorMessage(result); const errorMessage = result.error;
logger.error("Error with offer creation result:", result); logger.error("Error with offer creation result:", result);
this.$notify( this.$notify(
{ {
@@ -290,21 +287,6 @@ export default class OfferDialog extends Vue {
); );
} }
} }
// Helper functions for readability
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getOfferCreationErrorMessage(result: any) {
return (
serverMessageForUser(result) ||
result.error?.userMessage ||
result.error?.error
);
}
} }
</script> </script>

View File

@@ -14,8 +14,8 @@
</div> </div>
</h1> </h1>
The feed underneath this pop-up shows the latest contributions, The feed underneath this pop-up shows the latest contributions, some from
some from people and some from projects. people and some from projects.
<p v-if="isRegistered" class="mt-4"> <p v-if="isRegistered" class="mt-4">
You can now log things that you've seen: You can now log things that you've seen:
@@ -29,8 +29,7 @@
button to express your appreciation for... whatever. button to express your appreciation for... whatever.
</p> </p>
<p class="mt-4"> <p class="mt-4">
Once someone registers you, you can log your Once someone registers you, you can log your appreciation, too.
appreciation, too.
</p> </p>
<p class="mt-4"> <p class="mt-4">
@@ -260,7 +259,7 @@ export default class OnboardingDialog extends Vue {
this.visible = true; this.visible = true;
if (this.page === OnboardPage.Create) { if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages // we'll assume that they've been through all the other pages
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
finishedOnboarding: true, finishedOnboarding: true,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
@@ -274,7 +273,7 @@ export default class OnboardingDialog extends Vue {
async onClickClose(done?: boolean, goHome?: boolean) { async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false; this.visible = false;
if (done) { if (done) {
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
finishedOnboarding: true, finishedOnboarding: true,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {

View File

@@ -8,11 +8,7 @@
> >
<div class="h-full w-full object-contain" v-html="generateIcon()" /> <div class="h-full w-full object-contain" v-html="generateIcon()" />
</a> </a>
<div <div v-else class="h-full w-full object-contain" v-html="generateIcon()" />
v-else
class="h-full w-full object-contain"
v-html="generateIcon()"
/>
</template> </template>
<script lang="ts"> <script lang="ts">
import { toSvg } from "jdenticon"; import { toSvg } from "jdenticon";

View File

@@ -38,14 +38,13 @@ export default class TopMessage extends Vue {
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
) { ) {
const didPrefix = settings.activeDid?.slice(11, 15); const didPrefix = settings.activeDid?.slice(11, 15);
this.message = "You're linked to a non-prod server, user " + didPrefix; this.message = "You're not using prod, user " + didPrefix;
} else if ( } else if (
settings.warnIfProdServer && settings.warnIfProdServer &&
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
) { ) {
const didPrefix = settings.activeDid?.slice(11, 15); const didPrefix = settings.activeDid?.slice(11, 15);
this.message = this.message = "You are using prod, user " + didPrefix;
"You're linked to the production server, user " + didPrefix;
} }
} catch (err: unknown) { } catch (err: unknown) {
this.$notify( this.$notify(

View File

@@ -33,18 +33,18 @@ export const APP_SERVER =
export const DEFAULT_ENDORSER_API_SERVER = export const DEFAULT_ENDORSER_API_SERVER =
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER || import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
AppString.TEST_ENDORSER_API_SERVER; AppString.PROD_ENDORSER_API_SERVER;
export const DEFAULT_IMAGE_API_SERVER = export const DEFAULT_IMAGE_API_SERVER =
import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER || import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER ||
AppString.TEST_IMAGE_API_SERVER; AppString.PROD_IMAGE_API_SERVER;
export const DEFAULT_PARTNER_API_SERVER = export const DEFAULT_PARTNER_API_SERVER =
import.meta.env.VITE_DEFAULT_PARTNER_API_SERVER || import.meta.env.VITE_DEFAULT_PARTNER_API_SERVER ||
AppString.TEST_PARTNER_API_SERVER; AppString.PROD_PARTNER_API_SERVER;
export const DEFAULT_PUSH_SERVER = export const DEFAULT_PUSH_SERVER =
import.meta.env.VITE_DEFAULT_PUSH_SERVER || "https://timesafari.app"; import.meta.env.VITE_DEFAULT_PUSH_SERVER || AppString.PROD_PUSH_SERVER;
export const IMAGE_TYPE_PROFILE = "profile"; export const IMAGE_TYPE_PROFILE = "profile";

View File

@@ -37,7 +37,20 @@ export async function updateDefaultSettings(
} }
} }
export async function updateAccountSettings( export async function insertDidSpecificSettings(
did: string,
settings: Partial<Settings> = {},
): Promise<boolean> {
const platform = PlatformServiceFactory.getInstance();
const { sql, params } = generateInsertStatement(
{ ...settings, accountDid: did }, // make sure accountDid is set to the given value
"settings",
);
const result = await platform.dbExec(sql, params);
return result.changes === 1;
}
export async function updateDidSpecificSettings(
accountDid: string, accountDid: string,
settingsChanges: Settings, settingsChanges: Settings,
): Promise<boolean> { ): Promise<boolean> {
@@ -55,20 +68,7 @@ export async function updateAccountSettings(
); );
const updateResult = await platform.dbExec(updateSql, updateParams); const updateResult = await platform.dbExec(updateSql, updateParams);
return updateResult.changes === 1;
// If no record was updated, insert a new one
if (updateResult.changes === 1) {
return true;
} else {
const columns = Object.keys(settingsChanges);
const values = Object.values(settingsChanges);
const placeholders = values.map(() => "?").join(", ");
const insertSql = `INSERT INTO settings (${columns.join(", ")}) VALUES (${placeholders})`;
const result = await platform.dbExec(insertSql, values);
return result.changes === 1;
}
} }
const DEFAULT_SETTINGS: Settings = { const DEFAULT_SETTINGS: Settings = {
@@ -109,9 +109,6 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
const defaultSettings = await retrieveSettingsForDefaultAccount(); const defaultSettings = await retrieveSettingsForDefaultAccount();
// If no active DID, return defaults // If no active DID, return defaults
if (!defaultSettings.activeDid) { if (!defaultSettings.activeDid) {
logConsoleAndDb(
"[databaseUtil] No active DID found, returning default settings",
);
return defaultSettings; return defaultSettings;
} }
@@ -124,9 +121,7 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
); );
if (!result?.values?.length) { if (!result?.values?.length) {
logConsoleAndDb( // we created DID-specific settings when generated or imported, so this shouldn't happen
`[databaseUtil] No account-specific settings found for ${defaultSettings.activeDid}`,
);
return defaultSettings; return defaultSettings;
} }
@@ -135,6 +130,7 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
result.columns, result.columns,
result.values, result.values,
)[0] as Settings; )[0] as Settings;
const overrideSettingsFiltered = Object.fromEntries( const overrideSettingsFiltered = Object.fromEntries(
Object.entries(overrideSettings).filter(([_, v]) => v !== null), Object.entries(overrideSettings).filter(([_, v]) => v !== null),
); );
@@ -144,17 +140,7 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
// Handle searchBoxes parsing // Handle searchBoxes parsing
if (settings.searchBoxes) { if (settings.searchBoxes) {
try { settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
// @ts-expect-error - the searchBoxes field is a string in the DB
settings.searchBoxes = JSON.parse(settings.searchBoxes);
} catch (error) {
logConsoleAndDb(
`[databaseUtil] Failed to parse searchBoxes for ${defaultSettings.activeDid}: ${error}`,
true,
);
// Reset to empty array on parse failure
settings.searchBoxes = [];
}
} }
return settings; return settings;
@@ -233,9 +219,9 @@ export async function logConsoleAndDb(
isError = false, isError = false,
): Promise<void> { ): Promise<void> {
if (isError) { if (isError) {
logger.error(`${new Date().toISOString()} ${message}`); logger.error(`${new Date().toISOString()}`, message);
} else { } else {
logger.log(`${new Date().toISOString()} ${message}`); logger.log(`${new Date().toISOString()}`, message);
} }
await logToDb(message); await logToDb(message);
} }
@@ -254,6 +240,7 @@ export function generateInsertStatement(
const values = Object.values(model).filter((value) => value !== undefined); const values = Object.values(model).filter((value) => value !== undefined);
const placeholders = values.map(() => "?").join(", "); const placeholders = values.map(() => "?").join(", ");
const insertSql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; const insertSql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
return { return {
sql: insertSql, sql: insertSql,
params: values, params: values,
@@ -325,3 +312,115 @@ export function mapColumnsToValues(
return obj; return obj;
}); });
} }
/**
* Debug function to inspect raw settings data in the database
* This helps diagnose issues with data corruption or malformed JSON
* @param did Optional DID to inspect specific account settings
* @author Matthew Raymer
*/
export async function debugSettingsData(did?: string): Promise<void> {
try {
const platform = PlatformServiceFactory.getInstance();
// Get all settings records
const allSettings = await platform.dbQuery("SELECT * FROM settings");
logConsoleAndDb(
`[DEBUG] Total settings records: ${allSettings?.values?.length || 0}`,
false,
);
if (allSettings?.values?.length) {
allSettings.values.forEach((row, index) => {
const settings = mapColumnsToValues(allSettings.columns, [row])[0];
logConsoleAndDb(`[DEBUG] Settings record ${index + 1}:`, false);
logConsoleAndDb(`[DEBUG] - ID: ${settings.id}`, false);
logConsoleAndDb(`[DEBUG] - accountDid: ${settings.accountDid}`, false);
logConsoleAndDb(`[DEBUG] - activeDid: ${settings.activeDid}`, false);
if (settings.searchBoxes) {
logConsoleAndDb(
`[DEBUG] - searchBoxes type: ${typeof settings.searchBoxes}`,
false,
);
logConsoleAndDb(
`[DEBUG] - searchBoxes value: ${String(settings.searchBoxes)}`,
false,
);
// Try to parse it
try {
const parsed = JSON.parse(String(settings.searchBoxes));
logConsoleAndDb(
`[DEBUG] - searchBoxes parsed successfully: ${JSON.stringify(parsed)}`,
false,
);
} catch (parseError) {
logConsoleAndDb(
`[DEBUG] - searchBoxes parse error: ${parseError}`,
true,
);
}
}
logConsoleAndDb(
`[DEBUG] - Full record: ${JSON.stringify(settings, null, 2)}`,
false,
);
});
}
// If specific DID provided, also check accounts table
if (did) {
const account = await platform.dbQuery(
"SELECT * FROM accounts WHERE did = ?",
[did],
);
logConsoleAndDb(
`[DEBUG] Account for ${did}: ${JSON.stringify(account, null, 2)}`,
false,
);
}
} catch (error) {
logConsoleAndDb(`[DEBUG] Error inspecting settings data: ${error}`, true);
}
}
/**
* Platform-agnostic JSON parsing utility
* Handles different SQLite implementations:
* - Web SQLite (wa-sqlite/absurd-sql): Auto-parses JSON strings to objects
* - Capacitor SQLite: Returns raw strings that need manual parsing
*
* @param value The value to parse (could be string or already parsed object)
* @param defaultValue Default value if parsing fails
* @returns Parsed object or default value
* @author Matthew Raymer
*/
export function parseJsonField<T>(value: unknown, defaultValue: T): T {
try {
// If already an object (web SQLite auto-parsed), return as-is
if (typeof value === "object" && value !== null) {
return value as T;
}
// If it's a string (Capacitor SQLite or fallback), parse it
if (typeof value === "string") {
return JSON.parse(value) as T;
}
// If it's null/undefined, return default
if (value === null || value === undefined) {
return defaultValue;
}
return defaultValue;
} catch (error) {
logConsoleAndDb(
`[databaseUtil] Failed to parse JSON field: ${error}`,
true,
);
return defaultValue;
}
}

View File

@@ -1,6 +1,4 @@
import { AxiosResponse } from "axios";
import { GiverReceiverInputInfo } from "../libs/util"; import { GiverReceiverInputInfo } from "../libs/util";
import { ErrorResult, ResultWithType } from "./common";
export interface GiverOutputInfo { export interface GiverOutputInfo {
action: string; action: string;
@@ -47,12 +45,3 @@ export interface ProviderInfo {
*/ */
linkConfirmed: boolean; linkConfirmed: boolean;
} }
// Type for createAndSubmitClaim result
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
// Update SuccessResult to use ClaimResult
export interface SuccessResult extends ResultWithType {
type: "success";
response: AxiosResponse<ClaimResult>;
}

View File

@@ -15,10 +15,6 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
publicUrls?: Record<string, string>; publicUrls?: Record<string, string>;
} }
export interface ResultWithType {
type: string;
}
export interface ErrorResponse { export interface ErrorResponse {
error?: { error?: {
message?: string; message?: string;
@@ -30,11 +26,6 @@ export interface InternalError {
userMessage?: string; userMessage?: string;
} }
export interface ErrorResult extends ResultWithType {
type: "error";
error: InternalError;
}
export interface KeyMeta { export interface KeyMeta {
did: string; did: string;
publicKeyHex: string; publicKeyHex: string;

View File

@@ -29,18 +29,17 @@ import { z } from "zod";
// Add a union type of all valid route paths // Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = [ export const VALID_DEEP_LINK_ROUTES = [
"user-profile", // note that similar lists are below in deepLinkSchemas and in src/services/deepLinks.ts
"project-details",
"onboard-meeting-setup",
"invite-one-accept",
"contact-import",
"confirm-gift",
"claim", "claim",
"claim-cert",
"claim-add-raw", "claim-add-raw",
"contact-edit", "claim-cert",
"contacts", "confirm-gift",
"contact-import",
"did", "did",
"invite-one-accept",
"onboard-meeting-setup",
"project",
"user-profile",
] as const; ] as const;
// Create a type from the array // Create a type from the array
@@ -58,44 +57,39 @@ export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
// Parameter validation schemas for each route type // Parameter validation schemas for each route type
export const deepLinkSchemas = { export const deepLinkSchemas = {
"user-profile": z.object({ // note that similar lists are above in VALID_DEEP_LINK_ROUTES and in src/services/deepLinks.ts
id: z.string(),
}),
"project-details": z.object({
id: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
"invite-one-accept": z.object({
id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
"confirm-gift": z.object({
id: z.string(),
}),
claim: z.object({ claim: z.object({
id: z.string(), id: z.string(),
}), }),
"claim-cert": z.object({
id: z.string(),
}),
"claim-add-raw": z.object({ "claim-add-raw": z.object({
id: z.string(), id: z.string(),
claim: z.string().optional(), claim: z.string().optional(),
claimJwtId: z.string().optional(), claimJwtId: z.string().optional(),
}), }),
"contact-edit": z.object({ "claim-cert": z.object({
did: z.string(), id: z.string(),
}), }),
contacts: z.object({ "confirm-gift": z.object({
contacts: z.string(), // JSON string of contacts array id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}), }),
did: z.object({ did: z.object({
did: z.string(), did: z.string(),
}), }),
"invite-one-accept": z.object({
jwt: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
project: z.object({
id: z.string(),
}),
"user-profile": z.object({
id: z.string(),
}),
}; };
export type DeepLinkParams = { export type DeepLinkParams = {

View File

@@ -1,5 +1,6 @@
export type { export type {
// From common.ts // From common.ts
CreateAndSubmitClaimResult,
GenericCredWrapper, GenericCredWrapper,
GenericVerifiableCredential, GenericVerifiableCredential,
KeyMeta, KeyMeta,
@@ -18,11 +19,6 @@ export type {
RegisterActionClaim, RegisterActionClaim,
} from "./claims"; } from "./claims";
export type {
// From claims-result.ts
CreateAndSubmitClaimResult,
} from "./claims-result";
export type { export type {
// From records.ts // From records.ts
PlanSummaryRecord, PlanSummaryRecord,

View File

@@ -979,7 +979,7 @@ export const createAndSubmitConfirmation = async (
handleId: string | undefined, handleId: string | undefined,
apiServer: string, apiServer: string,
axios: Axios, axios: Axios,
) => { ): Promise<CreateAndSubmitClaimResult> => {
const goodClaim = removeSchemaContext( const goodClaim = removeSchemaContext(
removeVisibleToDids( removeVisibleToDids(
addLastClaimOrHandleAsIdIfMissing(claim, lastClaimId, handleId), addLastClaimOrHandleAsIdIfMissing(claim, lastClaimId, handleId),
@@ -1074,7 +1074,8 @@ export async function generateEndorserJwtUrlForAccount(
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo); const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI; const viewPrefix =
APP_SERVER + "/deep-link" + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
return viewPrefix + vcJwt; return viewPrefix + vcJwt;
} }

View File

@@ -44,6 +44,7 @@ import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { sha256 } from "ethereum-cryptography/sha256"; import { sha256 } from "ethereum-cryptography/sha256";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import { insertDidSpecificSettings, parseJsonField } from "../db/databaseUtil";
export interface GiverReceiverInputInfo { export interface GiverReceiverInputInfo {
did?: string; did?: string;
@@ -626,7 +627,9 @@ export const retrieveFullyDecryptedAccount = async (
return result; return result;
}; };
export const retrieveAllAccountsMetadata = async (): Promise<AccountEncrypted[]> => { export const retrieveAllAccountsMetadata = async (): Promise<
AccountEncrypted[]
> => {
const platformService = PlatformServiceFactory.getInstance(); const platformService = PlatformServiceFactory.getInstance();
const dbAccounts = await platformService.dbQuery(`SELECT * FROM accounts`); const dbAccounts = await platformService.dbQuery(`SELECT * FROM accounts`);
const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[]; const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[];
@@ -643,8 +646,12 @@ export const retrieveAllAccountsMetadata = async (): Promise<AccountEncrypted[]>
// This is not accurate because they can't be decrypted, but we're removing Dexie anyway. // This is not accurate because they can't be decrypted, but we're removing Dexie anyway.
const identityStr = JSON.stringify(identity); const identityStr = JSON.stringify(identity);
const encryptedAccount = { const encryptedAccount = {
identityEncrBase64: sha256(new TextEncoder().encode(identityStr)).toString(), identityEncrBase64: sha256(
mnemonicEncrBase64: sha256(new TextEncoder().encode(account.mnemonic)).toString(), new TextEncoder().encode(identityStr),
).toString(),
mnemonicEncrBase64: sha256(
new TextEncoder().encode(account.mnemonic),
).toString(),
...metadata, ...metadata,
}; };
return encryptedAccount as AccountEncrypted; return encryptedAccount as AccountEncrypted;
@@ -691,6 +698,7 @@ export async function saveNewIdentity(
]; ];
await platformService.dbExec(sql, params); await platformService.dbExec(sql, params);
await databaseUtil.updateDefaultSettings({ activeDid: identity.did }); await databaseUtil.updateDefaultSettings({ activeDid: identity.did });
await databaseUtil.insertDidSpecificSettings(identity.did);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage // one of the few times we use accountsDBPromise directly; try to avoid more usage
@@ -704,6 +712,7 @@ export async function saveNewIdentity(
publicKeyHex: identity.keys[0].publicKeyHex, publicKeyHex: identity.keys[0].publicKeyHex,
}); });
await updateDefaultSettings({ activeDid: identity.did }); await updateDefaultSettings({ activeDid: identity.did });
await insertDidSpecificSettings(identity.did);
} }
} catch (error) { } catch (error) {
logger.error("Failed to update default settings:", error); logger.error("Failed to update default settings:", error);
@@ -726,7 +735,9 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
const newId = newIdentifier(address, publicHex, privateHex, derivationPath); const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
await saveNewIdentity(newId, mnemonic, derivationPath); await saveNewIdentity(newId, mnemonic, derivationPath);
await databaseUtil.updateAccountSettings(newId.did, { isRegistered: false }); await databaseUtil.updateDidSpecificSettings(newId.did, {
isRegistered: false,
});
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await updateAccountSettings(newId.did, { isRegistered: false }); await updateAccountSettings(newId.did, { isRegistered: false });
} }
@@ -768,7 +779,7 @@ export const registerSaveAndActivatePasskey = async (
): Promise<Account> => { ): Promise<Account> => {
const account = await registerAndSavePasskey(keyName); const account = await registerAndSavePasskey(keyName);
await databaseUtil.updateDefaultSettings({ activeDid: account.did }); await databaseUtil.updateDefaultSettings({ activeDid: account.did });
await databaseUtil.updateAccountSettings(account.did, { await databaseUtil.updateDidSpecificSettings(account.did, {
isRegistered: false, isRegistered: false,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
@@ -856,7 +867,7 @@ export const contactToCsvLine = (contact: Contact): string => {
// Handle contactMethods array by stringifying it // Handle contactMethods array by stringifying it
const contactMethodsStr = contact.contactMethods const contactMethodsStr = contact.contactMethods
? escapeField(JSON.stringify(contact.contactMethods)) ? escapeField(JSON.stringify(parseJsonField(contact.contactMethods, [])))
: ""; : "";
const fields = [ const fields = [
@@ -871,6 +882,71 @@ export const contactToCsvLine = (contact: Contact): string => {
return fields.join(","); return fields.join(",");
}; };
/**
* Parses a CSV line into a Contact object. See contactToCsvLine for the format.
* @param lineRaw - The CSV line to parse
* @returns A Contact object
*/
export const csvLineToContact = (lineRaw: string): Contact => {
// Note that Endorser Mobile puts name first, then did, etc.
let line = lineRaw.trim();
let did, publicKeyInput, seesMe, registered;
let name;
let commaPos1 = -1;
if (line.startsWith('"')) {
let doubleDoubleQuotePos = line.lastIndexOf('""') + 2;
if (doubleDoubleQuotePos === -1) {
doubleDoubleQuotePos = 1;
}
const quote2Pos = line.indexOf('"', doubleDoubleQuotePos);
if (quote2Pos > -1) {
commaPos1 = line.indexOf(",", quote2Pos);
name = line.substring(1, quote2Pos).trim();
name = name.replace(/""/g, '"');
} else {
// something is weird with one " to start, so ignore it and start after "
line = line.substring(1);
commaPos1 = line.indexOf(",");
name = line.substring(0, commaPos1).trim();
}
} else {
commaPos1 = line.indexOf(",");
name = line.substring(0, commaPos1).trim();
}
if (commaPos1 > -1) {
did = line.substring(commaPos1 + 1).trim();
const commaPos2 = line.indexOf(",", commaPos1 + 1);
if (commaPos2 > -1) {
did = line.substring(commaPos1 + 1, commaPos2).trim();
publicKeyInput = line.substring(commaPos2 + 1).trim();
const commaPos3 = line.indexOf(",", commaPos2 + 1);
if (commaPos3 > -1) {
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
seesMe = line.substring(commaPos3 + 1).trim() == "true";
const commaPos4 = line.indexOf(",", commaPos3 + 1);
if (commaPos4 > -1) {
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
registered = line.substring(commaPos4 + 1).trim() == "true";
}
}
}
}
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput;
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
// it must be all hex (compressed public key), so convert
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
}
const newContact: Contact = {
did: did || "",
name,
publicKeyBase64,
seesMe,
registered,
};
return newContact;
};
/** /**
* Interface for the JSON export format of database tables * Interface for the JSON export format of database tables
*/ */
@@ -901,7 +977,7 @@ export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
did: contact.did, did: contact.did,
name: contact.name || null, name: contact.name || null,
contactMethods: contact.contactMethods contactMethods: contact.contactMethods
? JSON.stringify(contact.contactMethods) ? JSON.stringify(parseJsonField(contact.contactMethods, []))
: null, : null,
nextPubKeyHashB64: contact.nextPubKeyHashB64 || null, nextPubKeyHashB64: contact.nextPubKeyHashB64 || null,
notes: contact.notes || null, notes: contact.notes || null,

View File

@@ -34,8 +34,7 @@ import router from "./router";
import { handleApiError } from "./services/api"; import { handleApiError } from "./services/api";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks"; import { DeepLinkHandler } from "./services/deepLinks";
import { logConsoleAndDb } from "./db/databaseUtil"; import { logger, safeStringify } from "./utils/logger";
import { logger } from "./utils/logger";
logger.log("[Capacitor] Starting initialization"); logger.log("[Capacitor] Starting initialization");
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM); logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
@@ -72,10 +71,10 @@ const handleDeepLink = async (data: { url: string }) => {
await router.isReady(); await router.isReady();
await deepLinkHandler.handleDeepLink(data.url); await deepLinkHandler.handleDeepLink(data.url);
} catch (error) { } catch (error) {
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true); logger.error("[DeepLink] Error handling deep link: ", error);
handleApiError( handleApiError(
{ {
message: error instanceof Error ? error.message : String(error), message: error instanceof Error ? error.message : safeStringify(error),
} as AxiosError, } as AxiosError,
"deep-link", "deep-link",
); );

View File

@@ -83,6 +83,11 @@ const routes: Array<RouteRecordRaw> = [
name: "discover", name: "discover",
component: () => import("../views/DiscoverView.vue"), component: () => import("../views/DiscoverView.vue"),
}, },
{
path: "/deep-link/:path*",
name: "deep-link",
component: () => import("../views/DeepLinkRedirectView.vue"),
},
{ {
path: "/gifted-details", path: "/gifted-details",
name: "gifted-details", name: "gifted-details",

View File

@@ -6,7 +6,7 @@
*/ */
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { logger } from "../utils/logger"; import { logger, safeStringify } from "../utils/logger";
/** /**
* Handles API errors with platform-specific logging and error processing. * Handles API errors with platform-specific logging and error processing.
@@ -37,7 +37,8 @@ import { logger } from "../utils/logger";
*/ */
export const handleApiError = (error: AxiosError, endpoint: string) => { export const handleApiError = (error: AxiosError, endpoint: string) => {
if (process.env.VITE_PLATFORM === "capacitor") { if (process.env.VITE_PLATFORM === "capacitor") {
logger.error(`[Capacitor API Error] ${endpoint}:`, { const endpointStr = safeStringify(endpoint); // we've seen this as an object in deep links
logger.error(`[Capacitor API Error] ${endpointStr}:`, {
message: error.message, message: error.message,
status: error.response?.status, status: error.response?.status,
data: error.response?.data, data: error.response?.data,

View File

@@ -27,18 +27,16 @@
* timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2] * timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2]
* *
* Supported Routes: * Supported Routes:
* - user-profile: View user profile
* - project-details: View project details
* - onboard-meeting-setup: Setup onboarding meeting
* - invite-one-accept: Accept invitation
* - contact-import: Import contacts
* - confirm-gift: Confirm gift
* - claim: View claim * - claim: View claim
* - claim-cert: View claim certificate
* - claim-add-raw: Add raw claim * - claim-add-raw: Add raw claim
* - contact-edit: Edit contact * - claim-cert: View claim certificate
* - contacts: View contacts * - confirm-gift
* - contact-import: Import contacts
* - did: View DID * - did: View DID
* - invite-one-accept: Accept invitation
* - onboard-meeting-members
* - project: View project details
* - user-profile: View user profile
* *
* @example * @example
* const handler = new DeepLinkHandler(router); * const handler = new DeepLinkHandler(router);
@@ -81,18 +79,17 @@ export class DeepLinkHandler {
string, string,
{ name: string; paramKey?: string } { name: string; paramKey?: string }
> = { > = {
"user-profile": { name: "user-profile" }, // note that similar lists are in src/interfaces/deepLinks.ts
"project-details": { name: "project-details" },
"onboard-meeting-setup": { name: "onboard-meeting-setup" },
"invite-one-accept": { name: "invite-one-accept" },
"contact-import": { name: "contact-import" },
"confirm-gift": { name: "confirm-gift" },
claim: { name: "claim" }, claim: { name: "claim" },
"claim-cert": { name: "claim-cert" },
"claim-add-raw": { name: "claim-add-raw" }, "claim-add-raw": { name: "claim-add-raw" },
"contact-edit": { name: "contact-edit", paramKey: "did" }, "claim-cert": { name: "claim-cert" },
contacts: { name: "contacts" }, "confirm-gift": { name: "confirm-gift" },
"contact-import": { name: "contact-import", paramKey: "jwt" },
did: { name: "did", paramKey: "did" }, did: { name: "did", paramKey: "did" },
"invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" },
"onboard-meeting-members": { name: "onboard-meeting-members" },
project: { name: "project" },
"user-profile": { name: "user-profile" },
}; };
/** /**
@@ -101,7 +98,7 @@ export class DeepLinkHandler {
* *
* @param url - The deep link URL to parse (format: scheme://path[?query]) * @param url - The deep link URL to parse (format: scheme://path[?query])
* @throws {DeepLinkError} If URL format is invalid * @throws {DeepLinkError} If URL format is invalid
* @returns Parsed URL components (path, params, query) * @returns Parsed URL components (path: string, params: {KEY: string}, query: {KEY: string})
*/ */
private parseDeepLink(url: string) { private parseDeepLink(url: string) {
const parts = url.split("://"); const parts = url.split("://");
@@ -117,7 +114,16 @@ export class DeepLinkHandler {
}); });
const [path, queryString] = parts[1].split("?"); const [path, queryString] = parts[1].split("?");
const [routePath, param] = path.split("/"); const [routePath, ...pathParams] = path.split("/");
// logger.info(
// "[DeepLink] Debug:",
// "Route Path:",
// routePath,
// "Path Params:",
// pathParams,
// "Query String:",
// queryString,
// );
// Validate route exists before proceeding // Validate route exists before proceeding
if (!this.ROUTE_MAP[routePath]) { if (!this.ROUTE_MAP[routePath]) {
@@ -136,45 +142,14 @@ export class DeepLinkHandler {
} }
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (param) { if (pathParams) {
// Now we know routePath exists in ROUTE_MAP // Now we know routePath exists in ROUTE_MAP
const routeConfig = this.ROUTE_MAP[routePath]; const routeConfig = this.ROUTE_MAP[routePath];
params[routeConfig.paramKey ?? "id"] = param; params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
} }
return { path: routePath, params, query }; return { path: routePath, params, query };
} }
/**
* Processes incoming deep links and routes them appropriately.
* Handles validation, error handling, and routing to the correct view.
*
* @param url - The deep link URL to process
* @throws {DeepLinkError} If URL processing fails
*/
async handleDeepLink(url: string): Promise<void> {
try {
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
const { path, params, query } = this.parseDeepLink(url);
// Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
);
await this.validateAndRoute(path, sanitizedParams, query);
} catch (error) {
const deepLinkError = error as DeepLinkError;
logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
true,
);
throw {
code: deepLinkError.code || "UNKNOWN_ERROR",
message: deepLinkError.message,
details: deepLinkError.details,
};
}
}
/** /**
* Routes the deep link to appropriate view with validated parameters. * Routes the deep link to appropriate view with validated parameters.
* Validates route and parameters using Zod schemas before routing. * Validates route and parameters using Zod schemas before routing.
@@ -245,6 +220,39 @@ export class DeepLinkHandler {
code: "INVALID_PARAMETERS", code: "INVALID_PARAMETERS",
message: (error as Error).message, message: (error as Error).message,
details: error, details: error,
params: params,
query: query,
};
}
}
/**
* Processes incoming deep links and routes them appropriately.
* Handles validation, error handling, and routing to the correct view.
*
* @param url - The deep link URL to process
* @throws {DeepLinkError} If URL processing fails
*/
async handleDeepLink(url: string): Promise<void> {
try {
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
const { path, params, query } = this.parseDeepLink(url);
// Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
);
await this.validateAndRoute(path, sanitizedParams, query);
} catch (error) {
const deepLinkError = error as DeepLinkError;
logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
true,
);
throw {
code: deepLinkError.code || "UNKNOWN_ERROR",
message: deepLinkError.message,
details: deepLinkError.details,
}; };
} }
} }

View File

@@ -5,6 +5,7 @@ import {
CameraSource, CameraSource,
CameraDirection, CameraDirection,
} from "@capacitor/camera"; } from "@capacitor/camera";
import { Capacitor } from "@capacitor/core";
import { Share } from "@capacitor/share"; import { Share } from "@capacitor/share";
import { import {
SQLiteConnection, SQLiteConnection,
@@ -247,7 +248,7 @@ export class CapacitorPlatformService implements PlatformService {
hasFileSystem: true, hasFileSystem: true,
hasCamera: true, hasCamera: true,
isMobile: true, isMobile: true,
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), isIOS: Capacitor.getPlatform() === "ios",
hasFileDownload: false, hasFileDownload: false,
needsFileHandlingInstructions: true, needsFileHandlingInstructions: true,
isNativeApp: true, isNativeApp: true,

View File

@@ -1,6 +1,6 @@
import { logToDb } from "../db/databaseUtil"; import { logToDb } from "../db/databaseUtil";
function safeStringify(obj: unknown) { export function safeStringify(obj: unknown) {
const seen = new WeakSet(); const seen = new WeakSet();
return JSON.stringify(obj, (_key, value) => { return JSON.stringify(obj, (_key, value) => {
@@ -67,8 +67,9 @@ export const logger = {
// Errors will always be logged // Errors will always be logged
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(message, ...args); console.error(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; const messageString = safeStringify(message);
logToDb(message + argsString); const argsString = args.length > 0 ? safeStringify(args) : "";
logToDb(messageString + argsString);
}, },
}; };

View File

@@ -1015,7 +1015,6 @@ import {
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
updateAccountSettings, updateAccountSettings,
} from "../db/index"; } from "../db/index";
import { Account } from "../db/tables/accounts";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES, DEFAULT_PASSKEY_EXPIRATION_MINUTES,
@@ -1040,7 +1039,6 @@ import {
} from "../libs/util"; } from "../libs/util";
import { UserProfile } from "@/libs/partnerServer"; import { UserProfile } from "@/libs/partnerServer";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
const inputImportFileNameRef = ref<Blob>(); const inputImportFileNameRef = ref<Blob>();
@@ -1174,8 +1172,6 @@ export default class AccountViewView extends Vue {
5000, 5000,
); );
} }
} finally {
this.loadingProfile = false;
} }
} }
} catch (error) { } catch (error) {
@@ -1198,6 +1194,8 @@ export default class AccountViewView extends Vue {
}, },
5000, 5000,
); );
} finally {
this.loadingProfile = false;
} }
try { try {
@@ -1240,7 +1238,6 @@ export default class AccountViewView extends Vue {
*/ */
async initializeState() { async initializeState() {
let settings = await databaseUtil.retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
console.log("settings", settings);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.open(); await db.open();
settings = await retrieveSettingsForActiveAccount(); settings = await retrieveSettingsForActiveAccount();
@@ -1817,7 +1814,7 @@ export default class AccountViewView extends Vue {
if (!this.isRegistered) { if (!this.isRegistered) {
// the user was not known to be registered, but now they are (because we got no error) so let's record it // the user was not known to be registered, but now they are (because we got no error) so let's record it
try { try {
await databaseUtil.updateAccountSettings(did, { await databaseUtil.updateDidSpecificSettings(did, {
isRegistered: true, isRegistered: true,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
@@ -2021,7 +2018,7 @@ export default class AccountViewView extends Vue {
if ((error as any).response.status === 404) { if ((error as any).response.status === 404) {
logger.error("The image was already deleted:", error); logger.error("The image was already deleted:", error);
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
profileImageUrl: undefined, profileImageUrl: undefined,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {

View File

@@ -198,7 +198,7 @@ export default class ClaimAddRawView extends Vue {
this.apiServer, this.apiServer,
this.axios, this.axios,
); );
if (result.type === "success") { if (result.success) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",

View File

@@ -46,23 +46,35 @@
</h2> </h2>
<div class="flex justify-center w-full"> <div class="flex justify-center w-full">
<router-link <router-link
v-if="veriClaim.id"
:to="'/claim-cert/' + encodeURIComponent(veriClaim.id)" :to="'/claim-cert/' + encodeURIComponent(veriClaim.id)"
class="text-blue-500 mt-2" class="text-blue-500 mt-2"
title="Printable Certificate" title="View Printable Certificate"
> >
<font-awesome <font-awesome
icon="square" icon="square"
class="text-white bg-yellow-500 p-1" class="text-white bg-yellow-500 p-1"
/> />
</router-link> </router-link>
<button
v-if="veriClaim.id"
class="text-blue-500 ml-2 mt-2"
title="Copy Printable Certificate Link"
@click="
copyToClipboard(
'A link to the certificate page',
`${APP_SERVER}/deep-link/claim-cert/${veriClaim.id}`,
)
"
>
<font-awesome icon="link" class="text-yellow-500 p-1" />
</button>
</div> </div>
<!-- show link icon to copy this URL to the clipboard --> <!-- show link icon to copy this URL to the clipboard -->
<div class="flex justify-end w-full"> <div class="flex justify-end w-full">
<button <button
title="Copy Link" title="Copy Link"
@click=" @click="copyToClipboard('A link to this page', windowDeepLink)"
copyToClipboard('A link to this page', window.location.href)
"
> >
<font-awesome icon="link" class="text-slate-500" /> <font-awesome icon="link" class="text-slate-500" />
</button> </button>
@@ -292,15 +304,17 @@
<div class="text-sm"> <div class="text-sm">
{{ didInfo(confirmerId) }} {{ didInfo(confirmerId) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)"> <span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<a <router-link
:href="`/did/${confirmerId}`" :to="{
path: '/did/' + encodeURIComponent(confirmerId),
}"
class="text-blue-500" class="text-blue-500"
> >
<font-awesome <font-awesome
icon="arrow-up-right-from-square" icon="arrow-up-right-from-square"
class="fa-fw" class="fa-fw"
/> />
</a> </router-link>
</span> </span>
</div> </div>
</div> </div>
@@ -332,15 +346,17 @@
<div class="text-sm"> <div class="text-sm">
{{ didInfo(confsVisibleTo) }} {{ didInfo(confsVisibleTo) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)"> <span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
<a <router-link
:href="`/did/${confsVisibleTo}`" :to="{
path: '/did/' + encodeURIComponent(confsVisibleTo),
}"
class="text-blue-500" class="text-blue-500"
> >
<font-awesome <font-awesome
icon="arrow-up-right-from-square" icon="arrow-up-right-from-square"
class="fa-fw" class="fa-fw"
/> />
</a> </router-link>
</span> </span>
</div> </div>
</div> </div>
@@ -400,7 +416,7 @@
contacts can see more details: contacts can see more details:
<a <a
class="text-blue-500" class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)" @click="copyToClipboard('A link to this page', windowDeepLink)"
>click to copy this page info</a >click to copy this page info</a
> >
and see if they can make an introduction. Someone is connected to and see if they can make an introduction. Someone is connected to
@@ -423,7 +439,7 @@
If you'd like an introduction, If you'd like an introduction,
<a <a
class="text-blue-500" class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)" @click="copyToClipboard('A link to this page', windowDeepLink)"
>share this page with them and ask if they'll tell you more about >share this page with them and ask if they'll tell you more about
about the participants.</a about the participants.</a
> >
@@ -449,15 +465,17 @@
<span> <span>
{{ didInfo(visDid) }} {{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)"> <span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<a <router-link
:href="`/did/${visDid}`" :to="{
path: '/did/' + encodeURIComponent(visDid),
}"
class="text-blue-500" class="text-blue-500"
> >
<font-awesome <font-awesome
icon="arrow-up-right-from-square" icon="arrow-up-right-from-square"
class="fa-fw" class="fa-fw"
/> />
</a> </router-link>
</span> </span>
<span v-if="veriClaim.publicUrls?.[visDid]" <span v-if="veriClaim.publicUrls?.[visDid]"
>, found at&nbsp;<a >, found at&nbsp;<a
@@ -539,7 +557,7 @@ import { useClipboard } from "@vueuse/core";
import { GenericVerifiableCredential } from "../interfaces"; import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue"; import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { db } from "../db/index"; import { db } from "../db/index";
import { logConsoleAndDb } from "../db/databaseUtil"; import { logConsoleAndDb } from "../db/databaseUtil";
@@ -586,8 +604,9 @@ export default class ClaimView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = ""; veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {}; veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href; windowDeepLink = window.location.href; // changed in the setup for deep linking
APP_SERVER = APP_SERVER;
R = R; R = R;
yaml = yaml; yaml = yaml;
libsUtil = libsUtil; libsUtil = libsUtil;
@@ -664,6 +683,7 @@ export default class ClaimView extends Vue {
5000, 5000,
); );
} }
this.windowDeepLink = `${APP_SERVER}/deep-link/claim/${claimId}`;
this.canShare = !!navigator.share; this.canShare = !!navigator.share;
} }
@@ -934,7 +954,7 @@ export default class ClaimView extends Vue {
this.apiServer, this.apiServer,
this.axios, this.axios,
); );
if (result.type === "success") { if (result.success) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -999,11 +1019,11 @@ export default class ClaimView extends Vue {
} }
onClickShareClaim() { onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation); this.copyToClipboard("A link to this page", this.windowDeepLink);
window.navigator.share({ window.navigator.share({
title: "Help Connect Me", title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?", text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation, url: this.windowDeepLink,
}); });
} }

View File

@@ -407,14 +407,14 @@
</a> </a>
</div> </div>
<div class="mt-2 ml-2"> <div class="mt-2 ml-2">
<a <router-link
v-if="isRegistered" v-if="isRegistered"
class="text-blue-500 cursor-pointer" class="text-blue-500 cursor-pointer"
:href="urlForNewGive" :to="urlForNewGive"
> >
<font-awesome icon="file-lines" /> <font-awesome icon="file-lines" />
Record a Give Similar to the Original Record a Give Similar to the Original
</a> </router-link>
</div> </div>
</div> </div>
</div> </div>
@@ -436,7 +436,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
@@ -494,7 +494,7 @@ export default class ConfirmGiftView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = ""; veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {}; veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href; windowLocation = window.location.href; // this is changed to a deep link in the setup
R = R; R = R;
yaml = yaml; yaml = yaml;
@@ -566,6 +566,9 @@ export default class ConfirmGiftView extends Vue {
} }
const claimId = decodeURIComponent(pathParam); const claimId = decodeURIComponent(pathParam);
this.windowLocation = APP_SERVER + "/deep-link/confirm-gift/" + claimId;
await this.loadClaim(claimId, this.activeDid); await this.loadClaim(claimId, this.activeDid);
} }
@@ -676,12 +679,12 @@ export default class ConfirmGiftView extends Vue {
/** /**
* Add participant (giver/recipient) name & URL info * Add participant (giver/recipient) name & URL info
*/ */
this.giverName = this.didInfo(this.giveDetails?.agentDid);
if (this.giveDetails?.agentDid) { if (this.giveDetails?.agentDid) {
this.giverName = this.didInfo(this.giveDetails.agentDid);
this.urlForNewGive += `&giverDid=${encodeURIComponent(this.giveDetails.agentDid)}&giverName=${encodeURIComponent(this.giverName)}`; this.urlForNewGive += `&giverDid=${encodeURIComponent(this.giveDetails.agentDid)}&giverName=${encodeURIComponent(this.giverName)}`;
} }
this.recipientName = this.didInfo(this.giveDetails?.recipientDid);
if (this.giveDetails?.recipientDid) { if (this.giveDetails?.recipientDid) {
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`; this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`;
} }
@@ -831,7 +834,7 @@ export default class ConfirmGiftView extends Vue {
this.apiServer, this.apiServer,
this.axios, this.axios,
); );
if (result.type === "success") { if (result.success) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",

View File

@@ -138,11 +138,13 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db } from "../db/index";
import { Contact, ContactMethod } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { parseJsonField } from "../db/databaseUtil";
import { db } from "../db/index";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { Contact, ContactMethod } from "../db/tables/contacts";
import { AppString } from "../constants/app";
/** /**
* Contact Edit View Component * Contact Edit View Component
@@ -230,9 +232,7 @@ export default class ContactEditView extends Vue {
let contact: Contact | undefined = databaseUtil.mapQueryResultToValues( let contact: Contact | undefined = databaseUtil.mapQueryResultToValues(
dbContact, dbContact,
)[0] as unknown as Contact; )[0] as unknown as Contact;
contact.contactMethods = JSON.parse( contact.contactMethods = parseJsonField(contact?.contactMethods, []);
(contact?.contactMethods as unknown as string) || "[]",
);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.open(); await db.open();
contact = await db.contacts.get(contactDid || ""); contact = await db.contacts.get(contactDid || "");

View File

@@ -213,6 +213,7 @@ import {
} from "../db/index"; } from "../db/index";
import { Contact, ContactMethod } from "../db/tables/contacts"; import { Contact, ContactMethod } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { parseJsonField } from "../db/databaseUtil";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { import {
capitalizeAndInsertSpacesBeforeCaps, capitalizeAndInsertSpacesBeforeCaps,
@@ -289,7 +290,7 @@ function dbRecordToContact(record: ContactDbRecord): Contact {
profileImageUrl: safeString(record.profileImageUrl), profileImageUrl: safeString(record.profileImageUrl),
publicKeyBase64: safeString(record.publicKeyBase64), publicKeyBase64: safeString(record.publicKeyBase64),
nextPubKeyHashB64: safeString(record.nextPubKeyHashB64), nextPubKeyHashB64: safeString(record.nextPubKeyHashB64),
contactMethods: JSON.parse(record.contactMethods || "[]"), contactMethods: parseJsonField(record.contactMethods, []),
}; };
} }

View File

@@ -104,6 +104,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3"; import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
@@ -117,13 +118,20 @@ import { db } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { getContactJwtFromJwtUrl } from "../libs/crypto"; import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc"; import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import * as libsUtil from "../libs/util";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { setVisibilityUtil } from "../libs/endorserServer"; import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
setVisibilityUtil,
} from "../libs/endorserServer";
import UserNameDialog from "../components/UserNameDialog.vue"; import UserNameDialog from "../components/UserNameDialog.vue";
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
import { retrieveAccountMetadata } from "../libs/util"; import { retrieveAccountMetadata } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { parseJsonField } from "../db/databaseUtil";
import { Account } from "@/db/tables/accounts";
interface QRScanResult { interface QRScanResult {
rawValue?: string; rawValue?: string;
@@ -141,7 +149,7 @@ interface IUserNameDialog {
UserNameDialog, UserNameDialog,
}, },
}) })
export default class ContactQRScan extends Vue { export default class ContactQRScanFull extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router; $router!: Router;
@@ -150,6 +158,8 @@ export default class ContactQRScan extends Vue {
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
givenName = ""; givenName = "";
isRegistered = false;
profileImageUrl = "";
qrValue = ""; qrValue = "";
ETHR_DID_PREFIX = ETHR_DID_PREFIX; ETHR_DID_PREFIX = ETHR_DID_PREFIX;
@@ -171,19 +181,22 @@ export default class ContactQRScan extends Vue {
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || ""; this.givenName = settings.firstName || "";
this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
const account = await retrieveAccountMetadata(this.activeDid); const account = await retrieveAccountMetadata(this.activeDid);
if (account) { if (account) {
const name = const name =
(settings.firstName || "") + (settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : ""); (settings.lastName ? ` ${settings.lastName}` : "");
this.qrValue = await generateEndorserJwtUrlForAccount( const publicKeyBase64 = Buffer.from(
account, account.publicKeyHex,
!!settings.isRegistered, "hex",
name, ).toString("base64");
settings.profileImageUrl || "", this.qrValue =
false, CONTACT_CSV_HEADER +
); "\n" +
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
} }
} catch (error) { } catch (error) {
logger.error("Error initializing component:", { logger.error("Error initializing component:", {
@@ -335,6 +348,8 @@ export default class ContactQRScan extends Vue {
logger.info("Processing QR code scan result:", rawValue); logger.info("Processing QR code scan result:", rawValue);
let contact: Contact;
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
// Extract JWT // Extract JWT
const jwt = getContactJwtFromJwtUrl(rawValue); const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) { if (!jwt) {
@@ -343,7 +358,7 @@ export default class ContactQRScan extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Invalid QR Code", title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.", text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
}); });
return; return;
} }
@@ -376,15 +391,25 @@ export default class ContactQRScan extends Vue {
} }
// Create contact object // Create contact object
const contact = { contact = {
did: did, did: did,
name: contactInfo.name || "", name: contactInfo.name || "",
email: contactInfo.email || "", publicKeyBase64: contactInfo.publicKeyBase64 || "",
phone: contactInfo.phone || "", seesMe: contactInfo.seesMe || false,
company: contactInfo.company || "", registered: contactInfo.registered || false,
title: contactInfo.title || "",
notes: contactInfo.notes || "",
}; };
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
const lines = rawValue.split(/\n/);
contact = libsUtil.csvLineToContact(lines[1]);
} else {
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
});
return;
}
// Add contact but keep scanning // Add contact but keep scanning
logger.info("Adding new contact to database:", { logger.info("Adding new contact to database:", {
@@ -467,14 +492,16 @@ export default class ContactQRScan extends Vue {
title: "Contact Exists", title: "Contact Exists",
text: "This contact has already been added to your list.", text: "This contact has already been added to your list.",
}, },
3000, 5000,
); );
return; return;
} }
// Add new contact // Add new contact
// @ts-expect-error because we're just using the value to store to the DB // @ts-expect-error because we're just using the value to store to the DB
contact.contactMethods = JSON.stringify(contact.contactMethods); contact.contactMethods = JSON.stringify(
parseJsonField(contact.contactMethods, []),
);
const { sql, params } = databaseUtil.generateInsertStatement( const { sql, params } = databaseUtil.generateInsertStatement(
contact as unknown as Record<string, unknown>, contact as unknown as Record<string, unknown>,
"contacts", "contacts",
@@ -565,9 +592,19 @@ export default class ContactQRScan extends Vue {
); );
} }
onCopyUrlToClipboard() { async onCopyUrlToClipboard() {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
useClipboard() useClipboard()
.copy(this.qrValue) .copy(jwtUrl)
.then(() => { .then(() => {
this.$notify( this.$notify(
{ {

View File

@@ -159,6 +159,7 @@
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3"; import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
@@ -171,19 +172,23 @@ import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { parseJsonField } from "../db/databaseUtil";
import { getContactJwtFromJwtUrl } from "../libs/crypto"; import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount, generateEndorserJwtUrlForAccount,
register, register,
setVisibilityUtil, setVisibilityUtil,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc"; import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import { retrieveAccountMetadata } from "../libs/util"; import * as libsUtil from "../libs/util";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory"; import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { CameraState } from "@/services/QRScanner/types"; import { CameraState } from "@/services/QRScanner/types";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Account } from "@/db/tables/accounts";
interface QRScanResult { interface QRScanResult {
rawValue?: string; rawValue?: string;
@@ -213,6 +218,7 @@ export default class ContactQRScanShow extends Vue {
isRegistered = false; isRegistered = false;
qrValue = ""; qrValue = "";
isScanning = false; isScanning = false;
profileImageUrl = "";
error: string | null = null; error: string | null = null;
// QR Scanner properties // QR Scanner properties
@@ -250,19 +256,21 @@ export default class ContactQRScanShow extends Vue {
this.hideRegisterPromptOnNewContact = this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact; !!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings.isRegistered; this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
const account = await retrieveAccountMetadata(this.activeDid); const account = await libsUtil.retrieveAccountMetadata(this.activeDid);
if (account) { if (account) {
const name = const name =
(settings.firstName || "") + (settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : ""); (settings.lastName ? ` ${settings.lastName}` : "");
this.qrValue = await generateEndorserJwtUrlForAccount( const publicKeyBase64 = Buffer.from(
account, account.publicKeyHex,
!!settings.isRegistered, "hex",
name, ).toString("base64");
settings.profileImageUrl || "", this.qrValue =
false, CONTACT_CSV_HEADER +
); "\n" +
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
} }
} catch (error) { } catch (error) {
logger.error("Error initializing component:", { logger.error("Error initializing component:", {
@@ -273,7 +281,7 @@ export default class ContactQRScanShow extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Initialization Error", title: "Initialization Error",
text: "Failed to initialize QR scanner. Please try again.", text: "Failed to initialize QR renderer or scanner. Please try again.",
}); });
} }
} }
@@ -460,7 +468,8 @@ export default class ContactQRScanShow extends Vue {
logger.info("Processing QR code scan result:", rawValue); logger.info("Processing QR code scan result:", rawValue);
// Extract JWT let contact: Contact;
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
const jwt = getContactJwtFromJwtUrl(rawValue); const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) { if (!jwt) {
logger.warn("Invalid QR code format - no JWT found in URL"); logger.warn("Invalid QR code format - no JWT found in URL");
@@ -468,14 +477,14 @@ export default class ContactQRScanShow extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Invalid QR Code", title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.", text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
}); });
return; return;
} }
// Process JWT and contact info
logger.info("Decoding JWT payload from QR code"); logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt); const decodedJwt = await decodeEndorserJwt(jwt);
// Process JWT and contact info
if (!decodedJwt?.payload?.own) { if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field"); logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({ this.$notify({
@@ -501,11 +510,25 @@ export default class ContactQRScanShow extends Vue {
} }
// Create contact object // Create contact object
const contact = { contact = {
did: did, did: did,
name: contactInfo.name || "", name: contactInfo.name || "",
notes: contactInfo.notes || "", publicKeyBase64: contactInfo.publicKeyBase64 || "",
seesMe: contactInfo.seesMe || false,
registered: contactInfo.registered || false,
}; };
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
const lines = rawValue.split(/\n/);
contact = libsUtil.csvLineToContact(lines[1]);
} else {
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
});
return;
}
// Add contact but keep scanning // Add contact but keep scanning
logger.info("Adding new contact to database:", { logger.info("Adding new contact to database:", {
@@ -648,12 +671,20 @@ export default class ContactQRScanShow extends Vue {
}); });
} }
onCopyUrlToClipboard() { async onCopyUrlToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
useClipboard() useClipboard()
.copy(this.qrValue) .copy(jwtUrl)
.then(() => { .then(() => {
// console.log("Contact URL:", this.qrValue);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -771,14 +802,16 @@ export default class ContactQRScanShow extends Vue {
title: "Contact Exists", title: "Contact Exists",
text: "This contact has already been added to your list.", text: "This contact has already been added to your list.",
}, },
3000, 5000,
); );
return; return;
} }
// Add new contact // Add new contact
// @ts-expect-error because we're just using the value to store to the DB // @ts-expect-error because we're just using the value to store to the DB
contact.contactMethods = JSON.stringify(contact.contactMethods); contact.contactMethods = JSON.stringify(
parseJsonField(contact.contactMethods, []),
);
const { sql, params } = databaseUtil.generateInsertStatement( const { sql, params } = databaseUtil.generateInsertStatement(
contact as unknown as Record<string, unknown>, contact as unknown as Record<string, unknown>,
"contacts", "contacts",

View File

@@ -78,7 +78,7 @@
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2 h-10" class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2 h-10"
/> />
<button <button
class="px-4 rounded-r bg-green-200 border border-l-0 border-green-400" class="px-4 rounded-r bg-green-200 border border-green-400"
@click="onClickNewContact()" @click="onClickNewContact()"
> >
<font-awesome icon="plus" class="fa-fw" /> <font-awesome icon="plus" class="fa-fw" />
@@ -86,8 +86,8 @@
</div> </div>
<div v-if="contacts.length > 0" class="flex justify-between"> <div v-if="contacts.length > 0" class="flex justify-between">
<div class="w-full text-left"> <div class="">
<div v-if="!showGiveNumbers"> <div v-if="!showGiveNumbers" class="flex items-center">
<input <input
type="checkbox" type="checkbox"
:checked="contactsSelected.length === contacts.length" :checked="contactsSelected.length === contacts.length"
@@ -101,52 +101,32 @@
/> />
<button <button
v-if="!showGiveNumbers" v-if="!showGiveNumbers"
href="" :class="
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-3 px-3 py-1.5 rounded-md"
:style="
contactsSelected.length > 0 contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);' ? 'text-md bg-gradient-to-b from-blue-400 to-blue-700 ' +
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);' 'shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ' +
'ml-3 px-3 py-1.5 rounded-md cursor-pointer'
: '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-slate-300 ' +
'ml-3 px-3 py-1.5 rounded-md cursor-not-allowed'
" "
data-testId="copySelectedContactsButtonTop" data-testId="copySelectedContactsButtonTop"
@click="copySelectedContacts()" @click="copySelectedContacts()"
> >
Copy Selections Copy
</button> </button>
<button @click="showCopySelectionsInfo()">
<font-awesome <font-awesome
icon="circle-info" icon="circle-info"
class="text-xl text-blue-500 ml-4" class="text-2xl text-blue-500 ml-2"
@click="showCopySelectionsInfo()"
/> />
</button>
</div> </div>
</div> </div>
<div class="w-full text-right"> <div class="flex items-center gap-2">
<button <button
href="" v-if="showGiveNumbers"
class="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-3 py-1.5 rounded-md" class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="toggleShowContactAmounts()"
>
{{
showGiveNumbers ? "Hide Hours, Offer, etc" : "See Hours, Offer, etc"
}}
</button>
</div>
</div>
<div v-if="showGiveNumbers" class="flex justify-between mt-1">
<div class="w-full text-right">
In the following, only the most recent hours are included. To see more,
click
<span
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 px-1 py-1 rounded-md"
>
<font-awesome icon="file-lines" class="fa-fw" />
</span>
<br />
<button
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md mt-1"
:class="showGiveAmountsClassNames()" :class="showGiveAmountsClassNames()"
@click="toggleShowGiveTotals()" @click="toggleShowGiveTotals()"
> >
@@ -159,6 +139,24 @@
}} }}
<font-awesome icon="left-right" class="fa-fw" /> <font-awesome icon="left-right" class="fa-fw" />
</button> </button>
<button
class="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-3 py-1.5 rounded-md"
@click="toggleShowContactAmounts()"
>
{{ showGiveNumbers ? "Hide Actions" : "See Actions" }}
</button>
</div>
</div>
<div v-if="showGiveNumbers" class="my-3">
<div class="w-full text-center text-sm italic text-slate-600">
Only the most recent hours are included. <br />To see more, click
<span
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 px-1 py-0.5 rounded"
>
<font-awesome icon="file-lines" class="text-xs fa-fw" />
</span>
<br />
</div> </div>
</div> </div>
@@ -166,7 +164,7 @@
<ul <ul
v-if="contacts.length > 0" v-if="contacts.length > 0"
id="listContacts" id="listContacts"
class="border-t border-slate-300 mt-1" class="border-t border-slate-300 my-2"
> >
<li <li
v-for="contact in filteredContacts()" v-for="contact in filteredContacts()"
@@ -174,9 +172,8 @@
class="border-b border-slate-300 pt-1 pb-1" class="border-b border-slate-300 pt-1 pb-1"
data-testId="contactListItem" data-testId="contactListItem"
> >
<div class="grow overflow-hidden">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex overflow-hidden min-w-0 items-center gap-3">
<input <input
v-if="!showGiveNumbers" v-if="!showGiveNumbers"
type="checkbox" type="checkbox"
@@ -193,23 +190,26 @@
" "
/> />
<div
class="flex-shrink-0 w-12 h-12 flex items-center justify-center"
>
<EntityIcon <EntityIcon
:contact="contact" :contact="contact"
:icon-size="48" :icon-size="48"
class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer overflow-hidden" class="shrink-0 align-text-bottom border border-slate-300 rounded cursor-pointer overflow-hidden"
@click="showLargeIdenticon = contact" @click="showLargeIdenticon = contact"
/> />
</div>
<h2 class="text-base font-semibold w-1/3 truncate flex-shrink-0"> <div class="overflow-hidden">
<h2 class="text-base font-semibold truncate">
<router-link
:to="{
path: '/did/' + encodeURIComponent(contact.did),
}"
title="See more about this person"
>
{{ contactNameNonBreakingSpace(contact.name) }} {{ contactNameNonBreakingSpace(contact.name) }}
</router-link>
</h2> </h2>
<span> <div class="flex gap-1.5 items-center overflow-hidden">
<div class="flex gap-2 items-center">
<router-link <router-link
:to="{ :to="{
path: '/did/' + encodeURIComponent(contact.did), path: '/did/' + encodeURIComponent(contact.did),
@@ -218,31 +218,30 @@
> >
<font-awesome <font-awesome
icon="circle-info" icon="circle-info"
class="text-xl text-blue-500" class="text-base text-blue-500"
/> />
</router-link> </router-link>
<span class="text-sm overflow-hidden">{{ <span class="text-xs truncate">{{ contact.did }}</span>
libsUtil.shortDid(contact.did)
}}</span>
</div> </div>
<div class="text-sm"> <div class="text-sm">
{{ contact.notes }} {{ contact.notes }}
</div> </div>
</span> </div>
</div> </div>
<div <div
v-if="showGiveNumbers && contact.did != activeDid" v-if="showGiveNumbers && contact.did != activeDid"
class="flex gap-2 items-center" class="flex gap-1.5 items-end"
> >
<div class="text-center">
<div class="text-xs leading-none mb-1">From/To</div>
<div class="flex items-center">
<button <button
class="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-2 py-1.5 rounded-l-md" class="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-2.5 py-1.5 rounded-l-md"
:title="givenToMeDescriptions[contact.did] || ''" :title="givenToMeDescriptions[contact.did] || ''"
@click="confirmShowGiftedDialog(contact.did, activeDid)" @click="confirmShowGiftedDialog(contact.did, activeDid)"
> >
From:
<br />
{{ {{
/* eslint-disable prettier/prettier */ /* eslint-disable prettier/prettier */
showGiveTotals showGiveTotals
@@ -256,12 +255,10 @@
</button> </button>
<button <button
class="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 -ml-1.5 px-2 py-1.5 rounded-r-md border-l" class="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-2.5 py-1.5 rounded-r-md border-l"
:title="givenByMeDescriptions[contact.did] || ''" :title="givenByMeDescriptions[contact.did] || ''"
@click="confirmShowGiftedDialog(activeDid, contact.did)" @click="confirmShowGiftedDialog(activeDid, contact.did)"
> >
To:
<br />
{{ {{
/* eslint-disable prettier/prettier */ /* eslint-disable prettier/prettier */
showGiveTotals showGiveTotals
@@ -273,9 +270,11 @@
/* eslint-enable prettier/prettier */ /* eslint-enable prettier/prettier */
}} }}
</button> </button>
</div>
</div>
<button <button
class="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-2 py-1.5 rounded-md border border-blue-400" class="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-2 py-1.5 rounded-md"
data-testId="offerButton" data-testId="offerButton"
@click="openOfferDialog(contact.did, contact.name)" @click="openOfferDialog(contact.did, contact.name)"
> >
@@ -287,14 +286,13 @@
name: 'contact-amounts', name: 'contact-amounts',
query: { contactDid: contact.did }, query: { contactDid: contact.did },
}" }"
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-2 py-1.5 rounded-md border border-slate-400" 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-2 py-1.5 rounded-md"
title="See more given activity" title="See more given activity"
> >
<font-awesome icon="file-lines" class="fa-fw" /> <font-awesome icon="file-lines" class="fa-fw" />
</router-link> </router-link>
</div> </div>
</div> </div>
</div>
</li> </li>
</ul> </ul>
<p v-else>There are no contacts.</p> <p v-else>There are no contacts.</p>
@@ -314,16 +312,18 @@
/> />
<button <button
v-if="!showGiveNumbers" v-if="!showGiveNumbers"
href="" :class="
class="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 ml-3 px-3 py-1.5 rounded-md"
:style="
contactsSelected.length > 0 contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);' ? 'text-md bg-gradient-to-b from-blue-400 to-blue-700 ' +
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);' 'shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ' +
'ml-3 px-3 py-1.5 rounded-md cursor-pointer'
: '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-slate-300 ' +
'ml-3 px-3 py-1.5 rounded-md cursor-not-allowed'
" "
@click="copySelectedContacts()" @click="copySelectedContacts()"
> >
Copy Selections Copy
</button> </button>
</div> </div>
@@ -491,7 +491,7 @@ export default class ContactsView extends Vue {
private async processContactJwt() { private async processContactJwt() {
// handle a contact sent via URL // handle a contact sent via URL
// //
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts // For external links, use /deep-link/contact-import/:jwt with a JWT that has an array of contacts
// because that will do better error checking for things like missing data on iOS platforms. // because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = this.$route.query["contactJwt"] as string; const importedContactJwt = this.$route.query["contactJwt"] as string;
if (importedContactJwt) { if (importedContactJwt) {
@@ -542,7 +542,7 @@ export default class ContactsView extends Vue {
if (response.status != 201) { if (response.status != 201) {
throw { error: { response: response } }; throw { error: { response: response } };
} }
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
isRegistered: true, isRegistered: true,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
@@ -617,7 +617,7 @@ export default class ContactsView extends Vue {
title: "Error with Invite", title: "Error with Invite",
text: message, text: message,
}, },
5000, -1,
); );
} }
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter // if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
@@ -933,45 +933,9 @@ export default class ContactsView extends Vue {
} }
private async addContactFromEndorserMobileLine( private async addContactFromEndorserMobileLine(
line: string, lineRaw: string,
): Promise<IndexableType> { ): Promise<IndexableType> {
// Note that Endorser Mobile puts name first, then did, etc. const newContact = libsUtil.csvLineToContact(lineRaw);
let name = line;
let did = "";
let publicKeyInput, seesMe, registered;
const commaPos1 = line.indexOf(",");
if (commaPos1 > -1) {
name = line.substring(0, commaPos1).trim();
did = line.substring(commaPos1 + 1).trim();
const commaPos2 = line.indexOf(",", commaPos1 + 1);
if (commaPos2 > -1) {
did = line.substring(commaPos1 + 1, commaPos2).trim();
publicKeyInput = line.substring(commaPos2 + 1).trim();
const commaPos3 = line.indexOf(",", commaPos2 + 1);
if (commaPos3 > -1) {
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
seesMe = line.substring(commaPos3 + 1).trim() == "true";
const commaPos4 = line.indexOf(",", commaPos3 + 1);
if (commaPos4 > -1) {
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
registered = line.substring(commaPos4 + 1).trim() == "true";
}
}
}
}
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput;
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
// it must be all hex (compressed public key), so convert
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
}
const newContact = {
did,
name,
publicKeyBase64,
seesMe,
registered,
};
const platformService = PlatformServiceFactory.getInstance(); const platformService = PlatformServiceFactory.getInstance();
const { sql, params } = databaseUtil.generateInsertStatement( const { sql, params } = databaseUtil.generateInsertStatement(
newContact as unknown as Record<string, unknown>, newContact as unknown as Record<string, unknown>,
@@ -998,8 +962,6 @@ export default class ContactsView extends Vue {
newContact as unknown as Record<string, unknown>, newContact as unknown as Record<string, unknown>,
"contacts", "contacts",
); );
logger.error("sql", sql);
logger.error("params", params);
let contactPromise = platformService.dbExec(sql, params); let contactPromise = platformService.dbExec(sql, params);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
// @ts-expect-error since the result of this promise won't be used, and this will go away soon // @ts-expect-error since the result of this promise won't be used, and this will go away soon
@@ -1160,7 +1122,7 @@ export default class ContactsView extends Vue {
(regResult.error as string) || (regResult.error as string) ||
"Something went wrong during registration.", "Something went wrong during registration.",
}, },
5000, -1,
); );
} }
} catch (error) { } catch (error) {
@@ -1194,7 +1156,7 @@ export default class ContactsView extends Vue {
title: "Registration Error", title: "Registration Error",
text: userMessage, text: userMessage,
}, },
5000, -1,
); );
} }
} }
@@ -1215,7 +1177,6 @@ export default class ContactsView extends Vue {
); );
if (result.success) { if (result.success) {
//contact.seesMe = visibility; // why doesn't it affect the UI from here? //contact.seesMe = visibility; // why doesn't it affect the UI from here?
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
if (showSuccessAlert) { if (showSuccessAlert) {
this.$notify( this.$notify(
{ {
@@ -1431,14 +1392,11 @@ export default class ContactsView extends Vue {
} }
return contact; return contact;
}); });
// console.log(
// "Array of selected contacts:",
// JSON.stringify(selectedContacts),
// );
const contactsJwt = await createEndorserJwtForDid(this.activeDid, { const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
contacts: selectedContacts, contacts: selectedContacts,
}); });
const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt; const contactsJwtUrl =
APP_SERVER + "/deep-link/contact-import/" + contactsJwt;
useClipboard() useClipboard()
.copy(contactsJwtUrl) .copy(contactsJwtUrl)
.then(() => { .then(() => {

View File

@@ -66,9 +66,14 @@ const formattedPath = computed(() => {
const path = originalPath.value.replace(/^\/+/, ""); const path = originalPath.value.replace(/^\/+/, "");
// Log for debugging // Log for debugging
logger.log("Original Path:", originalPath.value); logger.log(
logger.log("Route Params:", route.params); "[DeepLinkError] Original Path:",
logger.log("Route Query:", route.query); originalPath.value,
"Route Params:",
route.params,
"Route Query:",
route.query,
);
return path; return path;
}); });

View File

@@ -0,0 +1,227 @@
<template>
<!-- CONTENT -->
<section id="Content" class="relative w-[100vw] h-[100vh]">
<div
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
>
<div class="mb-4">
<h1 class="text-xl text-center font-semibold relative mb-4">
Redirecting to Time Safari
</h1>
<div v-if="destinationUrl" class="space-y-4">
<!-- Platform-specific messaging -->
<div class="text-center text-gray-600 mb-4">
<p v-if="isMobile">
{{
isIOS
? "Opening Time Safari app on your iPhone..."
: "Opening Time Safari app on your Android device..."
}}
</p>
<p v-else>Opening Time Safari app...</p>
<p class="text-sm mt-2">
<span v-if="isMobile"
>If the app doesn't open automatically, use one of these
options:</span
>
<span v-else>Choose how you'd like to open this link:</span>
</p>
</div>
<!-- Deep Link Button -->
<div class="text-center">
<a
:href="deepLinkUrl || '#'"
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>
</a>
</div>
<!-- Web Fallback Link -->
<div class="text-center">
<a
:href="webUrl || '#'"
target="_blank"
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
@click="handleWebFallbackClick"
>
<span v-if="isMobile">Open in Web Browser Instead</span>
<span v-else>Open in Web Browser</span>
</a>
</div>
<!-- Manual Instructions -->
<div class="text-center text-sm text-gray-500 mt-4">
<p v-if="isMobile">
Or manually open:
<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:
<code class="bg-gray-100 px-2 py-1 rounded">{{
deepLinkUrl
}}</code>
</p>
</div>
<!-- Platform info for debugging -->
<div
v-if="isDevelopment"
class="text-center text-xs text-gray-400 mt-4"
>
<p>
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
</p>
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
</div>
</div>
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
{{ pageError }}
</div>
<div v-else class="text-center text-gray-600">
<p>Processing redirect...</p>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { APP_SERVER } from "@/constants/app";
import { logger } from "@/utils/logger";
import { errorStringForLog } from "@/libs/endorserServer";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({})
export default class DeepLinkRedirectView extends Vue {
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
pageError: string | null = null;
destinationUrl: string | null = null; // full path after "/deep-link/"
deepLinkUrl: string | null = null; // mobile link starting "timesafari://"
webUrl: string | null = null; // web link, eg "https://timesafari.app/..."
isDevelopment: boolean = false;
userAgent: string = "";
private platformService = PlatformServiceFactory.getInstance();
mounted() {
// Get the path from the route parameter (catch-all parameter)
const pathParam = this.$route.params.path;
// If pathParam is an array (catch-all parameter), join it
const fullPath = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
// Get query parameters from the route
const queryParams = this.$route.query;
// Build query string if there are query parameters
let queryString = "";
if (Object.keys(queryParams).length > 0) {
const searchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
const stringValue = Array.isArray(value) ? value[0] : value;
if (stringValue !== null && stringValue !== undefined) {
searchParams.append(key, stringValue);
}
}
});
queryString = "?" + searchParams.toString();
}
// Combine path with query parameters
const fullPathWithQuery = fullPath + queryString;
this.destinationUrl = fullPathWithQuery;
this.deepLinkUrl = `timesafari://${fullPathWithQuery}`;
this.webUrl = `${APP_SERVER}/${fullPathWithQuery}`;
this.isDevelopment = process.env.NODE_ENV !== "production";
this.userAgent = navigator.userAgent;
this.openDeepLink();
}
private openDeepLink() {
if (!this.deepLinkUrl || !this.webUrl) {
this.pageError =
"No deep link was provided. Check the URL and try again.";
return;
}
try {
// For mobile, try the deep link URL; for desktop, use the web URL
const redirectUrl = this.isMobile ? this.deepLinkUrl : this.webUrl;
// Method 1: Try window.location.href (works on most browsers)
window.location.href = redirectUrl;
// Method 2: Fallback - create and click a link element
setTimeout(() => {
try {
const link = document.createElement("a");
link.href = redirectUrl;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
logger.error(
"Fallback deep link failed: " + errorStringForLog(error),
);
this.pageError =
"Redirecting to the Time Safari 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.";
}
}
private handleDeepLinkClick(event: Event) {
if (!this.deepLinkUrl) return;
// Prevent default to handle the click manually
event.preventDefault();
this.openDeepLink();
}
private handleWebFallbackClick(event: Event) {
if (!this.webUrl) return;
// Get platform capabilities
const capabilities = this.platformService.getCapabilities();
// For mobile, try to open in a new tab/window
if (capabilities.isMobile) {
event.preventDefault();
window.open(this.webUrl, "_blank");
}
// For desktop, let the default behavior happen (opens in same tab)
}
// Computed properties for template
get isMobile(): boolean {
return this.platformService.getCapabilities().isMobile;
}
get isIOS(): boolean {
return this.platformService.getCapabilities().isIOS;
}
}
</script>

View File

@@ -523,9 +523,7 @@ export default class DiscoverView extends Vue {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { } catch (e: any) {
logger.error("Error with search all:", e); logger.error("Error with search all: " + errorStringForLog(e));
// this sometimes gives different information
logger.error("Error with search all (error added): " + e);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -617,7 +615,7 @@ export default class DiscoverView extends Vue {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { } catch (e: any) {
logger.error("Error with search local:", e); logger.error("Error with search local: " + errorStringForLog(e));
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -788,7 +786,7 @@ export default class DiscoverView extends Vue {
const route = { const route = {
path: this.isProjectsActive path: this.isProjectsActive
? "/project/" + encodeURIComponent(id) ? "/project/" + encodeURIComponent(id)
: "/userProfile/" + encodeURIComponent(id), : "/user-profile/" + encodeURIComponent(id),
}; };
this.$router.push(route); this.$router.push(route);
} }

View File

@@ -825,11 +825,8 @@ export default class GiftedDetails extends Vue {
); );
} }
if ( if (!result.success) {
result.type === "error" || const errorMessage = result.error;
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
logger.error("Error with give creation result:", result); logger.error("Error with give creation result:", result);
this.$notify( this.$notify(
{ {
@@ -902,28 +899,6 @@ export default class GiftedDetails extends Vue {
// Helper functions for readability // Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() { explainData() {
this.$notify( this.$notify(
{ {

View File

@@ -24,11 +24,11 @@
<!-- eslint-disable prettier/prettier max-len --> <!-- eslint-disable prettier/prettier max-len -->
<div> <div>
<p> <p>
This app focuses on gifts & gratitude, using them to build cool things together with your network. This app focuses on raw gratitude, using it to build cool things together with your network.
</p> </p>
<p class="ml-4"> <p class="ml-4">
If you'd like to see the page-by-page help, If you'd like to see the page-by-page help again,
<span <span
class="text-blue-500 cursor-pointer" class="text-blue-500 cursor-pointer"
@click="unsetFinishedOnboarding()" @click="unsetFinishedOnboarding()"
@@ -37,14 +37,16 @@
<h2 class="text-xl font-semibold">What is the idea here?</h2> <h2 class="text-xl font-semibold">What is the idea here?</h2>
<p> <p>
We are building networks of people who want to grow good society from the ground up, using modern We are building networks of people who want to grow good society from the ground up, using
technology that connects people peer-to-peer. modern technology that connects people peer-to-peer.
First of all, let's showcase gratitude: see what people have given, and recognize First of all, let's showcase gratitude: see what people have given, and recognize gifts
gifts you've seen. This is done in a way that leaves a permanent record -- one that you've seen. This is done in a way that leaves a permanent record -- one that provably
came from you, and one that the recipient can prove it was for them. This can be came from you, and one that the recipient can prove they were mentioned.
personally gratifying, but it extends to broader work: volunteers get This can be personally gratifying, but it extends to broader work: volunteers get
confirmation of activity, and they can selectively show off their contributions confirmation of activity, and they can selectively show off their contributions and
and network. network.
This is a way to build trust and reputation. It's a way to build a network of people who
are willing to help each other.
</p> </p>
<p class="mt-2"> <p class="mt-2">
With this, you highlight giving and you also offer help -- With this, you highlight giving and you also offer help --
@@ -555,9 +557,6 @@
initiative. initiative.
</p> </p>
<h2 class="text-xl font-semibold">What app version is this?</h2>
<p>{{ package.version }} ({{ commitHash }})</p>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
I have other questions or feedback, like getting a new profile or removing my data or requesting an improvement. I have other questions or feedback, like getting a new profile or removing my data or requesting an improvement.
</h2> </h2>
@@ -567,6 +566,28 @@
>info@TimeSafari.app</a >info@TimeSafari.app</a
> >
</p> </p>
<h2 class="text-xl font-semibold">What app version is this?</h2>
<p>{{ package.version }} ({{ commitHash }})</p>
<div v-if="Capacitor.isNativePlatform()">
<h2 class="text-xl font-semibold">
Do I have the latest version?
</h2>
<p v-if="Capacitor.getPlatform() === 'ios'">
<a href="https://apps.apple.com/us/app/time-safari/id6742664907" target="_blank" class="text-blue-500">
Check the App Store.
</a>
</p>
<p v-else-if="Capacitor.getPlatform() === 'android'">
<a href="https://timesafari.app/app.apk" target="_blank" class="text-blue-500">
Download the latest APK to see.
</a>
</p>
<p v-else>
Sorry, your platform of '{{ Capacitor.getPlatform() }}' is not recognized.
</p>
</div>
</div> </div>
<!-- eslint enable --> <!-- eslint enable -->
</section> </section>
@@ -603,6 +624,7 @@ export default class HelpView extends Vue {
showVerifiable = false; showVerifiable = false;
APP_SERVER = APP_SERVER; APP_SERVER = APP_SERVER;
Capacitor = Capacitor;
// Ideally, we put no functionality in here, especially in the setup, // Ideally, we put no functionality in here, especially in the setup,
// because we never want this page to have a chance of throwing an error. // because we never want this page to have a chance of throwing an error.
@@ -622,7 +644,7 @@ export default class HelpView extends Vue {
} }
if (settings.activeDid) { if (settings.activeDid) {
await databaseUtil.updateAccountSettings(settings.activeDid, { await databaseUtil.updateDidSpecificSettings(settings.activeDid, {
finishedOnboarding: false, finishedOnboarding: false,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {

View File

@@ -519,7 +519,6 @@ export default class HomeView extends Vue {
// Retrieve DIDs with better error handling // Retrieve DIDs with better error handling
try { try {
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();
logConsoleAndDb(`[HomeView] Retrieved ${this.allMyDids.length} DIDs`);
} catch (error) { } catch (error) {
logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true); logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true);
throw new Error( throw new Error(
@@ -552,9 +551,6 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount(); settings = await retrieveSettingsForActiveAccount();
} }
logConsoleAndDb(
`[HomeView] Retrieved settings for ${settings.activeDid || "no active DID"}`,
);
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
`[HomeView] Failed to retrieve settings: ${error}`, `[HomeView] Failed to retrieve settings: ${error}`,
@@ -581,9 +577,6 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
} }
logConsoleAndDb(
`[HomeView] Retrieved ${this.allContacts.length} contacts`,
);
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
`[HomeView] Failed to retrieve contacts: ${error}`, `[HomeView] Failed to retrieve contacts: ${error}`,
@@ -630,7 +623,7 @@ export default class HomeView extends Vue {
this.activeDid, this.activeDid,
); );
if (resp.status === 200) { if (resp.status === 200) {
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
isRegistered: true, isRegistered: true,
...(await databaseUtil.retrieveSettingsForActiveAccount()), ...(await databaseUtil.retrieveSettingsForActiveAccount()),
}); });
@@ -641,9 +634,6 @@ export default class HomeView extends Vue {
}); });
} }
this.isRegistered = true; this.isRegistered = true;
logConsoleAndDb(
`[HomeView] User ${this.activeDid} is now registered`,
);
} }
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
@@ -685,11 +675,6 @@ export default class HomeView extends Vue {
this.newOffersToUserHitLimit = offersToUser.hitLimit; this.newOffersToUserHitLimit = offersToUser.hitLimit;
this.numNewOffersToUserProjects = offersToProjects.data.length; this.numNewOffersToUserProjects = offersToProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit; this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit;
logConsoleAndDb(
`[HomeView] Retrieved ${this.numNewOffersToUser} user offers and ` +
`${this.numNewOffersToUserProjects} project offers`,
);
} }
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
@@ -785,7 +770,7 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount(); settings = await retrieveSettingsForActiveAccount();
} }
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
apiServer: this.apiServer, apiServer: this.apiServer,
isRegistered: true, isRegistered: true,
...settings, ...settings,
@@ -1843,7 +1828,7 @@ export default class HomeView extends Vue {
this.axios, this.axios,
); );
if (result.type === "success") { if (result.success) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",

View File

@@ -79,9 +79,14 @@ import {
newIdentifier, newIdentifier,
nextDerivationPath, nextDerivationPath,
} from "../libs/crypto"; } from "../libs/crypto";
import { accountsDBPromise, db } from "../db/index"; import * as databaseUtil from "../db/databaseUtil";
import { db } from "../db/index";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { retrieveAllAccountsMetadata, retrieveFullyDecryptedAccount, saveNewIdentity } from "../libs/util"; import {
retrieveAllAccountsMetadata,
retrieveFullyDecryptedAccount,
saveNewIdentity,
} from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { Account, AccountEncrypted } from "../db/tables/accounts"; import { Account, AccountEncrypted } from "../db/tables/accounts";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@@ -100,13 +105,20 @@ export default class ImportAccountView extends Vue {
async mounted() { async mounted() {
const accounts: AccountEncrypted[] = await retrieveAllAccountsMetadata(); const accounts: AccountEncrypted[] = await retrieveAllAccountsMetadata();
const decryptedAccounts: (Account | undefined)[] = await Promise.all(accounts.map(async (account) => { const decryptedAccounts: (Account | undefined)[] = await Promise.all(
accounts.map(async (account) => {
return retrieveFullyDecryptedAccount(account.did); return retrieveFullyDecryptedAccount(account.did);
})); }),
const filteredDecryptedAccounts: Account[] = decryptedAccounts.filter((account) => account !== undefined); );
const filteredDecryptedAccounts: Account[] = decryptedAccounts.filter(
(account) => account !== undefined,
);
// group by account.mnemonic // group by account.mnemonic
const groupedAccounts: Record<string, Account[]> = R.groupBy((a) => a.mnemonic || "", filteredDecryptedAccounts) as Record<string, Account[]>; const groupedAccounts: Record<string, Account[]> = R.groupBy(
(a) => a.mnemonic || "",
filteredDecryptedAccounts,
) as Record<string, Account[]>;
this.didArrays = groupedAccounts; this.didArrays = groupedAccounts;
if (Object.keys(this.didArrays).length > 0) { if (Object.keys(this.didArrays).length > 0) {
@@ -125,10 +137,13 @@ export default class ImportAccountView extends Vue {
public async incrementDerivation() { public async incrementDerivation() {
// find the maximum derivation path for the selected DIDs // find the maximum derivation path for the selected DIDs
const selectedArray: Array<Account> = const selectedArray: Array<Account> =
Object.values(this.didArrays).find((dids) => dids[0].did === this.selectedArrayFirstDid) || Object.values(this.didArrays).find(
[]; (dids) => dids[0].did === this.selectedArrayFirstDid,
) || [];
// extract the derivationPath array and sort it // extract the derivationPath array and sort it
const derivationPaths = selectedArray.map((account) => account.derivationPath); const derivationPaths = selectedArray.map(
(account) => account.derivationPath,
);
derivationPaths.sort((a, b) => { derivationPaths.sort((a, b) => {
const aParts = a?.split("/"); const aParts = a?.split("/");
const aLast = aParts?.[aParts.length - 1]; const aLast = aParts?.[aParts.length - 1];
@@ -137,7 +152,9 @@ export default class ImportAccountView extends Vue {
return parseInt(aLast || "0") - parseInt(bLast || "0"); return parseInt(aLast || "0") - parseInt(bLast || "0");
}); });
// we're sure there's at least one // we're sure there's at least one
const maxDerivPath: string = derivationPaths[derivationPaths.length - 1] as string; const maxDerivPath: string = derivationPaths[
derivationPaths.length - 1
] as string;
const newDerivPath = nextDerivationPath(maxDerivPath); const newDerivPath = nextDerivationPath(maxDerivPath);
@@ -148,23 +165,15 @@ export default class ImportAccountView extends Vue {
try { try {
await saveNewIdentity(newId, mne, newDerivPath); await saveNewIdentity(newId, mne, newDerivPath);
if (USE_DEXIE_DB) {
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: newDerivPath,
did: newId.did,
identity: JSON.stringify(newId),
mnemonic: mne,
publicKeyHex: newId.keys[0].publicKeyHex,
});
}
// record that as the active DID // record that as the active DID
const platformService = PlatformServiceFactory.getInstance(); const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec("UPDATE settings SET activeDid = ?", [ await platformService.dbExec("UPDATE settings SET activeDid = ?", [
newId.did, newId.did,
]); ]);
await databaseUtil.updateDidSpecificSettings(newId.did, {
isRegistered: false,
});
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did, activeDid: newId.did,

View File

@@ -83,7 +83,7 @@
<span <span
v-else v-else
class="text-center text-slate-500 cursor-pointer" class="text-center text-slate-500 cursor-pointer"
:title="inviteLink(invite.jwt)" :title="invite.inviteIdentifier"
@click=" @click="
showInvite( showInvite(
invite.inviteIdentifier, invite.inviteIdentifier,
@@ -241,7 +241,7 @@ export default class InviteOneView extends Vue {
} }
inviteLink(jwt: string): string { inviteLink(jwt: string): string {
return APP_SERVER + "/invite-one-accept/" + jwt; return APP_SERVER + "/deep-link/invite-one-accept/" + jwt;
} }
copyInviteAndNotify(inviteId: string, jwt: string) { copyInviteAndNotify(inviteId: string, jwt: string) {
@@ -324,7 +324,7 @@ export default class InviteOneView extends Vue {
); );
await axios.post( await axios.post(
this.apiServer + "/api/userUtil/invite", this.apiServer + "/api/userUtil/invite",
{ inviteIdentifier, inviteJwt, notes, expiresAt }, { inviteJwt, notes, expiresAt },
{ headers }, { headers },
); );
const newInvite = { const newInvite = {

View File

@@ -257,7 +257,7 @@ export default class NewActivityView extends Vue {
async expandOffersToUserAndMarkRead() { async expandOffersToUserAndMarkRead() {
this.showOffersDetails = !this.showOffersDetails; this.showOffersDetails = !this.showOffersDetails;
if (this.showOffersDetails) { if (this.showOffersDetails) {
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[0].jwtId, lastAckedOfferToUserJwtId: this.newOffersToUser[0].jwtId,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
@@ -285,7 +285,7 @@ export default class NewActivityView extends Vue {
); );
if (index !== -1 && index < this.newOffersToUser.length - 1) { if (index !== -1 && index < this.newOffersToUser.length - 1) {
// Set to the next offer's jwtId // Set to the next offer's jwtId
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[index + 1].jwtId, lastAckedOfferToUserJwtId: this.newOffersToUser[index + 1].jwtId,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
@@ -295,7 +295,7 @@ export default class NewActivityView extends Vue {
} }
} else { } else {
// it's the last entry (or not found), so just keep it the same // it's the last entry (or not found), so just keep it the same
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.lastAckedOfferToUserJwtId, lastAckedOfferToUserJwtId: this.lastAckedOfferToUserJwtId,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
@@ -319,7 +319,7 @@ export default class NewActivityView extends Vue {
this.showOffersToUserProjectsDetails = this.showOffersToUserProjectsDetails =
!this.showOffersToUserProjectsDetails; !this.showOffersToUserProjectsDetails;
if (this.showOffersToUserProjectsDetails) { if (this.showOffersToUserProjectsDetails) {
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId: lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[0].jwtId, this.newOffersToUserProjects[0].jwtId,
}); });
@@ -349,7 +349,7 @@ export default class NewActivityView extends Vue {
); );
if (index !== -1 && index < this.newOffersToUserProjects.length - 1) { if (index !== -1 && index < this.newOffersToUserProjects.length - 1) {
// Set to the next offer's jwtId // Set to the next offer's jwtId
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId: lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[index + 1].jwtId, this.newOffersToUserProjects[index + 1].jwtId,
}); });
@@ -361,7 +361,7 @@ export default class NewActivityView extends Vue {
} }
} else { } else {
// it's the last entry (or not found), so just keep it the same // it's the last entry (or not found), so just keep it the same
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId: lastAckedOfferToUserProjectsJwtId:
this.lastAckedOfferToUserProjectsJwtId, this.lastAckedOfferToUserProjectsJwtId,
}); });

View File

@@ -720,7 +720,7 @@ export default class OnboardMeetingView extends Vue {
onboardMeetingMembersLink(): string { onboardMeetingMembersLink(): string {
if (this.currentMeeting) { if (this.currentMeeting) {
return `${APP_SERVER}/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent( return `${APP_SERVER}/deep-link/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
this.currentMeeting?.password || "", this.currentMeeting?.password || "",
)}`; )}`;
} }

View File

@@ -27,6 +27,12 @@
> >
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" /> <font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button> </button>
<button title="Copy Link to Project" @click="onCopyLinkClick()">
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
/>
</button>
</h2> </h2>
</div> </div>
</div> </div>
@@ -52,19 +58,28 @@
icon="user" icon="user"
class="fa-fw text-slate-400" class="fa-fw text-slate-400"
></font-awesome> ></font-awesome>
<span class="truncate inline-block max-w-[calc(100%-2rem)]">
{{ issuerInfoObject?.displayName }} {{ issuerInfoObject?.displayName }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)"> </span>
<a
:href="`/did/${issuer}`" <span
class="text-blue-500" v-if="!serverUtil.isHiddenDid(issuer)"
class="inline-flex items-center"
>
<router-link
:to="{
path: '/did/' + encodeURIComponent(issuer),
}"
class="text-blue-500 ml-1"
title="See more about this person"
> >
<font-awesome <font-awesome
icon="arrow-up-right-from-square" icon="arrow-up-right-from-square"
class="fa-fw" class="fa-fw"
/> />
</a> </router-link>
</span> </span>
<span v-else-if="serverUtil.isHiddenDid(issuer)"> <span v-if="serverUtil.isHiddenDid(issuer)" class="ml-1">
<font-awesome <font-awesome
icon="info-circle" icon="info-circle"
class="fa-fw text-blue-500 cursor-pointer" class="fa-fw text-blue-500 cursor-pointer"
@@ -108,7 +123,7 @@
class="fa-fw text-slate-400" class="fa-fw text-slate-400"
></font-awesome> ></font-awesome>
<a <a
:href="addScheme(url)" :href="ensureScheme(url)"
target="_blank" target="_blank"
class="underline text-blue-500" class="underline text-blue-500"
> >
@@ -627,7 +642,7 @@ import TopMessage from "../components/TopMessage.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue"; import EntityIcon from "../components/EntityIcon.vue";
import ProjectIcon from "../components/ProjectIcon.vue"; import ProjectIcon from "../components/ProjectIcon.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { import {
db, db,
@@ -641,6 +656,7 @@ import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue"; import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { useClipboard } from "@vueuse/core";
/** /**
* Project View Component * Project View Component
* @author Matthew Raymer * @author Matthew Raymer
@@ -837,6 +853,28 @@ export default class ProjectViewView extends Vue {
}); });
} }
onCopyLinkClick() {
const shortestProjectId = this.projectId.startsWith(
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
)
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
: this.projectId;
const deepLink = `${APP_SERVER}/deep-link/project/${shortestProjectId}`;
useClipboard()
.copy(deepLink)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "A link to this project was copied to the clipboard.",
},
2000,
);
});
}
// Isn't there a better way to make this available to the template? // Isn't there a better way to make this available to the template?
expandText() { expandText() {
this.expanded = true; this.expanded = true;
@@ -1299,7 +1337,7 @@ export default class ProjectViewView extends Vue {
} }
// return an HTTPS URL if it's not a global URL // return an HTTPS URL if it's not a global URL
addScheme(url: string) { ensureScheme(url: string) {
if (!libsUtil.isGlobalUri(url)) { if (!libsUtil.isGlobalUri(url)) {
return "https://" + url; return "https://" + url;
} }
@@ -1428,7 +1466,7 @@ export default class ProjectViewView extends Vue {
this.apiServer, this.apiServer,
this.axios, this.axios,
); );
if (result.type === "success") { if (result.success) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -1460,7 +1498,13 @@ export default class ProjectViewView extends Vue {
} }
openHiddenDidDialog() { openHiddenDidDialog() {
const shortestProjectId = this.projectId.startsWith(
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
)
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
: this.projectId;
(this.$refs.hiddenDidDialog as HiddenDidDialog).open( (this.$refs.hiddenDidDialog as HiddenDidDialog).open(
"project/" + shortestProjectId,
"creator", "creator",
this.issuerVisibleToDids, this.issuerVisibleToDids,
this.allContacts, this.allContacts,

View File

@@ -155,7 +155,7 @@ import { Contact } from "../db/tables/contacts";
import { import {
GenericCredWrapper, GenericCredWrapper,
GenericVerifiableCredential, GenericVerifiableCredential,
ErrorResult, CreateAndSubmitClaimResult,
} from "../interfaces"; } from "../interfaces";
import { import {
BVC_MEETUPS_PROJECT_CLAIM_ID, BVC_MEETUPS_PROJECT_CLAIM_ID,
@@ -230,7 +230,9 @@ export default class QuickActionBvcBeginView extends Vue {
suppressMilliseconds: true, suppressMilliseconds: true,
}) || ""; }) || "";
this.allMyDids = (await retrieveAllAccountsMetadata()).map((account) => account.did); this.allMyDids = (await retrieveAllAccountsMetadata()).map(
(account) => account.did,
);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
const accountsDB = await accountsDBPromise; const accountsDB = await accountsDBPromise;
await accountsDB.open(); await accountsDB.open();
@@ -296,13 +298,14 @@ export default class QuickActionBvcBeginView extends Vue {
} }
// in parallel, make a confirmation for each selected claim and send them all to the server // in parallel, make a confirmation for each selected claim and send them all to the server
const confirmResults = await Promise.allSettled( const confirmResults: PromiseSettledResult<CreateAndSubmitClaimResult>[] =
await Promise.allSettled(
this.claimsToConfirmSelected.map(async (jwtId) => { this.claimsToConfirmSelected.map(async (jwtId) => {
const record = this.claimsToConfirm.find( const record = this.claimsToConfirm.find(
(claim) => claim.id === jwtId, (claim) => claim.id === jwtId,
); );
if (!record) { if (!record) {
return { type: "error", error: "Record not found." }; return { success: false, error: "Record not found." };
} }
return createAndSubmitConfirmation( return createAndSubmitConfirmation(
this.activeDid, this.activeDid,
@@ -316,8 +319,8 @@ export default class QuickActionBvcBeginView extends Vue {
); );
// check for any rejected confirmations // check for any rejected confirmations
const confirmsSucceeded = confirmResults.filter( const confirmsSucceeded = confirmResults.filter(
(result) => // 'fulfilled' is the status in a successful PromiseFulfilledResult
result.status === "fulfilled" && result.value.type === "success", (result) => result.status === "fulfilled" && result.value.success,
); );
if (confirmsSucceeded.length < this.claimsToConfirmSelected.length) { if (confirmsSucceeded.length < this.claimsToConfirmSelected.length) {
logger.error("Error sending confirmations:", confirmResults); logger.error("Error sending confirmations:", confirmResults);
@@ -351,7 +354,7 @@ export default class QuickActionBvcBeginView extends Vue {
undefined, undefined,
BVC_MEETUPS_PROJECT_CLAIM_ID, BVC_MEETUPS_PROJECT_CLAIM_ID,
); );
giveSucceeded = giveResult.type === "success"; giveSucceeded = giveResult.success;
if (!giveSucceeded) { if (!giveSucceeded) {
logger.error("Error sending give:", giveResult); logger.error("Error sending give:", giveResult);
this.$notify( this.$notify(
@@ -360,7 +363,7 @@ export default class QuickActionBvcBeginView extends Vue {
type: "danger", type: "danger",
title: "Error", title: "Error",
text: text:
(giveResult as ErrorResult)?.error?.userMessage || (giveResult as CreateAndSubmitClaimResult)?.error ||
"There was an error sending that give.", "There was an error sending that give.",
}, },
5000, 5000,

View File

@@ -215,7 +215,7 @@ export default class SearchAreaView extends Vue {
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.open(); await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
searchBoxes: [newSearchBox], searchBoxes: searchBoxes as unknown, // Type assertion for Dexie compatibility
}); });
} }
this.searchBox = newSearchBox; this.searchBox = newSearchBox;
@@ -269,7 +269,7 @@ export default class SearchAreaView extends Vue {
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.open(); await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
searchBoxes: [], searchBoxes: "[]" as unknown as string, // Type assertion for Dexie compatibility
filterFeedByNearby: false, filterFeedByNearby: false,
}); });
} }

View File

@@ -105,7 +105,7 @@ export default class ShareMyContactInfoView extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Copied", title: "Copied",
text: "Your contact info was copied to the clipboard. Have them paste it in the box on their 'Contacts' screen.", text: "Your contact info was copied to the clipboard. Have them click on it, or paste it in the box on their 'Contacts' screen.",
}, },
5000, 5000,
); );

View File

@@ -16,6 +16,7 @@
</button> </button>
Individual Profile Individual Profile
</h1> </h1>
<div class="text-sm text-center text-slate-500"></div>
</div> </div>
<!-- Loading Animation --> <!-- Loading Animation -->
@@ -32,6 +33,12 @@
<div class="text-sm"> <div class="text-sm">
<font-awesome icon="user" class="fa-fw text-slate-400"></font-awesome> <font-awesome icon="user" class="fa-fw text-slate-400"></font-awesome>
{{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }} {{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }}
<button title="Copy Link to Profile" @click="onCopyLinkClick()">
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
/>
</button>
</div> </div>
<p v-if="profile.description" class="mt-4 text-slate-600"> <p v-if="profile.description" class="mt-4 text-slate-600">
{{ profile.description }} {{ profile.description }}
@@ -100,6 +107,7 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import { import {
APP_SERVER,
DEFAULT_PARTNER_API_SERVER, DEFAULT_PARTNER_API_SERVER,
NotificationIface, NotificationIface,
USE_DEXIE_DB, USE_DEXIE_DB,
@@ -113,6 +121,7 @@ import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Settings } from "@/db/tables/settings"; import { Settings } from "@/db/tables/settings";
import { useClipboard } from "@vueuse/core";
@Component({ @Component({
components: { components: {
LMap, LMap,
@@ -186,6 +195,10 @@ export default class UserProfileView extends Vue {
if (response.status === 200) { if (response.status === 200) {
const result = await response.json(); const result = await response.json();
this.profile = result.data; this.profile = result.data;
if (this.profile && this.profile.rowId !== profileId) {
// currently the server returns "rowid" with lowercase "i"; remove when that's fixed
this.profile.rowId = profileId;
}
} else { } else {
throw new Error("Failed to load profile"); throw new Error("Failed to load profile");
} }
@@ -204,5 +217,22 @@ export default class UserProfileView extends Vue {
this.isLoading = false; this.isLoading = false;
} }
} }
onCopyLinkClick() {
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
useClipboard()
.copy(deepLink)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "A link to this profile was copied to the clipboard.",
},
2000,
);
});
}
} }
</script> </script>