<template> <QuickNav selected="Projects"></QuickNav> <!-- CONTENT --> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- Breadcrumb --> <div id="ViewBreadcrumb" class="mb-8"> <h1 class="text-lg text-center font-light relative px-7"> <!-- Cancel --> <!-- Back --> <button @click="$router.go(-1)" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" > <fa icon="chevron-left" class="fa-fw"></fa> </button> Edit Project Idea </h1> </div> <!-- Project Details --> <!-- Image - (see design model) Empty --> <div> {{ errorMessage }} </div> <input type="text" placeholder="Idea Name" class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" v-model="fullClaim.name" /> <div class="flex justify-center mt-4"> <span v-if="imageUrl" class="flex justify-between"> <a :href="imageUrl" target="_blank" class="text-blue-500 ml-4"> <img :src="imageUrl" class="h-24 rounded-xl" /> </a> <fa icon="trash-can" @click="confirmDeleteImage" class="text-red-500 fa-fw ml-8 mt-10" /> </span> <span v-else> <fa icon="camera" class="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-2 py-2 rounded-md" @click="openImageDialog" /> </span> </div> <ImageMethodDialog ref="imageDialog" /> <input type="text" placeholder="Other Authorized Representative" class="mt-4 block w-full rounded border border-slate-400 px-3 py-2" v-model="agentDid" /> <div class="mb-4"> <p v-if="activeDid != projectIssuerDid && agentDid != projectIssuerDid"> <span class="text-red-500">Beware!</span> If you save this, the original project owner will no longer be able to edit it. <button @click="agentDid = projectIssuerDid" class="text-blue-500"> Click here to make the original owner an authorized representative. </button> </p> </div> <textarea placeholder="Description" class="block w-full rounded border border-slate-400 px-3 py-2" rows="5" v-model="fullClaim.description" maxlength="5000" ></textarea> <div class="text-xs text-slate-500 italic"> If you want to be contacted, be sure to include your contact information -- just remember that this information is public and saved in a public history. </div> <div class="text-xs text-slate-500 italic"> {{ fullClaim.description?.length }}/5000 max. characters </div> <input v-model="fullClaim.url" placeholder="Website" autocapitalize="none" class="block w-full rounded border border-slate-400 mt-4 px-3 py-2" /> <div> <div class="flex items-center mt-4"> <span class="mr-2">Starts At</span> <input v-model="startDateInput" placeholder="Start Date" type="date" class="rounded border border-slate-400 px-3 py-2" /> <input :disabled="!startDateInput" placeholder="Start Time" v-model="startTimeInput" type="time" class="rounded border border-slate-400 ml-2 px-3 py-2" /> </div> <div class="flex w-full justify-end items-center"> <span class="w-full flex justify-end items-center"> {{ zoneName }} time zone </span> </div> <div class="flex items-center"> <div class="mr-2"> <span>Ends at</span> </div> <input v-model="endDateInput" placeholder="End Date" type="date" class="ml-2 rounded border border-slate-400 px-3 py-2" /> <input :disabled="!endDateInput" placeholder="End Time" v-model="endTimeInput" type="time" class="rounded border border-slate-400 ml-2 px-3 py-2" /> </div> </div> <div class="flex items-center mt-4" @click="includeLocation = !includeLocation" > <input type="checkbox" class="mr-2" v-model="includeLocation" /> <label for="includeLocation">Include Location</label> </div> <div v-if="includeLocation" class="mb-4 aspect-video"> <p class="text-sm mb-2 text-slate-500"> For your security, choose a location nearby but not exactly at the place. </p> <l-map ref="map" v-model:zoom="zoom" :center="[0, 0]" class="!z-40 rounded-md" @click=" (event) => { latitude = event.latlng.lat; longitude = event.latlng.lng; } " > <l-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" layer-type="base" name="OpenStreetMap" /> <l-marker v-if="latitude && longitude" :lat-lng="[latitude, longitude]" @click="confirmEraseLatLong()" /> </l-map> </div> <div v-if="showGeneralAdvanced && includeLocation" class="items-center mb-4" > <div class="flex" @click="sendToTrustroots = !sendToTrustroots"> <input type="checkbox" class="mr-2" v-model="sendToTrustroots" /> <label>Send to Trustroots</label> <fa icon="circle-info" class="text-blue-500 ml-2 cursor-pointer" @click.stop="showNostrPartnerInfo" /> </div> <!-- <div class="flex" @click="sendToTripHopping = !sendToTripHopping"> <input type="checkbox" class="mr-2" v-model="sendToTripHopping" /> <label>Send to TripHopping</label> <fa icon="circle-info" class="text-blue-500 ml-2 cursor-pointer" @click.stop="showNostrPartnerInfo" /> </div> --> </div> <div class="mt-8"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> <button :disabled="isHiddenSave" class="block w-full text-center text-lg font-bold uppercase 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-2 py-3 rounded-md mb-2" @click="onSaveProjectClick()" > <!-- SHOW if in idle state --> <span :class="{ hidden: isHiddenSave }">Save Project</span> <!-- SHOW if in saving state; DISABLE button while in saving state --> <span :class="{ hidden: isHiddenSpinner }"> <!-- icon no worky? --> <i class="fa-solid fa-spinner fa-spin-pulse"></i> Saving...</span > </button> <button type="button" class="block w-full text-center text-md uppercase 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.5 py-2 rounded-md" @click="onCancelClick()" > Cancel </button> </div> </div> </section> </template> <script lang="ts"> import "leaflet/dist/leaflet.css"; import { AxiosError, AxiosRequestHeaders } from "axios"; import { DateTime } from "luxon"; import { hexToBytes } from "@noble/hashes/utils"; // these core imports could also be included as "import type ..." import { EventTemplate, UnsignedEvent, VerifiedEvent, } from "nostr-tools/lib/types/core"; import { accountFromExtendedKey, extendedKeysFromSeedWords, } from "nostr-tools/lib/types/nip06"; import { finalizeEvent, serializeEvent } from "nostr-tools"; import { Component, Vue } from "vue-facing-decorator"; import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet"; import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import ImageMethodDialog from "@/components/ImageMethodDialog.vue"; import QuickNav from "@/components/QuickNav.vue"; import { DEFAULT_IMAGE_API_SERVER, DEFAULT_PARTNER_API_SERVER, NotificationIface, } from "@/constants/app"; import { retrieveSettingsForActiveAccount } from "@/db/index"; import { createEndorserJwtVcFromClaim, getHeaders, PlanVerifiableCredential, } from "@/libs/endorserServer"; import { retrieveAccountCount, retrieveFullyDecryptedAccount, } from "@/libs/util"; @Component({ components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav }, }) export default class NewEditProjectView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; errNote(message: string) { this.$notify( { group: "alert", type: "danger", title: "Error", text: message }, 5000, ); } activeDid = ""; agentDid = ""; apiServer = ""; endDateInput?: string; endTimeInput?: string; errorMessage = ""; fullClaim: PlanVerifiableCredential = { "@context": "https://schema.org", "@type": "PlanAction", name: "", description: "", }; // this default is only to avoid errors before plan is loaded imageUrl = ""; includeLocation = false; isHiddenSave = false; isHiddenSpinner = true; lastClaimJwtId = ""; latitude = 0; longitude = 0; numAccounts = 0; projectId = ""; projectIssuerDid = ""; sendToTrustroots = false; sendToTripHopping = false; showGeneralAdvanced = false; startDateInput?: string; startTimeInput?: string; zoneName = DateTime.local().zoneName; zoom = 2; async mounted() { this.numAccounts = await retrieveAccountCount(); const settings = await retrieveSettingsForActiveAccount(); this.activeDid = settings.activeDid || ""; this.apiServer = settings.apiServer || ""; this.showGeneralAdvanced = !!settings.showGeneralAdvanced; this.projectId = (this.$route as RouteLocationNormalizedLoaded).query["projectId"] || ""; if (this.projectId) { if (this.numAccounts === 0) { this.errNote("There was a problem loading your account info."); } else { this.loadProject(this.activeDid); } } } async loadProject(userDid: string) { const url = this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(this.projectId); const headers = await getHeaders(userDid); try { const resp = await this.axios.get(url, { headers }); if (resp.status === 200) { this.projectIssuerDid = resp.data.issuer; this.fullClaim = resp.data.claim; this.imageUrl = resp.data.claim.image || ""; this.lastClaimJwtId = resp.data.id; if (this.fullClaim?.location) { this.includeLocation = true; this.latitude = this.fullClaim.location.geo.latitude; this.longitude = this.fullClaim.location.geo.longitude; } if (this.fullClaim?.agent?.identifier) { this.agentDid = this.fullClaim.agent.identifier; } if (this.fullClaim.startTime) { const localDateTime = DateTime.fromISO( this.fullClaim.startTime as string, ).toLocal(); this.startDateInput = localDateTime.toFormat("yyyy-MM-dd"); this.startTimeInput = localDateTime.toFormat("HH:mm"); } if (this.fullClaim.endTime) { const localDateTime = DateTime.fromISO( this.fullClaim.endTime as string, ).toLocal(); this.endDateInput = localDateTime.toFormat("yyyy-MM-dd"); this.endTimeInput = localDateTime.toFormat("HH:mm"); } } } catch (error) { console.error("Got error retrieving that project", error); this.errNote("There was an error retrieving that project."); } } openImageDialog() { (this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => { this.imageUrl = imgUrl; }, "PlanAction"); } confirmDeleteImage() { this.$notify( { group: "modal", type: "confirm", title: "Are you sure you want to delete the image?", text: "", onYes: this.deleteImage, }, -1, ); } async deleteImage() { if (!this.imageUrl) { return; } try { const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders; const response = await this.axios.delete( DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(this.imageUrl), { headers }, ); if (response.status === 204) { // don't bother with a notification // (either they'll simply continue or they're canceling and going back) } else { console.error("Problem deleting image:", response); this.$notify( { group: "alert", type: "danger", title: "Error", text: "There was a problem deleting the image.", }, 5000, ); return; } this.imageUrl = ""; } catch (error) { console.error("Error deleting image:", error); // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((error as any).response.status === 404) { console.log("The image was already deleted:", error); this.imageUrl = ""; // it already doesn't exist so we won't say anything to the user } else { this.$notify( { group: "alert", type: "danger", title: "Error", text: "There was an error deleting the image.", }, 5000, ); } } } private async saveProject() { // Make a claim const vcClaim: PlanVerifiableCredential = this.fullClaim; if (this.projectId) { vcClaim.lastClaimId = this.lastClaimJwtId; } if (this.agentDid) { vcClaim.agent = { identifier: this.agentDid, }; } else { delete vcClaim.agent; } if (this.imageUrl) { vcClaim.image = this.imageUrl; } else { delete vcClaim.image; } if (this.includeLocation) { if (!this.latitude || !this.longitude) { this.$notify( { group: "alert", type: "danger", title: "Location Error", text: "The location was invalid so it was not set.", }, 5000, ); delete vcClaim.location; } else { vcClaim.location = { geo: { "@type": "GeoCoordinates", latitude: this.latitude, longitude: this.longitude, }, }; } } else { delete vcClaim.location; } if (this.startDateInput) { try { const startTimeFull = this.startTimeInput || "00:00:00"; const fullTimeString = this.startDateInput + " " + startTimeFull; // throw an error on an invalid date or time string vcClaim.startTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it } catch { // it's not a valid date so erase it and tell the user delete vcClaim.startTime; this.$notify( { group: "alert", type: "danger", title: "Date Error", text: "The start date was invalid so it was not set.", }, 5000, ); } } else { delete vcClaim.startTime; } if (this.endDateInput) { try { const endTimeFull = this.endTimeInput || "23:59:59"; const fullTimeString = this.endDateInput + " " + endTimeFull; // throw an error on an invalid date or time string vcClaim.endTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it } catch { // it's not a valid date so erase it and tell the user delete vcClaim.endTime; this.$notify( { group: "alert", type: "danger", title: "Date Error", text: "The end date was invalid so it was not set.", }, 5000, ); } } else { delete vcClaim.endTime; } const vcJwt = await createEndorserJwtVcFromClaim(this.activeDid, vcClaim); // Make the xhr request payload const payload = JSON.stringify({ jwtEncoded: vcJwt }); const url = this.apiServer + "/api/v2/claim"; const headers = await getHeaders(this.activeDid); try { const resp = await this.axios.post(url, payload, { headers }); if (resp.data?.success?.handleId) { this.$notify( { group: "alert", type: "success", title: "Saved", text: "The project was saved successfully.", }, 3000, ); this.errorMessage = ""; const projectPath = encodeURIComponent(resp.data.success.handleId); if (this.sendToTrustroots || this.sendToTripHopping) { if (this.latitude && this.longitude) { let payloadAndKey; // sign something to prove ownership of pubkey if (this.sendToTrustroots) { payloadAndKey = await this.signSomePayload(); // not going to await... the save was successful, so we'll continue to the next page this.sendToNostrPartner( "NOSTR-EVENT-TRUSTROOTS", "Trustroots", resp.data.success.claimId, payloadAndKey.signedEvent, payloadAndKey.publicExtendedKey, ); } if (this.sendToTripHopping) { if (!payloadAndKey) { payloadAndKey = await this.signSomePayload(); } // not going to await... the save was successful, so we'll continue to the next page this.sendToNostrPartner( "NOSTR-EVENT-TRIPHOPPING", "TripHopping", resp.data.success.claimId, payloadAndKey.signedEvent, payloadAndKey.publicExtendedKey, ); } } else { this.$notify( { group: "alert", type: "danger", title: "Partner Error", text: "A partner was selected but the location was not set, so it was not sent to any partner.", }, 5000, ); } } (this.$router as Router).push({ path: "/project/" + projectPath }); } else { console.error( "Got unexpected 'data' inside response from server", resp, ); this.$notify( { group: "alert", type: "danger", title: "Error Saving Idea", text: "Server did not save the idea. Try again.", }, 5000, ); } } catch (error) { let userMessage = "There was an error saving the project."; const serverError = error as AxiosError<{ error?: { message?: string }; }>; if (serverError) { console.error("Got error from server", serverError); if (Object.prototype.hasOwnProperty.call(serverError, "message")) { userMessage = (serverError.response?.data?.error?.message as string) || userMessage; this.$notify( { group: "alert", type: "danger", title: "User Message", text: userMessage, }, 5000, ); } else { this.$notify( { group: "alert", type: "danger", title: "Server Message", text: JSON.stringify(serverError.toJSON()), }, 5000, ); } } else { console.error("Here's the full error trying to save the claim:", error); this.$notify( { group: "alert", type: "danger", title: "Claim Error", text: error as string, }, 5000, ); } // Now set that error for the user to see. this.errorMessage = userMessage; } } /** * @return a signed payload and an extended public key for later transmission */ private async signSomePayload(): Promise<{ signedEvent: VerifiedEvent; publicExtendedKey: string; }> { const account = await retrieveFullyDecryptedAccount(this.activeDid); // get the last number of the derivationPath const finalDerNum = account?.derivationPath?.split?.("/")?.reverse()[0]; // remove any trailing ' const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, ""); const accountNum = Number(finalDerNumNoApostrophe || 0); const extPubPri = extendedKeysFromSeedWords( account?.mnemonic as string, "", accountNum, ); const publicExtendedKey: string = extPubPri?.publicExtendedKey; const privateExtendedKey = extPubPri?.privateExtendedKey; const privateKey = accountFromExtendedKey(privateExtendedKey).privateKey; const privateBytes = hexToBytes(privateKey); // No real content is necessary, we just want something signed, // so we might as well use nostr libs for nostr functions. // Besides: someday we may create real content that we can relay. const event: EventTemplate = { kind: 30402, tags: [[]], content: "", created_at: 0, }; const signedEvent: VerifiedEvent = finalizeEvent( // Why does IntelliJ not see matching types? event as EventTemplate, privateBytes, ) as VerifiedEvent; return { signedEvent, publicExtendedKey }; } private async sendToNostrPartner( linkCode: string, serviceName: string, jwtId: string, signedPayload: VerifiedEvent, publicExtendedKey: string, ) { try { let partnerServer = DEFAULT_PARTNER_API_SERVER; const settings = await retrieveSettingsForActiveAccount(); if (settings.partnerApiServer) { partnerServer = settings.partnerApiServer; } const endorserPartnerUrl = partnerServer + "/api/partner/link"; const timeSafariUrl = window.location.origin + "/claim/" + jwtId; const content = this.fullClaim.name + " - see " + timeSafariUrl; const publicKeyHex = accountFromExtendedKey(publicExtendedKey).publicKey; const unsignedPayload: UnsignedEvent = { // why doesn't "...signedPayload" work? kind: signedPayload.kind, tags: signedPayload.tags, content: signedPayload.content, created_at: signedPayload.created_at, pubkey: publicKeyHex, }; // Why does IntelliJ not see matching types? const payload = serializeEvent(unsignedPayload as UnsignedEvent); const partnerParams = { jwtId: jwtId, linkCode: linkCode, inputJson: JSON.stringify(content), pubKeyHex: publicKeyHex, pubKeyImage: payload, pubKeySigHex: signedPayload.sig, }; const headers = await getHeaders(this.activeDid); const linkResp = await this.axios.post( endorserPartnerUrl, partnerParams, { headers }, ); if (linkResp.status === 201) { this.$notify( { group: "alert", type: "success", title: `Sent to ${serviceName}`, text: `The project info was sent to ${serviceName}.`, }, 5000, ); } else { // axios never gets here because it throws an error, but just in case this.$notify( { group: "alert", type: "danger", title: `Failed Sending to ${serviceName}`, text: JSON.stringify(linkResp.data), }, 5000, ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { console.error(`Error sending to ${serviceName}`, error); let errorMessage = `There was an error sending to ${serviceName}.`; if (error.response?.data?.error?.message) { errorMessage = error.response.data.error.message; } this.$notify( { group: "alert", type: "danger", title: `Error Sending to ${serviceName}`, text: errorMessage, }, 7000, ); } } public async onSaveProjectClick() { this.isHiddenSave = true; this.isHiddenSpinner = false; if (this.numAccounts === 0) { console.error("Error: there is no account."); } else { this.saveProject(); } } confirmEraseLatLong() { this.$notify( { group: "modal", type: "confirm", title: "Erase Marker", text: "Are you sure you don't want to mark a location? This will erase the current location.", onYes: async () => { this.eraseLatLong(); }, }, -1, ); } public eraseLatLong() { this.latitude = 0; this.longitude = 0; this.includeLocation = false; } public onCancelClick() { (this.$router as Router).back(); } public showNostrPartnerInfo() { this.$notify( { group: "alert", type: "info", title: "About Nostr Events", text: "This will submit this project to a partner on the nostr network. It will contain your public key data which may allow correlation, so don't allow this if you're not comfortable with that.", }, 7000, ); } } </script>