forked from jsnbuchanan/crowd-funder-for-time-pwa
- Enhanced migration service error handling and logging - Added better recovery mechanisms for failed migrations - Improved database state validation before migration execution - Added comprehensive error reporting in migration service - Updated HomeView to handle migration errors gracefully - Fixed database initialization sequence to prevent constraint violations This addresses UNIQUE constraint failed errors during app startup and provides better user feedback during database migration issues.
1828 lines
52 KiB
Vue
1828 lines
52 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">
|
|
<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 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">
|
|
<!-- !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,
|
|
} from "../constants/app";
|
|
import { logConsoleAndDb } from "../db/index";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import { BoundingBox, checkIsAnyFeedFilterOn } 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";
|
|
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,
|
|
},
|
|
})
|
|
export default class HomeView extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
$router!: Router;
|
|
|
|
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 = 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();
|
|
} 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();
|
|
} 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 {
|
|
this.loadContacts();
|
|
} catch (error) {
|
|
logConsoleAndDb(
|
|
`[HomeView] Failed to retrieve contacts: ${error}`,
|
|
true,
|
|
);
|
|
this.allContacts = []; // Ensure we have a valid empty array
|
|
this.blockedContactDids = [];
|
|
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()),
|
|
});
|
|
this.isRegistered = true;
|
|
}
|
|
} 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;
|
|
}
|
|
} 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() {
|
|
const settings = await databaseUtil.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[];
|
|
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) {
|
|
const settings =
|
|
await databaseUtil.retrieveSettingsForActiveAccount();
|
|
await databaseUtil.updateDidSpecificSettings(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() {
|
|
const settings = await databaseUtil.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() {
|
|
const settings = await databaseUtil.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;
|
|
// 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
|
|
) {
|
|
await databaseUtil.updateDefaultSettings({
|
|
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.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>
|