add labels for contacts (as a way to group them)

This commit is contained in:
2026-01-11 19:07:08 -07:00
parent 02eb891ee9
commit 662da79df8
7 changed files with 429 additions and 14 deletions

View File

@@ -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)

View File

@@ -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);
`,
},
];
/**

View File

@@ -0,0 +1,4 @@
export type ContactLabel = {
did: string;
label: string;
};

View File

@@ -1496,6 +1496,126 @@ export const PlatformServiceMixin = {
}
},
/**
* Get labels for a specific contact - $getContactLabels()
* @param did Contact DID
* @returns Promise<string[]> Array of labels
*/
async $getContactLabels(did: string): Promise<string[]> {
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<string[]> Array of contacts with all labels
*/
async $getContactIdsWithAllLabels(labels: string[]): Promise<string[]> {
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<string, number> = results.values?.reduce(
(acc: Record<string, number>, 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<boolean> Success status
*/
async $addContactLabel(did: string, label: string): Promise<boolean> {
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<boolean> Success status
*/
async $deleteContactLabel(did: string, label: string): Promise<boolean> {
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<string[]> Array of unique labels
*/
async $getUniqueLabels(): Promise<string[]> {
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<Contact | null>;
$deleteContact(did: string): Promise<boolean>;
$contactCount(): Promise<number>;
$getContactLabels(did: string): Promise<string[]>;
$getContactIdsWithAllLabels(labels: string[]): Promise<string[]>;
$addContactLabel(did: string, label: string): Promise<boolean>;
$deleteContactLabel(did: string, label: string): Promise<boolean>;
$getUniqueLabels(): Promise<string[]>;
$getAllAccounts(): Promise<Account[]>;
$getAllAccountDids(): Promise<string[]>;
$insertEntity(
@@ -2074,10 +2199,7 @@ declare module "@vue/runtime-core" {
$withTransaction<T>(fn: () => Promise<T>): Promise<T>;
$getAvailableAccountDids(): Promise<string[]>;
// Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
$contactCount(): Promise<number>;
// Specialized shortcuts - settings fresh
$settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;
$normalizeContacts(rawContacts: ContactMaybeWithJsonStrings[]): Contact[];
@@ -2091,6 +2213,9 @@ declare module "@vue/runtime-core" {
// @deprecated; see implementation note above
// $saveMySettings(changes: Partial<Settings>): Promise<boolean>;
$contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
// Cache management methods
$refreshSettings(): Promise<Settings>;
$refreshContacts(): Promise<Contact[]>;
@@ -2106,6 +2231,12 @@ declare module "@vue/runtime-core" {
$getAllContacts(): Promise<Contact[]>;
$getContact(did: string): Promise<Contact | null>;
$deleteContact(did: string): Promise<boolean>;
$contactCount(): Promise<number>;
$getContactLabels(did: string): Promise<string[]>;
$getContactIdsWithAllLabels(labels: string[]): Promise<string[]>;
$addContactLabel(did: string, label: string): Promise<boolean>;
$deleteContactLabel(did: string, label: string): Promise<boolean>;
$getUniqueLabels(): Promise<string[]>;
$getAllAccounts(): Promise<Account[]>;
$getAllAccountDids(): Promise<string[]>;
$insertEntity(

View File

@@ -126,6 +126,67 @@
</button>
</div>
<!-- Contact Labels -->
<div class="mt-8 border-t pt-4">
<label class="block text-sm font-medium text-gray-700"> Labels </label>
<div class="flex flex-wrap gap-2 mt-2">
<span
v-for="label in contactLabels"
:key="label"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{{ label }}
<button
type="button"
class="flex-shrink-0 ml-1.5 inline-flex text-blue-400 hover:text-blue-600 focus:outline-none"
@click="removeLabel(label)"
>
<font-awesome icon="xmark" class="h-4 w-4" />
</button>
</span>
<span
v-if="contactLabels.length === 0"
class="text-sm text-gray-400 italic"
>
No labels attached
</span>
</div>
<div class="mt-2 flex gap-2">
<input
v-model="newLabel"
type="text"
placeholder="Add a label..."
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm p-2"
@keyup.enter="addLabel"
/>
<button
class="px-4 py-1 bg-green-500 text-white rounded-md text-sm"
@click="addLabel"
>
Add
</button>
</div>
<!-- All Available Labels -->
<div v-if="availableLabels.length > 0" class="mt-4">
<p class="text-sm font-medium text-gray-700 mb-2">Available labels:</p>
<div class="flex flex-wrap gap-2">
<button
v-for="label in availableLabels"
:key="label"
class="inline-flex items-center px-3 py-1 rounded text-sm font-medium transition-colors"
:class="
contactLabels.includes(label)
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
"
@click="toggleLabel(label)"
>
{{ label }}
</button>
</div>
</div>
</div>
<!-- Save Button -->
<div class="mt-8 flex justify-between">
<button
@@ -224,12 +285,36 @@ export default class ContactEditView extends Vue {
contactNotes = "";
/** Array of editable contact methods */
contactMethods: Array<ContactMethod> = [];
/** Array of contact labels */
contactLabels: string[] = [];
/** Labels before editing to track changes */
originalLabels: string[] = [];
/** New label input field */
newLabel = "";
/** All unique labels from other contacts for suggestions */
allUniqueLabels: string[] = [];
/** App string constants */
AppString = AppString;
/** Contact method types for datalist suggestions */
contactMethodTypes = CONTACT_METHOD_TYPES;
/**
* Filter unique labels that are not already attached to this contact
*/
get suggestedLabels() {
return this.allUniqueLabels.filter(
(label) => !this.contactLabels.includes(label),
);
}
/**
* Get all available labels for selection
*/
get availableLabels() {
return this.allUniqueLabels;
}
/**
* Component lifecycle hook that initializes the contact edit form
*
@@ -254,6 +339,14 @@ export default class ContactEditView extends Vue {
this.contactName = contact.name || "";
this.contactNotes = contact.notes || "";
this.contactMethods = contact.contactMethods || [];
// Load labels
const labels = await this.$getContactLabels(contactDid);
this.contactLabels = labels;
this.originalLabels = [...labels];
// Load all labels for suggestions
this.allUniqueLabels = await this.$getUniqueLabels();
} else {
this.notify.error(
`${NOTIFY_CONTACT_NOT_FOUND.message} ${contactDid}`,
@@ -285,6 +378,50 @@ export default class ContactEditView extends Vue {
this.contactMethods.splice(index, 1);
}
/**
* Adds a new label to the contact labels array
*/
addLabel() {
const label = this.newLabel.trim();
if (label && !this.contactLabels.includes(label)) {
this.contactLabels.push(label);
this.contactLabels.sort();
this.newLabel = "";
}
}
/**
* Adds a suggested label to the contact
* @param label The label to add
*/
addSuggestedLabel(label: string) {
if (!this.contactLabels.includes(label)) {
this.contactLabels.push(label);
this.contactLabels.sort();
}
}
/**
* Removes a label from the contact labels array
* @param label The label to remove
*/
removeLabel(label: string) {
this.contactLabels = this.contactLabels.filter((l) => l !== label);
}
/**
* Toggles a label on or off for the contact
* @param label The label to toggle
*/
toggleLabel(label: string) {
if (this.contactLabels.includes(label)) {
this.removeLabel(label);
} else {
this.contactLabels.push(label);
this.contactLabels.sort();
}
}
/**
* Saves the edited contact information to the database
*
@@ -320,13 +457,29 @@ export default class ContactEditView extends Vue {
}
// Save to database via PlatformServiceMixin
// Normalize empty strings to null to preserve database consistency
// Normalize empty strings to undefined to preserve database consistency
await this.$updateContact(this.contact?.did || "", {
name: this.contactName?.trim() || null,
notes: this.contactNotes?.trim() || null,
name: this.contactName?.trim() || undefined,
notes: this.contactNotes?.trim() || undefined,
contactMethods: contactMethods,
});
// Save labels
const contactDid = this.contact?.did || "";
const labelsToAdd = this.contactLabels.filter(
(l) => !this.originalLabels.includes(l),
);
const labelsToRemove = this.originalLabels.filter(
(l) => !this.contactLabels.includes(l),
);
for (const label of labelsToAdd) {
await this.$addContactLabel(contactDid, label);
}
for (const label of labelsToRemove) {
await this.$deleteContactLabel(contactDid, label);
}
// Notify success and redirect
this.notify.success(NOTIFY_CONTACT_SAVED.message, TIMEOUTS.STANDARD);
this.$router.back();

View File

@@ -78,6 +78,55 @@
</div>
</div>
<!-- Label Filter -->
<div v-if="allLabels.length > 0" class="mt-4 mb-2">
<div class="flex items-center justify-between pl-[16.666%] pr-[16.666%]">
<button
class="text-sm font-medium text-blue-600 flex items-center gap-1"
@click="showLabelFilter = !showLabelFilter"
>
<font-awesome
:icon="showLabelFilter ? 'chevron-up' : 'filter'"
class="text-xs"
/>
{{ showLabelFilter ? "Hide Filters" : "Filter by Labels" }}
<span
v-if="selectedLabels.length > 0"
class="bg-blue-600 text-white rounded-full px-1.5 py-0.5 text-[10px]"
>
{{ selectedLabels.length }}
</span>
</button>
<button
:class="[
'text-sm text-red-500 underline',
selectedLabels.length === 0 ? 'invisible' : '',
]"
@click="clearLabelFilters"
>
Clear all
</button>
</div>
<div
v-if="showLabelFilter"
class="flex flex-wrap gap-2 mt-3 p-3 bg-gray-50 rounded-lg border border-gray-200"
>
<button
v-for="label in allLabels"
:key="label"
class="px-2.5 py-1 rounded-full text-xs font-medium border transition-colors"
:class="
selectedLabels.includes(label)
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
"
@click="toggleLabel(label)"
>
{{ label }}
</button>
</div>
</div>
<!-- Results List -->
<ul
v-if="contacts.length > 0"
@@ -85,7 +134,7 @@
class="border-t border-slate-300 my-2"
>
<ContactListItem
v-for="contact in filteredContacts"
v-for="contact in contactsFiltered"
:key="contact.did"
:contact="contact"
:active-did="activeDid"
@@ -268,6 +317,7 @@ export default class ContactsView extends Vue {
activeDid = "";
apiServer = "";
contacts: Array<Contact> = [];
contactsFiltered: Array<Contact> = [];
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);

View File

@@ -43,6 +43,19 @@
</router-link>
</h2>
<!-- Labels -->
<div v-if="contactLabels.length > 0" class="mt-3">
<div class="flex flex-wrap gap-2">
<span
v-for="label in contactLabels"
:key="label"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{{ label }}
</span>
</div>
</div>
<!-- Notes -->
<div v-if="contactFromDid.notes" class="mt-3">
<p class="text-sm text-slate-700 whitespace-pre-wrap">
@@ -390,6 +403,7 @@ export default class DIDView extends Vue {
apiServer = "";
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
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 = [];
}
}