Browse Source

copy a list of contacts and then import

Trent Larson 3 months ago
parent
commit
c95b2178ef
  1. 4
      CHANGELOG.md
  2. 4
      src/db/tables/contacts.ts
  3. 4
      src/views/ContactQRScanShowView.vue
  4. 256
      src/views/ContactsView.vue
  5. 5
      src/views/DIDView.vue

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## ? ## ?
### Added
- Send list of contacts to someone
### Changed
- Moved contact actions from list onto detail page
## [0.3.20] - 2024.08.18 - 4064eb75a9743ca268bf00016fa0a5fc5dec4e30 ## [0.3.20] - 2024.08.18 - 4064eb75a9743ca268bf00016fa0a5fc5dec4e30

4
src/db/tables/contacts.ts

@ -4,8 +4,8 @@ export interface Contact {
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
profileImageUrl?: string; profileImageUrl?: string;
publicKeyBase64?: string; publicKeyBase64?: string;
seesMe?: boolean; seesMe?: boolean; // cached value of the server setting
registered?: boolean; registered?: boolean; // cached value of the server setting
} }
export const ContactSchema = { export const ContactSchema = {

4
src/views/ContactQRScanShowView.vue

@ -458,9 +458,9 @@ export default class ContactQRScanShow extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Copied", title: "Copied",
text: "Your DID was copied to the clipboard. Have them paste it on their 'People' screen to add you.", text: "Your DID was copied to the clipboard. Have them paste it in the box on their 'People' screen to add you.",
}, },
10000, 5000,
); );
}); });
} }

256
src/views/ContactsView.vue

@ -41,14 +41,43 @@
</button> </button>
</div> </div>
<div class="w-full text-right"> <div class="flex justify-between">
<button <div class="w-full text-left">
href="" <input
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 py-1 rounded-md" type="checkbox"
@click="toggleShowContactAmounts()" v-if="!showGiveNumbers"
> :checked="contactsSelected.length === contacts.length"
{{ showGiveNumbers ? "Hide Given Hours" : "Show Given Hours" }} @click="
</button> contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
/>
<button
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 py-1 ml-2 rounded-md"
:style="
contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
>
Copy Selected Contacts
</button>
</div>
<div class="w-full text-right">
<button
href=""
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 py-1 rounded-md"
@click="toggleShowContactAmounts()"
>
{{ showGiveNumbers ? "Hide Given Hours" : "Show Given Hours" }}
</button>
</div>
</div> </div>
<div class="flex justify-between mt-1" v-if="showGiveNumbers"> <div class="flex justify-between mt-1" v-if="showGiveNumbers">
<div class="w-full text-right"> <div class="w-full text-right">
@ -86,18 +115,37 @@
> >
<li <li
class="border-b border-slate-300 pt-1 pb-1" class="border-b border-slate-300 pt-1 pb-1"
v-for="contact in contacts" v-for="contact in filteredContacts()"
:key="contact.did" :key="contact.did"
> >
<div class="grow overflow-hidden"> <div class="grow overflow-hidden">
<h2 class="text-base font-semibold"> <div class="flex items-center">
<EntityIcon <EntityIcon
:contact="contact" :contact="contact"
:iconSize="24" :iconSize="24"
class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer" class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer"
@click="showLargeIdenticon = contact" @click="showLargeIdenticon = contact"
/> />
{{ contact.name || AppString.NO_CONTACT_NAME }}
<input
type="checkbox"
v-if="!showGiveNumbers"
:checked="contactsSelected.includes(contact.did)"
@click="
contactsSelected.includes(contact.did)
? contactsSelected.splice(
contactsSelected.indexOf(contact.did),
1,
)
: contactsSelected.push(contact.did)
"
class="ml-2 h-6 w-6"
/>
<h2 class="text-base font-semibold ml-2">
{{ contact.name || AppString.NO_CONTACT_NAME }}
</h2>
<router-link <router-link
:to="{ :to="{
path: '/did/' + encodeURIComponent(contact.did), path: '/did/' + encodeURIComponent(contact.did),
@ -106,7 +154,7 @@
> >
<fa icon="circle-info" class="text-blue-500 ml-4" /> <fa icon="circle-info" class="text-blue-500 ml-4" />
</router-link> </router-link>
</h2> </div>
<div id="ContactActions" class="flex gap-1.5 mt-2"> <div id="ContactActions" class="flex gap-1.5 mt-2">
<div <div
v-if="showGiveNumbers && contact.did != activeDid" v-if="showGiveNumbers && contact.did != activeDid"
@ -178,6 +226,22 @@
</ul> </ul>
<p v-else>There are no contacts.</p> <p v-else>There are no contacts.</p>
<div class="w-full text-left">
<button
href=""
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 py-1 rounded-md"
:style="
contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
>
Copy Selected Contacts
</button>
</div>
<GiftedDialog ref="customGivenDialog" /> <GiftedDialog ref="customGivenDialog" />
<OfferDialog ref="customOfferDialog" /> <OfferDialog ref="customOfferDialog" />
@ -203,6 +267,7 @@ import { IndexableType } from "dexie";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import { AppString, NotificationIface } from "@/constants/app"; import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index"; import { db } from "@/db/index";
@ -237,6 +302,7 @@ export default class ContactsView extends Vue {
contactInput = ""; contactInput = "";
contactEdit: Contact | null = null; contactEdit: Contact | null = null;
contactNewName = ""; contactNewName = "";
contactsSelected: Array<string> = [];
// { "did:...": concatenated-descriptions } entry for each contact // { "did:...": concatenated-descriptions } entry for each contact
givenByMeDescriptions: Record<string, string> = {}; givenByMeDescriptions: Record<string, string> = {};
// { "did:...": amount } entry for each contact // { "did:...": amount } entry for each contact
@ -262,7 +328,7 @@ export default class ContactsView extends Vue {
AppString = AppString; AppString = AppString;
libsUtil = libsUtil; libsUtil = libsUtil;
async created() { public async created() {
await db.open(); await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
@ -285,7 +351,7 @@ export default class ContactsView extends Vue {
); );
} }
danger(message: string, title: string = "Error", timeout = 5000) { private danger(message: string, title: string = "Error", timeout = 5000) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -297,7 +363,17 @@ export default class ContactsView extends Vue {
); );
} }
async loadGives() { private filteredContacts() {
return this.showGiveNumbers
? this.contactsSelected.length === 0
? this.contacts
: this.contacts.filter((contact) =>
this.contactsSelected.includes(contact.did),
)
: this.contacts;
}
private async loadGives() {
if (!this.activeDid) { if (!this.activeDid) {
return; return;
} }
@ -404,19 +480,20 @@ export default class ContactsView extends Vue {
} }
} }
async onClickNewContact(): Promise<void> { private async onClickNewContact(): Promise<void> {
if (!this.contactInput) { const contactInput = this.contactInput.trim();
if (!contactInput) {
this.danger("There was no contact info to add.", "No Contact"); this.danger("There was no contact info to add.", "No Contact");
return; return;
} }
if (this.contactInput.startsWith(CONTACT_URL_PREFIX)) { if (contactInput.startsWith(CONTACT_URL_PREFIX)) {
await this.addContactFromScan(this.contactInput); await this.addContactFromScan(contactInput);
return; return;
} }
if (this.contactInput.startsWith(CONTACT_CSV_HEADER)) { if (contactInput.startsWith(CONTACT_CSV_HEADER)) {
const lines = this.contactInput.split(/\n/); const lines = contactInput.split(/\n/);
const lineAdded = []; const lineAdded = [];
for (const line of lines) { for (const line of lines) {
if (!line.trim() || line.startsWith(CONTACT_CSV_HEADER)) { if (!line.trim() || line.startsWith(CONTACT_CSV_HEADER)) {
@ -448,44 +525,71 @@ export default class ContactsView extends Vue {
return; return;
} }
let did = this.contactInput; if (contactInput.startsWith("did:")) {
let name, publicKeyInput, nextPublicKeyHashInput; let did = contactInput;
const commaPos1 = this.contactInput.indexOf(","); let name, publicKeyInput, nextPublicKeyHashInput;
if (commaPos1 > -1) { const commaPos1 = contactInput.indexOf(",");
did = this.contactInput.substring(0, commaPos1).trim(); if (commaPos1 > -1) {
name = this.contactInput.substring(commaPos1 + 1).trim(); did = contactInput.substring(0, commaPos1).trim();
const commaPos2 = this.contactInput.indexOf(",", commaPos1 + 1); name = contactInput.substring(commaPos1 + 1).trim();
if (commaPos2 > -1) { const commaPos2 = contactInput.indexOf(",", commaPos1 + 1);
name = this.contactInput.substring(commaPos1 + 1, commaPos2).trim(); if (commaPos2 > -1) {
publicKeyInput = this.contactInput.substring(commaPos2 + 1).trim(); name = contactInput.substring(commaPos1 + 1, commaPos2).trim();
const commaPos3 = this.contactInput.indexOf(",", commaPos2 + 1); publicKeyInput = contactInput.substring(commaPos2 + 1).trim();
if (commaPos3 > -1) { const commaPos3 = contactInput.indexOf(",", commaPos2 + 1);
publicKeyInput = this.contactInput.substring(commaPos2 + 1, commaPos3).trim(); // eslint-disable-line prettier/prettier if (commaPos3 > -1) {
nextPublicKeyHashInput = this.contactInput.substring(commaPos3 + 1).trim(); // eslint-disable-line prettier/prettier publicKeyInput = contactInput.substring(commaPos2 + 1, commaPos3).trim(); // eslint-disable-line prettier/prettier
nextPublicKeyHashInput = contactInput.substring(commaPos3 + 1).trim(); // eslint-disable-line prettier/prettier
}
} }
} }
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput;
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
// it must be all hex (compressed public key), so convert
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString(
"base64",
);
}
let nextPubKeyHashB64 = nextPublicKeyHashInput;
if (nextPubKeyHashB64 && /^[0-9A-Fa-f]{66}$/i.test(nextPubKeyHashB64)) {
// it must be all hex (compressed public key), so convert
nextPubKeyHashB64 = Buffer.from(nextPubKeyHashB64, "hex").toString("base64"); // eslint-disable-line prettier/prettier
}
const newContact = {
did,
name,
publicKeyBase64,
nextPubKeyHashB64: nextPubKeyHashB64,
};
await this.addContact(newContact);
return;
} }
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput; if (contactInput.includes("[")) {
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) { // assume there's a JSON array of contacts in the input
// it must be all hex (compressed public key), so convert const jsonContactInput = contactInput.substring(
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64"); contactInput.indexOf("["),
} contactInput.lastIndexOf("]") + 1,
let nextPubKeyHashB64 = nextPublicKeyHashInput; );
if (nextPubKeyHashB64 && /^[0-9A-Fa-f]{66}$/i.test(nextPubKeyHashB64)) { try {
// it must be all hex (compressed public key), so convert const contacts = JSON.parse(jsonContactInput);
nextPubKeyHashB64 = Buffer.from(nextPubKeyHashB64, "hex").toString("base64"); // eslint-disable-line prettier/prettier (this.$router as Router).push({
name: "contact-import",
query: { contacts: JSON.stringify(contacts) },
});
} catch (e) {
this.danger("The input could not be parsed.", "Invalid Contact List");
}
return;
} }
const newContact = {
did, this.danger("No contact info was found in that input.", "No Contact Info");
name,
publicKeyBase64,
nextPubKeyHashB64: nextPubKeyHashB64,
};
await this.addContact(newContact);
} }
async addContactFromEndorserMobileLine(line: string): Promise<IndexableType> { private async addContactFromEndorserMobileLine(
line: string,
): Promise<IndexableType> {
// Note that Endorser Mobile puts name first, then did, etc. // Note that Endorser Mobile puts name first, then did, etc.
let name = line; let name = line;
let did = ""; let did = "";
@ -526,7 +630,7 @@ export default class ContactsView extends Vue {
return db.contacts.add(newContact); return db.contacts.add(newContact);
} }
async addContactFromScan(url: string): Promise<void> { private async addContactFromScan(url: string): Promise<void> {
const payload = getContactPayloadFromJwtUrl(url); const payload = getContactPayloadFromJwtUrl(url);
if (!payload) { if (!payload) {
this.$notify( this.$notify(
@ -551,7 +655,7 @@ export default class ContactsView extends Vue {
} }
} }
async addContact(newContact: Contact) { private async addContact(newContact: Contact) {
if (!newContact.did) { if (!newContact.did) {
this.danger("Cannot add a contact without a DID.", "Incomplete Contact"); this.danger("Cannot add a contact without a DID.", "Incomplete Contact");
return; return;
@ -641,7 +745,7 @@ export default class ContactsView extends Vue {
} }
// note that this is also in DIDView.vue // note that this is also in DIDView.vue
async confirmSetVisibility(contact: Contact, visibility: boolean) { private async confirmSetVisibility(contact: Contact, visibility: boolean) {
const visibilityPrompt = visibility const visibilityPrompt = visibility
? "Are you sure you want to make your activity visible to them?" ? "Are you sure you want to make your activity visible to them?"
: "Are you sure you want to hide all your activity from them?"; : "Are you sure you want to hide all your activity from them?";
@ -663,7 +767,7 @@ export default class ContactsView extends Vue {
} }
// note that this is also in DIDView.vue // note that this is also in DIDView.vue
async register(contact: Contact) { private async register(contact: Contact) {
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000); this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
try { try {
@ -729,7 +833,7 @@ export default class ContactsView extends Vue {
} }
// note that this is also in DIDView.vue // note that this is also in DIDView.vue
async setVisibility( private async setVisibility(
contact: Contact, contact: Contact,
visibility: boolean, visibility: boolean,
showSuccessAlert: boolean, showSuccessAlert: boolean,
@ -779,7 +883,7 @@ export default class ContactsView extends Vue {
} }
// note that this is also in DIDView.vue // note that this is also in DIDView.vue
async checkVisibility(contact: Contact) { private async checkVisibility(contact: Contact) {
const url = const url =
this.apiServer + this.apiServer +
"/api/report/canDidExplicitlySeeMe?did=" + "/api/report/canDidExplicitlySeeMe?did=" +
@ -846,7 +950,7 @@ export default class ContactsView extends Vue {
} }
} }
confirmShowGiftedDialog(giverDid: string, recipientDid: string) { private confirmShowGiftedDialog(giverDid: string, recipientDid: string) {
// if they have unconfirmed amounts, ask to confirm those // if they have unconfirmed amounts, ask to confirm those
if ( if (
recipientDid === this.activeDid && recipientDid === this.activeDid &&
@ -936,7 +1040,7 @@ export default class ContactsView extends Vue {
); );
} }
public async toggleShowContactAmounts() { private async toggleShowContactAmounts() {
const newShowValue = !this.showGiveNumbers; const newShowValue = !this.showGiveNumbers;
try { try {
await db.open(); await db.open();
@ -972,7 +1076,7 @@ export default class ContactsView extends Vue {
this.loadGives(); this.loadGives();
} }
} }
public toggleShowGiveTotals() { private toggleShowGiveTotals() {
if (this.showGiveTotals) { if (this.showGiveTotals) {
this.showGiveTotals = false; this.showGiveTotals = false;
this.showGiveConfirmed = true; this.showGiveConfirmed = true;
@ -985,7 +1089,7 @@ export default class ContactsView extends Vue {
} }
} }
public showGiveAmountsClassNames() { private showGiveAmountsClassNames() {
return { return {
"from-slate-400": this.showGiveTotals, "from-slate-400": this.showGiveTotals,
"to-slate-700": this.showGiveTotals, "to-slate-700": this.showGiveTotals,
@ -995,5 +1099,31 @@ export default class ContactsView extends Vue {
"to-yellow-700": !this.showGiveTotals && !this.showGiveConfirmed, "to-yellow-700": !this.showGiveTotals && !this.showGiveConfirmed,
}; };
} }
private copySelectedContacts() {
if (this.contactsSelected.length === 0) {
this.danger("You must select contacts to copy.");
return;
}
const selectedContacts = this.contacts.filter((c) =>
this.contactsSelected.includes(c.did),
);
const message =
"To add contacts, paste this into the box on the 'People' screen.\n\n" +
JSON.stringify(selectedContacts, null, 2);
useClipboard()
.copy(message)
.then(() => {
this.$notify(
{
group: "alert",
type: "info",
title: "Copied",
text: "Those contacts were copied to the clipboard. Have them paste it in the box on their 'People' screen.",
},
5000,
);
});
}
} }
</script> </script>

5
src/views/DIDView.vue

@ -22,10 +22,7 @@
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div> <div>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
{{ {{ contact?.name || "(no name)" }}
didInfoForContact(viewingDid, activeDid, contact, allMyDids)
.displayName
}}
<button <button
@click=" @click="
contactEdit = true; contactEdit = true;

Loading…
Cancel
Save