Changes: - Move v-model directives before other attributes - Move v-bind directives before event handlers - Reorder attributes for better readability - Fix template attribute ordering across components - Improve eslint rules - add default vite config for testing (handles nostr error too) This follows Vue.js style guide recommendations for attribute ordering and improves template consistency.
1005 lines
34 KiB
Vue
1005 lines
34 KiB
Vue
<template>
|
|
<QuickNav selected="Home" />
|
|
<TopMessage />
|
|
|
|
<!-- CONTENT -->
|
|
<section id="Content" class="p-2 pb-24 max-w-3xl mx-auto">
|
|
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
|
|
{{ AppString.APP_NAME }}
|
|
</h1>
|
|
|
|
<OnboardingDialog ref="onboardingDialog" />
|
|
|
|
<!-- prompt to install notifications with notificationsSupported, which we're making an advanced feature -->
|
|
<div class="mb-8 mt-8">
|
|
<div
|
|
v-if="false"
|
|
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
|
>
|
|
<p style="display: inline; align-items: center">
|
|
This currently doesn't support notifications, so let's fix that.
|
|
<br />
|
|
<!-- Note that that exact verbiage shows in the help. -->
|
|
|
|
<span v-if="userAgentInfo.getOS().name === 'iOS'">
|
|
Tap on "Share"<img
|
|
src="../assets/help/apple-share-icon.svg"
|
|
alt="Apple 'share' icon"
|
|
width="30"
|
|
style="display: inline; margin: 0 5px; vertical-align: middle"
|
|
/>and then "Add to Home Screen"
|
|
<font-awesome icon="square-plus" title="Apple 'Add' icon" />
|
|
and go click on that new app.
|
|
</span>
|
|
<span
|
|
v-else-if="userAgentInfo.getBrowser()?.name?.startsWith('Chrome')"
|
|
>
|
|
You should see a prompt to install, or you can click on the
|
|
top-right dots
|
|
<font-awesome icon="ellipsis-vertical" title="vertical ellipsis" />
|
|
/> and then "Install"<img
|
|
src="../assets/help/install-android-chrome.png"
|
|
alt="Android 'install' icon"
|
|
width="30"
|
|
style="display: inline; margin: 0 5px; vertical-align: middle"
|
|
/>
|
|
and go use that app. If you already did these steps, reload this app
|
|
so that it is fully detected.
|
|
</span>
|
|
<span v-else>
|
|
Try
|
|
<a href="https://www.google.com/chrome/" class="text-blue-500"
|
|
>Google Chrome</a
|
|
>
|
|
or look for a way to install as an app from this browser.
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showShortcutBvc" class="mb-4">
|
|
<router-link
|
|
:to="{ name: 'quick-action-bvc' }"
|
|
class="block text-center text-md 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 mt-2 px-2 py-3 rounded-md"
|
|
>
|
|
Bountiful Voluntaryist Community Actions
|
|
</router-link>
|
|
</div>
|
|
|
|
<div class="mb-8">
|
|
<div v-if="isCreatingIdentifier">
|
|
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
|
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
|
Loading…
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<!-- !isCreatingIdentifier -->
|
|
<!-- They should have an identifier, even if it's an auto-generated one that they'll never use. -->
|
|
<div class="mb-4">
|
|
<div
|
|
v-if="!isRegistered"
|
|
id="noticeSomeoneMustRegisterYou"
|
|
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
|
>
|
|
<!-- !isCreatingIdentifier && !isRegistered -->
|
|
To share, someone must register you.
|
|
<div class="block text-center">
|
|
<button
|
|
class="text-md 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 mt-2 px-2 py-3 rounded-md"
|
|
@click="showNameThenIdDialog()"
|
|
>
|
|
Show them {{ PASSKEYS_ENABLED ? "default" : "your" }} identifier
|
|
info
|
|
</button>
|
|
</div>
|
|
<UserNameDialog ref="userNameDialog" />
|
|
<div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full">
|
|
<router-link
|
|
:to="{ name: 'start' }"
|
|
class="block text-right text-md 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 mt-2 px-2 py-3 rounded-md"
|
|
>
|
|
See all your options first
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else id="sectionRecordSomethingGiven">
|
|
<!-- !isCreatingIdentifier && isRegistered -->
|
|
|
|
<!-- show the actions for recognizing a give -->
|
|
<div class="flex">
|
|
<h2 class="text-xl font-bold">What have you seen someone do?</h2>
|
|
<button
|
|
class="ml-2 block text-xs text-center 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 rounded-md"
|
|
@click="openGiftedPrompts()"
|
|
>
|
|
<font-awesome icon="lightbulb" class="fa-fw" />
|
|
</button>
|
|
</div>
|
|
|
|
<ul
|
|
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mt-4"
|
|
>
|
|
<li @click="openDialog()">
|
|
<img
|
|
src="../assets/blank-square.svg"
|
|
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
|
|
/>
|
|
<h3
|
|
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
|
>
|
|
Unnamed/Unknown
|
|
</h3>
|
|
</li>
|
|
<li v-if="allContacts.length === 0" class="text-sm">
|
|
(Add friends to see more people worthy of recognition.)
|
|
</li>
|
|
<li
|
|
v-for="contact in allContacts.slice(0, 6)"
|
|
:key="contact.did"
|
|
@click="openDialog(contact)"
|
|
>
|
|
<EntityIcon
|
|
:contact="contact"
|
|
:icon-size="64"
|
|
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
|
|
/>
|
|
<h3
|
|
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
|
>
|
|
{{ contact.name || contact.did }}
|
|
</h3>
|
|
</li>
|
|
<li>
|
|
<router-link
|
|
v-if="allContacts.length >= 6"
|
|
:to="{ name: 'contact-gift' }"
|
|
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
|
|
>
|
|
... or someone else...
|
|
</router-link>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<GiftedDialog ref="customDialog" />
|
|
<GiftedPrompts ref="giftedPrompts" />
|
|
<FeedFilters ref="feedFilters" />
|
|
|
|
<div class="relative">
|
|
<button
|
|
v-if="isRegistered"
|
|
class="absolute right-6 bottom-0 transform translate-y-1/2 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
|
|
@click="openDialog()"
|
|
>
|
|
<font-awesome icon="plus" class="fa-fw" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Results List -->
|
|
<div class="bg-slate-100 rounded-md px-4 py-3 mt-4 mb-4">
|
|
<div class="flex items-center mb-4">
|
|
<h2 class="text-xl font-bold">
|
|
Latest Activity
|
|
<button @click="openFeedFilters()">
|
|
<span class="text-xs text-white">
|
|
<font-awesome
|
|
v-if="resultsAreFiltered()"
|
|
icon="filter"
|
|
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-1 py-1.5 rounded-md"
|
|
/>
|
|
<font-awesome
|
|
v-else
|
|
icon="filter"
|
|
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-1 py-1.5 rounded-md"
|
|
/>
|
|
</span>
|
|
</button>
|
|
</h2>
|
|
</div>
|
|
|
|
<div
|
|
class="border-t p-2 border-slate-300"
|
|
@click="goToActivityToUserPage()"
|
|
>
|
|
<div class="flex justify-center">
|
|
<div
|
|
v-if="numNewOffersToUser"
|
|
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
|
|
>
|
|
<span
|
|
class="block text-center text-6xl"
|
|
data-testId="newDirectOffersActivityNumber"
|
|
>
|
|
{{ numNewOffersToUser }}{{ newOffersToUserHitLimit ? "+" : "" }}
|
|
</span>
|
|
<p class="text-center">
|
|
new offer{{ numNewOffersToUser === 1 ? "" : "s" }} to you
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-if="numNewOffersToUserProjects"
|
|
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
|
|
>
|
|
<span
|
|
class="block text-center text-6xl"
|
|
data-testId="newOffersToUserProjectsActivityNumber"
|
|
>
|
|
{{ numNewOffersToUserProjects
|
|
}}{{ newOffersToUserProjectsHitLimit ? "+" : "" }}
|
|
</span>
|
|
<p class="text-center">
|
|
new offer{{ numNewOffersToUserProjects === 1 ? "" : "s" }} to your
|
|
projects
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end mt-2">
|
|
<button class="text-blue-500">View All New Activity For You</button>
|
|
</div>
|
|
</div>
|
|
|
|
<InfiniteScroll @reached-bottom="loadMoreGives">
|
|
<ul id="listLatestActivity" class="border-t border-slate-300">
|
|
<li
|
|
v-for="record in feedData"
|
|
:key="record.jwtId"
|
|
class="border-b border-slate-300 py-2"
|
|
>
|
|
<div
|
|
v-if="record.jwtId == feedLastViewedClaimId"
|
|
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
|
|
>
|
|
You've already seen all the following
|
|
</div>
|
|
|
|
<div class="grid grid-cols-12">
|
|
<span class="pt-1 col-span-1 justify-self-start">
|
|
<span>
|
|
<font-awesome
|
|
icon="circle-user"
|
|
:class="
|
|
computeKnownPersonIconStyleClassNames(
|
|
record.giver.known || record.receiver.known,
|
|
)
|
|
"
|
|
@click="toastUser('This involves your contacts.')"
|
|
/>
|
|
<font-awesome
|
|
icon="gift"
|
|
class="pl-3 text-slate-500"
|
|
@click="toastUser('This is a gift.')"
|
|
/>
|
|
</span>
|
|
</span>
|
|
<span class="col-span-10 justify-self-stretch overflow-hidden">
|
|
<!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter
|
|
<span
|
|
v-if="
|
|
record.giver.profileImageUrl ||
|
|
record.receiver.profileImageUrl
|
|
"
|
|
>
|
|
<EntityIcon
|
|
v-if="record.agentDid !== activeDid"
|
|
:icon-size="32"
|
|
:profile-image-url="record.giver.profileImageUrl"
|
|
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
|
/>
|
|
<font-awesome
|
|
v-if="
|
|
record.agentDid !== activeDid &&
|
|
record.recipientDid !== activeDid &&
|
|
!record.fulfillsPlanHandleId
|
|
"
|
|
icon="ellipsis"
|
|
class="text-slate"
|
|
/>
|
|
<EntityIcon
|
|
v-if="
|
|
record.recipientDid !== activeDid &&
|
|
!record.fulfillsPlanHandleId
|
|
"
|
|
:iconSize="32"
|
|
:profile-image-url="record.receiver.profileImageUrl"
|
|
class="inline-block align-middle border border-slate-300 rounded-md ml-1"
|
|
/>
|
|
</span>
|
|
-->
|
|
<span class="pl-2 block break-words">
|
|
{{ giveDescription(record) }}
|
|
</span>
|
|
<a @click="onClickLoadClaim(record.jwtId)">
|
|
<font-awesome
|
|
icon="file-lines"
|
|
class="pl-2 text-slate-500 cursor-pointer"
|
|
/>
|
|
</a>
|
|
</span>
|
|
<span class="col-span-1 justify-self-end">
|
|
<router-link
|
|
v-if="record.fulfillsPlanHandleId"
|
|
:to="
|
|
'/project/' +
|
|
encodeURIComponent(record.fulfillsPlanHandleId)
|
|
"
|
|
>
|
|
<font-awesome icon="hammer" class="text-blue-500" />
|
|
</router-link>
|
|
<router-link
|
|
v-if="record.providerPlanHandleId"
|
|
:to="
|
|
'/project/' +
|
|
encodeURIComponent(record.providerPlanHandleId)
|
|
"
|
|
>
|
|
<font-awesome icon="hammer" class="text-blue-500" />
|
|
</router-link>
|
|
</span>
|
|
</div>
|
|
<div v-if="record.image" class="w-full">
|
|
<div
|
|
class="cursor-pointer"
|
|
@click="openImageViewer(record.image)"
|
|
>
|
|
<img
|
|
:src="record.image"
|
|
class="w-full aspect-[3/2] object-cover rounded-xl mt-2"
|
|
alt="shared content"
|
|
@load="cacheImageData($event, record.image)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</InfiniteScroll>
|
|
<div v-if="isFeedLoading">
|
|
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
|
<font-awesome icon="spinner" class="fa-spin-pulse" /> Loading…
|
|
</p>
|
|
</div>
|
|
<div v-if="!isFeedLoading && feedData.length === 0">
|
|
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
|
No claims match your filters.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<ChoiceButtonDialog ref="choiceButtonDialog" />
|
|
|
|
<ImageViewer
|
|
v-model:is-open="isImageViewerOpen"
|
|
:image-url="selectedImage"
|
|
:image-data="selectedImageData"
|
|
/>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { UAParser } from "ua-parser-js";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import { Router } from "vue-router";
|
|
|
|
//import App from "../App.vue";
|
|
import EntityIcon from "../components/EntityIcon.vue";
|
|
import GiftedDialog from "../components/GiftedDialog.vue";
|
|
import GiftedPrompts from "../components/GiftedPrompts.vue";
|
|
import FeedFilters from "../components/FeedFilters.vue";
|
|
import InfiniteScroll from "../components/InfiniteScroll.vue";
|
|
import OnboardingDialog from "../components/OnboardingDialog.vue";
|
|
import QuickNav from "../components/QuickNav.vue";
|
|
import TopMessage from "../components/TopMessage.vue";
|
|
import UserNameDialog from "../components/UserNameDialog.vue";
|
|
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
|
|
import ImageViewer from "../components/ImageViewer.vue";
|
|
import {
|
|
AppString,
|
|
NotificationIface,
|
|
PASSKEYS_ENABLED,
|
|
} from "../constants/app";
|
|
import {
|
|
db,
|
|
logConsoleAndDb,
|
|
retrieveSettingsForActiveAccount,
|
|
updateAccountSettings,
|
|
} from "../db/index";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import {
|
|
BoundingBox,
|
|
checkIsAnyFeedFilterOn,
|
|
MASTER_SETTINGS_KEY,
|
|
} from "../db/tables/settings";
|
|
import {
|
|
contactForDid,
|
|
containsNonHiddenDid,
|
|
didInfoForContact,
|
|
fetchEndorserRateLimits,
|
|
getHeaders,
|
|
getNewOffersToUser,
|
|
getNewOffersToUserProjects,
|
|
getPlanFromCache,
|
|
GiveSummaryRecord,
|
|
} from "../libs/endorserServer";
|
|
import {
|
|
generateSaveAndActivateIdentity,
|
|
retrieveAccountDids,
|
|
GiverReceiverInputInfo,
|
|
OnboardPage,
|
|
registerSaveAndActivatePasskey,
|
|
} from "../libs/util";
|
|
|
|
interface GiveRecordWithContactInfo extends GiveSummaryRecord {
|
|
jwtId: string;
|
|
giver: {
|
|
displayName: string;
|
|
known: boolean;
|
|
profileImageUrl?: string;
|
|
};
|
|
image?: string;
|
|
providerPlanName?: string;
|
|
recipientProjectName?: string;
|
|
receiver: {
|
|
displayName: string;
|
|
known: boolean;
|
|
profileImageUrl?: string;
|
|
};
|
|
}
|
|
|
|
@Component({
|
|
components: {
|
|
EntityIcon,
|
|
FeedFilters,
|
|
GiftedDialog,
|
|
GiftedPrompts,
|
|
InfiniteScroll,
|
|
OnboardingDialog,
|
|
ChoiceButtonDialog,
|
|
QuickNav,
|
|
TopMessage,
|
|
UserNameDialog,
|
|
ImageViewer,
|
|
},
|
|
})
|
|
export default class HomeView extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
$router!: Router;
|
|
|
|
AppString = AppString;
|
|
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
|
|
|
|
activeDid = "";
|
|
allContacts: Array<Contact> = [];
|
|
allMyDids: Array<string> = [];
|
|
apiServer = "";
|
|
feedData: GiveRecordWithContactInfo[] = [];
|
|
feedPreviousOldestId?: string;
|
|
feedLastViewedClaimId?: string;
|
|
givenName = "";
|
|
isAnyFeedFilterOn = false;
|
|
isCreatingIdentifier = false;
|
|
isFeedFilteredByVisible = false;
|
|
isFeedFilteredByNearby = false;
|
|
isFeedLoading = true;
|
|
isRegistered = false;
|
|
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
|
|
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
|
|
newOffersToUserHitLimit: boolean = false;
|
|
newOffersToUserProjectsHitLimit: boolean = false;
|
|
numNewOffersToUser: number = 0; // number of new offers-to-user
|
|
numNewOffersToUserProjects: number = 0; // number of new offers-to-user's-projects
|
|
searchBoxes: Array<{
|
|
name: string;
|
|
bbox: BoundingBox;
|
|
}> = [];
|
|
showShortcutBvc = false;
|
|
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
|
|
selectedImage = "";
|
|
selectedImageData: Blob | null = null;
|
|
isImageViewerOpen = false;
|
|
imageCache: Map<string, Blob | null> = new Map();
|
|
|
|
async mounted() {
|
|
try {
|
|
try {
|
|
this.allMyDids = await retrieveAccountDids();
|
|
if (this.allMyDids.length === 0) {
|
|
this.isCreatingIdentifier = true;
|
|
const newDid = await generateSaveAndActivateIdentity();
|
|
this.isCreatingIdentifier = false;
|
|
this.allMyDids = [newDid];
|
|
}
|
|
} catch (error) {
|
|
// continue because we want the feed to work, even anonymously
|
|
logConsoleAndDb(
|
|
"Error retrieving all account DIDs on home page:" + error,
|
|
true,
|
|
);
|
|
// some other piece will display an error about personal info
|
|
}
|
|
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
this.apiServer = settings.apiServer || "";
|
|
this.activeDid = settings.activeDid || "";
|
|
this.allContacts = await db.contacts.toArray();
|
|
this.feedLastViewedClaimId = settings.lastViewedClaimId;
|
|
this.givenName = settings.firstName || "";
|
|
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
|
|
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
|
this.isRegistered = !!settings.isRegistered;
|
|
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
|
|
this.lastAckedOfferToUserProjectsJwtId =
|
|
settings.lastAckedOfferToUserProjectsJwtId;
|
|
this.searchBoxes = settings.searchBoxes || [];
|
|
this.showShortcutBvc = !!settings.showShortcutBvc;
|
|
|
|
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
|
|
|
if (!settings.finishedOnboarding) {
|
|
(this.$refs.onboardingDialog as OnboardingDialog).open(
|
|
OnboardPage.Home,
|
|
);
|
|
}
|
|
|
|
// someone may have have registered after sharing contact info, so recheck
|
|
if (!this.isRegistered && this.activeDid) {
|
|
try {
|
|
const resp = await fetchEndorserRateLimits(
|
|
this.apiServer,
|
|
this.axios,
|
|
this.activeDid,
|
|
);
|
|
if (resp.status === 200) {
|
|
await updateAccountSettings(this.activeDid, {
|
|
isRegistered: true,
|
|
});
|
|
this.isRegistered = true;
|
|
}
|
|
} catch (e) {
|
|
// ignore the error... just keep us unregistered
|
|
}
|
|
}
|
|
|
|
// this returns a Promise but we don't need to wait for it
|
|
this.updateAllFeed();
|
|
|
|
if (this.activeDid) {
|
|
const offersToUserData = await getNewOffersToUser(
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
this.lastAckedOfferToUserJwtId,
|
|
);
|
|
this.numNewOffersToUser = offersToUserData.data.length;
|
|
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
|
|
}
|
|
|
|
if (this.activeDid) {
|
|
const offersToUserProjects = await getNewOffersToUserProjects(
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
this.lastAckedOfferToUserProjectsJwtId,
|
|
);
|
|
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
|
|
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text:
|
|
err.userMessage ||
|
|
"There was an error retrieving your settings or the latest activity.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
}
|
|
|
|
async generatePasskeyIdentifier() {
|
|
this.isCreatingIdentifier = true;
|
|
const account = await registerSaveAndActivatePasskey(
|
|
AppString.APP_NAME + (this.givenName ? " - " + this.givenName : ""),
|
|
);
|
|
this.activeDid = account.did;
|
|
this.allMyDids = this.allMyDids.concat(this.activeDid);
|
|
this.isCreatingIdentifier = false;
|
|
}
|
|
resultsAreFiltered() {
|
|
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
|
|
}
|
|
|
|
notificationsSupported() {
|
|
return "Notification" in window;
|
|
}
|
|
|
|
// only called when a setting was changed
|
|
async reloadFeedOnChange() {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
|
|
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
|
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
|
|
|
this.feedData = [];
|
|
this.feedPreviousOldestId = undefined;
|
|
await this.updateAllFeed();
|
|
}
|
|
|
|
/**
|
|
* Data loader used by infinite scroller
|
|
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
|
**/
|
|
async loadMoreGives(payload: boolean) {
|
|
// Since feed now loads projects along the way, it takes longer
|
|
// and the InfiniteScroll component triggers a load before finished.
|
|
// One alternative is to totally separate the project link loading.
|
|
if (payload && !this.isFeedLoading) {
|
|
await this.updateAllFeed();
|
|
}
|
|
}
|
|
|
|
latLongInAnySearchBox(lat: number, long: number) {
|
|
for (const boxInfo of this.searchBoxes) {
|
|
if (
|
|
boxInfo.bbox.westLong <= long &&
|
|
long <= boxInfo.bbox.eastLong &&
|
|
boxInfo.bbox.minLat <= lat &&
|
|
lat <= boxInfo.bbox.maxLat
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
async updateAllFeed() {
|
|
this.isFeedLoading = true;
|
|
let endOfResults = true;
|
|
await this.retrieveGives(this.apiServer, this.feedPreviousOldestId)
|
|
.then(async (results) => {
|
|
if (results.data.length > 0) {
|
|
endOfResults = false;
|
|
// include the descriptions of the giver and receiver
|
|
for (const record of results.data as GiveSummaryRecord[]) {
|
|
// similar code is in endorser-mobile utility.ts
|
|
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const claim = (record.fullClaim as any).claim || record.fullClaim;
|
|
// agent.did is for legacy data, before March 2023
|
|
const giverDid =
|
|
claim.agent?.identifier || (claim.agent as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
// recipient.did is for legacy data, before March 2023
|
|
const recipientDid =
|
|
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
|
|
// This has indeed proven problematic. See loadMoreGives
|
|
// We should display it immediately and then get the plan later.
|
|
const fulfillsPlan = await getPlanFromCache(
|
|
record.fulfillsPlanHandleId,
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
);
|
|
|
|
// check if the record should be filtered out
|
|
let anyMatch = false;
|
|
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) {
|
|
// has a visible DID so it's a keeper
|
|
anyMatch = true;
|
|
}
|
|
if (!anyMatch && this.isFeedFilteredByNearby) {
|
|
// check if the associated project has a location inside user's search box
|
|
if (record.fulfillsPlanHandleId) {
|
|
if (fulfillsPlan?.locLat && fulfillsPlan?.locLon) {
|
|
if (
|
|
this.latLongInAnySearchBox(
|
|
fulfillsPlan.locLat,
|
|
fulfillsPlan.locLon,
|
|
)
|
|
) {
|
|
anyMatch = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (this.isAnyFeedFilterOn && !anyMatch) {
|
|
continue;
|
|
}
|
|
|
|
// checking for arrays due to legacy data
|
|
const provider = Array.isArray(claim.provider)
|
|
? claim.provider[0]
|
|
: claim.provider;
|
|
const providedByPlan = await getPlanFromCache(
|
|
provider?.identifier as string,
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
);
|
|
|
|
const newRecord: GiveRecordWithContactInfo = {
|
|
...record,
|
|
jwtId: record.jwtId,
|
|
giver: didInfoForContact(
|
|
giverDid,
|
|
this.activeDid,
|
|
contactForDid(giverDid, this.allContacts),
|
|
this.allMyDids,
|
|
),
|
|
image: claim.image,
|
|
providerPlanHandleId: provider?.identifier as string,
|
|
providerPlanName: providedByPlan?.name as string,
|
|
recipientProjectName: fulfillsPlan?.name as string,
|
|
receiver: didInfoForContact(
|
|
recipientDid,
|
|
this.activeDid,
|
|
contactForDid(recipientDid, this.allContacts),
|
|
this.allMyDids,
|
|
),
|
|
};
|
|
this.feedData.push(newRecord);
|
|
}
|
|
this.feedPreviousOldestId =
|
|
results.data[results.data.length - 1].jwtId;
|
|
// The following update is only done on the first load.
|
|
if (
|
|
this.feedLastViewedClaimId == null ||
|
|
this.feedLastViewedClaimId < results.data[0].jwtId
|
|
) {
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
lastViewedClaimId: results.data[0].jwtId,
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
console.error("Error with feed load:", e);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Feed Error",
|
|
text: e.userMessage || "There was an error retrieving feed data.",
|
|
},
|
|
-1,
|
|
);
|
|
});
|
|
if (this.feedData.length === 0 && !endOfResults) {
|
|
// repeat until there's at least some data
|
|
await this.updateAllFeed();
|
|
}
|
|
this.isFeedLoading = false;
|
|
}
|
|
|
|
/**
|
|
* Retrieve claims in reverse chronological order
|
|
*
|
|
* @param beforeId the earliest ID (of previous searches) to search earlier
|
|
* @return claims in reverse chronological order
|
|
*/
|
|
async retrieveGives(endorserApiServer: string, beforeId?: string) {
|
|
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
|
const doNotShowErrorAgain = !!beforeId; // don't show error again if we're loading more
|
|
const headers = await getHeaders(
|
|
this.activeDid,
|
|
doNotShowErrorAgain ? undefined : this.$notify,
|
|
);
|
|
// retrieve headers for this user, but if an error happens then report it but proceed with the fetch with no header
|
|
const response = await fetch(
|
|
endorserApiServer +
|
|
"/api/v2/report/gives?giftNotTrade=true" +
|
|
beforeQuery,
|
|
{
|
|
method: "GET",
|
|
headers: headers,
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw await response.text();
|
|
}
|
|
|
|
const results = await response.json();
|
|
|
|
if (results.data) {
|
|
return results;
|
|
} else {
|
|
throw JSON.stringify(results);
|
|
}
|
|
}
|
|
|
|
giveDescription(giveRecord: GiveRecordWithContactInfo) {
|
|
// similar code is in endorser-mobile utility.ts
|
|
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const claim = (giveRecord.fullClaim as any).claim || giveRecord.fullClaim;
|
|
|
|
let gaveAmount = claim.object?.amountOfThisGood
|
|
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
|
: "";
|
|
if (claim.description) {
|
|
if (gaveAmount) {
|
|
gaveAmount = " (and " + gaveAmount + ")";
|
|
}
|
|
gaveAmount = claim.description + gaveAmount;
|
|
}
|
|
if (!gaveAmount) {
|
|
gaveAmount = "something not described";
|
|
}
|
|
|
|
/**
|
|
* Only show giver and/or receiver info first if they're named in your contacts.
|
|
* - If only giver is named, show "... gave"
|
|
* - If only receiver is named, show "... received"
|
|
*/
|
|
|
|
const giverInfo = giveRecord.giver;
|
|
const recipientInfo = giveRecord.receiver;
|
|
|
|
// any specific names should be shown first
|
|
if (giverInfo.known && recipientInfo.known) {
|
|
// both giver and recipient are named
|
|
return `${giverInfo.displayName} gave to ${recipientInfo.displayName}: ${gaveAmount}`;
|
|
} else if (giverInfo.known) {
|
|
// giver is known but recipient is not
|
|
|
|
// show the project name if to one
|
|
if (giveRecord.recipientProjectName) {
|
|
return `${giverInfo.displayName} gave: ${gaveAmount} (to the project "${giveRecord.recipientProjectName}")`;
|
|
} else {
|
|
// it's not to a project
|
|
return `${giverInfo.displayName} gave: ${gaveAmount} (to ${recipientInfo.displayName})`;
|
|
}
|
|
} else if (recipientInfo.known) {
|
|
// recipient is known but giver is not
|
|
|
|
// show the project name if from one
|
|
if (giveRecord.providerPlanName) {
|
|
return `${recipientInfo.displayName} received: ${gaveAmount} (from the project "${giveRecord.providerPlanName}")`;
|
|
} else {
|
|
// it's not from a project
|
|
return `${recipientInfo.displayName} received: ${gaveAmount} (from ${giverInfo.displayName})`;
|
|
}
|
|
} else {
|
|
// neither giver nor recipient are named
|
|
|
|
// create the part in parens
|
|
let peopleInfo = "";
|
|
if (giveRecord.providerPlanName || giveRecord.recipientProjectName) {
|
|
if (giveRecord.providerPlanName) {
|
|
peopleInfo = `from the project "${giveRecord.providerPlanName}"`;
|
|
} else {
|
|
peopleInfo = `from ${giverInfo.displayName}`;
|
|
}
|
|
if (giveRecord.recipientProjectName) {
|
|
peopleInfo += ` to the project "${giveRecord.recipientProjectName}"`;
|
|
} else {
|
|
peopleInfo += ` to ${recipientInfo.displayName}`;
|
|
}
|
|
} else {
|
|
if (giverInfo.displayName === recipientInfo.displayName) {
|
|
peopleInfo = `between two who are ${giverInfo.displayName}`;
|
|
} else {
|
|
peopleInfo = `from ${giverInfo.displayName} to ${recipientInfo.displayName}`;
|
|
}
|
|
}
|
|
|
|
return gaveAmount + " (" + peopleInfo + ")";
|
|
}
|
|
}
|
|
|
|
goToActivityToUserPage() {
|
|
this.$router.push({ name: "new-activity" });
|
|
}
|
|
|
|
onClickLoadClaim(jwtId: string) {
|
|
const route = {
|
|
path: "/claim/" + encodeURIComponent(jwtId),
|
|
};
|
|
this.$router.push(route);
|
|
}
|
|
|
|
displayAmount(code: string, amt: number) {
|
|
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1);
|
|
}
|
|
|
|
currencyShortWordForCode(unitCode: string, single: boolean) {
|
|
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
|
}
|
|
|
|
openDialog(giver?: GiverReceiverInputInfo, description?: string) {
|
|
(this.$refs.customDialog as GiftedDialog).open(
|
|
giver,
|
|
{
|
|
did: this.activeDid,
|
|
name: "you",
|
|
} as GiverReceiverInputInfo,
|
|
undefined,
|
|
"Given by " + (giver?.name || "someone not named"),
|
|
description,
|
|
);
|
|
}
|
|
|
|
openGiftedPrompts() {
|
|
(this.$refs.giftedPrompts as GiftedPrompts).open((giver, description) =>
|
|
this.openDialog(giver as GiverReceiverInputInfo, description),
|
|
);
|
|
}
|
|
|
|
openFeedFilters() {
|
|
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
|
|
}
|
|
|
|
toastUser(message: string) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "toast",
|
|
title: "FYI",
|
|
text: message,
|
|
},
|
|
2000,
|
|
);
|
|
}
|
|
|
|
computeKnownPersonIconStyleClassNames(known: boolean) {
|
|
return known ? "text-slate-500" : "text-slate-100";
|
|
}
|
|
|
|
showNameThenIdDialog() {
|
|
if (!this.givenName) {
|
|
(this.$refs.userNameDialog as UserNameDialog).open(() => {
|
|
this.promptForShareMethod();
|
|
});
|
|
} else {
|
|
this.promptForShareMethod();
|
|
}
|
|
}
|
|
|
|
promptForShareMethod() {
|
|
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
|
|
title: "How can you share your info?",
|
|
text: "",
|
|
option1Text: "We are in a meeting together",
|
|
option2Text: "We are nearby with cameras",
|
|
option3Text: "We will share some other way",
|
|
onOption1: () => {
|
|
this.$router.push({ name: "onboard-meeting-list" });
|
|
},
|
|
onOption2: () => {
|
|
this.$router.push({ name: "contact-qr" });
|
|
},
|
|
onOption3: () => {
|
|
this.$router.push({ name: "share-my-contact-info" });
|
|
},
|
|
});
|
|
}
|
|
|
|
async cacheImageData(event: Event, imageUrl: string) {
|
|
try {
|
|
// For images that might fail CORS, just store the URL
|
|
// The Web Share API will handle sharing the URL appropriately
|
|
this.imageCache.set(imageUrl, null);
|
|
} catch (error) {
|
|
console.warn("Failed to cache image:", error);
|
|
}
|
|
}
|
|
|
|
async openImageViewer(imageUrl: string) {
|
|
this.selectedImageData = this.imageCache.get(imageUrl) ?? null;
|
|
this.selectedImage = imageUrl;
|
|
this.isImageViewerOpen = true;
|
|
}
|
|
}
|
|
</script>
|