/** * @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 { 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, USE_DEXIE_DB, } 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 * as databaseUtil from "../db/databaseUtil"; import { contactForDid, containsNonHiddenDid, didInfoForContact, fetchEndorserRateLimits, getHeaders, getNewOffersToUser, getNewOffersToUserProjects, getPlanFromCache, } from "../libs/endorserServer"; import { generateSaveAndActivateIdentity, 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 { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; 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 { // Retrieve DIDs with better error handling try { this.allMyDids = await retrieveAccountDids(); logConsoleAndDb(`[HomeView] Retrieved ${this.allMyDids.length} DIDs`); } catch (error) { logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true); throw new Error( "Failed to load existing identities. Please try restarting the app.", ); } // Create new DID if needed if (this.allMyDids.length === 0) { try { this.isCreatingIdentifier = true; const newDid = await generateSaveAndActivateIdentity(); this.isCreatingIdentifier = false; this.allMyDids = [newDid]; logConsoleAndDb(`[HomeView] Created new identity: ${newDid}`); } catch (error) { this.isCreatingIdentifier = false; logConsoleAndDb( `[HomeView] Failed to create new identity: ${error}`, true, ); throw new Error("Failed to create new identity. Please try again."); } } // Load settings with better error context let settings; try { settings = await databaseUtil.retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) { settings = await retrieveSettingsForActiveAccount(); } logConsoleAndDb( `[HomeView] Retrieved settings for ${settings.activeDid || "no active DID"}`, ); } catch (error) { logConsoleAndDb( `[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 || ""; this.activeDid = settings.activeDid || ""; // Load contacts with graceful fallback try { const platformService = PlatformServiceFactory.getInstance(); const dbContacts = await platformService.dbQuery( "SELECT * FROM contacts", ); this.allContacts = databaseUtil.mapQueryResultToValues( dbContacts, ) as Contact[]; if (USE_DEXIE_DB) { this.allContacts = await db.contacts.toArray(); } logConsoleAndDb( `[HomeView] Retrieved ${this.allContacts.length} contacts`, ); } catch (error) { logConsoleAndDb( `[HomeView] Failed to retrieve contacts: ${error}`, true, ); this.allContacts = []; // Ensure we have a valid empty array this.$notify( { group: "alert", type: "warning", title: "Contact Loading Issue", text: "Some contact information may be unavailable.", }, 5000, ); } // 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) { await databaseUtil.updateDidSpecificSettings(this.activeDid, { isRegistered: true, ...(await databaseUtil.retrieveSettingsForActiveAccount()), }); if (USE_DEXIE_DB) { await updateAccountSettings(this.activeDid, { isRegistered: true, ...(await retrieveSettingsForActiveAccount()), }); } this.isRegistered = true; logConsoleAndDb( `[HomeView] User ${this.activeDid} is now registered`, ); } } catch (error) { logConsoleAndDb( `[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) => { logConsoleAndDb( `[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; logConsoleAndDb( `[HomeView] Retrieved ${this.numNewOffersToUser} user offers and ` + `${this.numNewOffersToUserProjects} project offers`, ); } } catch (error) { logConsoleAndDb( `[HomeView] Failed to initialize feed/offers: ${error}`, true, ); // Don't throw - we can continue with empty feed this.$notify( { group: "alert", type: "warning", title: "Feed Loading Issue", text: "Some feed data may be unavailable. Pull to refresh.", }, 5000, ); } } catch (error) { this.handleError(error); throw error; // Re-throw to be caught by mounted() } } /** * 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() { let settings = await databaseUtil.retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) { 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() { const platformService = PlatformServiceFactory.getInstance(); const dbContacts = await platformService.dbQuery("SELECT * FROM contacts"); this.allContacts = databaseUtil.mapQueryResultToValues( dbContacts, ) as unknown as Contact[]; if (USE_DEXIE_DB) { 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) { let settings = await databaseUtil.retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) { settings = await retrieveSettingsForActiveAccount(); } await databaseUtil.updateDidSpecificSettings(this.activeDid, { apiServer: this.apiServer, isRegistered: true, ...settings, }); if (USE_DEXIE_DB) { await updateAccountSettings(this.activeDid, { apiServer: this.apiServer, isRegistered: true, ...settings, }); } 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() { let settings = await databaseUtil.retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) { 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 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; logConsoleAndDb( `[HomeView] Initialization error: ${errorMessage}${userMessage ? ` (${userMessage})` : ""}`, true, ); this.$notify( { group: "alert", type: "danger", title: "Error", text: userMessage || "There was an error loading your data. Please try refreshing the page.", }, 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() { let settings = await databaseUtil.retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) { 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 databaseUtil.updateDefaultSettings({ lastViewedClaimId: records[0].jwtId, }); if (USE_DEXIE_DB) { 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.handleQRCodeClick(); }, 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, ); } private handleQRCodeClick() { if (Capacitor.isNativePlatform()) { this.$router.push({ name: "contact-qr-scan-full" }); } else { this.$router.push({ name: "contact-qr" }); } } } </script>