forked from jsnbuchanan/crowd-funder-for-time-pwa
Merge branch 'master' into test-playwright
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -6,7 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [0.3.16] - 2024.07.10
|
||||
## ?
|
||||
### Added
|
||||
- Send list of contacts to someone
|
||||
### Changed
|
||||
- Moved contact actions from list onto detail page
|
||||
|
||||
|
||||
## [0.3.20] - 2024.08.18 - 4064eb75a9743ca268bf00016fa0a5fc5dec4e30
|
||||
### Fixed
|
||||
- Bad "give" verbiage on offer page
|
||||
- Failing offer test
|
||||
|
||||
|
||||
## [0.3.19] - 2024.08.18 - ee9c14942ceba993bf21a11249601f205158ec71
|
||||
### Added
|
||||
- Update of an offer
|
||||
- Recipient description in offer list
|
||||
### Fixed
|
||||
- List of offers wasn't showing.
|
||||
- Destination page after sharing photo was wrong.
|
||||
|
||||
|
||||
## [0.3.17] - 2024.07.11 - cefa384ff1a2d922848c370640c096c529920fab
|
||||
### Added
|
||||
- Photos on more screens
|
||||
### Fixed
|
||||
|
||||
@@ -2,5 +2,10 @@
|
||||
|
||||
Welcome! We are happy to have your help with this project.
|
||||
|
||||
Note that all contributions will be under our
|
||||
[license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
|
||||
We expect contributions to include automated tests and pass linting. Run the `test-all` task.
|
||||
Note that some previous features don't have tests and adding more will make you friends quick.
|
||||
|
||||
Note that all contributions will be under our [license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
|
||||
|
||||
If you want to see a code of conduct, we're probably not the people you want to hang with.
|
||||
Basically, we'll work together as long as we both enjoy it, and we'll stop when that stops.
|
||||
|
||||
@@ -39,6 +39,8 @@ npm run lint
|
||||
|
||||
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
|
||||
|
||||
* Commit everything (since the commit hash is used the app).
|
||||
|
||||
* Record what version is currently on production.
|
||||
|
||||
* Run the correct build:
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "TimeSafari",
|
||||
"version": "0.3.17-beta",
|
||||
"version": "0.3.21-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "TimeSafari",
|
||||
"version": "0.3.17-beta",
|
||||
"version": "0.3.21-beta",
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^5.4.1",
|
||||
"@dicebear/core": "^5.4.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TimeSafari",
|
||||
"version": "0.3.17-beta",
|
||||
"version": "0.3.21-beta",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"serve": "vite preview",
|
||||
|
||||
@@ -72,9 +72,9 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
|
||||
/* Configure global timeout */
|
||||
/* Configure global timeout; default is 30000 milliseconds */
|
||||
// the image upload will often not succeed at 5 seconds
|
||||
//timeout: 7000,
|
||||
timeout: 20000,
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
/**
|
||||
@@ -86,7 +86,7 @@ export default defineConfig({
|
||||
* },
|
||||
*
|
||||
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails.
|
||||
* It is worth considering a change such that Time Safari's default Endorer API server is NOT set
|
||||
* It is worth considering a change such that Time Safari's default Endorser API server is NOT set
|
||||
* in the user's settings so that it can be blanked out and the default is used.
|
||||
*/
|
||||
webServer: {
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
Photo & Details ...
|
||||
Photo & more options ...
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
|
||||
<input
|
||||
type="text"
|
||||
data-testId="inputDescription"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="Description, prerequisites, terms, etc."
|
||||
v-model="description"
|
||||
@@ -23,6 +24,7 @@
|
||||
<fa icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
data-testId="inputOfferAmount"
|
||||
type="number"
|
||||
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
||||
v-model="amountInput"
|
||||
@@ -34,18 +36,27 @@
|
||||
<fa icon="chevron-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row mt-2">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
|
||||
<div class="mt-4 flex justify-center">
|
||||
<span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'offer-details',
|
||||
query: {
|
||||
amountInput,
|
||||
description,
|
||||
offererDid: activeDid,
|
||||
projectId,
|
||||
projectName,
|
||||
recipientDid,
|
||||
recipientName,
|
||||
unitCode: amountUnitCode,
|
||||
},
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
Expiration
|
||||
Conditions & more options...
|
||||
</router-link>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full border border-slate-400 px-2 py-2 rounded-r"
|
||||
:placeholder="datePlaceholder()"
|
||||
v-model="expirationDateInput"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-center mt-6 mb-2 italic">
|
||||
Sign & Send to publish to the world
|
||||
@@ -69,7 +80,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { DateTime } from "luxon";
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
@@ -82,7 +92,8 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
export default class OfferDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
@Prop projectId? = "";
|
||||
@Prop projectId?;
|
||||
@Prop projectName?;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
@@ -92,13 +103,15 @@ export default class OfferDialog extends Vue {
|
||||
description = "";
|
||||
expirationDateInput = "";
|
||||
recipientDid? = "";
|
||||
recipientName? = "";
|
||||
visible = false;
|
||||
|
||||
libsUtil = libsUtil;
|
||||
|
||||
async open(recipientDid?: string) {
|
||||
async open(recipientDid?: string, recipientName?: string) {
|
||||
try {
|
||||
this.recipientDid = recipientDid;
|
||||
this.recipientName = recipientName;
|
||||
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
@@ -144,12 +157,6 @@ export default class OfferDialog extends Vue {
|
||||
)}`;
|
||||
}
|
||||
|
||||
datePlaceholder() {
|
||||
return (
|
||||
"Date, eg. " + DateTime.now().plus({ month: 1 }).toISO().slice(0, 10)
|
||||
);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.close();
|
||||
this.eraseValues();
|
||||
|
||||
@@ -4,8 +4,8 @@ export interface Contact {
|
||||
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
||||
profileImageUrl?: string;
|
||||
publicKeyBase64?: string;
|
||||
seesMe?: boolean;
|
||||
registered?: boolean;
|
||||
seesMe?: boolean; // cached value of the server setting
|
||||
registered?: boolean; // cached value of the server setting
|
||||
}
|
||||
|
||||
export const ContactSchema = {
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface ClaimResult {
|
||||
}
|
||||
|
||||
export interface GenericVerifiableCredential {
|
||||
"@context"?: string;
|
||||
"@context"?: string; // optional when embedded, eg. in an Agree
|
||||
"@type": string;
|
||||
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
|
||||
id: string;
|
||||
issuedAt: string;
|
||||
issuer: string;
|
||||
publicUrls?: Record<string, string>; // only for IDs that want to be public
|
||||
}
|
||||
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
|
||||
{
|
||||
@@ -139,13 +140,13 @@ export interface GiveVerifiableCredential extends GenericVerifiableCredential {
|
||||
|
||||
// Note that previous VCs may have additional fields.
|
||||
// https://endorser.ch/doc/html/transactions.html#id8
|
||||
export interface OfferVerifiableCredential {
|
||||
"@context"?: string; // optional when embedded, eg. in an Agree
|
||||
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
|
||||
"@context"?: string; // optional when embedded... though it doesn't make sense to agree to an offer
|
||||
"@type": "Offer";
|
||||
description?: string;
|
||||
description?: string; // conditions for the offer
|
||||
includesObject?: { amountOfThisGood: number; unitCode: string };
|
||||
itemOffered?: {
|
||||
description?: string;
|
||||
description?: string; // description of the item
|
||||
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
|
||||
};
|
||||
offeredBy?: { identifier: string };
|
||||
@@ -155,7 +156,7 @@ export interface OfferVerifiableCredential {
|
||||
|
||||
// Note that previous VCs may have additional fields.
|
||||
// https://endorser.ch/doc/html/transactions.html#id7
|
||||
export interface PlanVerifiableCredential {
|
||||
export interface PlanVerifiableCredential extends GenericVerifiableCredential {
|
||||
"@context": "https://schema.org";
|
||||
"@type": "PlanAction";
|
||||
name: string;
|
||||
@@ -267,10 +268,6 @@ export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
|
||||
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
|
||||
const HIDDEN_DID = "did:none:HIDDEN";
|
||||
|
||||
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
|
||||
max: 500,
|
||||
});
|
||||
|
||||
export function isDid(did: string) {
|
||||
return did.startsWith("did:");
|
||||
}
|
||||
@@ -507,6 +504,10 @@ export async function getHeaders(did?: string) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
|
||||
max: 500,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param handleId nullable, in which case "undefined" will be returned
|
||||
* @param requesterDid optional, in which case no private info will be returned
|
||||
@@ -563,6 +564,8 @@ export async function setPlanInCache(
|
||||
|
||||
/**
|
||||
* Construct GiveAction VC for submission to server
|
||||
*
|
||||
* @param lastClaimId supplied when editing a previous claim
|
||||
*/
|
||||
export function hydrateGive(
|
||||
vcClaimOrig?: GiveVerifiableCredential,
|
||||
@@ -587,6 +590,7 @@ export function hydrateGive(
|
||||
};
|
||||
|
||||
if (lastClaimId) {
|
||||
// this is an edit
|
||||
vcClaim.lastClaimId = lastClaimId;
|
||||
delete vcClaim.identifier;
|
||||
}
|
||||
@@ -594,7 +598,8 @@ export function hydrateGive(
|
||||
vcClaim.agent = fromDid ? { identifier: fromDid } : undefined;
|
||||
vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
|
||||
vcClaim.description = description || undefined;
|
||||
vcClaim.object = amount
|
||||
vcClaim.object =
|
||||
amount && !isNaN(amount)
|
||||
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
|
||||
: undefined;
|
||||
|
||||
@@ -603,7 +608,7 @@ export function hydrateGive(
|
||||
vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : [];
|
||||
}
|
||||
// ... and replace or add each element, ending with Trade or Donate
|
||||
// I realize this doesn't change any elements that are not PlanAction or Offer or Trade/Action.
|
||||
// I realize the following doesn't change any elements that are not PlanAction or Offer or Trade/Action.
|
||||
vcClaim.fulfills = vcClaim.fulfills.filter(
|
||||
(elem) => elem["@type"] !== "PlanAction",
|
||||
);
|
||||
@@ -639,8 +644,8 @@ export function hydrateGive(
|
||||
*
|
||||
* @param fromDid may be null
|
||||
* @param toDid
|
||||
* @param description may be null; should have this or amount
|
||||
* @param amount may be null; should have this or description
|
||||
* @param description may be null
|
||||
* @param amount may be null
|
||||
*/
|
||||
export async function createAndSubmitGive(
|
||||
axios: Axios,
|
||||
@@ -667,6 +672,7 @@ export async function createAndSubmitGive(
|
||||
fulfillsOfferHandleId,
|
||||
isTrade,
|
||||
imageUrl,
|
||||
undefined,
|
||||
);
|
||||
return createAndSubmitClaim(
|
||||
vcClaim as GenericVerifiableCredential,
|
||||
@@ -680,9 +686,9 @@ export async function createAndSubmitGive(
|
||||
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
||||
*
|
||||
* @param fromDid may be null
|
||||
* @param toDid
|
||||
* @param description may be null; should have this or amount
|
||||
* @param amount may be null; should have this or description
|
||||
* @param toDid may be null if project is provided
|
||||
* @param description may be null
|
||||
* @param amount may be null
|
||||
*/
|
||||
export async function editAndSubmitGive(
|
||||
axios: Axios,
|
||||
@@ -720,51 +726,128 @@ export async function editAndSubmitGive(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct Offer VC for submission to server
|
||||
*
|
||||
* @param lastClaimId supplied when editing a previous claim
|
||||
*/
|
||||
export function hydrateOffer(
|
||||
vcClaimOrig?: OfferVerifiableCredential,
|
||||
fromDid?: string,
|
||||
toDid?: string,
|
||||
itemDescription?: string,
|
||||
amount?: number,
|
||||
unitCode?: string,
|
||||
conditionDescription?: string,
|
||||
fulfillsProjectHandleId?: string,
|
||||
validThrough?: string,
|
||||
lastClaimId?: string,
|
||||
): OfferVerifiableCredential {
|
||||
// Remember: replace values or erase if it's null
|
||||
|
||||
const vcClaim: OfferVerifiableCredential = vcClaimOrig
|
||||
? R.clone(vcClaimOrig)
|
||||
: {
|
||||
"@context": SCHEMA_ORG_CONTEXT,
|
||||
"@type": "Offer",
|
||||
};
|
||||
|
||||
if (lastClaimId) {
|
||||
// this is an edit
|
||||
vcClaim.lastClaimId = lastClaimId;
|
||||
delete vcClaim.identifier;
|
||||
}
|
||||
|
||||
vcClaim.offeredBy = fromDid ? { identifier: fromDid } : undefined;
|
||||
vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
|
||||
vcClaim.description = conditionDescription || undefined;
|
||||
|
||||
vcClaim.includesObject =
|
||||
amount && !isNaN(amount)
|
||||
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
|
||||
: undefined;
|
||||
|
||||
if (itemDescription || fulfillsProjectHandleId) {
|
||||
vcClaim.itemOffered = vcClaim.itemOffered || {};
|
||||
vcClaim.itemOffered.description = itemDescription || undefined;
|
||||
if (fulfillsProjectHandleId) {
|
||||
vcClaim.itemOffered.isPartOf = {
|
||||
"@type": "PlanAction",
|
||||
identifier: fulfillsProjectHandleId,
|
||||
};
|
||||
}
|
||||
}
|
||||
vcClaim.validThrough = validThrough || undefined;
|
||||
|
||||
return vcClaim;
|
||||
}
|
||||
|
||||
/**
|
||||
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
||||
*
|
||||
* @param identity
|
||||
* @param description may be null; should have this or amount
|
||||
* @param amount may be null; should have this or description
|
||||
* @param expirationDate ISO 8601 date string YYYY-MM-DD (may be null)
|
||||
* @param description may be null
|
||||
* @param amount may be null
|
||||
* @param validThrough ISO 8601 date string YYYY-MM-DD (may be null)
|
||||
* @param fulfillsProjectHandleId ID of project to which this contributes (may be null)
|
||||
*/
|
||||
export async function createAndSubmitOffer(
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
issuerDid: string,
|
||||
description?: string,
|
||||
itemDescription: string,
|
||||
amount?: number,
|
||||
unitCode?: string,
|
||||
expirationDate?: string,
|
||||
conditionDescription?: string,
|
||||
validThrough?: string,
|
||||
recipientDid?: string,
|
||||
fulfillsProjectHandleId?: string,
|
||||
): Promise<CreateAndSubmitClaimResult> {
|
||||
const vcClaim: OfferVerifiableCredential = {
|
||||
"@context": SCHEMA_ORG_CONTEXT,
|
||||
"@type": "Offer",
|
||||
offeredBy: { identifier: issuerDid },
|
||||
validThrough: expirationDate || undefined,
|
||||
};
|
||||
if (amount) {
|
||||
vcClaim.includesObject = {
|
||||
amountOfThisGood: amount,
|
||||
unitCode: unitCode || "HUR",
|
||||
};
|
||||
}
|
||||
if (description) {
|
||||
vcClaim.itemOffered = { description };
|
||||
}
|
||||
if (recipientDid) {
|
||||
vcClaim.recipient = { identifier: recipientDid };
|
||||
}
|
||||
if (fulfillsProjectHandleId) {
|
||||
vcClaim.itemOffered = vcClaim.itemOffered || {};
|
||||
vcClaim.itemOffered.isPartOf = {
|
||||
"@type": "PlanAction",
|
||||
identifier: fulfillsProjectHandleId,
|
||||
};
|
||||
const vcClaim = hydrateOffer(
|
||||
undefined,
|
||||
issuerDid,
|
||||
recipientDid,
|
||||
itemDescription,
|
||||
amount,
|
||||
unitCode,
|
||||
conditionDescription,
|
||||
fulfillsProjectHandleId,
|
||||
validThrough,
|
||||
undefined,
|
||||
);
|
||||
return createAndSubmitClaim(
|
||||
vcClaim as OfferVerifiableCredential,
|
||||
issuerDid,
|
||||
apiServer,
|
||||
axios,
|
||||
);
|
||||
}
|
||||
|
||||
export async function editAndSubmitOffer(
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
fullClaim: GenericCredWrapper<OfferVerifiableCredential>,
|
||||
issuerDid: string,
|
||||
itemDescription: string,
|
||||
amount?: number,
|
||||
unitCode?: string,
|
||||
conditionDescription?: string,
|
||||
validThrough?: string,
|
||||
recipientDid?: string,
|
||||
fulfillsProjectHandleId?: string,
|
||||
): Promise<CreateAndSubmitClaimResult> {
|
||||
const vcClaim = hydrateOffer(
|
||||
fullClaim.claim,
|
||||
issuerDid,
|
||||
recipientDid,
|
||||
itemDescription,
|
||||
amount,
|
||||
unitCode,
|
||||
conditionDescription,
|
||||
fulfillsProjectHandleId,
|
||||
validThrough,
|
||||
fullClaim.id,
|
||||
);
|
||||
return createAndSubmitClaim(
|
||||
vcClaim as OfferVerifiableCredential,
|
||||
issuerDid,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// many of these are also found in endorser-mobile utility.ts
|
||||
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import { Buffer } from "buffer";
|
||||
import * as R from "ramda";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import { DEFAULT_PUSH_SERVER } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import {
|
||||
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
|
||||
MASTER_SETTINGS_KEY,
|
||||
@@ -18,11 +21,9 @@ import {
|
||||
OfferVerifiableCredential,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as serverUtil from "@/libs/endorserServer";
|
||||
import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
|
||||
|
||||
import { Buffer } from "buffer";
|
||||
import { KeyMeta } from "@/libs/crypto/vc";
|
||||
import { createPeerDid } from "@/libs/crypto/vc/didPeer";
|
||||
import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
|
||||
|
||||
export const PRIVACY_MESSAGE =
|
||||
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
|
||||
@@ -30,8 +31,8 @@ export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";
|
||||
|
||||
/* eslint-disable prettier/prettier */
|
||||
export const UNIT_SHORT: Record<string, string> = {
|
||||
"BX": "BX",
|
||||
"BTC": "BTC",
|
||||
"BX": "BX",
|
||||
"ETH": "ETH",
|
||||
"HUR": "Hours",
|
||||
"USD": "US $",
|
||||
@@ -40,8 +41,8 @@ export const UNIT_SHORT: Record<string, string> = {
|
||||
|
||||
/* eslint-disable prettier/prettier */
|
||||
export const UNIT_LONG: Record<string, string> = {
|
||||
"BX": "Buxbe",
|
||||
"BTC": "Bitcoin",
|
||||
"BX": "Buxbe",
|
||||
"ETH": "Ethereum",
|
||||
"HUR": "hours",
|
||||
"USD": "dollars",
|
||||
@@ -91,6 +92,28 @@ export const isGiveAction = (
|
||||
return veriClaim.claimType === "GiveAction";
|
||||
};
|
||||
|
||||
export const nameForDid = (
|
||||
activeDid: string,
|
||||
contacts: Array<Contact>,
|
||||
did: string,
|
||||
): string => {
|
||||
if (did === activeDid) {
|
||||
return "you";
|
||||
}
|
||||
const contact = R.find((con) => con.did == did, contacts);
|
||||
return nameForContact(contact);
|
||||
};
|
||||
|
||||
export const nameForContact = (
|
||||
contact?: Contact,
|
||||
capitalize?: boolean,
|
||||
): string => {
|
||||
return (
|
||||
(contact?.name as string) ||
|
||||
(capitalize ? "This" : "this") + " unnamed user"
|
||||
);
|
||||
};
|
||||
|
||||
export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
|
||||
fn();
|
||||
useClipboard()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { register } from "register-service-worker";
|
||||
|
||||
// NODE_ENV is "production" by default with "vite build". See https://vitejs.dev/guide/env-and-mode
|
||||
if (import.meta.env.NODE_ENV === "production") {
|
||||
register("/sw_scripts-combined.js", {
|
||||
ready() {
|
||||
|
||||
@@ -91,7 +91,7 @@ const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/gifted-details",
|
||||
name: "gifted-details",
|
||||
component: () => import("../views/GiftedDetails.vue"),
|
||||
component: () => import("@/views/GiftedDetailsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/help",
|
||||
@@ -143,6 +143,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "new-identifier",
|
||||
component: () => import("../views/NewIdentifierView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/offer-details/:id?",
|
||||
name: "offer-details",
|
||||
component: () => import("../views/OfferDetailsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/project/:id?",
|
||||
name: "project",
|
||||
@@ -189,6 +194,9 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "shared-photo",
|
||||
component: () => import("@/views/SharedPhotoView.vue"),
|
||||
},
|
||||
|
||||
// /share-target is also an endpoint in the service worker
|
||||
|
||||
{
|
||||
path: "/start",
|
||||
name: "start",
|
||||
|
||||
@@ -831,10 +831,16 @@ export default class AccountViewView extends Vue {
|
||||
* Beware! I've seen where we never get to this point because "ready" never resolves.
|
||||
*/
|
||||
} catch (error) {
|
||||
// this can happen when running automated tests in dev mode because notifications don't work
|
||||
console.error(
|
||||
"Telling user to clear cache at page create because:",
|
||||
error,
|
||||
);
|
||||
// this sometimes gives different information on the error
|
||||
console.error(
|
||||
"Telling user to clear cache at page create because (error added): " +
|
||||
error,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -1282,17 +1288,6 @@ export default class AccountViewView extends Vue {
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleRateLimitsError(error);
|
||||
|
||||
try {
|
||||
await db.open();
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
isRegistered: false,
|
||||
});
|
||||
this.isRegistered = false;
|
||||
} catch (err) {
|
||||
console.error("Got an error marking user not registered:", err);
|
||||
// already set an error notification for the user
|
||||
}
|
||||
}
|
||||
|
||||
this.loadingLimits = false;
|
||||
|
||||
@@ -24,13 +24,15 @@
|
||||
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
|
||||
<button
|
||||
v-if="
|
||||
veriClaim.claimType === 'GiveAction' &&
|
||||
veriClaim.issuer === activeDid
|
||||
['GiveAction', 'Offer'].includes(
|
||||
veriClaim.claimType as string,
|
||||
) && veriClaim.issuer === activeDid
|
||||
"
|
||||
@click="onClickEditClaim"
|
||||
title="Edit"
|
||||
data-testId="editClaimButton"
|
||||
>
|
||||
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1"></fa>
|
||||
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
||||
</button>
|
||||
</h2>
|
||||
<div class="text-sm">
|
||||
@@ -49,9 +51,12 @@
|
||||
</button>
|
||||
<span v-show="showIdCopy">Copied ID</span>
|
||||
</div>
|
||||
<div>
|
||||
<div data-testId="description">
|
||||
<fa icon="message" class="fa-fw text-slate-400" />
|
||||
{{ veriClaim.claim?.description }}
|
||||
{{
|
||||
veriClaim.claim?.itemOffered?.description ||
|
||||
veriClaim.claim?.description
|
||||
}}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="user" class="fa-fw text-slate-400" />
|
||||
@@ -399,7 +404,7 @@
|
||||
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
|
||||
<pre
|
||||
v-if="showVeriClaimDump"
|
||||
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
|
||||
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
|
||||
>{{ veriClaimDump }}</pre
|
||||
>
|
||||
</div>
|
||||
@@ -422,7 +427,10 @@
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<pre>{{ fullClaimDump }}</pre>
|
||||
<pre
|
||||
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
|
||||
>{{ fullClaimDump }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<a
|
||||
@@ -840,6 +848,7 @@ export default class ClaimView extends Vue {
|
||||
}
|
||||
|
||||
onClickEditClaim() {
|
||||
if (this.veriClaim.claimType === "GiveAction") {
|
||||
const route = {
|
||||
name: "gifted-details",
|
||||
query: {
|
||||
@@ -849,6 +858,31 @@ export default class ClaimView extends Vue {
|
||||
},
|
||||
};
|
||||
(this.$router as Router).push(route);
|
||||
} else if (this.veriClaim.claimType === "Offer") {
|
||||
const route = {
|
||||
name: "offer-details",
|
||||
query: {
|
||||
prevCredToEdit: JSON.stringify(this.veriClaim),
|
||||
destinationPathAfter:
|
||||
"/claim/" + encodeURIComponent(this.veriClaim.handleId),
|
||||
},
|
||||
};
|
||||
(this.$router as Router).push(route);
|
||||
} else {
|
||||
console.error(
|
||||
"Unrecognized claim type for edit:",
|
||||
this.veriClaim.claimType,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "This is an unrecognized claim type.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -271,7 +271,7 @@ export default class ContactAmountssView extends Vue {
|
||||
// Make the xhr request payload
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = this.apiServer + "/api/v2/claim";
|
||||
const headers = getHeaders(this.activeDid) as AxiosRequestHeaders;
|
||||
const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
|
||||
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
|
||||
@@ -458,9 +458,9 @@ export default class ContactQRScanShow extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,7 +37,35 @@
|
||||
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
@click="onClickNewContact()"
|
||||
>
|
||||
<fa icon="plus" class="fa-fw"></fa>
|
||||
<fa icon="plus" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="w-full text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-if="!showGiveNumbers"
|
||||
:checked="contactsSelected.length === contacts.length"
|
||||
@click="
|
||||
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 ml-2 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>
|
||||
|
||||
@@ -50,6 +78,7 @@
|
||||
{{ showGiveNumbers ? "Hide Given Hours" : "Show Given Hours" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-1" v-if="showGiveNumbers">
|
||||
<div class="w-full text-right">
|
||||
In the following, only the most recent hours are included. To see more,
|
||||
@@ -82,147 +111,51 @@
|
||||
<ul
|
||||
id="listContacts"
|
||||
v-if="contacts.length > 0"
|
||||
class="border-t border-slate-300"
|
||||
class="border-t border-slate-300 mt-1"
|
||||
>
|
||||
<li
|
||||
class="border-b border-slate-300 pt-2.5 pb-4"
|
||||
v-for="contact in contacts"
|
||||
class="border-b border-slate-300 pt-1 pb-1"
|
||||
v-for="contact in filteredContacts()"
|
||||
:key="contact.did"
|
||||
>
|
||||
<div class="grow overflow-hidden">
|
||||
<h2 class="text-base font-semibold">
|
||||
<div class="flex items-center">
|
||||
<EntityIcon
|
||||
:contact="contact"
|
||||
:iconSize="24"
|
||||
class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer"
|
||||
@click="showLargeIdenticon = contact"
|
||||
/>
|
||||
{{ contact.name || AppString.NO_CONTACT_NAME }}
|
||||
<button
|
||||
|
||||
<input
|
||||
type="checkbox"
|
||||
v-if="!showGiveNumbers"
|
||||
:checked="contactsSelected.includes(contact.did)"
|
||||
@click="
|
||||
contactEdit = contact;
|
||||
contactNewName = contact.name || '';
|
||||
contactsSelected.includes(contact.did)
|
||||
? contactsSelected.splice(
|
||||
contactsSelected.indexOf(contact.did),
|
||||
1,
|
||||
)
|
||||
: contactsSelected.push(contact.did)
|
||||
"
|
||||
title="Edit"
|
||||
>
|
||||
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1"></fa>
|
||||
</button>
|
||||
class="ml-2 h-6 w-6"
|
||||
/>
|
||||
|
||||
<h2 class="text-base font-semibold ml-2">
|
||||
{{ contact.name || AppString.NO_CONTACT_NAME }}
|
||||
</h2>
|
||||
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(contact.did),
|
||||
}"
|
||||
title="See more about this DID"
|
||||
title="See more about this person"
|
||||
>
|
||||
<fa icon="circle-info" class="text-blue-500 ml-4" />
|
||||
</router-link>
|
||||
</h2>
|
||||
<div class="text-sm truncate">
|
||||
Identifier:
|
||||
<button
|
||||
@click="
|
||||
libsUtil.doCopyTwoSecRedo(
|
||||
contact.did,
|
||||
() => (showDidCopy = !showDidCopy),
|
||||
)
|
||||
"
|
||||
>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||
</button>
|
||||
<span v-show="showDidCopy" class="text-green-500">Copied DID</span>
|
||||
{{ contact.did }}
|
||||
</div>
|
||||
<div class="text-sm truncate" v-if="contact.publicKeyBase64">
|
||||
Public Key (base 64):
|
||||
<button
|
||||
@click="
|
||||
libsUtil.doCopyTwoSecRedo(
|
||||
contact.publicKeyBase64,
|
||||
() => (showPubKeyCopy = !showPubKeyCopy),
|
||||
)
|
||||
"
|
||||
>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||
</button>
|
||||
<span v-show="showPubKeyCopy" class="text-green-500"
|
||||
>Copied Key</span
|
||||
>
|
||||
{{ contact.publicKeyBase64 }}
|
||||
</div>
|
||||
<div class="text-sm truncate" v-if="contact.nextPubKeyHashB64">
|
||||
Next Public Key Hash (base 64):
|
||||
<button
|
||||
@click="
|
||||
libsUtil.doCopyTwoSecRedo(
|
||||
contact.nextPubKeyHashB64,
|
||||
() => (showPubKeyHashCopy = !showPubKeyHashCopy),
|
||||
)
|
||||
"
|
||||
>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||
</button>
|
||||
<span v-show="showPubKeyHashCopy" class="text-green-500"
|
||||
>Copied Hash</span
|
||||
>
|
||||
{{ contact.nextPubKeyHashB64 }}
|
||||
</div>
|
||||
|
||||
<div id="ContactActions" class="flex gap-1.5 mt-2">
|
||||
<div v-if="activeDid">
|
||||
<button
|
||||
v-if="contact.seesMe && contact.did !== activeDid"
|
||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
@click="confirmSetVisibility(contact, false)"
|
||||
title="They can see you"
|
||||
>
|
||||
<fa icon="eye" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!contact.seesMe && contact.did !== activeDid"
|
||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
@click="confirmSetVisibility(contact, true)"
|
||||
title="They cannot see you"
|
||||
>
|
||||
<fa icon="eye-slash" class="fa-fw" />
|
||||
</button>
|
||||
<!-- otherwise it's this user so hide it -->
|
||||
<fa v-else icon="eye" class="text-white mx-2.5" />
|
||||
|
||||
<button
|
||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
@click="checkVisibility(contact)"
|
||||
title="Check Visibility"
|
||||
v-if="contact.did !== activeDid"
|
||||
>
|
||||
<fa icon="rotate" class="fa-fw" />
|
||||
</button>
|
||||
<!-- otherwise it's this user so hide it -->
|
||||
<fa v-else icon="rotate" class="text-white mx-2.5" />
|
||||
|
||||
<button
|
||||
@click="confirmRegister(contact)"
|
||||
class="text-sm 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 ml-6 px-2 py-1.5 rounded-md"
|
||||
v-if="contact.did !== activeDid"
|
||||
title="Registration"
|
||||
>
|
||||
<fa
|
||||
v-if="contact.registered"
|
||||
icon="person-circle-check"
|
||||
class="fa-fw"
|
||||
/>
|
||||
<fa v-else icon="person-circle-question" class="fa-fw" />
|
||||
</button>
|
||||
<!-- otherwise it's this user so hide it -->
|
||||
<fa v-else icon="rotate" class="text-white ml-6 px-2.5" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="confirmDeleteContact(contact)"
|
||||
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 px-2 py-1.5 rounded-md"
|
||||
title="Delete"
|
||||
>
|
||||
<fa icon="trash-can" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showGiveNumbers && contact.did != activeDid"
|
||||
class="ml-auto flex gap-1.5"
|
||||
@@ -271,7 +204,7 @@
|
||||
|
||||
<button
|
||||
class="text-sm 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-1.5 rounded-md border border-blue-400"
|
||||
@click="openOfferDialog(contact.did)"
|
||||
@click="openOfferDialog(contact.did, contact.name)"
|
||||
>
|
||||
Offer
|
||||
</button>
|
||||
@@ -293,6 +226,33 @@
|
||||
</ul>
|
||||
<p v-else>There are no contacts.</p>
|
||||
|
||||
<div class="mt-2 w-full text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-if="!showGiveNumbers"
|
||||
:checked="contactsSelected.length === contacts.length"
|
||||
@click="
|
||||
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 from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 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" />
|
||||
<OfferDialog ref="customOfferDialog" />
|
||||
|
||||
@@ -308,33 +268,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="contactEdit !== null" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1>
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="Name"
|
||||
v-model="contactNewName"
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
|
||||
@click="onClickSaveName(contactEdit, contactNewName)"
|
||||
>
|
||||
<fa icon="save" />
|
||||
</button>
|
||||
<span class="inline-block w-2" />
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
|
||||
@click="onClickCancelName()"
|
||||
>
|
||||
<fa icon="ban" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -345,6 +278,7 @@ import { IndexableType } from "dexie";
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import { AppString, NotificationIface } from "@/constants/app";
|
||||
import { db } from "@/db/index";
|
||||
@@ -379,6 +313,7 @@ export default class ContactsView extends Vue {
|
||||
contactInput = "";
|
||||
contactEdit: Contact | null = null;
|
||||
contactNewName = "";
|
||||
contactsSelected: Array<string> = [];
|
||||
// { "did:...": concatenated-descriptions } entry for each contact
|
||||
givenByMeDescriptions: Record<string, string> = {};
|
||||
// { "did:...": amount } entry for each contact
|
||||
@@ -404,7 +339,7 @@ export default class ContactsView extends Vue {
|
||||
AppString = AppString;
|
||||
libsUtil = libsUtil;
|
||||
|
||||
async created() {
|
||||
public async created() {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
@@ -427,7 +362,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(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -439,7 +374,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) {
|
||||
return;
|
||||
}
|
||||
@@ -546,19 +491,20 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async onClickNewContact(): Promise<void> {
|
||||
if (!this.contactInput) {
|
||||
private async onClickNewContact(): Promise<void> {
|
||||
const contactInput = this.contactInput.trim();
|
||||
if (!contactInput) {
|
||||
this.danger("There was no contact info to add.", "No Contact");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.contactInput.startsWith(CONTACT_URL_PREFIX)) {
|
||||
await this.addContactFromScan(this.contactInput);
|
||||
if (contactInput.startsWith(CONTACT_URL_PREFIX)) {
|
||||
await this.addContactFromScan(contactInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.contactInput.startsWith(CONTACT_CSV_HEADER)) {
|
||||
const lines = this.contactInput.split(/\n/);
|
||||
if (contactInput.startsWith(CONTACT_CSV_HEADER)) {
|
||||
const lines = contactInput.split(/\n/);
|
||||
const lineAdded = [];
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.startsWith(CONTACT_CSV_HEADER)) {
|
||||
@@ -590,20 +536,21 @@ export default class ContactsView extends Vue {
|
||||
return;
|
||||
}
|
||||
|
||||
let did = this.contactInput;
|
||||
if (contactInput.startsWith("did:")) {
|
||||
let did = contactInput;
|
||||
let name, publicKeyInput, nextPublicKeyHashInput;
|
||||
const commaPos1 = this.contactInput.indexOf(",");
|
||||
const commaPos1 = contactInput.indexOf(",");
|
||||
if (commaPos1 > -1) {
|
||||
did = this.contactInput.substring(0, commaPos1).trim();
|
||||
name = this.contactInput.substring(commaPos1 + 1).trim();
|
||||
const commaPos2 = this.contactInput.indexOf(",", commaPos1 + 1);
|
||||
did = contactInput.substring(0, commaPos1).trim();
|
||||
name = contactInput.substring(commaPos1 + 1).trim();
|
||||
const commaPos2 = contactInput.indexOf(",", commaPos1 + 1);
|
||||
if (commaPos2 > -1) {
|
||||
name = this.contactInput.substring(commaPos1 + 1, commaPos2).trim();
|
||||
publicKeyInput = this.contactInput.substring(commaPos2 + 1).trim();
|
||||
const commaPos3 = this.contactInput.indexOf(",", commaPos2 + 1);
|
||||
name = contactInput.substring(commaPos1 + 1, commaPos2).trim();
|
||||
publicKeyInput = contactInput.substring(commaPos2 + 1).trim();
|
||||
const commaPos3 = contactInput.indexOf(",", commaPos2 + 1);
|
||||
if (commaPos3 > -1) {
|
||||
publicKeyInput = this.contactInput.substring(commaPos2 + 1, commaPos3).trim(); // eslint-disable-line prettier/prettier
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -611,7 +558,9 @@ export default class ContactsView extends Vue {
|
||||
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");
|
||||
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
let nextPubKeyHashB64 = nextPublicKeyHashInput;
|
||||
if (nextPubKeyHashB64 && /^[0-9A-Fa-f]{66}$/i.test(nextPubKeyHashB64)) {
|
||||
@@ -625,9 +574,33 @@ export default class ContactsView extends Vue {
|
||||
nextPubKeyHashB64: nextPubKeyHashB64,
|
||||
};
|
||||
await this.addContact(newContact);
|
||||
return;
|
||||
}
|
||||
|
||||
async addContactFromEndorserMobileLine(line: string): Promise<IndexableType> {
|
||||
if (contactInput.includes("[")) {
|
||||
// assume there's a JSON array of contacts in the input
|
||||
const jsonContactInput = contactInput.substring(
|
||||
contactInput.indexOf("["),
|
||||
contactInput.lastIndexOf("]") + 1,
|
||||
);
|
||||
try {
|
||||
const contacts = JSON.parse(jsonContactInput);
|
||||
(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;
|
||||
}
|
||||
|
||||
this.danger("No contact info was found in that input.", "No Contact Info");
|
||||
}
|
||||
|
||||
private async addContactFromEndorserMobileLine(
|
||||
line: string,
|
||||
): Promise<IndexableType> {
|
||||
// Note that Endorser Mobile puts name first, then did, etc.
|
||||
let name = line;
|
||||
let did = "";
|
||||
@@ -668,7 +641,7 @@ export default class ContactsView extends Vue {
|
||||
return db.contacts.add(newContact);
|
||||
}
|
||||
|
||||
async addContactFromScan(url: string): Promise<void> {
|
||||
private async addContactFromScan(url: string): Promise<void> {
|
||||
const payload = getContactPayloadFromJwtUrl(url);
|
||||
if (!payload) {
|
||||
this.$notify(
|
||||
@@ -693,7 +666,7 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async addContact(newContact: Contact) {
|
||||
private async addContact(newContact: Contact) {
|
||||
if (!newContact.did) {
|
||||
this.danger("Cannot add a contact without a DID.", "Incomplete Contact");
|
||||
return;
|
||||
@@ -782,56 +755,30 @@ export default class ContactsView extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
// prompt with confirmation if they want to delete a contact
|
||||
confirmDeleteContact(contact: Contact) {
|
||||
// note that this is also in DIDView.vue
|
||||
private async confirmSetVisibility(contact: Contact, visibility: boolean) {
|
||||
const visibilityPrompt = visibility
|
||||
? "Are you sure you want to make your activity visible to them?"
|
||||
: "Are you sure you want to hide all your activity from them?";
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Delete",
|
||||
text:
|
||||
"Are you sure you want to remove " +
|
||||
this.nameForDid(this.contacts, contact.did) +
|
||||
" with DID " +
|
||||
contact.did +
|
||||
" from your contact list?",
|
||||
title: "Set Visibility",
|
||||
text: visibilityPrompt,
|
||||
onYes: async () => {
|
||||
await this.deleteContact(contact);
|
||||
const success = await this.setVisibility(contact, visibility, true);
|
||||
if (success) {
|
||||
contact.seesMe = visibility; // didn't work inside setVisibility
|
||||
}
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteContact(contact: Contact) {
|
||||
await db.open();
|
||||
await db.contacts.delete(contact.did);
|
||||
this.contacts = R.without([contact], this.contacts);
|
||||
}
|
||||
|
||||
// confirm to register a new contact
|
||||
async confirmRegister(contact: Contact) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Register",
|
||||
text:
|
||||
"Are you sure you want to register " +
|
||||
this.nameForDid(this.contacts, contact.did) +
|
||||
(contact.registered
|
||||
? " -- especially since they are already marked as registered"
|
||||
: "") +
|
||||
"?",
|
||||
onYes: async () => {
|
||||
await this.register(contact);
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
async register(contact: Contact) {
|
||||
// note that this is also in DIDView.vue
|
||||
private async register(contact: Contact) {
|
||||
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
|
||||
|
||||
try {
|
||||
@@ -896,28 +843,8 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async confirmSetVisibility(contact: Contact, visibility: boolean) {
|
||||
const visibilityPrompt = visibility
|
||||
? "Are you sure you want to make your activity visible to them?"
|
||||
: "Are you sure you want to hide all your activity from them?";
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Set Visibility",
|
||||
text: visibilityPrompt,
|
||||
onYes: async () => {
|
||||
const success = await this.setVisibility(contact, visibility, true);
|
||||
if (success) {
|
||||
contact.seesMe = visibility; // didn't work inside setVisibility
|
||||
}
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
async setVisibility(
|
||||
// note that this is also in DIDView.vue
|
||||
private async setVisibility(
|
||||
contact: Contact,
|
||||
visibility: boolean,
|
||||
showSuccessAlert: boolean,
|
||||
@@ -966,7 +893,8 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async checkVisibility(contact: Contact) {
|
||||
// note that this is also in DIDView.vue
|
||||
private async checkVisibility(contact: Contact) {
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/report/canDidExplicitlySeeMe?did=" +
|
||||
@@ -999,7 +927,7 @@ export default class ContactsView extends Vue {
|
||||
type: "info",
|
||||
title: "Visibility Refreshed",
|
||||
text:
|
||||
this.nameForContact(contact, true) +
|
||||
libsUtil.nameForContact(contact, true) +
|
||||
" can " +
|
||||
(visibility ? "" : "not ") +
|
||||
"see your activity.",
|
||||
@@ -1033,22 +961,7 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
private nameForDid(contacts: Array<Contact>, did: string): string {
|
||||
if (did === this.activeDid) {
|
||||
return "you";
|
||||
}
|
||||
const contact = R.find((con) => con.did == did, contacts);
|
||||
return this.nameForContact(contact);
|
||||
}
|
||||
|
||||
private nameForContact(contact?: Contact, capitalize?: boolean): string {
|
||||
return (
|
||||
(contact?.name as string) ||
|
||||
(capitalize ? "This" : "this") + " unnamed user"
|
||||
);
|
||||
}
|
||||
|
||||
confirmShowGiftedDialog(giverDid: string, recipientDid: string) {
|
||||
private confirmShowGiftedDialog(giverDid: string, recipientDid: string) {
|
||||
// if they have unconfirmed amounts, ask to confirm those
|
||||
if (
|
||||
recipientDid === this.activeDid &&
|
||||
@@ -1093,13 +1006,13 @@ export default class ContactsView extends Vue {
|
||||
if (giverDid) {
|
||||
giver = {
|
||||
did: giverDid,
|
||||
name: this.nameForDid(this.contacts, giverDid),
|
||||
name: libsUtil.nameForDid(this.activeDid, this.contacts, giverDid),
|
||||
};
|
||||
}
|
||||
if (recipientDid) {
|
||||
receiver = {
|
||||
did: recipientDid,
|
||||
name: this.nameForDid(this.contacts, recipientDid),
|
||||
name: libsUtil.nameForDid(this.activeDid, this.contacts, recipientDid),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1131,23 +1044,14 @@ export default class ContactsView extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
openOfferDialog(recipientDid: string) {
|
||||
(this.$refs.customOfferDialog as OfferDialog).open(recipientDid);
|
||||
openOfferDialog(recipientDid: string, recipientName?: string) {
|
||||
(this.$refs.customOfferDialog as OfferDialog).open(
|
||||
recipientDid,
|
||||
recipientName,
|
||||
);
|
||||
}
|
||||
|
||||
private async onClickCancelName() {
|
||||
this.contactEdit = null;
|
||||
this.contactNewName = "";
|
||||
}
|
||||
|
||||
private async onClickSaveName(contact: Contact, newName: string) {
|
||||
contact.name = newName;
|
||||
return db.contacts
|
||||
.update(contact.did, { name: newName })
|
||||
.then(() => (this.contactEdit = null));
|
||||
}
|
||||
|
||||
public async toggleShowContactAmounts() {
|
||||
private async toggleShowContactAmounts() {
|
||||
const newShowValue = !this.showGiveNumbers;
|
||||
try {
|
||||
await db.open();
|
||||
@@ -1183,7 +1087,7 @@ export default class ContactsView extends Vue {
|
||||
this.loadGives();
|
||||
}
|
||||
}
|
||||
public toggleShowGiveTotals() {
|
||||
private toggleShowGiveTotals() {
|
||||
if (this.showGiveTotals) {
|
||||
this.showGiveTotals = false;
|
||||
this.showGiveConfirmed = true;
|
||||
@@ -1196,7 +1100,7 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
public showGiveAmountsClassNames() {
|
||||
private showGiveAmountsClassNames() {
|
||||
return {
|
||||
"from-slate-400": this.showGiveTotals,
|
||||
"to-slate-700": this.showGiveTotals,
|
||||
@@ -1206,76 +1110,31 @@ export default class ContactsView extends Vue {
|
||||
"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>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
/*
|
||||
Tooltip, generated on "title" attributes on "fa" icons
|
||||
Kudos to https://www.w3schools.com/css/css_tooltip.asp
|
||||
*/
|
||||
/* Tooltip container */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
|
||||
}
|
||||
/* Tooltip text */
|
||||
.tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 200px;
|
||||
background-color: black;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 5px 0;
|
||||
border-radius: 6px;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
/* How do we share with the above so code isn't duplicated? */
|
||||
.tooltip .tooltiptext-left {
|
||||
visibility: hidden;
|
||||
width: 200px;
|
||||
background-color: black;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 5px 0;
|
||||
border-radius: 6px;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
bottom: 0%;
|
||||
right: 105%;
|
||||
margin-left: -60px;
|
||||
}
|
||||
|
||||
/* Show the tooltip text when you mouse over the tooltip container */
|
||||
.tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
}
|
||||
.tooltip:hover .tooltiptext-left {
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,12 +22,21 @@
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">
|
||||
{{
|
||||
didInfoForContact(viewingDid, activeDid, contact, allMyDids)
|
||||
.displayName
|
||||
}}
|
||||
{{ contact?.name || "(no name)" }}
|
||||
<button
|
||||
@click="
|
||||
contactEdit = true;
|
||||
contactNewName = contact.name || '';
|
||||
"
|
||||
title="Edit"
|
||||
>
|
||||
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
||||
</button>
|
||||
</h2>
|
||||
<button @click="showDidDetails = !showDidDetails" class="ml-2 mr-2">
|
||||
<button
|
||||
@click="showDidDetails = !showDidDetails"
|
||||
class="ml-2 mr-2 mt-4"
|
||||
>
|
||||
Details
|
||||
<fa v-if="showDidDetails" icon="chevron-up" class="text-blue-400" />
|
||||
<fa v-else icon="chevron-down" class="text-blue-400" />
|
||||
@@ -49,8 +58,68 @@
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex justify-center">Auto-Generated Icon:</div>
|
||||
<div class="flex justify-between mt-4">
|
||||
<div class="flex items-center">
|
||||
<div v-if="activeDid" class="flex justify-between">
|
||||
<div>
|
||||
<button
|
||||
v-if="contact?.seesMe && contact.did !== activeDid"
|
||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
@click="confirmSetVisibility(contact, false)"
|
||||
title="They can see you"
|
||||
>
|
||||
<fa icon="eye" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!contact?.seesMe && contact?.did !== activeDid"
|
||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
@click="confirmSetVisibility(contact, true)"
|
||||
title="They cannot see you"
|
||||
>
|
||||
<fa icon="eye-slash" class="fa-fw" />
|
||||
</button>
|
||||
<!-- otherwise it's this user so hide it -->
|
||||
<fa v-else icon="eye" class="text-white mx-2.5" />
|
||||
|
||||
<button
|
||||
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
@click="checkVisibility(contact)"
|
||||
title="Check Visibility"
|
||||
v-if="contact?.did !== activeDid"
|
||||
>
|
||||
<fa icon="rotate" class="fa-fw" />
|
||||
</button>
|
||||
<!-- otherwise it's this user so hide it -->
|
||||
<fa v-else icon="rotate" class="text-white mx-2.5" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="confirmRegister(contact)"
|
||||
class="text-sm 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 ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
v-if="contact?.did !== activeDid"
|
||||
title="Registration"
|
||||
>
|
||||
<fa
|
||||
v-if="contact?.registered"
|
||||
icon="person-circle-check"
|
||||
class="fa-fw"
|
||||
/>
|
||||
<fa v-else icon="person-circle-question" class="fa-fw" />
|
||||
</button>
|
||||
<!-- otherwise it's this user so hide it -->
|
||||
<fa v-else icon="rotate" class="text-white ml-6 px-2.5" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="confirmDeleteContact(contact)"
|
||||
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
title="Delete"
|
||||
>
|
||||
<fa icon="trash-can" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!contact?.profileImageUrl">
|
||||
<div>Auto-Generated Icon</div>
|
||||
<div class="flex justify-center">
|
||||
<EntityIcon
|
||||
:entityId="viewingDid"
|
||||
@@ -60,6 +129,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showLargeIdenticonId || showLargeIdenticonUrl"
|
||||
class="fixed z-[100] top-0 inset-x-0 w-full"
|
||||
@@ -80,6 +150,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="contactEdit" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1>
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="Name"
|
||||
v-model="contactNewName"
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
|
||||
@click="onClickSaveName(contactNewName)"
|
||||
>
|
||||
<fa icon="save" />
|
||||
</button>
|
||||
<span class="inline-block w-2" />
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
|
||||
@click="onClickCancelName()"
|
||||
>
|
||||
<fa icon="ban" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
<div
|
||||
@@ -126,15 +222,16 @@
|
||||
v-if="!isLoading && claims.length === 0"
|
||||
class="flex justify-center mt-4"
|
||||
>
|
||||
<span>They Are in No Claims Visible to You</span>
|
||||
<span>They are in no claims visible to you.</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError } from "axios";
|
||||
import * as yaml from "js-yaml";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
@@ -152,6 +249,8 @@ import {
|
||||
GenericVerifiableCredential,
|
||||
GiveVerifiableCredential,
|
||||
OfferVerifiableCredential,
|
||||
register,
|
||||
setVisibilityUtil,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
@@ -174,7 +273,9 @@ export default class DIDView extends Vue {
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
|
||||
contact?: Contact;
|
||||
contact: Contact;
|
||||
contactEdit = false;
|
||||
contactNewName?: string;
|
||||
contactYaml = "";
|
||||
hitEnd = false;
|
||||
isLoading = false;
|
||||
@@ -195,23 +296,29 @@ export default class DIDView extends Vue {
|
||||
this.apiServer = (settings?.apiServer as string) || "";
|
||||
|
||||
const pathParam = window.location.pathname.substring("/did/".length);
|
||||
let theContact: Contact | undefined;
|
||||
if (pathParam) {
|
||||
this.viewingDid = decodeURIComponent(pathParam);
|
||||
this.contact = await db.contacts.get(this.viewingDid);
|
||||
this.contactYaml = yaml.dump(this.contact);
|
||||
await this.loadClaimsAbout();
|
||||
theContact = await db.contacts.get(this.viewingDid);
|
||||
}
|
||||
if (theContact) {
|
||||
this.contact = theContact;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "No claim ID was provided.",
|
||||
text: "No valid claim ID was provided.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.contactYaml = yaml.dump(this.contact);
|
||||
await this.loadClaimsAbout();
|
||||
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
@@ -227,6 +334,128 @@ export default class DIDView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
// prompt with confirmation if they want to delete a contact
|
||||
confirmDeleteContact(contact: Contact) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Delete",
|
||||
text:
|
||||
"Are you sure you want to remove " +
|
||||
libsUtil.nameForContact(contact, false) +
|
||||
" from your contact list?",
|
||||
onYes: async () => {
|
||||
await this.deleteContact(contact);
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteContact(contact: Contact) {
|
||||
await db.open();
|
||||
await db.contacts.delete(contact.did);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Deleted",
|
||||
text: "Contact has been removed.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
(this.$router as Router).push({ name: "contacts" });
|
||||
}
|
||||
|
||||
// confirm to register a new contact
|
||||
async confirmRegister(contact: Contact) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Register",
|
||||
text:
|
||||
"Are you sure you want to register " +
|
||||
libsUtil.nameForContact(this.contact, false) +
|
||||
(contact.registered
|
||||
? " -- especially since they are already marked as registered"
|
||||
: "") +
|
||||
"?",
|
||||
onYes: async () => {
|
||||
await this.register(contact);
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
// note that this is also in ContactView.vue
|
||||
async register(contact: Contact) {
|
||||
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
|
||||
|
||||
try {
|
||||
const regResult = await register(
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
contact,
|
||||
);
|
||||
if (regResult.success) {
|
||||
contact.registered = true;
|
||||
await db.contacts.update(contact.did, { registered: true });
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Registration Success",
|
||||
text:
|
||||
(contact.name || "That unnamed person") + " has been registered.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Registration Error",
|
||||
text:
|
||||
(regResult.error as string) ||
|
||||
"Something went wrong during registration.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error when registering:", error);
|
||||
let userMessage = "There was an error. See logs for more info.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError) {
|
||||
if (serverError.response?.data?.error?.message) {
|
||||
userMessage = serverError.response.data.error.message;
|
||||
} else if (serverError.message) {
|
||||
userMessage = serverError.message; // Info for the user
|
||||
} else {
|
||||
userMessage = JSON.stringify(serverError.toJSON());
|
||||
}
|
||||
} else {
|
||||
userMessage = error as string;
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Registration Error",
|
||||
text: userMessage,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async loadClaimsAbout() {
|
||||
if (!this.viewingDid) {
|
||||
console.error("This should never be called without a DID.");
|
||||
@@ -323,5 +552,178 @@ export default class DIDView extends Vue {
|
||||
claimDescription(claim: GenericVerifiableCredential) {
|
||||
return claim.claim.name || claim.claim.description || "";
|
||||
}
|
||||
|
||||
private async onClickCancelName() {
|
||||
this.contactEdit = false;
|
||||
}
|
||||
|
||||
private async onClickSaveName(newName: string) {
|
||||
this.contact.name = newName;
|
||||
return db.contacts
|
||||
.update(this.contact.did, { name: newName })
|
||||
.then(() => (this.contactEdit = false));
|
||||
}
|
||||
|
||||
// note that this is also in ContactView.vue
|
||||
async confirmSetVisibility(contact: Contact, visibility: boolean) {
|
||||
const visibilityPrompt = visibility
|
||||
? "Are you sure you want to make your activity visible to them?"
|
||||
: "Are you sure you want to hide all your activity from them?";
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Set Visibility",
|
||||
text: visibilityPrompt,
|
||||
onYes: async () => {
|
||||
const success = await this.setVisibility(contact, visibility, true);
|
||||
if (success) {
|
||||
contact.seesMe = visibility; // didn't work inside setVisibility
|
||||
}
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
// note that this is also in ContactView.vue
|
||||
async setVisibility(
|
||||
contact: Contact,
|
||||
visibility: boolean,
|
||||
showSuccessAlert: boolean,
|
||||
) {
|
||||
const result = await setVisibilityUtil(
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
db,
|
||||
contact,
|
||||
visibility,
|
||||
);
|
||||
if (result.success) {
|
||||
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
|
||||
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
|
||||
if (showSuccessAlert) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Visibility Set",
|
||||
text:
|
||||
(contact.name || "That user") +
|
||||
" can " +
|
||||
(visibility ? "" : "not ") +
|
||||
"see your activity.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
console.error("Got strange result from setting visibility:", result);
|
||||
const message =
|
||||
(result.error as string) || "Could not set visibility on the server.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Visibility",
|
||||
text: message,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// note that this is also in ContactView.vue
|
||||
async checkVisibility(contact: Contact) {
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/report/canDidExplicitlySeeMe?did=" +
|
||||
encodeURIComponent(contact.did);
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
if (!headers["Authorization"]) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "No Identity",
|
||||
text: "There is no identity to use to check visibility.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
const visibility = resp.data;
|
||||
contact.seesMe = visibility;
|
||||
//console.log("Visi check:", visibility, contact.seesMe, contact.did);
|
||||
await db.contacts.update(contact.did, { seesMe: visibility });
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Visibility Refreshed",
|
||||
text:
|
||||
libsUtil.nameForContact(contact, true) +
|
||||
" can " +
|
||||
(visibility ? "" : "not ") +
|
||||
"see your activity.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
console.error("Got bad server response checking visibility:", resp);
|
||||
const message = resp.data.error?.message || "Got bad server response.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Checking Visibility",
|
||||
text: message,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Caught error from request to check visibility:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Checking Visibility",
|
||||
text: "Check connectivity and try again.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -265,6 +265,8 @@ export default class DiscoverView extends Vue {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
console.error("Error with feed load:", e);
|
||||
// this sometimes gives different information
|
||||
console.error("Error with feed load (error added): " + e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -269,6 +269,7 @@ export default class GiftedDetails extends Vue {
|
||||
this.hideBackButton =
|
||||
(this.$route as Router).query["hideBackButton"] === "true";
|
||||
this.message = ((this.$route as Router).query["message"] as string) || "";
|
||||
|
||||
// find any offer ID
|
||||
const fulfills = this.prevCredToEdit?.claim?.fulfills;
|
||||
const fulfillsArray = Array.isArray(fulfills)
|
||||
@@ -351,6 +352,7 @@ export default class GiftedDetails extends Vue {
|
||||
);
|
||||
}
|
||||
}
|
||||
// these should be functions but something's wrong with the syntax in the <> conditional
|
||||
this.givenToProject = !!this.projectId;
|
||||
this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
|
||||
|
||||
@@ -549,7 +551,7 @@ export default class GiftedDetails extends Vue {
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "To assign to a project, you must open this dialog through a project.",
|
||||
text: "To assign to a project, you must open this page through a project.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
@@ -574,7 +576,7 @@ export default class GiftedDetails extends Vue {
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "To assign to a recipient, you must open this dialog from a contact.",
|
||||
text: "To assign to a recipient, you must open this page from a contact.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
@@ -694,7 +696,6 @@ export default class GiftedDetails extends Vue {
|
||||
constructGiveParam() {
|
||||
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined;
|
||||
const projectId = this.givenToProject ? this.projectId : undefined;
|
||||
// const giveClaim = constructGive(
|
||||
const giveClaim = hydrateGive(
|
||||
this.prevCredToEdit?.claim as GiveVerifiableCredential,
|
||||
this.giverDid,
|
||||
@@ -97,8 +97,8 @@
|
||||
/>
|
||||
<input
|
||||
:disabled="!startDateInput"
|
||||
v-model="startTimeInput"
|
||||
placeholder="Start Time"
|
||||
v-model="startTimeInput"
|
||||
type="time"
|
||||
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
|
||||
/>
|
||||
@@ -309,7 +309,7 @@ export default class NewEditProjectView extends Vue {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const headers = getHeaders(this.activeDid) as AxiosRequestHeaders;
|
||||
const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
|
||||
const response = await this.axios.delete(
|
||||
DEFAULT_IMAGE_API_SERVER +
|
||||
"/image/" +
|
||||
|
||||
633
src/views/OfferDetailsView.vue
Normal file
633
src/views/OfferDetailsView.vue
Normal file
@@ -0,0 +1,633 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
<TopMessage />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Back -->
|
||||
<div
|
||||
v-if="!hideBackButton"
|
||||
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="cancelBack()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 class="text-4xl text-center font-light px-4 mb-4">What Is Offered</h1>
|
||||
|
||||
<h1 class="text-xl font-bold text-center mb-4">
|
||||
<span>
|
||||
Offer to
|
||||
{{
|
||||
offeredToProject
|
||||
? projectName
|
||||
: offeredToRecipient
|
||||
? recipientName
|
||||
: "someone unidentified"
|
||||
}}</span
|
||||
>
|
||||
</h1>
|
||||
<textarea
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="What is offered"
|
||||
v-model="itemDescription"
|
||||
data-testId="itemDescription"
|
||||
/>
|
||||
<div class="flex flex-row justify-center">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
|
||||
@click="changeUnitCode()"
|
||||
>
|
||||
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
|
||||
</span>
|
||||
<div
|
||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="amountInput === '0' ? null : decrement()"
|
||||
>
|
||||
<fa icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
|
||||
v-model="amountInput"
|
||||
data-testId="inputOfferAmount"
|
||||
/>
|
||||
<div
|
||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="increment()"
|
||||
>
|
||||
<fa icon="chevron-right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row mt-2">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
|
||||
>
|
||||
Conditions
|
||||
</span>
|
||||
<textarea
|
||||
class="w-full border border-slate-400 px-3 py-2 rounded-r"
|
||||
placeholder="Prerequisites, other people to include, etc."
|
||||
v-model="conditionDescription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row mt-2">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
|
||||
>
|
||||
{{ validThroughDateInput ? "" : "No" }} Expiration
|
||||
</span>
|
||||
<input
|
||||
v-model="validThroughDateInput"
|
||||
type="date"
|
||||
class="w-full rounded border border-slate-400 px-3 py-2 rounded-r"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-7 mt-4 flex">
|
||||
<input
|
||||
v-if="projectId && !offeredToRecipient"
|
||||
type="checkbox"
|
||||
class="h-6 w-6 mr-2"
|
||||
v-model="offeredToProject"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="square"
|
||||
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
|
||||
@click="notifyUserOfProject()"
|
||||
/>
|
||||
<label class="text-sm mt-1">
|
||||
{{
|
||||
projectId
|
||||
? "This is offered to " + projectName
|
||||
: "No project was chosen"
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="h-7 mt-4 flex">
|
||||
<input
|
||||
v-if="recipientDid && !offeredToProject"
|
||||
type="checkbox"
|
||||
class="h-6 w-6 mr-2"
|
||||
v-model="offeredToRecipient"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="square"
|
||||
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
|
||||
@click="notifyUserOfRecipient()"
|
||||
/>
|
||||
<label class="text-sm mt-1">
|
||||
{{
|
||||
recipientDid
|
||||
? "This is offered to " + recipientName
|
||||
: "No recipient was chosen."
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'claim-add-raw',
|
||||
query: {
|
||||
claim: constructOfferParam(),
|
||||
},
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
Edit & Submit Raw
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<p class="text-center mb-2 mt-6 italic">
|
||||
Sign & Send to publish to the world
|
||||
<fa
|
||||
icon="circle-info"
|
||||
class="pl-2 text-blue-500 cursor-pointer"
|
||||
@click="explainData()"
|
||||
/>
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
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"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<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="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import {
|
||||
createAndSubmitOffer,
|
||||
didInfo,
|
||||
editAndSubmitOffer,
|
||||
GenericCredWrapper,
|
||||
getPlanFromCache,
|
||||
hydrateOffer,
|
||||
OfferVerifiableCredential,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
},
|
||||
})
|
||||
export default class OfferDetailsView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
|
||||
amountInput = "0";
|
||||
conditionDescription = "";
|
||||
itemDescription = "";
|
||||
destinationPathAfter = "";
|
||||
offeredToProject = false;
|
||||
offeredToRecipient = false;
|
||||
offererDid: string | undefined;
|
||||
hideBackButton = false;
|
||||
message = "";
|
||||
offerId = "";
|
||||
prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>;
|
||||
projectId = "";
|
||||
projectName = "a project";
|
||||
recipientDid = "";
|
||||
recipientName = "";
|
||||
unitCode = "HUR";
|
||||
validThroughDateInput = "";
|
||||
|
||||
libsUtil = libsUtil;
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
|
||||
? (JSON.parse(
|
||||
(this.$route as Router).query["prevCredToEdit"],
|
||||
) as GenericCredWrapper<OfferVerifiableCredential>)
|
||||
: undefined;
|
||||
} catch (error) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Retrieval Error",
|
||||
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
|
||||
},
|
||||
6000,
|
||||
);
|
||||
}
|
||||
|
||||
const prevAmount =
|
||||
this.prevCredToEdit?.claim?.includesObject?.amountOfThisGood;
|
||||
this.amountInput =
|
||||
(this.$route as Router).query["amountInput"] ||
|
||||
(prevAmount ? String(prevAmount) : "") ||
|
||||
this.amountInput;
|
||||
this.unitCode = ((this.$route as Router).query["unitCode"] ||
|
||||
this.prevCredToEdit?.claim?.includesObject?.unitCode ||
|
||||
this.unitCode) as string;
|
||||
|
||||
this.conditionDescription =
|
||||
this.prevCredToEdit?.claim?.description || this.conditionDescription;
|
||||
this.itemDescription =
|
||||
(this.$route as Router).query["description"] ||
|
||||
this.prevCredToEdit?.claim?.itemOffered?.description ||
|
||||
this.itemDescription;
|
||||
this.destinationPathAfter = (this.$route as Router).query[
|
||||
"destinationPathAfter"
|
||||
];
|
||||
this.offererDid = ((this.$route as Router).query["offererDid"] ||
|
||||
this.prevCredToEdit?.claim?.agent?.identifier ||
|
||||
this.offererDid) as string;
|
||||
this.hideBackButton =
|
||||
(this.$route as Router).query["hideBackButton"] === "true";
|
||||
this.message = ((this.$route as Router).query["message"] as string) || "";
|
||||
|
||||
// find any project ID
|
||||
let project;
|
||||
if (
|
||||
this.prevCredToEdit?.claim?.itemOffered?.isPartOf?.["@type"] ===
|
||||
"PlanAction"
|
||||
) {
|
||||
project = this.prevCredToEdit?.claim?.itemOffered?.isPartOf;
|
||||
}
|
||||
this.projectId = ((this.$route as Router).query["projectId"] ||
|
||||
project?.identifier ||
|
||||
this.projectId) as string;
|
||||
this.projectName = ((this.$route as Router).query["projectName"] ||
|
||||
project?.name ||
|
||||
this.projectName) as string;
|
||||
|
||||
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
|
||||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
|
||||
this.recipientName =
|
||||
((this.$route as Router).query["recipientName"] as string) || "";
|
||||
|
||||
this.validThroughDateInput =
|
||||
this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput;
|
||||
|
||||
try {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
|
||||
let allContacts: Contact[] = [];
|
||||
let allMyDids: string[] = [];
|
||||
if (this.recipientDid && !this.recipientName) {
|
||||
allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
allMyDids = allAccounts.map((acc) => acc.did);
|
||||
this.recipientName = didInfo(
|
||||
this.recipientDid,
|
||||
this.activeDid,
|
||||
allMyDids,
|
||||
allContacts,
|
||||
);
|
||||
}
|
||||
// these should be functions but something's wrong with the syntax in the <> conditional
|
||||
this.offeredToProject = !!this.projectId;
|
||||
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.error("Error retrieving settings from database:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: err.message || "There was an error retrieving your settings.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.projectId && !this.projectName) {
|
||||
// console.log("Getting project name from cache", this.projectId);
|
||||
const project = await getPlanFromCache(
|
||||
this.projectId,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
this.projectName = project?.name
|
||||
? "the project: " + project.name
|
||||
: "a project";
|
||||
}
|
||||
}
|
||||
|
||||
changeUnitCode() {
|
||||
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
||||
const index = units.indexOf(this.unitCode);
|
||||
this.unitCode = units[(index + 1) % units.length];
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.amountInput = `${Math.max(
|
||||
0,
|
||||
(parseFloat(this.amountInput) || 1) - 1,
|
||||
)}`;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this.destinationPathAfter) {
|
||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||
} else {
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
}
|
||||
|
||||
cancelBack() {
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identifier before you can record a offer.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (parseFloat(this.amountInput) < 0) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
text: "You may not send a negative number.",
|
||||
title: "",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.itemDescription && !parseFloat(this.amountInput)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `You must enter a description or some number of ${
|
||||
this.libsUtil.UNIT_LONG[this.unitCode]
|
||||
}.`,
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
text: "Recording the offer...",
|
||||
title: "",
|
||||
},
|
||||
1000,
|
||||
);
|
||||
|
||||
// this is asynchronous, but we don't need to wait for it to complete
|
||||
await this.recordOffer();
|
||||
}
|
||||
|
||||
notifyUserOfProject() {
|
||||
if (!this.projectId) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "To assign to a project, you must open this page through a project.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
// must be because offeredToRecipient is true
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "You cannot assign both to a project and to a recipient.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
notifyUserOfRecipient() {
|
||||
if (!this.recipientDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "To assign to a recipient, you must open this page from a contact.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
// must be because offeredToProject is true
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "You cannot assign both to a recipient and to a project.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param offererDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param amountInput may be 0
|
||||
* @param unitCode may be omitted, defaults to "HUR"
|
||||
*/
|
||||
public async recordOffer() {
|
||||
try {
|
||||
const recipientDid = this.offeredToRecipient
|
||||
? this.recipientDid
|
||||
: undefined;
|
||||
const projectId = this.offeredToProject ? this.projectId : undefined;
|
||||
let result;
|
||||
if (this.prevCredToEdit) {
|
||||
// don't create from a blank one in case some properties were set from a different interface
|
||||
result = await editAndSubmitOffer(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.prevCredToEdit,
|
||||
this.activeDid,
|
||||
this.itemDescription,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
this.conditionDescription,
|
||||
this.validThroughDateInput,
|
||||
recipientDid,
|
||||
projectId,
|
||||
);
|
||||
} else {
|
||||
result = await createAndSubmitOffer(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
this.itemDescription,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
this.conditionDescription,
|
||||
this.validThroughDateInput,
|
||||
recipientDid,
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.type === "error" || this.isCreationError(result.response)) {
|
||||
const errorMessage = this.getCreationErrorMessage(result);
|
||||
console.error("Error with offer creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error creating the offer.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: `That offer was recorded.`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
localStorage.removeItem("imageUrl");
|
||||
if (this.destinationPathAfter) {
|
||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||
} else {
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.error("Error with offer recordation caught:", error);
|
||||
const errorMessage =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the offer.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
constructOfferParam() {
|
||||
const recipientDid = this.offeredToRecipient
|
||||
? this.recipientDid
|
||||
: undefined;
|
||||
const projectId = this.offeredToProject ? this.projectId : undefined;
|
||||
const offerClaim = hydrateOffer(
|
||||
this.prevCredToEdit?.claim as OfferVerifiableCredential,
|
||||
this.activeDid,
|
||||
recipientDid,
|
||||
this.itemDescription,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
this.conditionDescription,
|
||||
projectId,
|
||||
this.validThroughDateInput,
|
||||
this.prevCredToEdit?.id as string,
|
||||
);
|
||||
const claimStr = JSON.stringify(offerClaim);
|
||||
return claimStr;
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result response "data" from the server
|
||||
* @returns true if the result indicates an error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getCreationErrorMessage(result: any) {
|
||||
return (
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
|
||||
explainData() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Data Sharing",
|
||||
text: libsUtil.PRIVACY_MESSAGE,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -162,6 +162,7 @@
|
||||
<div v-if="activeDid && isRegistered" class="mt-4">
|
||||
<div class="text-center">
|
||||
<button
|
||||
data-testId="offerButton"
|
||||
@click="openOfferDialog()"
|
||||
class="block w-full text-lg font-bold 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"
|
||||
>
|
||||
@@ -169,7 +170,11 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
|
||||
<OfferDialog
|
||||
ref="customOfferDialog"
|
||||
:projectId="this.projectId"
|
||||
:projectName="this.name"
|
||||
/>
|
||||
|
||||
<div v-if="activeDid && isRegistered">
|
||||
<div class="text-center">
|
||||
|
||||
@@ -109,6 +109,19 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
To
|
||||
{{
|
||||
offer.fulfillsPlanHandleId
|
||||
? projectNameFromHandleId[offer.fulfillsPlanHandleId]
|
||||
: didInfo(
|
||||
offer.recipientDid,
|
||||
activeDid,
|
||||
allMyDids,
|
||||
allContacts,
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div>
|
||||
{{ offer.objectDescription }}
|
||||
</div>
|
||||
@@ -244,11 +257,14 @@ import QuickNav from "@/components/QuickNav.vue";
|
||||
import ProjectIcon from "@/components/ProjectIcon.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import {
|
||||
didInfo,
|
||||
getHeaders,
|
||||
getPlanFromCache,
|
||||
OfferSummaryRecord,
|
||||
PlanData,
|
||||
} from "@/libs/endorserServer";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
@Component({
|
||||
components: { EntityIcon, InfiniteScroll, QuickNav, ProjectIcon, TopMessage },
|
||||
@@ -263,16 +279,19 @@ export default class ProjectsView extends Vue {
|
||||
}
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
projects: PlanData[] = [];
|
||||
isLoading = false;
|
||||
isRegistered = false;
|
||||
numAccounts = 0;
|
||||
offers: OfferSummaryRecord[] = [];
|
||||
projectNameFromHandleId: Record<string, string> = {}; // mapping from handleId to description
|
||||
showOffers = true;
|
||||
showProjects = false;
|
||||
|
||||
libsUtil = libsUtil;
|
||||
didInfo = didInfo;
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
@@ -282,9 +301,13 @@ export default class ProjectsView extends Vue {
|
||||
this.apiServer = (settings?.apiServer as string) || "";
|
||||
this.isRegistered = !!settings?.isRegistered;
|
||||
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
if (this.numAccounts === 0) {
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
|
||||
if (allAccounts.length === 0) {
|
||||
console.error("No accounts found.");
|
||||
this.errNote("You need an identifier to load your projects.");
|
||||
} else {
|
||||
@@ -343,10 +366,7 @@ export default class ProjectsView extends Vue {
|
||||
async loadMoreProjectData(payload: boolean) {
|
||||
if (this.projects.length > 0 && payload) {
|
||||
const latestProject = this.projects[this.projects.length - 1];
|
||||
await this.loadProjects(
|
||||
this.activeDid,
|
||||
`beforeId=${latestProject.rowid}`,
|
||||
);
|
||||
await this.loadProjects(`beforeId=${latestProject.rowid}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +375,7 @@ export default class ProjectsView extends Vue {
|
||||
* @param issuerDid of the user
|
||||
* @param urlExtra additional url parameters in a string
|
||||
**/
|
||||
async loadProjects(activeDid?: string, urlExtra: string = "") {
|
||||
async loadProjects(urlExtra: string = "") {
|
||||
const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`;
|
||||
await this.projectDataLoader(url);
|
||||
}
|
||||
@@ -396,13 +416,37 @@ export default class ProjectsView extends Vue {
|
||||
* @param token Authorization token
|
||||
**/
|
||||
async offerDataLoader(url: string) {
|
||||
const headers = getHeaders(this.activeDid);
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
|
||||
if (resp.status === 200 && resp.data.data) {
|
||||
this.offers = this.offers.concat(resp.data.data);
|
||||
// add one-by-one as they retrieve project names, potentially from the server
|
||||
for (const offer of resp.data.data) {
|
||||
if (offer.fulfillsPlanHandleId) {
|
||||
const project = await getPlanFromCache(
|
||||
offer.fulfillsPlanHandleId,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
const projectName = project?.name as string;
|
||||
console.log(
|
||||
"now have name for",
|
||||
offer.fulfillsPlanHandleId,
|
||||
projectName,
|
||||
);
|
||||
this.projectNameFromHandleId[offer.fulfillsPlanHandleId] =
|
||||
projectName;
|
||||
console.log(
|
||||
"now have a real name for",
|
||||
offer.fulfillsPlanHandleId,
|
||||
this.projectNameFromHandleId[offer.fulfillsPlanHandleId],
|
||||
);
|
||||
}
|
||||
this.offers = this.offers.concat([offer]);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Bad server response & data for offers:",
|
||||
@@ -443,7 +487,7 @@ export default class ProjectsView extends Vue {
|
||||
async loadMoreOfferData(payload: boolean) {
|
||||
if (this.offers.length > 0 && payload) {
|
||||
const latestOffer = this.offers[this.offers.length - 1];
|
||||
await this.loadOffers(this.activeDid, `&beforeId=${latestOffer.jwtId}`);
|
||||
await this.loadOffers(`&beforeId=${latestOffer.jwtId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,8 +496,8 @@ export default class ProjectsView extends Vue {
|
||||
* @param issuerDid of the user
|
||||
* @param urlExtra additional url parameters in a string
|
||||
**/
|
||||
async loadOffers(issuerDid?: string, urlExtra: string = "") {
|
||||
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${issuerDid}${urlExtra}`;
|
||||
async loadOffers(urlExtra: string = "") {
|
||||
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${this.activeDid}${urlExtra}`;
|
||||
await this.offerDataLoader(url);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,17 @@
|
||||
</div>
|
||||
<div v-else class="text-center mb-4">
|
||||
<p>No image found.</p>
|
||||
<p class="mt-4">
|
||||
If you shared an image, the cause is usually that you do not have the
|
||||
recent version of this app, or that the app has not refreshed the
|
||||
service code underneath. To fix this, first make sure you have latest
|
||||
version by comparing your version at the bottom of "Help" with the
|
||||
version at the bottom of https://timesafari.app/help in a browser. After
|
||||
that, it may eventually work, but you can speed up the process by
|
||||
clearing your data cache (in the browser on mobile, even if you
|
||||
installed it) and/or reinstalling the app (after backing up all your
|
||||
data, of course).
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -122,7 +133,7 @@ export default class SharedPhotoView extends Vue {
|
||||
name: "gifted-details",
|
||||
// this might be wrong since "name" goes with params, but it works so test well when you change it
|
||||
query: {
|
||||
destinationPathAfter: "/home",
|
||||
destinationPathAfter: "/",
|
||||
hideBackButton: true,
|
||||
imageUrl: url,
|
||||
recipientDid: this.activeDid,
|
||||
|
||||
@@ -566,14 +566,27 @@ async function getNotificationCount() {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function blobToBase64String(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result); // potential problem if it returns an ArrayBuffer?
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
// Store the image blob and go immediate to a page to upload it.
|
||||
// @param photo - image Blob to store for later retrieval after redirect
|
||||
async function savePhoto(photo) {
|
||||
try {
|
||||
const photoBase64 = await blobToBase64String(photo);
|
||||
const db = await openIndexedDB("TimeSafari");
|
||||
const transaction = db.transaction("temp", "readwrite");
|
||||
const store = transaction.objectStore("temp");
|
||||
await updateRecord(store, { id: "shared-photo", blob: photo });
|
||||
await updateRecord(store, {
|
||||
id: "shared-photo-base64",
|
||||
blobB64: photoBase64,
|
||||
});
|
||||
transaction.oncomplete = () => db.close();
|
||||
} catch (error) {
|
||||
console.error("safari-notifications logMessage IndexedDB error", error);
|
||||
|
||||
@@ -12,8 +12,8 @@ test('Create new project, then search for it', async ({ page }) => {
|
||||
const finalRandomString = randomString.substring(0, 16);
|
||||
|
||||
// Standard texts
|
||||
const standardTitle = "Idea ";
|
||||
const standardDescription = "Description of Idea ";
|
||||
const standardTitle = 'Idea ';
|
||||
const standardDescription = 'Description of Idea ';
|
||||
|
||||
// Combine texts with the random string
|
||||
const finalTitle = standardTitle + finalRandomString;
|
||||
@@ -43,12 +43,10 @@ test('Create new project, then search for it', async ({ page }) => {
|
||||
// Search for newly-created project in /projects
|
||||
await page.goto('./projects');
|
||||
await page.getByRole('link', { name: 'Projects', exact: true }).click();
|
||||
await page.waitForTimeout(3000); // Wait for a bit
|
||||
await expect(page.locator('ul#listProjects li.border-b:nth-child(1)')).toContainText(finalRandomString); // Assumes newest project always appears first in the Projects tab list
|
||||
|
||||
// Search for newly-created project in /discover
|
||||
await page.goto('./discover');
|
||||
await page.waitForTimeout(3000); // Wait for a bit
|
||||
await page.getByPlaceholder('Search…').fill(finalRandomString);
|
||||
await page.locator('#QuickSearch button').click();
|
||||
await expect(page.locator('ul#listDiscoverResults li.border-b:nth-child(1)')).toContainText(finalRandomString);
|
||||
|
||||
@@ -2,23 +2,17 @@ import { test, expect } from '@playwright/test';
|
||||
import { importUser } from './testUtils';
|
||||
|
||||
test('Record something given', async ({ page }) => {
|
||||
// Generate a random string of 16 characters
|
||||
let randomString = Math.random().toString(36).substring(2, 18);
|
||||
|
||||
// In case the string is shorter than 16 characters, generate more characters until it is 16 characters long
|
||||
while (randomString.length < 16) {
|
||||
randomString += Math.random().toString(36).substring(2, 18);
|
||||
}
|
||||
const finalRandomString = randomString.substring(0, 16);
|
||||
// Generate a random string of a few characters
|
||||
const randomString = Math.random().toString(36).substring(2, 6);
|
||||
|
||||
// Generate a random non-zero single-digit number
|
||||
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
|
||||
|
||||
// Standard title prefix
|
||||
const standardTitle = "Gift ";
|
||||
const standardTitle = 'Gift ';
|
||||
|
||||
// Combine title prefix with the random string
|
||||
const finalTitle = standardTitle + finalRandomString;
|
||||
const finalTitle = standardTitle + randomString;
|
||||
|
||||
// Import user 00
|
||||
await importUser(page, '00');
|
||||
@@ -27,7 +21,7 @@ test('Record something given', async ({ page }) => {
|
||||
await page.goto('./');
|
||||
await page.getByRole('heading', { name: 'Unnamed/Unknown' }).click();
|
||||
await page.getByPlaceholder('What was given').fill(finalTitle);
|
||||
await page.getByRole('spinbutton', { id: 'inputGivenAmount' }).fill(randomNonZeroNumber.toString());
|
||||
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'path';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { importUser } from './testUtils';
|
||||
|
||||
test('Record item given from image-share', async ({ page }) => {
|
||||
|
||||
@@ -8,11 +9,7 @@ test('Record item given from image-share', async ({ page }) => {
|
||||
// Combine title prefix with the random string
|
||||
const finalTitle = `Gift ${randomString} from image-share`;
|
||||
|
||||
// Create new ID using seed phrase "rigid shrug mobile…"
|
||||
await page.goto('./start');
|
||||
await page.getByText('You have a seed').click();
|
||||
await page.getByPlaceholder('Seed Phrase').fill('rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage');
|
||||
await page.getByRole('button', { name: 'Import' }).click();
|
||||
await importUser(page, '00');
|
||||
|
||||
// Record something given
|
||||
await page.goto('./test');
|
||||
@@ -38,3 +35,35 @@ test('Record item given from image-share', async ({ page }) => {
|
||||
const item1 = page.locator('li').filter({ hasText: finalTitle });
|
||||
await expect(item1.getByRole('img')).toBeVisible();
|
||||
});
|
||||
|
||||
// // I believe there's a way to test this service worker feature.
|
||||
// // The following is what I got from ChatGPT. I wonder if it doesn't work because it's not registering the service worker correctly.
|
||||
//
|
||||
// test('Trigger a photo-sharing fetch event in service worker with POST to /share-target', async ({ page }) => {
|
||||
// await importUser(page, '00');
|
||||
//
|
||||
// // Create a FormData object with a photo
|
||||
// const photoPath = path.join(__dirname, '..', 'public', 'img', 'icons', 'android-chrome-192x192.png');
|
||||
// const photoContent = await fs.readFileSync(photoPath);
|
||||
// const [response] = await Promise.all([
|
||||
// page.waitForResponse(response => response.url().includes('/share-target')), // also check for response.status() === 303 ?
|
||||
// page.evaluate(async (photoContent) => {
|
||||
// const formData = new FormData();
|
||||
// formData.append('photo', new Blob([photoContent], { type: 'image/png' }), 'test-photo.jpg');
|
||||
//
|
||||
// const response = await fetch('/share-target', {
|
||||
// method: 'POST',
|
||||
// body: formData,
|
||||
// });
|
||||
//
|
||||
// return response;
|
||||
// }, photoContent)
|
||||
// ]);
|
||||
//
|
||||
// // Verify the response redirected to /shared-photo
|
||||
// //expect(response.status).toBe(303);
|
||||
// console.log('response headers', response.headers());
|
||||
// console.log('response status', response.status());
|
||||
// console.log('response url', response.url());
|
||||
// expect(response.url()).toContain('/shared-photo');
|
||||
// });
|
||||
|
||||
@@ -15,20 +15,20 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
|
||||
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
|
||||
|
||||
// Standard title prefix
|
||||
const standardTitle = "Gift ";
|
||||
const standardTitle = 'Gift ';
|
||||
|
||||
// Combine title prefix with the random string
|
||||
const finalTitle = standardTitle + finalRandomString;
|
||||
|
||||
// Contact name
|
||||
const contactName = 'Contact 00';
|
||||
const contactName = 'Contact #000';
|
||||
|
||||
// Import user 01
|
||||
await importUser(page, '01');
|
||||
|
||||
// Add new contact 00
|
||||
await page.goto('./contacts');
|
||||
await page.getByPlaceholder('URL or DID, Name, Public Key').fill('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F');
|
||||
await page.getByPlaceholder('URL or DID, Name, Public Key').fill('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F, User #000');
|
||||
await page.locator('button > svg.fa-plus').click();
|
||||
await expect(page.locator('div[role="alert"]')).toBeVisible();
|
||||
|
||||
@@ -36,10 +36,11 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
|
||||
// await page.locator('div[role="alert"] button:has-text("Yes")').click();
|
||||
|
||||
// Verify added contact
|
||||
await expect(page.locator('li.border-b')).toContainText('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F');
|
||||
await expect(page.locator('li.border-b')).toContainText('User #000');
|
||||
|
||||
// Rename contact
|
||||
await page.locator('li.border-b h2 > button[title="Edit"]').click();
|
||||
await page.locator('li.border-b div div > a[title="See more about this person"]').click();
|
||||
await page.locator('h2 > button[title="Edit"]').click();
|
||||
await expect(page.locator('div.dialog-overlay > div.dialog').filter({ hasText: 'Edit Name' })).toBeVisible();
|
||||
await page.getByPlaceholder('Name', { exact: true }).fill(contactName);
|
||||
await page.locator('.dialog > .flex > button').first().click();
|
||||
|
||||
63
test-playwright/50-record-offer.spec.ts
Normal file
63
test-playwright/50-record-offer.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { importUser } from './testUtils';
|
||||
|
||||
test('Record an offer', async ({ page }) => {
|
||||
// Generate a random string of 3 characters, skipping the "0." at the beginning
|
||||
const randomString = Math.random().toString(36).substring(2, 5);
|
||||
// Standard title prefix
|
||||
const description = `Offering of ${randomString}`;
|
||||
const updatedDescription = `Updated ${description}`;
|
||||
const randomNonZeroNumber = Math.floor(Math.random() * 998) + 1;
|
||||
|
||||
// Create new ID for default user
|
||||
await importUser(page);
|
||||
|
||||
// Select a project
|
||||
await page.goto('./discover');
|
||||
await page.locator('ul#listDiscoverResults li:nth-child(1)').click();
|
||||
|
||||
// Record an offer
|
||||
await page.getByTestId('offerButton').click();
|
||||
await page.getByTestId('inputDescription').fill(description);
|
||||
await page.getByTestId('inputOfferAmount').fill(randomNonZeroNumber.toString());
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That offer was recorded.')).toBeVisible();
|
||||
|
||||
// go to the offer and check the values
|
||||
await page.goto('./projects');
|
||||
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
|
||||
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
||||
await expect(page.getByText(description, { exact: true })).toBeVisible();
|
||||
const serverPagePromise = page.waitForEvent('popup');
|
||||
await page.getByRole('link', { name: 'View on the Public Server' }).click();
|
||||
const serverPage = await serverPagePromise;
|
||||
await serverPage.getByText(description);
|
||||
await serverPage.getByText('did:none:HIDDEN');
|
||||
|
||||
// Now update that offer
|
||||
|
||||
// find the edit page and check the old values again
|
||||
await page.goto('./projects');
|
||||
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
|
||||
await page.getByTestId('editClaimButton').click();
|
||||
await page.locator('heading', { hasText: 'What is offered' }).isVisible();
|
||||
const itemDesc = await page.getByTestId('itemDescription');
|
||||
await expect(itemDesc).toHaveValue(description);
|
||||
const amount = await page.getByTestId('inputOfferAmount');
|
||||
await expect(amount).toHaveValue(randomNonZeroNumber.toString());
|
||||
// update the values
|
||||
await itemDesc.fill(updatedDescription);
|
||||
await amount.fill(String(randomNonZeroNumber + 1));
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
|
||||
// go to the offer claim again and check the updated values
|
||||
await page.goto('./projects');
|
||||
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
|
||||
const newItemDesc = await page.getByTestId('description');
|
||||
await expect(newItemDesc).toHaveText(updatedDescription);
|
||||
|
||||
// go to edit page
|
||||
await page.getByTestId('editClaimButton').click();
|
||||
const newAmount = await page.getByTestId('inputOfferAmount');
|
||||
await expect(newAmount).toHaveValue((randomNonZeroNumber + 1).toString());
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
|
||||
export async function importUser(page, id) {
|
||||
export async function importUser(page: Page, id?: string): Promise<void> {
|
||||
let seedPhrase, userName, did;
|
||||
|
||||
// Set seed phrase and DID based on user ID
|
||||
@@ -21,6 +21,8 @@ export async function importUser(page, id) {
|
||||
await page.getByText('You have a seed').click();
|
||||
await page.getByPlaceholder('Seed Phrase').fill(seedPhrase);
|
||||
await page.getByRole('button', { name: 'Import' }).click();
|
||||
await expect(page.locator('#sectionUsageLimits')).toContainText('You have done');
|
||||
await expect(page.locator('#sectionUsageLimits')).toContainText('You have uploaded');
|
||||
|
||||
// Set name
|
||||
await page.getByRole('link', { name: 'Set Your Name' }).click();
|
||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
||||
srcDir: '.',
|
||||
filename: 'sw_scripts-combined.js',
|
||||
manifest: {
|
||||
// This is used for the app name. It doesn't include a space, because iOS complains if i recall correctly.
|
||||
// This is used for the app name. It doesn't include a space, because iOS complains if I recall correctly.
|
||||
// There is a name with spaces in the constants/app.js file for use internally.
|
||||
name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
|
||||
short_name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
|
||||
|
||||
Reference in New Issue
Block a user