You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1155 lines
38 KiB
1155 lines
38 KiB
<template>
|
|
<QuickNav selected="Home" />
|
|
<TopMessage />
|
|
|
|
<!-- CONTENT -->
|
|
<section id="Content" class="p-2 pb-24 max-w-3xl mx-auto">
|
|
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
|
|
{{ AppString.APP_NAME }}
|
|
</h1>
|
|
|
|
<OnboardingDialog ref="onboardingDialog" />
|
|
|
|
<!--
|
|
prompt to install notifications with notificationsSupported, which we're making an advanced
|
|
feature
|
|
-->
|
|
<div class="mb-8 mt-8">
|
|
<div
|
|
v-if="false"
|
|
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
|
>
|
|
<p style="display: inline; align-items: center">
|
|
This currently doesn't support notifications, so let's fix that.
|
|
<br />
|
|
<!-- Note that that exact verbiage shows in the help. -->
|
|
|
|
<span v-if="userAgentInfo.getOS().name === 'iOS'">
|
|
Tap on "Share"<img
|
|
src="../assets/help/apple-share-icon.svg"
|
|
alt="Apple 'share' icon"
|
|
width="30"
|
|
style="display: inline; margin: 0 5px; vertical-align: middle"
|
|
/>and then "Add to Home Screen"
|
|
<font-awesome icon="square-plus" title="Apple 'Add' icon" />
|
|
and go click on that new app.
|
|
</span>
|
|
<span
|
|
v-else-if="userAgentInfo.getBrowser()?.name?.startsWith('Chrome')"
|
|
>
|
|
You should see a prompt to install, or you can click on the
|
|
top-right dots
|
|
<font-awesome icon="ellipsis-vertical" title="vertical ellipsis" />
|
|
/> and then "Install"<img
|
|
src="../assets/help/install-android-chrome.png"
|
|
alt="Android 'install' icon"
|
|
width="30"
|
|
style="display: inline; margin: 0 5px; vertical-align: middle"
|
|
/>
|
|
and go use that app. If you already did these steps, reload this app
|
|
so that it is fully detected.
|
|
</span>
|
|
<span v-else>
|
|
Try
|
|
<a href="https://www.google.com/chrome/" class="text-blue-500"
|
|
>Google Chrome</a
|
|
>
|
|
or look for a way to install as an app from this browser.
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showShortcutBvc" class="mb-4">
|
|
<router-link
|
|
:to="{ name: 'quick-action-bvc' }"
|
|
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
|
>
|
|
Bountiful Voluntaryist Community Actions
|
|
</router-link>
|
|
</div>
|
|
|
|
<div class="mb-8">
|
|
<div v-if="isCreatingIdentifier">
|
|
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
|
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
|
Loading…
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<!-- !isCreatingIdentifier -->
|
|
<!--
|
|
They should have an identifier, even if it's an auto-generated one that they'll never use.
|
|
-->
|
|
<div class="mb-4">
|
|
<div
|
|
v-if="!isRegistered"
|
|
id="noticeSomeoneMustRegisterYou"
|
|
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
|
>
|
|
<!-- !isCreatingIdentifier && !isRegistered -->
|
|
To share, someone must register you.
|
|
<div class="block text-center">
|
|
<button
|
|
class="text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
|
@click="showNameThenIdDialog()"
|
|
>
|
|
Show them {{ PASSKEYS_ENABLED ? "default" : "your" }} identifier
|
|
info
|
|
</button>
|
|
</div>
|
|
<UserNameDialog ref="userNameDialog" />
|
|
<div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full">
|
|
<router-link
|
|
:to="{ name: 'start' }"
|
|
class="block text-right text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
|
>
|
|
See all your options first
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else id="sectionRecordSomethingGiven">
|
|
<!-- !isCreatingIdentifier && isRegistered -->
|
|
|
|
<!-- show the actions for recognizing a give -->
|
|
<div class="flex">
|
|
<h2 class="text-xl font-bold">What have you seen someone do?</h2>
|
|
<button
|
|
class="ml-2 block text-xs text-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 rounded-md"
|
|
@click="openGiftedPrompts()"
|
|
>
|
|
<font-awesome icon="lightbulb" class="fa-fw" />
|
|
</button>
|
|
</div>
|
|
|
|
<ul
|
|
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mt-4"
|
|
>
|
|
<li @click="openDialog()">
|
|
<img
|
|
src="../assets/blank-square.svg"
|
|
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
|
|
/>
|
|
<h3
|
|
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
|
>
|
|
Unnamed/Unknown
|
|
</h3>
|
|
</li>
|
|
<li v-if="allContacts.length === 0" class="text-sm">
|
|
(Add friends to see more people worthy of recognition.)
|
|
</li>
|
|
<li
|
|
v-for="contact in allContacts.slice(0, 6)"
|
|
:key="contact.did"
|
|
@click="openDialog(contact)"
|
|
>
|
|
<EntityIcon
|
|
:contact="contact"
|
|
:icon-size="64"
|
|
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
|
|
/>
|
|
<h3
|
|
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
|
>
|
|
{{ contact.name || contact.did }}
|
|
</h3>
|
|
</li>
|
|
<li>
|
|
<router-link
|
|
v-if="allContacts.length >= 6"
|
|
:to="{ name: 'contact-gift' }"
|
|
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
|
|
>
|
|
... or someone else...
|
|
</router-link>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<GiftedDialog ref="customDialog" />
|
|
<GiftedPrompts ref="giftedPrompts" />
|
|
<FeedFilters ref="feedFilters" />
|
|
|
|
<div class="relative">
|
|
<button
|
|
v-if="isRegistered"
|
|
class="absolute right-6 bottom-0 transform translate-y-1/2 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
|
|
@click="openDialog()"
|
|
>
|
|
<font-awesome icon="plus" class="fa-fw" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Results List -->
|
|
<div class="mt-4 mb-4">
|
|
<div class="flex items-center mb-4">
|
|
<h2 class="text-xl font-bold flex items-center gap-4">
|
|
Latest Activity
|
|
<button
|
|
v-if="resultsAreFiltered()"
|
|
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
|
|
@click="openFeedFilters()"
|
|
>
|
|
<font-awesome icon="filter" class="fa-fw" />
|
|
</button>
|
|
<button
|
|
v-else
|
|
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
|
|
@click="openFeedFilters()"
|
|
>
|
|
<font-awesome icon="filter" class="fa-fw" />
|
|
</button>
|
|
</h2>
|
|
</div>
|
|
|
|
<div
|
|
class="border-t p-2 border-slate-300"
|
|
@click="goToActivityToUserPage()"
|
|
>
|
|
<div class="flex justify-center">
|
|
<div
|
|
v-if="numNewOffersToUser"
|
|
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
|
|
>
|
|
<span
|
|
class="block text-center text-6xl"
|
|
data-testId="newDirectOffersActivityNumber"
|
|
>
|
|
{{ numNewOffersToUser }}{{ newOffersToUserHitLimit ? "+" : "" }}
|
|
</span>
|
|
<p class="text-center">
|
|
new offer{{ numNewOffersToUser === 1 ? "" : "s" }} to you
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-if="numNewOffersToUserProjects"
|
|
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
|
|
>
|
|
<span
|
|
class="block text-center text-6xl"
|
|
data-testId="newOffersToUserProjectsActivityNumber"
|
|
>
|
|
{{ numNewOffersToUserProjects
|
|
}}{{ newOffersToUserProjectsHitLimit ? "+" : "" }}
|
|
</span>
|
|
<p class="text-center">
|
|
new offer{{ numNewOffersToUserProjects === 1 ? "" : "s" }} to your
|
|
projects
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end mt-2">
|
|
<button class="text-blue-500">View All New Activity For You</button>
|
|
</div>
|
|
</div>
|
|
|
|
<InfiniteScroll @reached-bottom="loadMoreGives">
|
|
<ul id="listLatestActivity" class="space-y-4">
|
|
<ActivityListItem
|
|
v-for="record in feedData"
|
|
:key="record.jwtId"
|
|
:record="record"
|
|
:last-viewed-claim-id="feedLastViewedClaimId"
|
|
:is-registered="isRegistered"
|
|
:active-did="activeDid"
|
|
:confirmer-id-list="record.confirmerIdList"
|
|
@load-claim="onClickLoadClaim"
|
|
@view-image="openImageViewer"
|
|
@cache-image="cacheImageData"
|
|
@confirm-claim="confirmClaim"
|
|
/>
|
|
</ul>
|
|
</InfiniteScroll>
|
|
<div v-if="isFeedLoading">
|
|
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
|
<font-awesome icon="spinner" class="fa-spin-pulse" /> Loading…
|
|
</p>
|
|
</div>
|
|
<div v-if="!isFeedLoading && feedData.length === 0">
|
|
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
|
No claims match your filters.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<ChoiceButtonDialog ref="choiceButtonDialog" />
|
|
|
|
<ImageViewer
|
|
v-model:is-open="isImageViewerOpen"
|
|
:image-url="selectedImage"
|
|
:image-data="selectedImageData"
|
|
/>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { UAParser } from "ua-parser-js";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import { Router } from "vue-router";
|
|
|
|
//import App from "../App.vue";
|
|
import EntityIcon from "../components/EntityIcon.vue";
|
|
import GiftedDialog from "../components/GiftedDialog.vue";
|
|
import GiftedPrompts from "../components/GiftedPrompts.vue";
|
|
import FeedFilters from "../components/FeedFilters.vue";
|
|
import InfiniteScroll from "../components/InfiniteScroll.vue";
|
|
import OnboardingDialog from "../components/OnboardingDialog.vue";
|
|
import QuickNav from "../components/QuickNav.vue";
|
|
import TopMessage from "../components/TopMessage.vue";
|
|
import UserNameDialog from "../components/UserNameDialog.vue";
|
|
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
|
|
import ImageViewer from "../components/ImageViewer.vue";
|
|
import ActivityListItem from "../components/ActivityListItem.vue";
|
|
import {
|
|
AppString,
|
|
NotificationIface,
|
|
PASSKEYS_ENABLED,
|
|
} from "../constants/app";
|
|
import {
|
|
db,
|
|
logConsoleAndDb,
|
|
retrieveSettingsForActiveAccount,
|
|
updateAccountSettings,
|
|
} from "../db/index";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import {
|
|
BoundingBox,
|
|
checkIsAnyFeedFilterOn,
|
|
MASTER_SETTINGS_KEY,
|
|
} from "../db/tables/settings";
|
|
import {
|
|
contactForDid,
|
|
containsNonHiddenDid,
|
|
didInfoForContact,
|
|
fetchEndorserRateLimits,
|
|
getHeaders,
|
|
getNewOffersToUser,
|
|
getNewOffersToUserProjects,
|
|
getPlanFromCache,
|
|
} from "../libs/endorserServer";
|
|
import {
|
|
generateSaveAndActivateIdentity,
|
|
retrieveAccountDids,
|
|
GiverReceiverInputInfo,
|
|
OnboardPage,
|
|
} from "../libs/util";
|
|
import { GiveSummaryRecord } from "../interfaces";
|
|
import * as serverUtil from "../libs/endorserServer";
|
|
import { logger } from "../utils/logger";
|
|
import { GiveRecordWithContactInfo } from "types";
|
|
|
|
|
|
/**
|
|
* HomeView - Main view component for the application's home page
|
|
*
|
|
* Workflow:
|
|
* 1. On mount, initializes user identity, settings, and data
|
|
* 2. Handles user registration status
|
|
* 3. Manages feed of activities and offers
|
|
* 4. Provides interface for creating and viewing claims
|
|
*/
|
|
@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
|
|
*/
|
|
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
|
|
* @throws Logs error if DID retrieval fails
|
|
*/
|
|
private async initializeIdentity() {
|
|
try {
|
|
this.allMyDids = await retrieveAccountDids();
|
|
if (this.allMyDids.length === 0) {
|
|
this.isCreatingIdentifier = true;
|
|
const newDid = await generateSaveAndActivateIdentity();
|
|
this.isCreatingIdentifier = false;
|
|
this.allMyDids = [newDid];
|
|
}
|
|
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
this.apiServer = settings.apiServer || "";
|
|
this.activeDid = settings.activeDid || "";
|
|
this.allContacts = await db.contacts.toArray();
|
|
this.feedLastViewedClaimId = settings.lastViewedClaimId;
|
|
this.givenName = settings.firstName || "";
|
|
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
|
|
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
|
this.isRegistered = !!settings.isRegistered;
|
|
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
|
|
this.lastAckedOfferToUserProjectsJwtId =
|
|
settings.lastAckedOfferToUserProjectsJwtId;
|
|
this.searchBoxes = settings.searchBoxes || [];
|
|
this.showShortcutBvc = !!settings.showShortcutBvc;
|
|
|
|
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
|
|
|
if (!settings.finishedOnboarding) {
|
|
(this.$refs.onboardingDialog as OnboardingDialog).open(
|
|
OnboardPage.Home,
|
|
);
|
|
}
|
|
|
|
// someone may have have registered after sharing contact info, so recheck
|
|
if (!this.isRegistered && this.activeDid) {
|
|
try {
|
|
const resp = await fetchEndorserRateLimits(
|
|
this.apiServer,
|
|
this.axios,
|
|
this.activeDid,
|
|
);
|
|
if (resp.status === 200) {
|
|
await updateAccountSettings(this.activeDid, {
|
|
isRegistered: true,
|
|
...(await retrieveSettingsForActiveAccount()),
|
|
});
|
|
this.isRegistered = true;
|
|
}
|
|
} catch (e) {
|
|
// ignore the error... just keep us unregistered
|
|
}
|
|
}
|
|
|
|
// this returns a Promise but we don't need to wait for it
|
|
this.updateAllFeed();
|
|
|
|
if (this.activeDid) {
|
|
const offersToUserData = await getNewOffersToUser(
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
this.lastAckedOfferToUserJwtId,
|
|
);
|
|
this.numNewOffersToUser = offersToUserData.data.length;
|
|
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
|
|
}
|
|
|
|
if (this.activeDid) {
|
|
const offersToUserProjects = await getNewOffersToUserProjects(
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
this.lastAckedOfferToUserProjectsJwtId,
|
|
);
|
|
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
|
|
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text:
|
|
err.userMessage ||
|
|
"There was an error retrieving your settings or the latest activity.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads user settings from storage
|
|
* Sets component state for:
|
|
* - API server
|
|
* - Active DID
|
|
* - Feed filters and view settings
|
|
* - Registration status
|
|
* - Notification acknowledgments
|
|
*/
|
|
private async loadSettings() {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
this.apiServer = settings.apiServer || "";
|
|
this.activeDid = settings.activeDid || "";
|
|
this.feedLastViewedClaimId = settings.lastViewedClaimId;
|
|
this.givenName = settings.firstName || "";
|
|
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
|
|
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
|
this.isRegistered = !!settings.isRegistered;
|
|
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
|
|
this.lastAckedOfferToUserProjectsJwtId =
|
|
settings.lastAckedOfferToUserProjectsJwtId;
|
|
this.searchBoxes = settings.searchBoxes || [];
|
|
this.showShortcutBvc = !!settings.showShortcutBvc;
|
|
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
|
}
|
|
|
|
/**
|
|
* Loads user contacts from database
|
|
* Used for displaying contact info in feed and actions
|
|
*/
|
|
private async loadContacts() {
|
|
this.allContacts = await db.contacts.toArray();
|
|
}
|
|
|
|
/**
|
|
* Verifies user registration status with endorser service
|
|
* - Checks if unregistered user can access API
|
|
* - Updates registration status if successful
|
|
* - Preserves unregistered state on failure
|
|
*/
|
|
private async checkRegistrationStatus() {
|
|
if (!this.isRegistered && this.activeDid) {
|
|
try {
|
|
const resp = await fetchEndorserRateLimits(
|
|
this.apiServer,
|
|
this.axios,
|
|
this.activeDid,
|
|
);
|
|
if (resp.status === 200) {
|
|
await updateAccountSettings(this.activeDid, {
|
|
apiServer: this.apiServer,
|
|
isRegistered: true,
|
|
...(await retrieveSettingsForActiveAccount()),
|
|
});
|
|
this.isRegistered = true;
|
|
}
|
|
} catch (e) {
|
|
// ignore the error... just keep us unregistered
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes feed data
|
|
* Triggers updateAllFeed() to populate activity feed
|
|
*/
|
|
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
|
|
* @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
|
|
*/
|
|
private async checkOnboarding() {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
if (!settings.finishedOnboarding) {
|
|
(this.$refs.onboardingDialog as OnboardingDialog).open(OnboardPage.Home);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles errors during initialization
|
|
* - Logs error to console and database
|
|
* - Displays user notification
|
|
* @param err Error object with optional userMessage
|
|
*/
|
|
private handleError(err: unknown) {
|
|
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text:
|
|
err.userMessage ||
|
|
"There was an error retrieving your settings or the latest activity.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks if feed results are being filtered
|
|
* @returns true if visible or nearby filters are active
|
|
*/
|
|
resultsAreFiltered() {
|
|
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
|
|
}
|
|
|
|
/**
|
|
* Checks if browser notifications are supported
|
|
* @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
|
|
*/
|
|
async reloadFeedOnChange() {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
|
|
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
|
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
|
|
|
this.feedData = [];
|
|
this.feedPreviousOldestId = undefined;
|
|
await this.updateAllFeed();
|
|
}
|
|
|
|
/**
|
|
* Loads more feed items for infinite scroll
|
|
* @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();
|
|
}
|
|
}
|
|
|
|
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
|
|
* - Handles filtering of results
|
|
* - Updates last viewed claim ID
|
|
* - Manages loading state
|
|
*/
|
|
async updateAllFeed() {
|
|
this.isFeedLoading = true;
|
|
let endOfResults = true;
|
|
await this.retrieveGives(this.apiServer, this.feedPreviousOldestId)
|
|
.then(async (results) => {
|
|
if (results.data.length > 0) {
|
|
endOfResults = false;
|
|
// include the descriptions of the giver and receiver
|
|
for (const record of results.data as GiveSummaryRecord[]) {
|
|
// 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 = (record.fullClaim as any).claim || record.fullClaim;
|
|
// agent.did is for legacy data, before March 2023
|
|
const giverDid =
|
|
claim.agent?.identifier || (claim.agent as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
// recipient.did is for legacy data, before March 2023
|
|
const recipientDid =
|
|
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
|
|
// This has indeed proven problematic. See loadMoreGives
|
|
// We should display it immediately and then get the plan later.
|
|
const fulfillsPlan = await getPlanFromCache(
|
|
record.fulfillsPlanHandleId,
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
);
|
|
|
|
// check if the record should be filtered out
|
|
let anyMatch = false;
|
|
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) {
|
|
// has a visible DID so it's a keeper
|
|
anyMatch = true;
|
|
}
|
|
if (!anyMatch && this.isFeedFilteredByNearby) {
|
|
// check if the associated project has a location inside user's search box
|
|
if (record.fulfillsPlanHandleId) {
|
|
if (fulfillsPlan?.locLat && fulfillsPlan?.locLon) {
|
|
if (
|
|
this.latLongInAnySearchBox(
|
|
fulfillsPlan.locLat,
|
|
fulfillsPlan.locLon,
|
|
)
|
|
) {
|
|
anyMatch = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (this.isAnyFeedFilterOn && !anyMatch) {
|
|
continue;
|
|
}
|
|
|
|
// checking for arrays due to legacy data
|
|
const provider = Array.isArray(claim.provider)
|
|
? claim.provider[0]
|
|
: claim.provider;
|
|
const providedByPlan = await getPlanFromCache(
|
|
provider?.identifier as string,
|
|
this.axios,
|
|
this.apiServer,
|
|
this.activeDid,
|
|
);
|
|
|
|
const newRecord: GiveRecordWithContactInfo = {
|
|
...record,
|
|
jwtId: record.jwtId,
|
|
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,
|
|
),
|
|
};
|
|
this.feedData.push(newRecord);
|
|
}
|
|
this.feedPreviousOldestId =
|
|
results.data[results.data.length - 1].jwtId;
|
|
// The following update is only done on the first load.
|
|
if (
|
|
this.feedLastViewedClaimId == null ||
|
|
this.feedLastViewedClaimId < results.data[0].jwtId
|
|
) {
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
lastViewedClaimId: results.data[0].jwtId,
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
logger.error("Error with feed load:", e);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Feed Error",
|
|
text: e.userMessage || "There was an error retrieving feed data.",
|
|
},
|
|
-1,
|
|
);
|
|
});
|
|
if (this.feedData.length === 0 && !endOfResults) {
|
|
// repeat until there's at least some data
|
|
await this.updateAllFeed();
|
|
}
|
|
this.isFeedLoading = false;
|
|
}
|
|
|
|
/**
|
|
* Retrieve claims in reverse chronological order
|
|
*
|
|
* @param beforeId the earliest ID (of previous searches) to search earlier
|
|
* @return 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);
|
|
}
|
|
}
|
|
|
|
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 + ")";
|
|
}
|
|
}
|
|
|
|
goToActivityToUserPage() {
|
|
this.$router.push({ name: "new-activity" });
|
|
}
|
|
|
|
onClickLoadClaim(jwtId: string) {
|
|
const route = {
|
|
path: "/claim/" + encodeURIComponent(jwtId),
|
|
};
|
|
this.$router.push(route);
|
|
}
|
|
|
|
displayAmount(code: string, amt: number) {
|
|
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1);
|
|
}
|
|
|
|
currencyShortWordForCode(unitCode: string, single: boolean) {
|
|
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
|
}
|
|
|
|
/**
|
|
* Opens dialog for creating new gift/claim
|
|
* @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
|
|
* Links to openDialog for selected prompt
|
|
*/
|
|
openGiftedPrompts() {
|
|
(this.$refs.giftedPrompts as GiftedPrompts).open((giver, description) =>
|
|
this.openDialog(giver as GiverReceiverInputInfo, description),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Opens feed filter configuration
|
|
* @param reloadFeedOnChange Callback for when filters are updated
|
|
*/
|
|
openFeedFilters() {
|
|
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
|
|
}
|
|
|
|
toastUser(message: string) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "toast",
|
|
title: "FYI",
|
|
text: message,
|
|
},
|
|
2000,
|
|
);
|
|
}
|
|
|
|
computeKnownPersonIconStyleClassNames(known: boolean) {
|
|
return known ? "text-slate-500" : "text-slate-100";
|
|
}
|
|
|
|
showNameThenIdDialog() {
|
|
if (!this.givenName) {
|
|
(this.$refs.userNameDialog as UserNameDialog).open(() => {
|
|
this.promptForShareMethod();
|
|
});
|
|
} else {
|
|
this.promptForShareMethod();
|
|
}
|
|
}
|
|
|
|
promptForShareMethod() {
|
|
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
|
|
title: "How can you share your info?",
|
|
text: "",
|
|
option1Text: "We are in a meeting together",
|
|
option2Text: "We are nearby with cameras",
|
|
option3Text: "We will share some other way",
|
|
onOption1: () => {
|
|
this.$router.push({ name: "onboard-meeting-list" });
|
|
},
|
|
onOption2: () => {
|
|
this.$router.push({ name: "contact-qr" });
|
|
},
|
|
onOption3: () => {
|
|
this.$router.push({ name: "share-my-contact-info" });
|
|
},
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
async openImageViewer(imageUrl: string) {
|
|
this.selectedImageData = this.imageCache.get(imageUrl) ?? null;
|
|
this.selectedImage = imageUrl;
|
|
this.isImageViewerOpen = true;
|
|
}
|
|
|
|
async confirmClaim(record: GiveRecordWithContactInfo) {
|
|
this.$notify(
|
|
{
|
|
group: "modal",
|
|
type: "confirm",
|
|
title: "Confirm",
|
|
text: "Do you personally confirm that this is true?",
|
|
onYes: async () => {
|
|
const goodClaim = serverUtil.removeSchemaContext(
|
|
serverUtil.removeVisibleToDids(
|
|
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
|
record.fullClaim,
|
|
record.jwtId,
|
|
record.handleId,
|
|
),
|
|
),
|
|
);
|
|
|
|
const confirmationClaim = {
|
|
"@context": "https://schema.org",
|
|
"@type": "AgreeAction",
|
|
object: goodClaim,
|
|
};
|
|
|
|
const result = await serverUtil.createAndSubmitClaim(
|
|
confirmationClaim,
|
|
this.activeDid,
|
|
this.apiServer,
|
|
this.axios,
|
|
);
|
|
|
|
if (result.type === "success") {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "success",
|
|
title: "Success",
|
|
text: "Confirmation submitted.",
|
|
},
|
|
3000,
|
|
);
|
|
|
|
// Refresh the feed to show updated confirmation status
|
|
await this.updateAllFeed();
|
|
} else {
|
|
logger.error("Error submitting confirmation:", result);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "There was a problem submitting the confirmation.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
},
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
}
|
|
</script>
|
|
|