Compare commits

..

1 Commits

Author SHA1 Message Date
Jose Olarte III
c4eb6f2d1d fix(GiftedDialog): preserve recipient when changing giver project
Modified selectProject() to only set receiver to "You" if no receiver
has been selected yet, preventing recipient from being reset when
changing giver project in Project-to-Person context.
2025-11-18 15:50:11 +08:00
9 changed files with 136 additions and 186 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -338,10 +338,9 @@ export default class ContactEditView extends Vue {
} }
// Save to database via PlatformServiceMixin // Save to database via PlatformServiceMixin
// Normalize empty strings to null to preserve database consistency
await this.$updateContact(this.contact?.did || "", { await this.$updateContact(this.contact?.did || "", {
name: this.contactName?.trim() || null, name: this.contactName,
notes: this.contactNotes?.trim() || null, notes: this.contactNotes,
contactMethods: contactMethods, contactMethods: contactMethods,
}); });

View File

@@ -898,13 +898,7 @@ export default class HomeView extends Vue {
this.starredPlanHandleIds, this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId, this.lastAckedStarredPlanChangesJwtId,
); );
// filter out any data elements where there is no wrappedClaimBefore this.numNewStarredProjectChanges = starredProjectChanges.data.length;
const filteredNewStarredProjectChanges =
starredProjectChanges.data.filter(
(change) => change.wrappedClaimBefore !== undefined,
);
this.numNewStarredProjectChanges =
filteredNewStarredProjectChanges.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit; this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) { } catch (error) {
// Don't show errors for starred project changes as it's a secondary feature // Don't show errors for starred project changes as it's a secondary feature

View File

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

View File

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