<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 px-4 mb-8"> {{ AppString.APP_NAME }} </h1> <!-- prompt to install notifications --> <div class="mb-8"> <div v-if="!notificationsSupported()" 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" <fa 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 <fa icon="ellipsis-vertical" title="vertical ellipsis" class="fa-fw" /> 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"> <fa 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" > <!-- activeDid && !isRegistered --> To share, someone must register you. <router-link :to="{ name: 'contact-qr' }" 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" > Show Them {{ PASSKEYS_ENABLED ? "Default" : "Your" }} Identifier Info </router-link> <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> <!-- activeDid && isRegistered --> <!-- show the actions for recognizing a give --> <div class="mb-4"> <h2 class="text-xl font-bold">Record Something Given By:</h2> </div> <ul class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5" > <li @click="openDialog()"> <img src="../assets/blank-square.svg" class="mx-auto border border-slate-300 rounded-md mb-1" /> <h3 class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden" > Unnamed/Unknown </h3> </li> <li v-for="contact in allContacts.slice(0, 7)" :key="contact.did" @click="openDialog(contact)" > <EntityIcon :contact="contact" :iconSize="64" class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer" /> <h3 class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden" > {{ contact.name || contact.did }} </h3> </li> </ul> <div class="flex justify-between"> <router-link v-if="allContacts.length >= 7" :to="{ name: 'contact-gift' }" class="block text-center text-md font-bold 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-2 py-3 rounded-md" > Choose From All Contacts </router-link> <button @click="openGiftedPrompts()" class="block text-center text-md 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-4 py-2 rounded-md" > Ideas... </button> </div> </div> </div> </div> </div> <GiftedDialog ref="customDialog" /> <GiftedPrompts ref="giftedPrompts" /> <FeedFilters ref="feedFilters" /> <!-- Results List --> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> <div class="flex items-center mb-4"> <h2 class="text-xl font-bold">Latest Activity</h2> <button @click="openFeedFilters()" class="block text-center ml-auto"> <span class="text-sm text-white"> <span 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" > Filtered </span> <span 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" > Unfiltered </span> </span> </button> </div> <InfiniteScroll @reached-bottom="loadMoreGives"> <ul id="listLatestActivity" class="border-t border-slate-300"> <li class="border-b border-slate-300 py-2" v-for="record in feedData" :key="record.jwtId" > <div class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold text-sm" v-if="record.jwtId == feedLastViewedClaimId" > You've already seen all the following </div> <div class="grid grid-cols-12"> <span class="pt-1 col-span-1 justify-self-start"> <span> <fa icon="circle-user" :class=" computeKnownPersonIconStyleClassNames( record.giver.known || record.receiver.known, ) " @click="toastUser('This involves your contacts.')" /> <fa icon="gift" class="pl-3 text-slate-500" @click="toastUser('This is a gift.')" /> </span> </span> <span class="col-span-10 justify-self-stretch"> <!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter <span v-if=" record.giver.profileImageUrl || record.receiver.profileImageUrl " > <EntityIcon v-if="record.agentDid !== activeDid" :icon-size="32" :profile-image-url="record.giver.profileImageUrl" class="inline-block align-middle border border-slate-300 rounded-md mr-1" /> <fa v-if=" record.agentDid !== activeDid && record.recipientDid !== activeDid && !record.fulfillsPlanHandleId " icon="ellipsis" class="text-slate" /> <EntityIcon v-if=" record.recipientDid !== activeDid && !record.fulfillsPlanHandleId " :iconSize="32" :profile-image-url="record.receiver.profileImageUrl" class="inline-block align-middle border border-slate-300 rounded-md ml-1" /> </span> --> <span class="pl-2"> {{ giveDescription(record) }} </span> <a @click="onClickLoadClaim(record.jwtId)"> <fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" /> </a> </span> <span class="col-span-1 justify-self-end"> <router-link v-if="record.fulfillsPlanHandleId" :to=" '/project/' + encodeURIComponent(record.fulfillsPlanHandleId) " > <fa icon="hammer" class="text-blue-500" /> </router-link> </span> </div> <div v-if="record.image" class="flex justify-center"> <a :href="record.image" target="_blank"> <img :src="record.image" class="h-24 mt-2 rounded-xl" /> </a> </div> </li> </ul> </InfiniteScroll> <div v-if="isFeedLoading"> <p class="text-slate-500 text-center italic mt-4 mb-4"> <fa 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> </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 QuickNav from "@/components/QuickNav.vue"; import TopMessage from "@/components/TopMessage.vue"; import { AppString, NotificationIface, PASSKEYS_ENABLED, } from "@/constants/app"; import { db, accountsDB } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import { BoundingBox, isAnyFeedFilterOn, MASTER_SETTINGS_KEY, Settings, } from "@/db/tables/settings"; import { contactForDid, containsNonHiddenDid, didInfoForContact, fetchEndorserRateLimits, getHeaders, getPlanFromCache, GiverReceiverInputInfo, GiveSummaryRecord, } from "@/libs/endorserServer"; import { generateSaveAndActivateIdentity, registerSaveAndActivatePasskey, } from "@/libs/util"; interface GiveRecordWithContactInfo extends GiveSummaryRecord { giver: { displayName: string; known: boolean; profileImageUrl?: string; }; image?: string; recipientProjectName?: string; receiver: { displayName: string; known: boolean; profileImageUrl?: string; }; } @Component({ computed: { App() { return App; }, }, components: { GiftedDialog, GiftedPrompts, FeedFilters, QuickNav, EntityIcon, InfiniteScroll, TopMessage, }, }) export default class HomeView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; AppString = AppString; PASSKEYS_ENABLED = PASSKEYS_ENABLED; activeDid = ""; allContacts: Array<Contact> = []; allMyDids: Array<string> = []; apiServer = ""; feedData: GiveRecordWithContactInfo[] = []; feedPreviousOldestId?: string; feedLastViewedClaimId?: string; givenName = ""; isAnyFeedFilterOn: boolean; isCreatingIdentifier = false; isFeedFilteredByVisible = false; isFeedFilteredByNearby = false; isFeedLoading = true; isRegistered = false; 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 async mounted() { try { await accountsDB.open(); const allAccounts = await accountsDB.accounts.toArray(); if (allAccounts.length > 0) { this.allMyDids = allAccounts.map((acc) => acc.did); } else { this.isCreatingIdentifier = true; const newDid = await generateSaveAndActivateIdentity(); this.isCreatingIdentifier = false; this.allMyDids = [newDid]; } await db.open(); const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; 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.searchBoxes = settings?.searchBoxes || []; this.showShortcutBvc = !!settings?.showShortcutBvc; this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings); // 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) { // we just needed to know that they're registered await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { isRegistered: true, }); 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 await this.updateAllFeed(); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { console.error("Error retrieving settings or feed.", err); this.$notify( { group: "alert", type: "danger", title: "Error", text: err.userMessage || "There was an error retrieving your settings or the latest activity.", }, -1, ); } } async generatePasskeyIdentifier() { this.isCreatingIdentifier = true; const account = await registerSaveAndActivatePasskey( AppString.APP_NAME + (this.givenName ? " - " + this.givenName : ""), ); this.activeDid = account.did; this.allMyDids = this.allMyDids.concat(this.activeDid); this.isCreatingIdentifier = false; } resultsAreFiltered() { return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby; } notificationsSupported() { return "Notification" in window; } // only called when a setting was changed async reloadFeedOnChange() { await db.open(); const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; this.isFeedFilteredByVisible = !!settings?.filterFeedByVisible; this.isFeedFilteredByNearby = !!settings?.filterFeedByNearby; this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings); this.feedData = []; this.feedPreviousOldestId = undefined; this.updateAllFeed(); } /** * Data loader used by infinite scroller * @param payload is the flag from the InfiniteScroll indicating if it should load **/ 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) { 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; } } } 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: GiveSummaryRecord of results.data) { // 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 plan = 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 (plan?.locLat && plan?.locLon) { if (this.latLongInAnySearchBox(plan.locLat, plan.locLon)) { anyMatch = true; } } } } if (this.isAnyFeedFilterOn && !anyMatch) { continue; } const newRecord: GiveRecordWithContactInfo = { ...record, giver: didInfoForContact( giverDid, this.activeDid, contactForDid(giverDid, this.allContacts), this.allMyDids, ), image: claim.image, recipientProjectName: plan?.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) => { console.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 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 response = await fetch( endorserApiServer + "/api/v2/report/gives?giftNotTrade=true" + beforeQuery, { method: "GET", headers: await getHeaders(this.activeDid), }, ); 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. * - If only giver is named, show "... gave" * - If only receiver is named, show "... received" */ const giverInfo = giveRecord.giver; const recipientInfo = giveRecord.receiver; 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 named but recipient is not // show the project name if to one if (giveRecord.recipientProjectName) { // retrieve the project name return `${giverInfo.displayName} gave: ${gaveAmount} (to the project ${giveRecord.recipientProjectName})`; } // it's not to a project return `${giverInfo.displayName} gave: ${gaveAmount} (to ${recipientInfo.displayName})`; } else if (recipientInfo.known) { // recipient is named but giver is not return `${recipientInfo.displayName} received: ${gaveAmount} (from ${giverInfo.displayName})`; } else { // neither giver nor recipient are named // show the project name if to one if (giveRecord.recipientProjectName) { // retrieve the project name return `${gaveAmount} (to the project ${giveRecord.recipientProjectName})`; } // it's not to a project let peopleInfo; if (giverInfo.displayName === recipientInfo.displayName) { peopleInfo = `between two who are ${giverInfo.displayName}`; } else { peopleInfo = `from ${giverInfo.displayName} to ${recipientInfo.displayName}`; } return gaveAmount + " (" + peopleInfo + ")"; } } onClickLoadClaim(jwtId: string) { const route = { path: "/claim/" + encodeURIComponent(jwtId), }; (this.$router as 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; } openDialog(giver?: GiverReceiverInputInfo) { (this.$refs.customDialog as GiftedDialog).open( giver, { did: this.activeDid, name: "you", }, undefined, "Given by " + (giver?.name || "someone not named"), ); } openGiftedPrompts() { (this.$refs.giftedPrompts as GiftedPrompts).open(); } openFeedFilters() { (this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange); } toastUser(message) { this.$notify( { group: "alert", type: "toast", title: "FYI", text: message, }, 2000, ); } computeKnownPersonIconStyleClassNames(known: boolean) { return known ? "text-slate-500" : "text-slate-100"; } } </script>