forked from jsnbuchanan/crowd-funder-for-time-pwa
- Update certificate view canvas rendering and QR code generation - Upgrade dependencies (expo-file-system, expo-font, expo-keep-awake) - Fix type imports for nostr-tools and dexie-export-import - Update vite config for better dependency resolution - Clean up main entry points (capacitor, electron, pywebview) - Improve error handling in API and plan services - Add type safety to API error handling - Update build configuration for platform-specific builds This is a work in progress commit focusing on certificate view improvements and dependency maintenance. Some type definitions and build configurations may need further refinement.
828 lines
25 KiB
Vue
828 lines
25 KiB
Vue
<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 { finalizeEvent } from "nostr-tools";
|
|
import * as nip06 from "nostr-tools/nip06";
|
|
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";
|
|
import { EventTemplate, UnsignedEvent, VerifiedEvent } from "nostr-tools/core";
|
|
|
|
@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;
|
|
if (
|
|
window.location.hostname === "localhost" &&
|
|
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
|
) {
|
|
console.log(
|
|
"Using shared image API server, so only users on that server can play with images.",
|
|
);
|
|
}
|
|
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 = nip06.extendedKeysFromSeedWords(
|
|
account?.mnemonic as string,
|
|
"",
|
|
accountNum,
|
|
);
|
|
const publicExtendedKey: string = extPubPri?.publicExtendedKey;
|
|
const privateExtendedKey = extPubPri?.privateExtendedKey;
|
|
const privateBytes: Uint8Array =
|
|
nip06.accountFromExtendedKey(privateExtendedKey).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 =
|
|
nip06.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>
|