You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1730 lines
49 KiB
1730 lines
49 KiB
/** * @file HomeView.vue * @description Main view component for the
|
|
application's home page. Handles user identity, feed management, * and
|
|
interaction with various dialogs and components. Implements infinite scrolling
|
|
for activity feed * and manages user registration status. * * @author Matthew
|
|
Raymer * @version 1.0.0 */
|
|
|
|
<template>
|
|
<QuickNav selected="Home" />
|
|
<TopMessage />
|
|
|
|
<!-- CONTENT -->
|
|
<section id="Content" class="p-6 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="mt-4 mb-4">
|
|
<div class="flex items-center mb-4">
|
|
<h2 class="text-xl font-bold flex items-center gap-4">
|
|
Latest Activity
|
|
<button
|
|
v-if="resultsAreFiltered()"
|
|
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
|
|
@click="openFeedFilters()"
|
|
>
|
|
<font-awesome icon="filter" class="fa-fw" />
|
|
</button>
|
|
<button
|
|
v-else
|
|
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
|
|
@click="openFeedFilters()"
|
|
>
|
|
<font-awesome icon="filter" class="fa-fw" />
|
|
</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="space-y-4">
|
|
<ActivityListItem
|
|
v-for="record in feedData"
|
|
:key="record.jwtId"
|
|
:record="record"
|
|
:last-viewed-claim-id="feedLastViewedClaimId"
|
|
:is-registered="isRegistered"
|
|
:active-did="activeDid"
|
|
:confirmer-id-list="record.confirmerIdList"
|
|
@load-claim="onClickLoadClaim"
|
|
@view-image="openImageViewer"
|
|
@cache-image="cacheImageData"
|
|
@confirm-claim="confirmClaim"
|
|
/>
|
|
</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 ActivityListItem from "../components/ActivityListItem.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,
|
|
} from "../libs/endorserServer";
|
|
import {
|
|
generateSaveAndActivateIdentity,
|
|
retrieveAccountDids,
|
|
GiverReceiverInputInfo,
|
|
OnboardPage,
|
|
} from "../libs/util";
|
|
import { GiveSummaryRecord } from "../interfaces";
|
|
import * as serverUtil from "../libs/endorserServer";
|
|
import { logger } from "../utils/logger";
|
|
import { GiveRecordWithContactInfo } from "types";
|
|
|
|
interface Claim {
|
|
claim?: Claim; // For nested claims in Verifiable Credentials
|
|
agent?: {
|
|
identifier?: string;
|
|
did?: string;
|
|
};
|
|
recipient?: {
|
|
identifier?: string;
|
|
did?: string;
|
|
};
|
|
provider?:
|
|
| {
|
|
identifier?: string;
|
|
}
|
|
| Array<{ identifier?: string }>;
|
|
object?: {
|
|
amountOfThisGood?: number;
|
|
unitCode?: string;
|
|
};
|
|
description?: string;
|
|
image?: string;
|
|
}
|
|
|
|
interface FulfillsPlan {
|
|
locLat?: number;
|
|
locLon?: number;
|
|
name?: string;
|
|
}
|
|
|
|
interface Provider {
|
|
identifier?: string;
|
|
}
|
|
|
|
interface ProvidedByPlan {
|
|
name?: string;
|
|
}
|
|
|
|
interface FeedError {
|
|
userMessage?: string;
|
|
}
|
|
|
|
/**
|
|
* HomeView Component
|
|
*
|
|
* Main view component that handles:
|
|
* 1. User identity and registration management
|
|
* 2. Activity feed with infinite scrolling
|
|
* 3. Contact management and display
|
|
* 4. Gift/claim creation and viewing
|
|
* 5. Feed filtering and settings
|
|
*
|
|
* Template Usage:
|
|
* ```vue
|
|
* <HomeView>
|
|
* <!-- Content is managed internally -->
|
|
* </HomeView>
|
|
* ```
|
|
*
|
|
* Component Dependencies:
|
|
* - QuickNav: Navigation component
|
|
* - TopMessage: Message display component
|
|
* - OnboardingDialog: User onboarding flow
|
|
* - GiftedDialog: Gift creation interface
|
|
* - FeedFilters: Feed filtering options
|
|
* - InfiniteScroll: Infinite scrolling functionality
|
|
* - ActivityListItem: Individual activity display
|
|
*/
|
|
@Component({
|
|
components: {
|
|
EntityIcon,
|
|
FeedFilters,
|
|
GiftedDialog,
|
|
GiftedPrompts,
|
|
InfiniteScroll,
|
|
OnboardingDialog,
|
|
ChoiceButtonDialog,
|
|
QuickNav,
|
|
TopMessage,
|
|
UserNameDialog,
|
|
ImageViewer,
|
|
ActivityListItem,
|
|
},
|
|
})
|
|
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();
|
|
|
|
/**
|
|
* Initializes the component on mount
|
|
* Sequence:
|
|
* 1. Initialize identity (create if needed)
|
|
* 2. Load user settings
|
|
* 3. Load contacts
|
|
* 4. Check registration status
|
|
* 5. Load feed data
|
|
* 6. Load new offers
|
|
* 7. Check onboarding status
|
|
*
|
|
* @internal
|
|
* Called automatically by Vue lifecycle system
|
|
*/
|
|
async mounted() {
|
|
try {
|
|
await this.initializeIdentity();
|
|
await this.loadSettings();
|
|
await this.loadContacts();
|
|
await this.checkRegistrationStatus();
|
|
await this.loadFeedData();
|
|
await this.loadNewOffers();
|
|
await this.checkOnboarding();
|
|
} catch (err: unknown) {
|
|
this.handleError(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes user identity
|
|
* - Retrieves existing DIDs
|
|
* - Creates new DID if none exists
|
|
* - Loads user settings and contacts
|
|
* - Checks registration status
|
|
*
|
|
* @internal
|
|
* Called by mounted()
|
|
* @throws Logs error if DID retrieval fails
|
|
*/
|
|
private async initializeIdentity() {
|
|
try {
|
|
this.allMyDids = await retrieveAccountDids();
|
|
if (this.allMyDids.length === 0) {
|
|
this.isCreatingIdentifier = true;
|
|
const newDid = await generateSaveAndActivateIdentity();
|
|
this.isCreatingIdentifier = false;
|
|
this.allMyDids = [newDid];
|
|
}
|
|
|
|
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,
|
|
...(await retrieveSettingsForActiveAccount()),
|
|
});
|
|
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 as { userMessage?: string })?.userMessage ||
|
|
"There was an error retrieving your settings or the latest activity.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads user settings from storage
|
|
* Sets component state for:
|
|
* - API server
|
|
* - Active DID
|
|
* - Feed filters and view settings
|
|
* - Registration status
|
|
* - Notification acknowledgments
|
|
*
|
|
* @internal
|
|
* Called by mounted() and reloadFeedOnChange()
|
|
*/
|
|
private async loadSettings() {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
this.apiServer = settings.apiServer || "";
|
|
this.activeDid = settings.activeDid || "";
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Loads user contacts from database
|
|
* Used for displaying contact info in feed and actions
|
|
*
|
|
* @internal
|
|
* Called by mounted() and initializeIdentity()
|
|
*/
|
|
private async loadContacts() {
|
|
this.allContacts = await db.contacts.toArray();
|
|
}
|
|
|
|
/**
|
|
* Verifies user registration status with endorser service
|
|
* - Checks if unregistered user can access API
|
|
* - Updates registration status if successful
|
|
* - Preserves unregistered state on failure
|
|
*
|
|
* @internal
|
|
* Called by mounted() and initializeIdentity()
|
|
*/
|
|
private async checkRegistrationStatus() {
|
|
if (!this.isRegistered && this.activeDid) {
|
|
try {
|
|
const resp = await fetchEndorserRateLimits(
|
|
this.apiServer,
|
|
this.axios,
|
|
this.activeDid,
|
|
);
|
|
if (resp.status === 200) {
|
|
await updateAccountSettings(this.activeDid, {
|
|
apiServer: this.apiServer,
|
|
isRegistered: true,
|
|
...(await retrieveSettingsForActiveAccount()),
|
|
});
|
|
this.isRegistered = true;
|
|
}
|
|
} catch (e) {
|
|
// ignore the error... just keep us unregistered
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes feed data
|
|
* Triggers updateAllFeed() to populate activity feed
|
|
*
|
|
* @internal
|
|
* Called by mounted()
|
|
*/
|
|
private async loadFeedData() {
|
|
await this.updateAllFeed();
|
|
}
|
|
|
|
/**
|
|
* Loads new offers for user and their projects
|
|
* Updates:
|
|
* - Number of new direct offers
|
|
* - Number of new project offers
|
|
* - Rate limit status for both
|
|
*
|
|
* @internal
|
|
* Called by mounted() and initializeIdentity()
|
|
* @requires Active DID
|
|
*/
|
|
private async loadNewOffers() {
|
|
if (this.activeDid) {
|
|
const offersToUserData = await getNewOffersToUser(
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
this.lastAckedOfferToUserJwtId,
|
|
);
|
|
this.numNewOffersToUser = offersToUserData.data.length;
|
|
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
|
|
|
|
const offersToUserProjects = await getNewOffersToUserProjects(
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
this.lastAckedOfferToUserProjectsJwtId,
|
|
);
|
|
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
|
|
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if user needs onboarding
|
|
* Opens onboarding dialog if not completed
|
|
*
|
|
* @internal
|
|
* Called by mounted()
|
|
*/
|
|
private async checkOnboarding() {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
if (!settings.finishedOnboarding) {
|
|
(this.$refs.onboardingDialog as OnboardingDialog).open(OnboardPage.Home);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles errors during initialization
|
|
* - Logs error to console and database
|
|
* - Displays user notification
|
|
*
|
|
* @internal
|
|
* Called by mounted() and handleFeedError()
|
|
* @param err Error object with optional userMessage
|
|
*/
|
|
private handleError(err: unknown) {
|
|
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text:
|
|
(err as { userMessage?: string })?.userMessage ||
|
|
"There was an error retrieving your settings or the latest activity.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks if feed results are being filtered
|
|
*
|
|
* @public
|
|
* Used in template for filter button display
|
|
* @returns true if visible or nearby filters are active
|
|
*/
|
|
resultsAreFiltered() {
|
|
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
|
|
}
|
|
|
|
/**
|
|
* Checks if browser notifications are supported
|
|
*
|
|
* @public
|
|
* Used in template for notification feature detection
|
|
* @returns true if Notification API is available
|
|
*/
|
|
notificationsSupported() {
|
|
return "Notification" in window;
|
|
}
|
|
|
|
/**
|
|
* Reloads feed when filter settings change
|
|
* - Updates filter states
|
|
* - Clears existing feed data
|
|
* - Triggers new feed load
|
|
*
|
|
* @public
|
|
* Called by FeedFilters component when filters change
|
|
*/
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* Loads more feed items for infinite scroll
|
|
*
|
|
* @public
|
|
* Called by InfiniteScroll component when bottom is reached
|
|
* @param payload Boolean indicating if more items should be loaded
|
|
*/
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if coordinates fall within any search box
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: shouldIncludeRecord()
|
|
* Calls: None
|
|
*
|
|
* @chain
|
|
* shouldIncludeRecord() -> latLongInAnySearchBox()
|
|
*
|
|
* @requires
|
|
* - this.searchBoxes
|
|
*
|
|
* @param lat Latitude to check
|
|
* @param long Longitude to check
|
|
* @returns true if coordinates are within any search box
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates feed with latest activity
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by:
|
|
* - loadMoreGives()
|
|
* - initializeIdentity()
|
|
* Calls:
|
|
* - retrieveGives()
|
|
* - processFeedResults()
|
|
* - updateFeedLastViewedId()
|
|
* - handleFeedError()
|
|
*
|
|
* @chain
|
|
* loadMoreGives() -> updateAllFeed()
|
|
* initializeIdentity() -> updateAllFeed()
|
|
*
|
|
* @requires
|
|
* - this.apiServer
|
|
* - this.activeDid
|
|
* - this.feedPreviousOldestId
|
|
*
|
|
* @modifies
|
|
* - this.isFeedLoading
|
|
* - this.feedData (via processFeedResults)
|
|
* - this.feedLastViewedClaimId (via updateFeedLastViewedId)
|
|
*/
|
|
async updateAllFeed() {
|
|
this.isFeedLoading = true;
|
|
let endOfResults = true;
|
|
|
|
try {
|
|
const results = await this.retrieveGives(
|
|
this.apiServer,
|
|
this.feedPreviousOldestId,
|
|
);
|
|
if (results.data.length > 0) {
|
|
endOfResults = false;
|
|
await this.processFeedResults(results.data);
|
|
await this.updateFeedLastViewedId(results.data);
|
|
}
|
|
} catch (e) {
|
|
this.handleFeedError(e);
|
|
}
|
|
|
|
if (this.feedData.length === 0 && !endOfResults) {
|
|
await this.updateAllFeed();
|
|
}
|
|
|
|
this.isFeedLoading = false;
|
|
}
|
|
|
|
/**
|
|
* Processes feed results and adds them to feedData
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: updateAllFeed()
|
|
* Calls: processRecord()
|
|
*
|
|
* @chain
|
|
* updateAllFeed() -> processFeedResults()
|
|
*
|
|
* @requires
|
|
* - this.feedData
|
|
* - this.feedPreviousOldestId
|
|
*
|
|
* @modifies
|
|
* - this.feedData
|
|
* - this.feedPreviousOldestId
|
|
*
|
|
* @param records Array of feed records to process
|
|
*/
|
|
private async processFeedResults(records: GiveSummaryRecord[]) {
|
|
for (const record of records) {
|
|
const processedRecord = await this.processRecord(record);
|
|
if (processedRecord) {
|
|
this.feedData.push(processedRecord);
|
|
}
|
|
}
|
|
this.feedPreviousOldestId = records[records.length - 1].jwtId;
|
|
}
|
|
|
|
/**
|
|
* Processes a single record and returns it if it passes filters
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: processFeedResults()
|
|
* Calls:
|
|
* - extractClaim()
|
|
* - extractGiverDid()
|
|
* - extractRecipientDid()
|
|
* - getFulfillsPlan()
|
|
* - shouldIncludeRecord()
|
|
* - extractProvider()
|
|
* - getProvidedByPlan()
|
|
* - createFeedRecord()
|
|
*
|
|
* @chain
|
|
* updateAllFeed() -> processFeedResults() -> processRecord()
|
|
*
|
|
* @requires
|
|
* - this.isAnyFeedFilterOn
|
|
* - this.isFeedFilteredByVisible
|
|
* - this.isFeedFilteredByNearby
|
|
* - this.activeDid
|
|
* - this.allContacts
|
|
*
|
|
* @modifies
|
|
* - this.feedData (via createFeedRecord)
|
|
*
|
|
* @param record The record to process
|
|
* @returns Processed record with contact info if it passes filters, null otherwise
|
|
*/
|
|
private async processRecord(
|
|
record: GiveSummaryRecord,
|
|
): Promise<GiveRecordWithContactInfo | null> {
|
|
const claim = this.extractClaim(record);
|
|
const giverDid = this.extractGiverDid(claim);
|
|
const recipientDid = this.extractRecipientDid(claim);
|
|
|
|
const fulfillsPlan = await this.getFulfillsPlan(record);
|
|
if (!this.shouldIncludeRecord(record, fulfillsPlan)) {
|
|
return null;
|
|
}
|
|
|
|
const provider = this.extractProvider(claim);
|
|
const providedByPlan = await this.getProvidedByPlan(provider);
|
|
|
|
return this.createFeedRecord(
|
|
record,
|
|
claim,
|
|
giverDid,
|
|
recipientDid,
|
|
provider,
|
|
fulfillsPlan,
|
|
providedByPlan,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extracts claim from record, handling both direct and wrapped claims
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: processRecord()
|
|
* Calls: None
|
|
*
|
|
* @chain
|
|
* processRecord() -> extractClaim()
|
|
*
|
|
* @requires
|
|
* - record.fullClaim
|
|
*
|
|
* @param record The record containing the claim
|
|
* @returns The extracted claim object
|
|
*/
|
|
private extractClaim(record: GiveSummaryRecord) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return (record.fullClaim as any).claim || record.fullClaim;
|
|
}
|
|
|
|
/**
|
|
* Extracts giver DID from claim
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: processRecord()
|
|
* Calls: None
|
|
*
|
|
* @chain
|
|
* processRecord() -> extractGiverDid()
|
|
*
|
|
* @requires
|
|
* - claim.agent
|
|
*
|
|
* @param claim The claim object containing giver information
|
|
* @returns The giver's DID
|
|
*/
|
|
private extractGiverDid(claim: Claim) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return claim.agent?.identifier || (claim.agent as any)?.did;
|
|
}
|
|
|
|
/**
|
|
* Extracts recipient DID from claim
|
|
*
|
|
* @internal
|
|
* Called by processRecord()
|
|
*/
|
|
private extractRecipientDid(claim: Claim) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return claim.recipient?.identifier || (claim.recipient as any)?.did;
|
|
}
|
|
|
|
/**
|
|
* Gets fulfills plan from cache
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: processRecord()
|
|
* Calls: getPlanFromCache()
|
|
*
|
|
* @chain
|
|
* processRecord() -> getFulfillsPlan()
|
|
*
|
|
* @requires
|
|
* - this.axios
|
|
* - this.apiServer
|
|
* - this.activeDid
|
|
*
|
|
* @param record The record containing the plan handle ID
|
|
* @returns The fulfills plan object
|
|
*/
|
|
private async getFulfillsPlan(record: GiveSummaryRecord) {
|
|
return await getPlanFromCache(
|
|
record.fulfillsPlanHandleId,
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks if record should be included based on filters
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: processRecord()
|
|
* Calls:
|
|
* - containsNonHiddenDid()
|
|
* - latLongInAnySearchBox()
|
|
*
|
|
* @chain
|
|
* processRecord() -> shouldIncludeRecord()
|
|
*
|
|
* @requires
|
|
* - this.isAnyFeedFilterOn
|
|
* - this.isFeedFilteredByVisible
|
|
* - this.isFeedFilteredByNearby
|
|
* - this.searchBoxes
|
|
*
|
|
* @param record The record to check
|
|
* @param fulfillsPlan The fulfills plan object
|
|
* @returns true if record should be included based on filters
|
|
*/
|
|
private shouldIncludeRecord(
|
|
record: GiveSummaryRecord,
|
|
fulfillsPlan?: FulfillsPlan,
|
|
): boolean {
|
|
if (!this.isAnyFeedFilterOn) {
|
|
return true;
|
|
}
|
|
|
|
let anyMatch = false;
|
|
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) {
|
|
anyMatch = true;
|
|
}
|
|
|
|
if (
|
|
!anyMatch &&
|
|
this.isFeedFilteredByNearby &&
|
|
record.fulfillsPlanHandleId
|
|
) {
|
|
if (fulfillsPlan?.locLat && fulfillsPlan?.locLon) {
|
|
anyMatch =
|
|
this.latLongInAnySearchBox(
|
|
fulfillsPlan.locLat,
|
|
fulfillsPlan.locLon,
|
|
) ?? false;
|
|
}
|
|
}
|
|
|
|
return anyMatch;
|
|
}
|
|
|
|
/**
|
|
* Extracts provider from claim
|
|
*
|
|
* @internal
|
|
* Called by processRecord()
|
|
*/
|
|
private extractProvider(claim: Claim): Provider | undefined {
|
|
return Array.isArray(claim.provider) ? claim.provider[0] : claim.provider;
|
|
}
|
|
|
|
/**
|
|
* Gets provided by plan from cache
|
|
*
|
|
* @internal
|
|
* Called by processRecord()
|
|
*/
|
|
private async getProvidedByPlan(provider: Provider | undefined) {
|
|
return await getPlanFromCache(
|
|
provider?.identifier as string,
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates a feed record with contact info
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: processRecord()
|
|
* Calls:
|
|
* - didInfoForContact()
|
|
* - contactForDid()
|
|
*
|
|
* @chain
|
|
* processRecord() -> createFeedRecord()
|
|
*
|
|
* @requires
|
|
* - this.activeDid
|
|
* - this.allContacts
|
|
* - this.allMyDids
|
|
*
|
|
* @param record The base record
|
|
* @param claim The claim object
|
|
* @param giverDid The giver's DID
|
|
* @param recipientDid The recipient's DID
|
|
* @param provider The provider object
|
|
* @param fulfillsPlan The fulfills plan object
|
|
* @param providedByPlan The provided by plan object
|
|
* @returns A feed record with contact information
|
|
*/
|
|
private createFeedRecord(
|
|
record: GiveSummaryRecord,
|
|
claim: Claim,
|
|
giverDid: string,
|
|
recipientDid: string,
|
|
provider: Provider | undefined,
|
|
fulfillsPlan?: FulfillsPlan,
|
|
providedByPlan?: ProvidedByPlan,
|
|
): GiveRecordWithContactInfo {
|
|
return {
|
|
...record,
|
|
jwtId: record.jwtId,
|
|
fullClaim: record.fullClaim,
|
|
description: record.description || "",
|
|
handleId: record.handleId,
|
|
issuerDid: record.issuerDid,
|
|
fulfillsPlanHandleId: record.fulfillsPlanHandleId,
|
|
giver: didInfoForContact(
|
|
giverDid,
|
|
this.activeDid,
|
|
contactForDid(giverDid, this.allContacts),
|
|
this.allMyDids,
|
|
),
|
|
image: claim.image,
|
|
issuer: didInfoForContact(
|
|
record.issuerDid,
|
|
this.activeDid,
|
|
contactForDid(record.issuerDid, this.allContacts),
|
|
this.allMyDids,
|
|
),
|
|
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,
|
|
),
|
|
} as GiveRecordWithContactInfo;
|
|
}
|
|
|
|
/**
|
|
* Updates the last viewed claim ID in settings
|
|
*
|
|
* @internal
|
|
* Called by updateAllFeed()
|
|
*/
|
|
private async updateFeedLastViewedId(records: GiveSummaryRecord[]) {
|
|
if (
|
|
this.feedLastViewedClaimId == null ||
|
|
this.feedLastViewedClaimId < records[0].jwtId
|
|
) {
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
lastViewedClaimId: records[0].jwtId,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles feed error and shows notification
|
|
*
|
|
* @internal
|
|
* Called by updateAllFeed()
|
|
*/
|
|
private handleFeedError(e: unknown) {
|
|
logger.error("Error with feed load:", e);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Feed Error",
|
|
text:
|
|
(e as FeedError)?.userMessage ||
|
|
"There was an error retrieving feed data.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Retrieve claims in reverse chronological order
|
|
*
|
|
* @internal
|
|
* Called by updateAllFeed()
|
|
* @param endorserApiServer API server URL
|
|
* @param beforeId OptioCalled by updateAllFeed()nal ID to fetch earlier results
|
|
* @returns 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formats gift description with giver and recipient info
|
|
*
|
|
* @public
|
|
* @callGraph
|
|
* Called by: Template
|
|
* Calls: displayAmount()
|
|
*
|
|
* @chain
|
|
* Template -> giveDescription() -> displayAmount() -> currencyShortWordForCode()
|
|
*
|
|
* @requires
|
|
* - giveRecord.fullClaim
|
|
* - giveRecord.giver
|
|
* - giveRecord.receiver
|
|
* - giveRecord.recipientProjectName
|
|
* - giveRecord.providerPlanName
|
|
*
|
|
* @param giveRecord Record containing gift information
|
|
* @returns formatted description string
|
|
*/
|
|
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 + ")";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Navigates to activity page
|
|
*
|
|
* @public
|
|
* Called by template click handler
|
|
*/
|
|
goToActivityToUserPage() {
|
|
this.$router.push({ name: "new-activity" });
|
|
}
|
|
|
|
/**
|
|
* Navigates to claim details page
|
|
*
|
|
* @public
|
|
* Called by ActivityListItem component
|
|
* @param jwtId ID of the claim to view
|
|
*/
|
|
onClickLoadClaim(jwtId: string) {
|
|
const route = {
|
|
path: "/claim/" + encodeURIComponent(jwtId),
|
|
};
|
|
this.$router.push(route);
|
|
}
|
|
|
|
/**
|
|
* Formats amount with currency code
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: giveDescription()
|
|
* Calls: currencyShortWordForCode()
|
|
*
|
|
* @chain
|
|
* giveDescription() -> displayAmount() -> currencyShortWordForCode()
|
|
*
|
|
* @requires
|
|
* - code: string (currency code)
|
|
* - amt: number (amount to format)
|
|
*
|
|
* @param code Currency code
|
|
* @param amt Amount to format
|
|
* @returns formatted amount string
|
|
*/
|
|
displayAmount(code: string, amt: number) {
|
|
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1);
|
|
}
|
|
|
|
/**
|
|
* Gets currency word based on code and plurality
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: displayAmount()
|
|
* Calls: None
|
|
*
|
|
* @chain
|
|
* giveDescription() -> displayAmount() -> currencyShortWordForCode()
|
|
*
|
|
* @requires
|
|
* - unitCode: string (currency code)
|
|
* - single: boolean (whether to use singular form)
|
|
*
|
|
* @param unitCode Currency code
|
|
* @param single Whether to use singular form
|
|
* @returns formatted currency word
|
|
*/
|
|
currencyShortWordForCode(unitCode: string, single: boolean) {
|
|
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
|
}
|
|
|
|
/**
|
|
* Opens dialog for creating new gift/claim
|
|
*
|
|
* @public
|
|
* @callGraph
|
|
* Called by:
|
|
* - Template
|
|
* - openGiftedPrompts()
|
|
* Calls: GiftedDialog.open()
|
|
*
|
|
* @chain
|
|
* Template -> openDialog()
|
|
* openGiftedPrompts() -> openDialog()
|
|
*
|
|
* @requires
|
|
* - this.$refs.customDialog
|
|
* - this.activeDid
|
|
*
|
|
* @param giver Optional contact info for giver
|
|
* @param description Optional gift description
|
|
*/
|
|
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,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Opens prompts for gift ideas
|
|
*
|
|
* @public
|
|
* @callGraph
|
|
* Called by: Template
|
|
* Calls: openDialog()
|
|
*
|
|
* @chain
|
|
* Template -> openGiftedPrompts() -> openDialog()
|
|
*
|
|
* @requires
|
|
* - this.$refs.giftedPrompts
|
|
*
|
|
* @param callback Function to handle selected gift info
|
|
*/
|
|
openGiftedPrompts() {
|
|
(this.$refs.giftedPrompts as GiftedPrompts).open((giver, description) =>
|
|
this.openDialog(giver as GiverReceiverInputInfo, description),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Opens feed filter configuration
|
|
*
|
|
* @public
|
|
* Called by template click handler
|
|
*/
|
|
openFeedFilters() {
|
|
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
|
|
}
|
|
|
|
/**
|
|
* Shows toast notification to user
|
|
*
|
|
* @internal
|
|
* Used for various user notifications
|
|
* @param message Message to display
|
|
*/
|
|
toastUser(message: string) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "toast",
|
|
title: "FYI",
|
|
text: message,
|
|
},
|
|
2000,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Computes CSS classes for known person icons
|
|
*
|
|
* @public
|
|
* Used in template for icon styling
|
|
* @param known Whether the person is known
|
|
* @returns CSS class string
|
|
*/
|
|
computeKnownPersonIconStyleClassNames(known: boolean) {
|
|
return known ? "text-slate-500" : "text-slate-100";
|
|
}
|
|
|
|
/**
|
|
* Shows name input dialog if needed
|
|
*
|
|
* @public
|
|
* @callGraph
|
|
* Called by: Template
|
|
* Calls:
|
|
* - UserNameDialog.open()
|
|
* - promptForShareMethod()
|
|
*
|
|
* @chain
|
|
* Template -> showNameThenIdDialog() -> promptForShareMethod()
|
|
*
|
|
* @requires
|
|
* - this.$refs.userNameDialog
|
|
* - this.givenName
|
|
*/
|
|
showNameThenIdDialog() {
|
|
if (!this.givenName) {
|
|
(this.$refs.userNameDialog as UserNameDialog).open(() => {
|
|
this.promptForShareMethod();
|
|
});
|
|
} else {
|
|
this.promptForShareMethod();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows dialog for sharing method selection
|
|
*
|
|
* @internal
|
|
* @callGraph
|
|
* Called by: showNameThenIdDialog()
|
|
* Calls: ChoiceButtonDialog.open()
|
|
*
|
|
* @chain
|
|
* Template -> showNameThenIdDialog() -> promptForShareMethod()
|
|
*
|
|
* @requires
|
|
* - this.$refs.choiceButtonDialog
|
|
* - this.$router
|
|
*/
|
|
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" });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Caches image data for sharing
|
|
*
|
|
* @public
|
|
* Called by ActivityListItem component
|
|
* @param event Event object
|
|
* @param imageUrl URL of image to cache
|
|
*/
|
|
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) {
|
|
logger.warn("Failed to cache image:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens image viewer dialog
|
|
*
|
|
* @public
|
|
* Called by ActivityListItem component
|
|
* @param imageUrl URL of image to display
|
|
*/
|
|
async openImageViewer(imageUrl: string) {
|
|
this.selectedImageData = this.imageCache.get(imageUrl) ?? null;
|
|
this.selectedImage = imageUrl;
|
|
this.isImageViewerOpen = true;
|
|
}
|
|
|
|
/**
|
|
* Handles claim confirmation
|
|
*
|
|
* @public
|
|
* Called by ActivityListItem component
|
|
* @param record Record to confirm
|
|
*/
|
|
async confirmClaim(record: GiveRecordWithContactInfo) {
|
|
this.$notify(
|
|
{
|
|
group: "modal",
|
|
type: "confirm",
|
|
title: "Confirm",
|
|
text: "Do you personally confirm that this is true?",
|
|
onYes: async () => {
|
|
const goodClaim = serverUtil.removeSchemaContext(
|
|
serverUtil.removeVisibleToDids(
|
|
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
|
record.fullClaim,
|
|
record.jwtId,
|
|
record.handleId,
|
|
),
|
|
),
|
|
);
|
|
|
|
const confirmationClaim = {
|
|
"@context": "https://schema.org",
|
|
"@type": "AgreeAction",
|
|
object: goodClaim,
|
|
};
|
|
|
|
const result = await serverUtil.createAndSubmitClaim(
|
|
confirmationClaim,
|
|
this.activeDid,
|
|
this.apiServer,
|
|
this.axios,
|
|
);
|
|
|
|
if (result.type === "success") {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "success",
|
|
title: "Success",
|
|
text: "Confirmation submitted.",
|
|
},
|
|
3000,
|
|
);
|
|
|
|
// Refresh the feed to show updated confirmation status
|
|
await this.updateAllFeed();
|
|
} else {
|
|
logger.error("Error submitting confirmation:", result);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "There was a problem submitting the confirmation.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
},
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
}
|
|
</script>
|
|
|