Browse Source

Merge branch 'build-improvement' into performance-optimizations-testing

pull/159/head
Matthew Raymer 2 weeks ago
parent
commit
8e2cbdbd1b
  1. 781
      BUILDING.md
  2. 84
      docs/contact-sharing-url-solution.md
  3. 1
      package.json
  4. 6
      src/components/GiftedDialog.vue
  5. 11
      src/constants/notifications.ts
  6. 2
      src/db/tables/settings.ts
  7. 2
      src/libs/partnerServer.ts
  8. 22
      src/libs/util.ts
  9. 11
      src/services/AbsurdSqlDatabaseService.ts
  10. 20
      src/utils/PlatformServiceMixin.ts
  11. 7
      src/views/ClaimView.vue
  12. 39
      src/views/ContactQRScanShowView.vue
  13. 25
      src/views/GiftedDetailsView.vue

781
BUILDING.md

File diff suppressed because it is too large

84
docs/contact-sharing-url-solution.md

@ -0,0 +1,84 @@
# Contact Sharing - URL Solution
## Overview
Simple implementation to switch ContactQRScanShowView from copying QR value (CSV) to copying a URL for better user experience.
## Problem
The ContactQRScanShowView was copying QR value (CSV content) to clipboard instead of a URL, making contact sharing less user-friendly.
## Solution
Updated the `onCopyUrlToClipboard()` method in ContactQRScanShowView.vue to generate and copy a URL instead of the QR value.
## Changes Made
### ContactQRScanShowView.vue
**Added Imports:**
```typescript
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
import { Account } from "@/db/tables/accounts";
```
**Updated Method:**
```typescript
async onCopyUrlToClipboard() {
try {
// Generate URL for sharing
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
// Copy the URL to clipboard
useClipboard()
.copy(jwtUrl)
.then(() => {
this.notify.toast(
"Copied",
NOTIFY_QR_URL_COPIED.message,
QR_TIMEOUT_MEDIUM,
);
});
} catch (error) {
logger.error("Failed to generate contact URL:", error);
this.notify.error("Failed to generate contact URL. Please try again.");
}
}
```
## Benefits
1. **Better UX**: Recipients can click the URL to add contact directly
2. **Consistency**: Both ContactQRScanShowView and ContactQRScanFullView now use URL format
3. **Error Handling**: Graceful fallback if URL generation fails
4. **Simple**: Minimal changes, no new components needed
## User Experience
**Before:**
- Click QR code → Copy CSV data to clipboard
- Recipient must paste CSV into input field
**After:**
- Click QR code → Copy URL to clipboard
- Recipient clicks URL → Contact added automatically
## Testing
- ✅ Linting passes
- ✅ Error handling implemented
- ✅ Consistent with ContactQRScanFullView behavior
- ✅ Maintains existing notification system
## Deployment
Ready for deployment. No breaking changes, maintains backward compatibility.

1
package.json

@ -92,6 +92,7 @@
"clean:android": "adb uninstall app.timesafari.app || true", "clean:android": "adb uninstall app.timesafari.app || true",
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true", "clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
"clean:electron": "./scripts/build-electron.sh --clean", "clean:electron": "./scripts/build-electron.sh --clean",
"clean:all": "npm run clean:ios && npm run clean:android && npm run clean:electron",
"build:android": "./scripts/build-android.sh", "build:android": "./scripts/build-android.sh",
"build:android:dev": "./scripts/build-android.sh --dev", "build:android:dev": "./scripts/build-android.sh --dev",
"build:android:test": "./scripts/build-android.sh --test", "build:android:test": "./scripts/build-android.sh --test",

6
src/components/GiftedDialog.vue

@ -126,15 +126,15 @@ export default class GiftedDialog extends Vue {
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description) firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
offerId = ""; offerId = "";
projects: PlanData[] = [];
prompt = ""; prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo; receiver?: libsUtil.GiverReceiverInputInfo;
stepType = "giver";
unitCode = "HUR"; unitCode = "HUR";
visible = false; visible = false;
libsUtil = libsUtil; libsUtil = libsUtil;
projects: PlanData[] = [];
didInfo = didInfo; didInfo = didInfo;
// Computed property to help debug template logic // Computed property to help debug template logic
@ -188,8 +188,6 @@ export default class GiftedDialog extends Vue {
return false; return false;
} }
stepType = "giver";
async open( async open(
giver?: libsUtil.GiverReceiverInputInfo, giver?: libsUtil.GiverReceiverInputInfo,
receiver?: libsUtil.GiverReceiverInputInfo, receiver?: libsUtil.GiverReceiverInputInfo,

11
src/constants/notifications.ts

@ -1191,17 +1191,6 @@ export const NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER = {
message: "You must select an identifier before you can record a give.", message: "You must select an identifier before you can record a give.",
}; };
export const NOTIFY_GIFTED_DETAILS_PROJECT_PROVIDER_INFO = {
title: "Project Provider Info",
message:
"To select a project as a provider, you must open this page through a project.",
};
export const NOTIFY_GIFTED_DETAILS_BOTH_PROVIDER_SELECTED = {
title: "Invalid Selection",
message: "You cannot select both a giving project and person.",
};
export const NOTIFY_GIFTED_DETAILS_RECORDING_GIVE = { export const NOTIFY_GIFTED_DETAILS_RECORDING_GIVE = {
title: "", title: "",
message: "Recording the give...", message: "Recording the give...",

2
src/db/tables/settings.ts

@ -10,6 +10,8 @@ export type BoundingBox = {
/** /**
* Settings type encompasses user-specific configuration details. * Settings type encompasses user-specific configuration details.
*
* New entries that are boolean should also be added to PlatformServiceMixin._mapColumnsToValues
*/ */
export type Settings = { export type Settings = {
// default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID // default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID

2
src/libs/partnerServer.ts

@ -4,6 +4,6 @@ export interface UserProfile {
locLon?: number; locLon?: number;
locLat2?: number; locLat2?: number;
locLon2?: number; locLon2?: number;
issuerDid?: string; issuerDid: string;
rowId?: string; // set on profile retrieved from server rowId?: string; // set on profile retrieved from server
} }

22
src/libs/util.ts

@ -7,7 +7,7 @@ import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app"; import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
import { Account, AccountEncrypted } from "../db/tables/accounts"; import { Account, AccountEncrypted } from "../db/tables/accounts";
import { Contact, ContactWithJsonStrings } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings"; import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
import { import {
arrayBufferToBase64, arrayBufferToBase64,
@ -34,7 +34,7 @@ import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto"; import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
// Consolidate this with src/utils/PlatformServiceMixin._parseJsonField // Consolidate this with src/utils/PlatformServiceMixin.mapQueryResultToValues
function mapQueryResultToValues( function mapQueryResultToValues(
record: { columns: string[]; values: unknown[][] } | undefined, record: { columns: string[]; values: unknown[][] } | undefined,
): Array<Record<string, unknown>> { ): Array<Record<string, unknown>> {
@ -57,10 +57,10 @@ async function getPlatformService() {
} }
export interface GiverReceiverInputInfo { export interface GiverReceiverInputInfo {
did?: string; did?: string; // only for people
name?: string; name?: string;
image?: string; image?: string;
handleId?: string; handleId?: string; // only for projects
} }
export enum OnboardPage { export enum OnboardPage {
@ -900,24 +900,12 @@ export interface DatabaseExport {
* @returns DatabaseExport object in the standardized format * @returns DatabaseExport object in the standardized format
*/ */
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => { export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
// Convert each contact to a plain object and ensure all fields are included
const rows = contacts.map((contact) => {
const exContact: ContactWithJsonStrings = R.omit(
["contactMethods"],
contact,
);
exContact.contactMethods = contact.contactMethods
? JSON.stringify(contact.contactMethods)
: undefined;
return exContact;
});
return { return {
data: { data: {
data: [ data: [
{ {
tableName: "contacts", tableName: "contacts",
rows, rows: contacts,
}, },
], ],
}, },

11
src/services/AbsurdSqlDatabaseService.ts

@ -182,14 +182,6 @@ class AbsurdSqlDatabaseService implements DatabaseService {
} }
operation.resolve(result); operation.resolve(result);
} catch (error) { } catch (error) {
// logger.error( // DISABLED
// "Error while processing SQL queue:",
// error,
// " ... for sql:",
// operation.sql,
// " ... with params:",
// operation.params,
// );
logger.error( logger.error(
"Error while processing SQL queue:", "Error while processing SQL queue:",
error, error,
@ -242,9 +234,6 @@ class AbsurdSqlDatabaseService implements DatabaseService {
// If initialized but no db, something went wrong // If initialized but no db, something went wrong
if (!this.db) { if (!this.db) {
// logger.error( // DISABLED
// `Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null`,
// );
logger.error( logger.error(
`Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null`, `Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null`,
); );

20
src/utils/PlatformServiceMixin.ts

@ -246,6 +246,15 @@ export const PlatformServiceMixin = {
// Keep null values as null // Keep null values as null
} }
// Handle JSON fields like contactMethods
if (column === "contactMethods" && typeof value === "string") {
try {
value = JSON.parse(value);
} catch {
value = [];
}
}
obj[column] = value; obj[column] = value;
}); });
return obj; return obj;
@ -1051,12 +1060,18 @@ export const PlatformServiceMixin = {
contact.profileImageUrl !== undefined contact.profileImageUrl !== undefined
? contact.profileImageUrl ? contact.profileImageUrl
: null, : null,
contactMethods:
contact.contactMethods !== undefined
? Array.isArray(contact.contactMethods)
? JSON.stringify(contact.contactMethods)
: contact.contactMethods
: null,
}; };
await this.$dbExec( await this.$dbExec(
`INSERT OR REPLACE INTO contacts `INSERT OR REPLACE INTO contacts
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl) (did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
safeContact.did, safeContact.did,
safeContact.name, safeContact.name,
@ -1065,6 +1080,7 @@ export const PlatformServiceMixin = {
safeContact.registered, safeContact.registered,
safeContact.nextPubKeyHashB64, safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl, safeContact.profileImageUrl,
safeContact.contactMethods,
], ],
); );
return true; return true;

7
src/views/ClaimView.vue

@ -1055,13 +1055,8 @@ export default class ClaimView extends Vue {
if (this.projectInfo) { if (this.projectInfo) {
// Recipient is a project // Recipient is a project
recipient = { recipient = {
did:
this.detailsForGive?.fulfillsPlanHandleId ||
this.detailsForOffer?.fulfillsPlanHandleId,
name: this.projectInfo.name, name: this.projectInfo.name,
handleId: handleId: this.detailsForOffer?.fulfillsPlanHandleId,
this.detailsForGive?.fulfillsPlanHandleId ||
this.detailsForOffer?.fulfillsPlanHandleId,
image: this.projectInfo.imageUrl, image: this.projectInfo.imageUrl,
}; };
} else { } else {

39
src/views/ContactQRScanShowView.vue

@ -153,6 +153,7 @@ import {
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
register, register,
setVisibilityUtil, setVisibilityUtil,
generateEndorserJwtUrlForAccount,
} 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 * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
@ -187,6 +188,7 @@ import {
QR_TIMEOUT_STANDARD, QR_TIMEOUT_STANDARD,
QR_TIMEOUT_LONG, QR_TIMEOUT_LONG,
} from "@/constants/notifications"; } from "@/constants/notifications";
import { Account } from "@/db/tables/accounts";
interface QRScanResult { interface QRScanResult {
rawValue?: string; rawValue?: string;
@ -610,16 +612,33 @@ export default class ContactQRScanShow extends Vue {
} }
async onCopyUrlToClipboard() { async onCopyUrlToClipboard() {
// Copy the CSV format QR code value instead of generating a deep link try {
useClipboard() // Generate URL for sharing
.copy(this.qrValue) const account = (await libsUtil.retrieveFullyDecryptedAccount(
.then(() => { this.activeDid,
this.notify.toast( )) as Account;
"Copied", const jwtUrl = await generateEndorserJwtUrlForAccount(
NOTIFY_QR_URL_COPIED.message, account,
QR_TIMEOUT_MEDIUM, this.isRegistered,
); this.givenName,
}); this.profileImageUrl,
true,
);
// Copy the URL to clipboard
useClipboard()
.copy(jwtUrl)
.then(() => {
this.notify.toast(
"Copied",
NOTIFY_QR_URL_COPIED.message,
QR_TIMEOUT_MEDIUM,
);
});
} catch (error) {
logger.error("Failed to generate contact URL:", error);
this.notify.error("Failed to generate contact URL. Please try again.");
}
} }
toastQRCodeHelp() { toastQRCodeHelp() {

25
src/views/GiftedDetailsView.vue

@ -296,8 +296,6 @@ import {
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM, NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM,
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR, NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR,
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER, NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER,
NOTIFY_GIFTED_DETAILS_PROJECT_PROVIDER_INFO,
NOTIFY_GIFTED_DETAILS_BOTH_PROVIDER_SELECTED,
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE, NOTIFY_GIFTED_DETAILS_RECORDING_GIVE,
NOTIFY_GIFTED_DETAILS_CREATE_GIVE_ERROR, NOTIFY_GIFTED_DETAILS_CREATE_GIVE_ERROR,
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED, NOTIFY_GIFTED_DETAILS_GIFT_RECORDED,
@ -635,29 +633,32 @@ export default class GiftedDetails extends Vue {
} }
notifyUserOfGiver() { notifyUserOfGiver() {
// there's no individual giver or there's a provider project
if (!this.giverDid) { if (!this.giverDid) {
this.notify.warning( this.notify.warning(
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER.message, "To assign a giver, you must choose a person in a previous step.",
TIMEOUTS.SHORT, TIMEOUTS.SHORT,
); );
} else { } else {
// must be because providedByProject is true
this.notify.warning( this.notify.warning(
NOTIFY_GIFTED_DETAILS_BOTH_PROVIDER_SELECTED.message, "You cannot assign both a giver and a project.",
TIMEOUTS.SHORT, TIMEOUTS.SHORT,
); );
} }
} }
notifyUserOfRecipient() { notifyUserOfRecipient() {
// there's no individual recipient or there's a fulfills project
if (!this.recipientDid) { if (!this.recipientDid) {
this.notify.warning( this.notify.warning(
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER.message, "To assign a recipient, you must choose a person in a previous step.",
TIMEOUTS.SHORT, TIMEOUTS.SHORT,
); );
} else { } else {
// must be because givenToProject is true // must be because givenToProject is true
this.notify.warning( this.notify.warning(
NOTIFY_GIFTED_DETAILS_BOTH_PROVIDER_SELECTED.message, "You cannot assign both to a recipient and to a project.",
TIMEOUTS.SHORT, TIMEOUTS.SHORT,
); );
} }
@ -667,13 +668,13 @@ export default class GiftedDetails extends Vue {
// we're here because they clicked and either there is no provider project or there is a giver chosen // we're here because they clicked and either there is no provider project or there is a giver chosen
if (!this.providerProjectId) { if (!this.providerProjectId) {
this.notify.warning( this.notify.warning(
NOTIFY_GIFTED_DETAILS_PROJECT_PROVIDER_INFO.message, "To select a project as a provider, you must choose a project in a previous step.",
TIMEOUTS.SHORT, TIMEOUTS.SHORT,
); );
} else { } else {
// no providing project was chosen // no providing project was chosen, so there must be an individual giver
this.notify.warning( this.notify.warning(
NOTIFY_GIFTED_DETAILS_BOTH_PROVIDER_SELECTED.message, "You cannot select both a giving project and person.",
TIMEOUTS.SHORT, TIMEOUTS.SHORT,
); );
} }
@ -683,13 +684,13 @@ export default class GiftedDetails extends Vue {
// we're here because they clicked and either there is no fulfills project or there is a recipient chosen // we're here because they clicked and either there is no fulfills project or there is a recipient chosen
if (!this.fulfillsProjectId) { if (!this.fulfillsProjectId) {
this.notify.warning( this.notify.warning(
NOTIFY_GIFTED_DETAILS_PROJECT_PROVIDER_INFO.message, "To assign a project as a recipient, you must choose a project in a previous step.",
TIMEOUTS.SHORT, TIMEOUTS.SHORT,
); );
} else { } else {
// no fulfills project was chosen // no fulfills project was chosen, so there must be an individual recipient
this.notify.warning( this.notify.warning(
NOTIFY_GIFTED_DETAILS_BOTH_PROVIDER_SELECTED.message, "You cannot select both a receiving project and person.",
TIMEOUTS.SHORT, TIMEOUTS.SHORT,
); );
} }

Loading…
Cancel
Save