Files
crowd-funder-from-jason/src/views/HomeView.vue
Matthew Raymer e038bb63d9 Refactor ActivityListItem to use function props for image caching
Replace @Emit decorator with function prop pattern for better parent control over image caching behavior. ActivityListItem now accepts onImageCache function prop that parent components can use to handle image caching with custom logic. Updated HomeView to use new function prop interface with simplified method signature.
2025-07-18 07:41:06 +00:00

1848 lines
53 KiB
Vue

/** * @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 }}
<span class="text-xs text-gray-500">{{ package.version }}</span>
</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">
<!--
They should have an identifier, even if it's an auto-generated one that they'll never use.
Identity creation is now handled by router navigation guard.
-->
<div class="mb-4">
<div
v-if="!isUserRegistered"
id="noticeSomeoneMustRegisterYou"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
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 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 advanced options
</router-link>
</div>
</div>
<div v-else id="sectionRecordSomethingGiven">
<!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
<h2 class="text-xl font-bold">Record something given by:</h2>
<button
class="block ms-auto text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
@click="openGiftedPrompts()"
>
<font-awesome
icon="lightbulb"
class="block text-center w-[1em]"
/>
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
@click="openDialogPerson()"
>
<font-awesome icon="user" />
Person
</button>
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
@click="openProjectDialog()"
>
<font-awesome icon="folder-open" />
Project
</button>
</div>
</div>
</div>
</div>
</div>
<GiftedDialog ref="customDialog" :show-projects="showProjectsDialog" />
<GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" />
<!-- Results List -->
<div class="mt-4 mb-4">
<div class="flex gap-2 items-center mb-3">
<h2 class="text-xl font-bold">Latest Activity</h2>
<button
v-if="resultsAreFiltered()"
class="block ms-auto text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
@click="openFeedFilters()"
>
<font-awesome
icon="filter"
class="block text-center w-[1em] translate-y-[0.05em]"
/>
</button>
<button
v-else
class="block ms-auto text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
@click="openFeedFilters()"
>
<font-awesome
icon="filter"
class="block text-center w-[1em] translate-y-[0.05em]"
/>
</button>
</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"
:on-image-cache="cacheImageData"
@load-claim="onClickLoadClaim"
@view-image="openImageViewer"
@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 { Capacitor } from "@capacitor/core";
//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 { Contact } from "../db/tables/contacts";
import { BoundingBox, checkIsAnyFeedFilterOn } from "../db/tables/settings";
import {
contactForDid,
containsNonHiddenDid,
didInfoForContact,
fetchEndorserRateLimits,
getHeaders,
getNewOffersToUser,
getNewOffersToUserProjects,
getPlanFromCache,
} from "../libs/endorserServer";
import {
retrieveAccountDids,
GiverReceiverInputInfo,
OnboardPage,
} from "../libs/util";
import { GiveSummaryRecord } from "../interfaces/records";
import * as serverUtil from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { GiveRecordWithContactInfo } from "../interfaces/give";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import {
NOTIFY_CONTACT_LOADING_ISSUE,
NOTIFY_FEED_LOADING_ISSUE,
NOTIFY_CONFIRMATION_ERROR,
} from "@/constants/notifications";
import * as Package from "../../package.json";
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,
},
mixins: [PlatformServiceMixin],
})
export default class HomeView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
notify!: ReturnType<typeof createNotifyHelpers>;
AppString = AppString;
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
package = Package;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
blockedContactDids: Array<string> = [];
feedData: GiveRecordWithContactInfo[] = [];
feedPreviousOldestId?: string;
feedLastViewedClaimId?: string;
givenName = "";
isAnyFeedFilterOn = false;
// isCreatingIdentifier removed - identity creation now handled by router guard
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();
showProjectsDialog = false;
/**
* Initializes notification helpers
*/
created() {
this.notify = createNotifyHelpers(this.$notify);
}
/**
* 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();
// Settings already loaded in initializeIdentity()
await this.loadContacts();
// Registration check already handled in initializeIdentity()
await this.loadFeedData();
await this.loadNewOffers();
await this.checkOnboarding();
} catch (err: unknown) {
this.handleError(err);
}
}
/**
* Watch for changes in the current activeDid
* Reload settings when user switches identities
*/
async onActiveDidChanged(newDid: string | null, oldDid: string | null) {
if (newDid !== oldDid) {
// Re-initialize identity with new settings (loads settings internally)
await this.initializeIdentity();
}
}
/**
* 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 {
// Retrieve DIDs with better error handling
try {
this.allMyDids = await retrieveAccountDids();
} catch (error) {
this.$logAndConsole(
`[HomeView] Failed to retrieve DIDs: ${error}`,
true,
);
throw new Error(
"Failed to load existing identities. Please try restarting the app.",
);
}
// Identity creation is now handled by router navigation guard
// If we reach here, an identity should already exist
if (this.allMyDids.length === 0) {
this.$logAndConsole(
`[HomeView] No identities found - this should not happen with router guard`,
true,
);
throw new Error("No identity found. Please try refreshing the page.");
}
// Load settings with better error context using ultra-concise mixin
let settings;
try {
settings = await this.$settings({
apiServer: "",
activeDid: "",
isRegistered: false,
});
} catch (error) {
this.$logAndConsole(
`[HomeView] Failed to retrieve settings: ${error}`,
true,
);
throw new Error(
"Failed to load user settings. Some features may be limited.",
);
}
// Update component state
this.apiServer = settings.apiServer || "";
// **CRITICAL**: Ensure correct API server for platform
await this.ensureCorrectApiServer();
this.activeDid = settings.activeDid || "";
// Load contacts with graceful fallback
try {
this.loadContacts();
} catch (error) {
this.$logAndConsole(
`[HomeView] Failed to retrieve contacts: ${error}`,
true,
);
this.allContacts = []; // Ensure we have a valid empty array
this.blockedContactDids = [];
this.notify.warning(
NOTIFY_CONTACT_LOADING_ISSUE.message,
TIMEOUTS.LONG,
);
}
// Update remaining settings
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);
// Check onboarding status
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Home,
);
}
// Check registration status if needed
if (!this.isRegistered && this.activeDid) {
try {
const resp = await fetchEndorserRateLimits(
this.apiServer,
this.axios,
this.activeDid,
);
if (resp.status === 200) {
// Ultra-concise settings update with automatic cache invalidation!
await this.$saveMySettings({ isRegistered: true });
this.isRegistered = true;
// Force Vue to re-render the template
await this.$nextTick();
}
} catch (error) {
// Consolidate logging: Only log unexpected errors, not expected 400s
const axiosError = error as any;
if (axiosError?.response?.status !== 400) {
this.$logAndConsole(
`[HomeView] Registration check failed: ${error}`,
true,
);
}
// Continue as unregistered - this is expected for new users
}
}
// Initialize feed and offers
try {
// Start feed update in background
this.updateAllFeed().catch((error) => {
this.$logAndConsole(
`[HomeView] Background feed update failed: ${error}`,
true,
);
});
// Load new offers if we have an active DID
if (this.activeDid) {
const [offersToUser, offersToProjects] = await Promise.all([
getNewOffersToUser(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserJwtId,
),
getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserProjectsJwtId,
),
]);
this.numNewOffersToUser = offersToUser.data.length;
this.newOffersToUserHitLimit = offersToUser.hitLimit;
this.numNewOffersToUserProjects = offersToProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit;
}
} catch (error) {
this.$logAndConsole(
`[HomeView] Failed to initialize feed/offers: ${error}`,
true,
);
// Don't throw - we can continue with empty feed
this.notify.warning(NOTIFY_FEED_LOADING_ISSUE.message, TIMEOUTS.LONG);
}
} catch (error) {
this.handleError(error);
throw error; // Re-throw to be caught by mounted()
}
}
/**
* Ensures API server is correctly set for the current platform
* For Electron, always use production endpoint regardless of saved settings
*
* @internal
* Called after loading settings to ensure correct API endpoint
*/
private async ensureCorrectApiServer() {
const { DEFAULT_ENDORSER_API_SERVER } = await import("../constants/app");
if (process.env.VITE_PLATFORM === "electron") {
// **CRITICAL FIX**: Always use production API server for Electron
// This prevents the capacitor-electron:// protocol from being used for API calls
this.apiServer = DEFAULT_ENDORSER_API_SERVER;
} else if (!this.apiServer) {
// **FIX**: Set default API server for web/development if not already set
this.apiServer = DEFAULT_ENDORSER_API_SERVER;
}
}
/**
* Loads user settings from storage using ultra-concise mixin utilities
* 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() {
// Use the current activeDid (set in initializeIdentity) to get user-specific settings
const settings = await this.$accountSettings(this.activeDid, {
apiServer: "",
activeDid: "",
filterFeedByVisible: false,
filterFeedByNearby: false,
isRegistered: false,
});
this.apiServer = settings.apiServer || "";
// **CRITICAL**: Ensure correct API server for platform
await this.ensureCorrectApiServer();
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 using ultra-concise mixin
* Used for displaying contact info in feed and actions
*
* @internal
* Called by mounted() and initializeIdentity()
*/
private async loadContacts() {
this.allContacts = await this.$contacts();
this.blockedContactDids = this.allContacts
.filter((c) => !c.iViewContent)
.map((c) => c.did);
}
/**
* 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) {
// Ultra-concise settings update with automatic cache invalidation!
await this.$saveMySettings({ isRegistered: true });
this.isRegistered = true;
// Force Vue to re-render the template
await this.$nextTick();
}
} 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 using ultra-concise mixin utilities
* Opens onboarding dialog if not completed
*
* @internal
* Called by mounted()
*/
private async checkOnboarding() {
const settings = await this.$settings();
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 initializeIdentity()
* @param err Error object with optional userMessage
*/
private handleError(err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
const userMessage = (err as { userMessage?: string })?.userMessage;
this.$logAndConsole(
`[HomeView] Initialization error: ${errorMessage}${userMessage ? ` (${userMessage})` : ""}`,
true,
);
this.notify.error(
userMessage ||
"There was an error loading your data. Please try refreshing the page.",
TIMEOUTS.LONG,
);
}
/**
* 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 using ultra-concise mixin utilities
* - Updates filter states
* - Clears existing feed data
* - Triggers new feed load
*
* @public
* Called by FeedFilters component when filters change
*/
async reloadFeedOnChange() {
const settings = await this.$accountSettings(this.activeDid, {
filterFeedByVisible: false,
filterFeedByNearby: false,
});
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 data from endorser service with error handling
* - Retrieves new gives from API
* - Processes records through filters
* - Updates last viewed claim ID
* - Handles paging if needed
*
* @internal
* @callGraph
* Called by: loadFeedData(), manual refresh
* Calls:
* - retrieveGives()
* - processFeedResults()
* - updateFeedLastViewedId()
* - handleFeedError()
*
* @chain
* loadFeedData() -> updateAllFeed() -> retrieveGives()
*
* @requires
* - this.apiServer
* - 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;
// gather any contacts that user has blocked from view
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 & preferences
*
* @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.blockedContactDids.includes(record.issuerDid)) {
return false;
}
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
) {
// Ultra-concise default settings update with automatic cache invalidation!
await this.$saveSettings({
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.error(
(e as FeedError)?.userMessage ||
"There was an error retrieving feed data.",
TIMEOUTS.MODAL,
);
}
/**
* Retrieves gift data from endorser API with error handling
* - Fetches gives from API endpoint
* - Handles authentication headers
* - Processes API response with comprehensive error handling
*
* @public
* @callGraph
* Called by: updateAllFeed()
* Calls:
* - getHeaders()
* - fetch()
*
* @chain
* updateAllFeed() -> retrieveGives() -> getHeaders()
*
* @requires
* - this.activeDid
* - this.$notify
*
* @param endorserApiServer API server URL
* @param beforeId Optional ID for pagination
* @returns Promise resolving to API response data
*/
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 responseText = await response.text();
const results = JSON.parse(responseText);
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 | "Unnamed", description?: string) {
if (giver === "Unnamed") {
// Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
(this.$refs.customDialog as GiftedDialog).open(
undefined,
{
did: this.activeDid,
name: "You",
} as GiverReceiverInputInfo,
undefined,
"Given by Unnamed",
description,
);
// Immediately select "Unnamed" and move to Step 2
(this.$refs.customDialog as GiftedDialog).selectGiver();
} else {
(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.toast("FYI", message, TIMEOUTS.SHORT);
}
/**
* 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.handleQRCodeClick();
},
onOption3: () => {
this.$router.push({ name: "share-my-contact-info" });
},
});
}
/**
* Caches image data for sharing
*
* @public
* Called by ActivityListItem component function prop
* @param imageUrl URL of image to cache
*/
async cacheImageData(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.confirm(
"Do you personally confirm that this is true?",
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.success) {
this.notify.confirmationSubmitted();
// Refresh the feed to show updated confirmation status
await this.updateAllFeed();
} else {
logger.error("Error submitting confirmation:", result);
this.notify.error(NOTIFY_CONFIRMATION_ERROR.message, TIMEOUTS.LONG);
}
},
);
}
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
openDialogPerson(
giver?: GiverReceiverInputInfo | "Unnamed",
description?: string,
) {
this.showProjectsDialog = false;
this.openDialog(giver, description);
}
openProjectDialog() {
this.showProjectsDialog = true;
(this.$refs.customDialog as GiftedDialog).open();
}
/**
* Computed property for registration status
*
* @public
* Used in template for registration-dependent UI elements
*/
get isUserRegistered() {
return this.isRegistered;
}
}
</script>