You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							392 lines
						
					
					
						
							13 KiB
						
					
					
				
			
		
		
		
			
			
			
				
					
				
				
					
				
			
		
		
	
	
							392 lines
						
					
					
						
							13 KiB
						
					
					
				| <template> | |
|   <QuickNav selected="Contacts"></QuickNav> | |
|   <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> | |
|     <!-- Back --> | |
|     <div class="text-lg text-center font-light relative px-7"> | |
|       <h1 | |
|         class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" | |
|         @click="$router.back()" | |
|       > | |
|         <font-awesome icon="chevron-left" class="fa-fw"></font-awesome> | |
|       </h1> | |
|     </div> | |
| 
 | |
|     <!-- Heading --> | |
|     <h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> | |
|       Contact Import | |
|     </h1> | |
| 
 | |
|     <div v-if="checkingImports" class="text-center"> | |
|       <font-awesome icon="spinner" class="animate-spin" /> | |
|     </div> | |
|     <div v-else> | |
|       <span | |
|         v-if="contactsImporting.length > sameCount" | |
|         class="flex justify-center" | |
|       > | |
|         <input type="checkbox" v-model="makeVisible" class="mr-2" /> | |
|         Make my activity visible to these contacts. | |
|       </span> | |
|  | |
|       <div v-if="sameCount > 0"> | |
|         <span v-if="sameCount == 1" | |
|           >One contact is the same as an existing contact</span | |
|         > | |
|         <span v-else | |
|           >{{ sameCount }} contacts are the same as existing contacts</span | |
|         > | |
|       </div> | |
|  | |
|       <!-- Results List --> | |
|       <ul | |
|         v-if="contactsImporting.length > sameCount" | |
|         class="border-t border-slate-300" | |
|       > | |
|         <li v-for="(contact, index) in contactsImporting" :key="contact.did"> | |
|           <div | |
|             v-if=" | |
|               !contactsExisting[contact.did] || | |
|               !R.isEmpty(contactDifferences[contact.did]) | |
|             " | |
|             class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4" | |
|           > | |
|             <h2 class="text-base font-semibold"> | |
|               <input type="checkbox" v-model="contactsSelected[index]" /> | |
|               {{ contact.name || AppString.NO_CONTACT_NAME }} | |
|               - | |
|               <span v-if="contactsExisting[contact.did]" class="text-orange-500" | |
|                 >Existing</span | |
|               > | |
|               <span v-else class="text-green-500">New</span> | |
|             </h2> | |
|             <div class="text-sm truncate"> | |
|               {{ contact.did }} | |
|             </div> | |
|             <div v-if="contactDifferences[contact.did]"> | |
|               <div> | |
|                 <div class="grid grid-cols-3 gap-2"> | |
|                   <div></div> | |
|                   <div class="font-bold">Old Value</div> | |
|                   <div class="font-bold">New Value</div> | |
|                 </div> | |
|                 <div | |
|                   v-for="(value, contactField) in contactDifferences[ | |
|                     contact.did | |
|                   ]" | |
|                   :key="contactField" | |
|                   class="grid grid-cols-3 border" | |
|                 > | |
|                   <div class="border font-bold p-1"> | |
|                     {{ capitalizeAndInsertSpacesBeforeCaps(contactField) }} | |
|                   </div> | |
|                   <div class="border p-1">{{ value.old }}</div> | |
|                   <div class="border p-1">{{ value.new }}</div> | |
|                 </div> | |
|               </div> | |
|             </div> | |
|           </div> | |
|         </li> | |
|         <button | |
|           class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded" | |
|           @click="importContacts" | |
|         > | |
|           Import Selected Contacts | |
|         </button> | |
|       </ul> | |
|       <p v-else-if="contactsImporting.length > 0"> | |
|         All those contacts are already in your list with the same information. | |
|       </p> | |
|       <div v-else> | |
|         There are no contacts in that import. If some were sent, try again to | |
|         get the full text and paste it. (Note that iOS cuts off data in text | |
|         messages.) Ask the person to send the data a different way, eg. email. | |
|         <div class="mt-4 text-center"> | |
|           <textarea | |
|             v-model="inputJwt" | |
|             placeholder="Contact-import data" | |
|             class="mt-4 border-2 border-gray-300 p-2 rounded" | |
|             cols="30" | |
|             @input="() => checkContactJwt(inputJwt)" | |
|           /> | |
|           <br /> | |
|           <button | |
|             @click="() => processContactJwt(inputJwt)" | |
|             class="ml-2 p-2 bg-blue-500 text-white rounded" | |
|           > | |
|             Check Import | |
|           </button> | |
|         </div> | |
|       </div> | |
|     </div> | |
|   </section> | |
| </template> | |
|  | |
| <script lang="ts"> | |
| import * as R from "ramda"; | |
| import { Component, Vue } from "vue-facing-decorator"; | |
| import { RouteLocationNormalizedLoaded, Router } from "vue-router"; | |
|  | |
| import QuickNav from "../components/QuickNav.vue"; | |
| import EntityIcon from "../components/EntityIcon.vue"; | |
| import OfferDialog from "../components/OfferDialog.vue"; | |
| import { APP_SERVER, AppString, NotificationIface } from "../constants/app"; | |
| import { | |
|   db, | |
|   logConsoleAndDb, | |
|   retrieveSettingsForActiveAccount, | |
| } from "../db/index"; | |
| import { Contact, ContactMethod } from "../db/tables/contacts"; | |
| import * as libsUtil from "../libs/util"; | |
| import { | |
|   capitalizeAndInsertSpacesBeforeCaps, | |
|   errorStringForLog, | |
|   setVisibilityUtil, | |
| } from "../libs/endorserServer"; | |
| import { getContactJwtFromJwtUrl } from "../libs/crypto"; | |
| import { decodeEndorserJwt } from "../libs/crypto/vc"; | |
|  | |
| @Component({ | |
|   components: { EntityIcon, OfferDialog, QuickNav }, | |
| }) | |
| export default class ContactImportView extends Vue { | |
|   $notify!: (notification: NotificationIface, timeout?: number) => void; | |
|  | |
|   AppString = AppString; | |
|   capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps; | |
|   libsUtil = libsUtil; | |
|   R = R; | |
|  | |
|   activeDid = ""; | |
|   apiServer = ""; | |
|   contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID | |
|   contactsImporting: Array<Contact> = []; // contacts from the import | |
|   contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected | |
|   contactDifferences: Record< | |
|     string, | |
|     Record< | |
|       string, | |
|       { | |
|         new: string | boolean | Array<ContactMethod> | undefined; | |
|         old: string | boolean | Array<ContactMethod> | undefined; | |
|       } | |
|     > | |
|   > = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key | |
|   checkingImports = false; | |
|   inputJwt: string = ""; | |
|   makeVisible = true; | |
|   sameCount = 0; | |
|  | |
|   async created() { | |
|     const settings = await retrieveSettingsForActiveAccount(); | |
|     this.activeDid = settings.activeDid || ""; | |
|     this.apiServer = settings.apiServer || ""; | |
|  | |
|     // look for any imported contact array from the query parameter | |
|     const importedContacts = (this.$route as RouteLocationNormalizedLoaded) | |
|       .query["contacts"] as string; | |
|     if (importedContacts) { | |
|       await this.setContactsSelected(JSON.parse(importedContacts)); | |
|     } | |
|  | |
|     // look for a JWT after /contact-import/ in the window.location.pathname | |
|     const jwt = window.location.pathname.match( | |
|       /\/contact-import\/(ey.+)$/, | |
|     )?.[1]; | |
|     if (jwt) { | |
|       // would prefer to validate but we've got an error with JWTs on QR codes generated in the future | |
|       // eslint-disable-next-line prettier/prettier | |
|       // const parsedJwt: Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt"> = await decodeAndVerifyJwt(jwt); | |
|       // decode the JWT | |
|       const parsedJwt = decodeEndorserJwt(jwt); | |
| 
 | |
|       const contacts: Array<Contact> = | |
|         parsedJwt.payload.contacts || // someday this will be the only payload sent to this page | |
|         (Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined); | |
|       if (!contacts && parsedJwt.payload.own) { | |
|         // handle this single-contact JWT in the contacts page, better suited to single additions | |
|         (this.$router as Router).push({ | |
|           name: "contacts", | |
|           query: { contactJwt: jwt }, | |
|         }); | |
|       } | |
|       if (contacts) { | |
|         await this.setContactsSelected(contacts); | |
|       } else { | |
|         // no contacts found so default message should be OK | |
|       } | |
|     } | |
| 
 | |
|     if ( | |
|       this.contactsImporting.length === 1 && | |
|       R.isEmpty(this.contactsExisting) | |
|     ) { | |
|       // if there is only one contact and it's new, then we will automatically import it | |
|       this.contactsSelected[0] = true; | |
|       this.importContacts(); // ... which routes to the contacts list | |
|     } | |
|   } | |
| 
 | |
|   async setContactsSelected(contacts: Array<Contact>) { | |
|     this.contactsImporting = contacts; | |
|     this.contactsSelected = new Array(this.contactsImporting.length).fill(true); | |
| 
 | |
|     await db.open(); | |
|     const baseContacts = await db.contacts.toArray(); | |
|     // set the existing contacts, keyed by DID, if they exist in contactsImporting | |
|     for (let i = 0; i < this.contactsImporting.length; i++) { | |
|       const contactIn = this.contactsImporting[i]; | |
|       const existingContact = baseContacts.find( | |
|         (contact) => contact.did === contactIn.did, | |
|       ); | |
|       if (existingContact) { | |
|         this.contactsExisting[contactIn.did] = existingContact; | |
| 
 | |
|         const differences: Record< | |
|           string, | |
|           { | |
|             new: string | boolean | Array<ContactMethod> | undefined; | |
|             old: string | boolean | Array<ContactMethod> | undefined; | |
|           } | |
|         > = {}; | |
|         Object.keys(contactIn).forEach((key) => { | |
|           // eslint-disable-next-line prettier/prettier | |
|           if (!R.equals(contactIn[key as keyof Contact], existingContact[key as keyof Contact])) { | |
|             differences[key] = { | |
|               old: existingContact[key as keyof Contact], | |
|               new: contactIn[key as keyof Contact], | |
|             }; | |
|           } | |
|         }); | |
|         this.contactDifferences[contactIn.did] = differences; | |
|         if (R.isEmpty(differences)) { | |
|           this.sameCount++; | |
|         } | |
| 
 | |
|         // don't automatically import previous data | |
|         this.contactsSelected[i] = false; | |
|       } | |
|     } | |
|   } | |
| 
 | |
|   // check the contact-import JWT | |
|   async checkContactJwt(jwtInput: string) { | |
|     if ( | |
|       jwtInput.endsWith(APP_SERVER) || | |
|       jwtInput.endsWith(APP_SERVER + "/") || | |
|       jwtInput.endsWith("contact-import") || | |
|       jwtInput.endsWith("contact-import/") | |
|     ) { | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "danger", | |
|           title: "Error", | |
|           text: "That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.", | |
|         }, | |
|         5000, | |
|       ); | |
|     } | |
|   } | |
| 
 | |
|   // process the invite JWT and/or text message containing the URL with the JWT | |
|   async processContactJwt(jwtInput: string) { | |
|     this.checkingImports = true; | |
| 
 | |
|     try { | |
|       // (For another approach used with invites, see InviteOneAcceptView.processInvite) | |
|       const jwt: string = getContactJwtFromJwtUrl(jwtInput); | |
|       // JWT format: { header, payload, signature, data } | |
|       const payload = decodeEndorserJwt(jwt).payload; | |
| 
 | |
|       if (Array.isArray(payload.contacts)) { | |
|         await this.setContactsSelected(payload.contacts); | |
|       } else { | |
|         throw new Error("Invalid contact-import JWT or URL: " + jwtInput); | |
|       } | |
|     } catch (error) { | |
|       const fullError = "Error importing contacts: " + errorStringForLog(error); | |
|       logConsoleAndDb(fullError, true); | |
|       this.$notify( | |
|         { | |
|           group: "alert", | |
|           type: "danger", | |
|           title: "Error", | |
|           text: "There was an error processing the contact-import data.", | |
|         }, | |
|         3000, | |
|       ); | |
|     } | |
|     this.checkingImports = false; | |
|   } | |
| 
 | |
|   async importContacts() { | |
|     this.checkingImports = true; | |
|     let importedCount = 0, | |
|       updatedCount = 0; | |
|     for (let i = 0; i < this.contactsImporting.length; i++) { | |
|       if (this.contactsSelected[i]) { | |
|         const contact = this.contactsImporting[i]; | |
|         const existingContact = this.contactsExisting[contact.did]; | |
|         if (existingContact) { | |
|           await db.contacts.update(contact.did, contact); | |
|           updatedCount++; | |
|         } else { | |
|           // without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned. | |
|           // DataError: Failed to execute 'add' on 'IDBObjectStore': Evaluating the object store's key path yielded a value that is not a valid key. | |
|           await db.contacts.add(R.clone(contact)); | |
|           importedCount++; | |
|         } | |
|       } | |
|     } | |
|     if (this.makeVisible) { | |
|       const failedVisibileToContacts = []; | |
|       for (let i = 0; i < this.contactsImporting.length; i++) { | |
|         if (this.contactsSelected[i]) { | |
|           const contact = this.contactsImporting[i]; | |
|           if (contact) { | |
|             const visResult = await setVisibilityUtil( | |
|               this.activeDid, | |
|               this.apiServer, | |
|               this.axios, | |
|               db, | |
|               contact, | |
|               true, | |
|             ); | |
|             if (!visResult.success) { | |
|               failedVisibileToContacts.push(contact); | |
|             } | |
|           } | |
|         } | |
|       } | |
|       if (failedVisibileToContacts.length > 0) { | |
|         this.$notify( | |
|           { | |
|             group: "alert", | |
|             type: "danger", | |
|             title: "Visibility Error", | |
|             text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${ | |
|               failedVisibileToContacts.length == 1 ? "" : "s" | |
|             }. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`, | |
|           }, | |
|           -1, | |
|         ); | |
|       } | |
|     } | |
| 
 | |
|     this.checkingImports = false; | |
| 
 | |
|     this.$notify( | |
|       { | |
|         group: "alert", | |
|         type: "success", | |
|         title: "Imported", | |
|         text: | |
|           `${importedCount} contact${importedCount == 1 ? "" : "s"} imported.` + | |
|           (updatedCount ? ` ${updatedCount} updated.` : ""), | |
|       }, | |
|       3000, | |
|     ); | |
|     (this.$router as Router).push({ name: "contacts" }); | |
|   } | |
| } | |
| </script>
 | |
| 
 |