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

/** * @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&hellip;
</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&hellip;
</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>