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: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:all": "npm run clean:ios && npm run clean:android && npm run clean:electron",
"build:android": "./scripts/build-android.sh",
"build:android:dev": "./scripts/build-android.sh --dev",
"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)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
offerId = "";
projects: PlanData[] = [];
prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo;
stepType = "giver";
unitCode = "HUR";
visible = false;
libsUtil = libsUtil;
projects: PlanData[] = [];
didInfo = didInfo;
// Computed property to help debug template logic
@ -188,8 +188,6 @@ export default class GiftedDialog extends Vue {
return false;
}
stepType = "giver";
async open(
giver?: 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.",
};
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 = {
title: "",
message: "Recording the give...",

2
src/db/tables/settings.ts

@ -10,6 +10,8 @@ export type BoundingBox = {
/**
* Settings type encompasses user-specific configuration details.
*
* New entries that are boolean should also be added to PlatformServiceMixin._mapColumnsToValues
*/
export type Settings = {
// 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;
locLat2?: number;
locLon2?: number;
issuerDid?: string;
issuerDid: string;
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 { 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 {
arrayBufferToBase64,
@ -34,7 +34,7 @@ import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { IIdentifier } from "@veramo/core";
import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
// Consolidate this with src/utils/PlatformServiceMixin._parseJsonField
// Consolidate this with src/utils/PlatformServiceMixin.mapQueryResultToValues
function mapQueryResultToValues(
record: { columns: string[]; values: unknown[][] } | undefined,
): Array<Record<string, unknown>> {
@ -57,10 +57,10 @@ async function getPlatformService() {
}
export interface GiverReceiverInputInfo {
did?: string;
did?: string; // only for people
name?: string;
image?: string;
handleId?: string;
handleId?: string; // only for projects
}
export enum OnboardPage {
@ -900,24 +900,12 @@ export interface DatabaseExport {
* @returns DatabaseExport object in the standardized format
*/
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 {
data: {
data: [
{
tableName: "contacts",
rows,
rows: contacts,
},
],
},

11
src/services/AbsurdSqlDatabaseService.ts

@ -182,14 +182,6 @@ class AbsurdSqlDatabaseService implements DatabaseService {
}
operation.resolve(result);
} catch (error) {
// logger.error( // DISABLED
// "Error while processing SQL queue:",
// error,
// " ... for sql:",
// operation.sql,
// " ... with params:",
// operation.params,
// );
logger.error(
"Error while processing SQL queue:",
error,
@ -242,9 +234,6 @@ class AbsurdSqlDatabaseService implements DatabaseService {
// If initialized but no db, something went wrong
if (!this.db) {
// logger.error( // DISABLED
// `Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null`,
// );
logger.error(
`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
}
// Handle JSON fields like contactMethods
if (column === "contactMethods" && typeof value === "string") {
try {
value = JSON.parse(value);
} catch {
value = [];
}
}
obj[column] = value;
});
return obj;
@ -1051,12 +1060,18 @@ export const PlatformServiceMixin = {
contact.profileImageUrl !== undefined
? contact.profileImageUrl
: null,
contactMethods:
contact.contactMethods !== undefined
? Array.isArray(contact.contactMethods)
? JSON.stringify(contact.contactMethods)
: contact.contactMethods
: null,
};
await this.$dbExec(
`INSERT OR REPLACE INTO contacts
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
safeContact.did,
safeContact.name,
@ -1065,6 +1080,7 @@ export const PlatformServiceMixin = {
safeContact.registered,
safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl,
safeContact.contactMethods,
],
);
return true;

7
src/views/ClaimView.vue

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

39
src/views/ContactQRScanShowView.vue

@ -153,6 +153,7 @@ import {
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
register,
setVisibilityUtil,
generateEndorserJwtUrlForAccount,
} from "../libs/endorserServer";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import * as libsUtil from "../libs/util";
@ -187,6 +188,7 @@ import {
QR_TIMEOUT_STANDARD,
QR_TIMEOUT_LONG,
} from "@/constants/notifications";
import { Account } from "@/db/tables/accounts";
interface QRScanResult {
rawValue?: string;
@ -610,16 +612,33 @@ export default class ContactQRScanShow extends Vue {
}
async onCopyUrlToClipboard() {
// Copy the CSV format QR code value instead of generating a deep link
useClipboard()
.copy(this.qrValue)
.then(() => {
this.notify.toast(
"Copied",
NOTIFY_QR_URL_COPIED.message,
QR_TIMEOUT_MEDIUM,
);
});
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.");
}
}
toastQRCodeHelp() {

25
src/views/GiftedDetailsView.vue

@ -296,8 +296,6 @@ import {
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM,
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR,
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_CREATE_GIVE_ERROR,
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED,
@ -635,29 +633,32 @@ export default class GiftedDetails extends Vue {
}
notifyUserOfGiver() {
// there's no individual giver or there's a provider project
if (!this.giverDid) {
this.notify.warning(
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER.message,
"To assign a giver, you must choose a person in a previous step.",
TIMEOUTS.SHORT,
);
} else {
// must be because providedByProject is true
this.notify.warning(
NOTIFY_GIFTED_DETAILS_BOTH_PROVIDER_SELECTED.message,
"You cannot assign both a giver and a project.",
TIMEOUTS.SHORT,
);
}
}
notifyUserOfRecipient() {
// there's no individual recipient or there's a fulfills project
if (!this.recipientDid) {
this.notify.warning(
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER.message,
"To assign a recipient, you must choose a person in a previous step.",
TIMEOUTS.SHORT,
);
} else {
// must be because givenToProject is true
this.notify.warning(
NOTIFY_GIFTED_DETAILS_BOTH_PROVIDER_SELECTED.message,
"You cannot assign both to a recipient and to a project.",
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
if (!this.providerProjectId) {
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,
);
} else {
// no providing project was chosen
// no providing project was chosen, so there must be an individual giver
this.notify.warning(
NOTIFY_GIFTED_DETAILS_BOTH_PROVIDER_SELECTED.message,
"You cannot select both a giving project and person.",
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
if (!this.fulfillsProjectId) {
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,
);
} else {
// no fulfills project was chosen
// no fulfills project was chosen, so there must be an individual recipient
this.notify.warning(
NOTIFY_GIFTED_DETAILS_BOTH_PROVIDER_SELECTED.message,
"You cannot select both a receiving project and person.",
TIMEOUTS.SHORT,
);
}

Loading…
Cancel
Save