diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index a3fbb43e..5a7d6064 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -842,7 +842,8 @@ export const NOTIFY_EXPORT_DATA_PROMPT = { // Used in: ContactsView.vue (showCopySelectionsInfo method - info about copying contacts) export const NOTIFY_CONTACT_INFO_COPY = { title: "Info", - message: "Contact info will include name, ID, profile image, and public key.", + message: + "Copied contact info will include name, ID, profile image, and public key.", }; // Used in: ContactsView.vue (copySelectedContacts method - no contacts selected error) diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index 4a177786..bdc6abe3 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -199,6 +199,20 @@ const MIGRATIONS = [ ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT; `, }, + { + name: "006_add_labels_for_contacts", + sql: ` + -- Create mapping table for contact labels + CREATE TABLE contact_labels ( + did TEXT NOT NULL, + label TEXT NOT NULL, + PRIMARY KEY (did, label), + FOREIGN KEY (did) REFERENCES contacts(did) ON DELETE CASCADE + ); + CREATE INDEX idx_contact_labels_label ON contact_labels(label); + CREATE INDEX idx_contact_labels_did ON contact_labels(did); + `, + }, ]; /** diff --git a/src/db/tables/contactLabels.ts b/src/db/tables/contactLabels.ts new file mode 100644 index 00000000..75b0d6af --- /dev/null +++ b/src/db/tables/contactLabels.ts @@ -0,0 +1,4 @@ +export type ContactLabel = { + did: string; + label: string; +}; diff --git a/src/utils/PlatformServiceMixin.ts b/src/utils/PlatformServiceMixin.ts index 32424cf3..422a20e8 100644 --- a/src/utils/PlatformServiceMixin.ts +++ b/src/utils/PlatformServiceMixin.ts @@ -1496,6 +1496,126 @@ export const PlatformServiceMixin = { } }, + /** + * Get labels for a specific contact - $getContactLabels() + * @param did Contact DID + * @returns Promise Array of labels + */ + async $getContactLabels(did: string): Promise { + try { + const results = (await this.$dbQuery( + "SELECT label FROM contact_labels WHERE did = ? ORDER BY label", + [did], + )) as QueryExecResult; + return results.values.map((r: SqlValue[]) => r[0] as string); + } catch (error) { + logger.error( + `[PlatformServiceMixin] Error getting labels for contact ${did}:`, + error, + ); + return []; + } + }, + + /** + * Get contact IDs with all labels + * @param labels Array of labels + * @returns Promise Array of contacts with all labels + */ + async $getContactIdsWithAllLabels(labels: string[]): Promise { + try { + const numberOfLabels = labels.length; + const questionsForInput = labels.map(() => `?`).join(", "); + const results = (await this.$dbQuery( + `SELECT did FROM contact_labels WHERE label IN (${questionsForInput})`, + [...labels], + )) as QueryExecResult; + // count the occurrences of each did + const didCounts: Record = results.values?.reduce( + (acc: Record, curr: SqlValue[]) => { + acc[curr[0] as unknown as string] = + (acc[curr[0] as unknown as string] || 0) + 1; + return acc; + }, + {}, + ); + // filter out the dids that do not occur for as many labels as there are labels + const contactIdsWithAllLabels = Object.keys(didCounts).filter( + (did: string) => didCounts[did] === numberOfLabels, + ); + return contactIdsWithAllLabels; + } catch (error) { + logger.error( + `[PlatformServiceMixin] Error getting contact IDs with all labels ${labels}:`, + error, + ); + return []; + } + }, + + /** + * Add a label to a contact - $addContactLabel() + * @param did Contact DID + * @param label Label string + * @returns Promise Success status + */ + async $addContactLabel(did: string, label: string): Promise { + try { + await this.$dbExec( + "INSERT OR IGNORE INTO contact_labels (did, label) VALUES (?, ?)", + [did, label], + ); + return true; + } catch (error) { + logger.error( + `[PlatformServiceMixin] Error adding label ${label} to contact ${did}:`, + error, + ); + return false; + } + }, + + /** + * Remove a label from a contact - $deleteContactLabel() + * @param did Contact DID + * @param label Label string + * @returns Promise Success status + */ + async $deleteContactLabel(did: string, label: string): Promise { + try { + await this.$dbExec( + "DELETE FROM contact_labels WHERE did = ? AND label = ?", + [did, label], + ); + return true; + } catch (error) { + logger.error( + `[PlatformServiceMixin] Error deleting label ${label} from contact ${did}:`, + error, + ); + return false; + } + }, + + /** + * Get all unique labels available - $getUniqueLabels() + * @returns Promise Array of unique labels + */ + async $getUniqueLabels(): Promise { + try { + const results = (await this.$dbQuery( + "SELECT DISTINCT label FROM contact_labels ORDER BY label", + )) as QueryExecResult; + return results.values?.map((r: SqlValue[]) => r[0] as string) || []; + } catch (error) { + logger.error( + "[PlatformServiceMixin] Error getting unique labels:", + error, + ); + return []; + } + }, + /** * Get all accounts - $getAllAccounts() * Retrieves all account metadata from the accounts table @@ -1965,6 +2085,11 @@ export interface IPlatformServiceMixin { $getContact(did: string): Promise; $deleteContact(did: string): Promise; $contactCount(): Promise; + $getContactLabels(did: string): Promise; + $getContactIdsWithAllLabels(labels: string[]): Promise; + $addContactLabel(did: string, label: string): Promise; + $deleteContactLabel(did: string, label: string): Promise; + $getUniqueLabels(): Promise; $getAllAccounts(): Promise; $getAllAccountDids(): Promise; $insertEntity( @@ -2074,10 +2199,7 @@ declare module "@vue/runtime-core" { $withTransaction(fn: () => Promise): Promise; $getAvailableAccountDids(): Promise; - // Specialized shortcuts - contacts cached, settings fresh - $contacts(): Promise; - $contactsByDateAdded(): Promise; - $contactCount(): Promise; + // Specialized shortcuts - settings fresh $settings(defaults?: Settings): Promise; $accountSettings(did?: string, defaults?: Settings): Promise; $normalizeContacts(rawContacts: ContactMaybeWithJsonStrings[]): Contact[]; @@ -2091,6 +2213,9 @@ declare module "@vue/runtime-core" { // @deprecated; see implementation note above // $saveMySettings(changes: Partial): Promise; + $contacts(): Promise; + $contactsByDateAdded(): Promise; + // Cache management methods $refreshSettings(): Promise; $refreshContacts(): Promise; @@ -2106,6 +2231,12 @@ declare module "@vue/runtime-core" { $getAllContacts(): Promise; $getContact(did: string): Promise; $deleteContact(did: string): Promise; + $contactCount(): Promise; + $getContactLabels(did: string): Promise; + $getContactIdsWithAllLabels(labels: string[]): Promise; + $addContactLabel(did: string, label: string): Promise; + $deleteContactLabel(did: string, label: string): Promise; + $getUniqueLabels(): Promise; $getAllAccounts(): Promise; $getAllAccountDids(): Promise; $insertEntity( diff --git a/src/views/ContactEditView.vue b/src/views/ContactEditView.vue index e53cceb2..ab9b70f3 100644 --- a/src/views/ContactEditView.vue +++ b/src/views/ContactEditView.vue @@ -126,6 +126,67 @@ + +
+ +
+ + {{ label }} + + + + No labels attached + +
+
+ + +
+ +
+

Available labels:

+
+ +
+
+
+
+ +
+
+ + +
+
+ +
+
+
    = []; + contactsFiltered: Array = []; contactInput = ""; contactEdit: Contact | null = null; contactNewName = ""; @@ -294,6 +344,11 @@ export default class ContactsView extends Vue { showGiveConfirmed = true; showLargeIdenticon?: Contact; + /** Label management state */ + selectedLabels: string[] = []; + allLabels: string[] = []; + showLabelFilter = false; + APP_SERVER = APP_SERVER; AppString = AppString; libsUtil = libsUtil; @@ -333,8 +388,9 @@ export default class ContactsView extends Vue { this.loadGives(); } - // Replace PlatformServiceFactory and manual SQL with mixin method this.contacts = await this.$getAllContacts(); + this.contactsFiltered = await this.filteredContacts(); + this.allLabels = await this.$getUniqueLabels(); } private async processContactJwt() { @@ -501,14 +557,48 @@ export default class ContactsView extends Vue { } // Computed properties for template streamlining - get filteredContacts() { - return this.showGiveNumbers + async filteredContacts() { + let filtered = this.showGiveNumbers ? this.contactsSelected.length === 0 ? this.contacts : this.contacts.filter((contact) => this.contactsSelected.includes(contact.did), ) : this.contacts; + + // Apply label filtering if any labels are selected + if (this.selectedLabels.length > 0) { + const contactIdsWithAllLabels = await this.$getContactIdsWithAllLabels( + this.selectedLabels, + ); + filtered = filtered.filter((contact: Contact) => + contactIdsWithAllLabels.includes(contact.did), + ); + } + return filtered; + } + + /** + * Toggle a label in the filter selection + * @param label The label to toggle + */ + async toggleLabel(label: string) { + if (this.selectedLabels.includes(label)) { + this.selectedLabels = this.selectedLabels.filter((l) => l !== label); + } else { + this.selectedLabels.push(label); + } + this.contactsSelected = []; + this.contactsFiltered = await this.filteredContacts(); + } + + /** + * Clear all active label filters + */ + async clearLabelFilters() { + this.selectedLabels = []; + this.contactsSelected = []; + this.contactsFiltered = await this.filteredContacts(); } get copyButtonClass() { @@ -537,7 +627,7 @@ export default class ContactsView extends Vue { } get allContactsSelected() { - return this.contactsSelected.length === this.contacts.length; + return this.contactsSelected.length === this.contactsFiltered.length; } // Helper methods for template interactions @@ -545,7 +635,9 @@ export default class ContactsView extends Vue { if (this.allContactsSelected) { this.contactsSelected = []; } else { - this.contactsSelected = this.contacts.map((contact) => contact.did); + this.contactsSelected = this.contactsFiltered.map( + (contact) => contact.did, + ); } } @@ -722,6 +814,7 @@ export default class ContactsView extends Vue { } this.contacts = await this.$getAllContacts(); + this.contactsFiltered = await this.filteredContacts(); return true; } return false; @@ -839,6 +932,7 @@ export default class ContactsView extends Vue { // Update local contacts list this.updateContactsList(newContact); + this.contactsFiltered = await this.filteredContacts(); // Set visibility and get success message const addedMessage = await this.handleContactVisibility(newContact); diff --git a/src/views/DIDView.vue b/src/views/DIDView.vue index e8336146..01b26bb0 100644 --- a/src/views/DIDView.vue +++ b/src/views/DIDView.vue @@ -43,6 +43,19 @@ + +
    +
    + + {{ label }} + +
    +
    +

    @@ -390,6 +403,7 @@ export default class DIDView extends Vue { apiServer = ""; claims: Array> = []; contactFromDid?: Contact; + contactLabels: string[] = []; contactYaml = ""; hitEnd = false; @@ -485,9 +499,13 @@ export default class DIDView extends Vue { if (contact) { this.contactFromDid = contact; this.contactYaml = yaml.dump(this.contactFromDid); + + // Load labels for this contact + this.contactLabels = await this.$getContactLabels(this.viewingDid); } else { this.contactFromDid = undefined; this.contactYaml = ""; + this.contactLabels = []; } }