Browse Source

feat: add a notification for changes to starred projects

pull/193/head
Trent Larson 3 weeks ago
parent
commit
24a7cf5eb6
  1. 3
      src/db/tables/settings.ts
  2. 6
      src/interfaces/records.ts
  3. 49
      src/libs/endorserServer.ts
  4. 66
      src/views/HomeView.vue
  5. 142
      src/views/NewActivityView.vue
  6. 5
      src/views/ProjectViewView.vue

3
src/db/tables/settings.ts

@ -36,6 +36,7 @@ export type Settings = {
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing 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 lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
lastAckedStarredProjectChangesJwtId?: string; // the last JWT ID for starred project changes that they've acknowledged seeing
// The claim list has a most recent one used in notifications that's separate from the last viewed // The claim list has a most recent one used in notifications that's separate from the last viewed
lastNotifiedClaimId?: string; lastNotifiedClaimId?: string;
@ -61,7 +62,7 @@ export type Settings = {
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
// List of starred project IDs, which are recommended to be handleIds // List of starred project handleIds
starredProjectIds?: Array<string>; starredProjectIds?: Array<string>;
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push

6
src/interfaces/records.ts

@ -1,4 +1,5 @@
import { GiveActionClaim, OfferClaim } from "./claims"; import { GiveActionClaim, OfferClaim } from "./claims";
import { ClaimObject } from "./common";
// a summary record; the VC is found the fullClaim field // a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord { export interface GiveSummaryRecord {
@ -61,6 +62,11 @@ export interface PlanSummaryRecord {
jwtId?: string; jwtId?: string;
} }
export interface PlanSummaryAndPreviousClaim {
plan: PlanSummaryRecord;
wrappedClaimBefore: ClaimObject;
}
/** /**
* Represents data about a project * Represents data about a project
* *

49
src/libs/endorserServer.ts

@ -56,7 +56,12 @@ import {
KeyMetaWithPrivate, KeyMetaWithPrivate,
KeyMetaMaybeWithPrivate, KeyMetaMaybeWithPrivate,
} from "../interfaces/common"; } from "../interfaces/common";
import { PlanSummaryRecord } from "../interfaces/records"; import {
OfferSummaryRecord,
OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim,
PlanSummaryRecord,
} from "../interfaces/records";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { APP_SERVER } from "@/constants/app"; import { APP_SERVER } from "@/constants/app";
@ -730,7 +735,7 @@ export async function getNewOffersToUser(
activeDid: string, activeDid: string,
afterOfferJwtId?: string, afterOfferJwtId?: string,
beforeOfferJwtId?: string, beforeOfferJwtId?: string,
) { ): Promise<{ data: Array<OfferSummaryRecord>; hitLimit: boolean }> {
let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`; let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`;
if (afterOfferJwtId) { if (afterOfferJwtId) {
url += "&afterId=" + afterOfferJwtId; url += "&afterId=" + afterOfferJwtId;
@ -752,7 +757,7 @@ export async function getNewOffersToUserProjects(
activeDid: string, activeDid: string,
afterOfferJwtId?: string, afterOfferJwtId?: string,
beforeOfferJwtId?: string, beforeOfferJwtId?: string,
) { ): Promise<{ data: Array<OfferToPlanSummaryRecord>; hitLimit: boolean }> {
let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`; let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`;
if (afterOfferJwtId) { if (afterOfferJwtId) {
url += "?afterId=" + afterOfferJwtId; url += "?afterId=" + afterOfferJwtId;
@ -766,6 +771,44 @@ export async function getNewOffersToUserProjects(
return response.data; return response.data;
} }
/**
* Get starred projects that have been updated since the last check
*
* @param axios - axios instance
* @param apiServer - endorser API server URL
* @param activeDid - user's DID for authentication
* @param starredProjectIds - array of starred project handle IDs
* @param afterId - JWT ID to check for changes after (from lastAckedStarredProjectChangesJwtId)
* @returns { data: Array<PlanSummaryAndPreviousClaim>, hitLimit: boolean }
*/
export async function getStarredProjectsWithChanges(
axios: Axios,
apiServer: string,
activeDid: string,
starredProjectIds: string[],
afterId?: string,
): Promise<{ data: Array<PlanSummaryAndPreviousClaim>; hitLimit: boolean }> {
if (!starredProjectIds || starredProjectIds.length === 0) {
return { data: [], hitLimit: false };
}
if (!afterId) {
return { data: [], hitLimit: false };
}
// Use POST method for larger lists of project IDs
const url = `${apiServer}/api/v2/report/plansLastUpdatedBetween`;
const headers = await getHeaders(activeDid);
const requestBody = {
planIds: starredProjectIds,
afterId: afterId,
};
const response = await axios.post(url, requestBody, { headers });
return response.data;
}
/** /**
* Construct GiveAction VC for submission to server * Construct GiveAction VC for submission to server
* *

66
src/views/HomeView.vue

@ -201,6 +201,22 @@ Raymer * @version 1.0.0 */
projects projects
</p> </p>
</div> </div>
<div
v-if="numNewStarredProjectChanges"
class="bg-gradient-to-b from-yellow-400 to-yellow-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="newStarredProjectChangesActivityNumber"
>
{{ numNewStarredProjectChanges
}}{{ newStarredProjectChangesHitLimit ? "+" : "" }}
</span>
<p class="text-center">
starred project{{ numNewStarredProjectChanges === 1 ? "" : "s" }}
with changes
</p>
</div>
</div> </div>
<div class="flex justify-end mt-2"> <div class="flex justify-end mt-2">
<button class="text-blue-500">View All New Activity For You</button> <button class="text-blue-500">View All New Activity For You</button>
@ -268,6 +284,7 @@ import {
getHeaders, getHeaders,
getNewOffersToUser, getNewOffersToUser,
getNewOffersToUserProjects, getNewOffersToUserProjects,
getStarredProjectsWithChanges,
getPlanFromCache, getPlanFromCache,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { import {
@ -283,6 +300,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_CONTACT_LOADING_ISSUE } from "@/constants/notifications"; import { NOTIFY_CONTACT_LOADING_ISSUE } from "@/constants/notifications";
import * as Package from "../../package.json"; import * as Package from "../../package.json";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import * as databaseUtil from "../db/databaseUtil";
// consolidate this with GiveActionClaim in src/interfaces/claims.ts // consolidate this with GiveActionClaim in src/interfaces/claims.ts
interface Claim { interface Claim {
@ -395,10 +413,14 @@ export default class HomeView extends Vue {
isRegistered = false; isRegistered = false;
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing 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 lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
lastAckedStarredProjectChangesJwtId?: string; // the last JWT ID for starred project changes that they've acknowledged seeing
newOffersToUserHitLimit: boolean = false; newOffersToUserHitLimit: boolean = false;
newOffersToUserProjectsHitLimit: boolean = false; newOffersToUserProjectsHitLimit: boolean = false;
newStarredProjectChangesHitLimit: boolean = false;
numNewOffersToUser: number = 0; // number of new offers-to-user numNewOffersToUser: number = 0; // number of new offers-to-user
numNewOffersToUserProjects: number = 0; // number of new offers-to-user's-projects numNewOffersToUserProjects: number = 0; // number of new offers-to-user's-projects
numNewStarredProjectChanges: number = 0; // number of new starred project changes
starredProjectIds: Array<string> = []; // list of starred project IDs
searchBoxes: Array<{ searchBoxes: Array<{
name: string; name: string;
bbox: BoundingBox; bbox: BoundingBox;
@ -438,6 +460,7 @@ export default class HomeView extends Vue {
// Registration check already handled in initializeIdentity() // Registration check already handled in initializeIdentity()
await this.loadFeedData(); await this.loadFeedData();
await this.loadNewOffers(); await this.loadNewOffers();
await this.loadNewStarredProjectChanges();
await this.checkOnboarding(); await this.checkOnboarding();
} catch (err: unknown) { } catch (err: unknown) {
this.handleError(err); this.handleError(err);
@ -542,8 +565,14 @@ export default class HomeView extends Vue {
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId; this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
this.lastAckedOfferToUserProjectsJwtId = this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId; settings.lastAckedOfferToUserProjectsJwtId;
this.lastAckedStarredProjectChangesJwtId =
settings.lastAckedStarredProjectChangesJwtId;
this.searchBoxes = settings.searchBoxes || []; this.searchBoxes = settings.searchBoxes || [];
this.showShortcutBvc = !!settings.showShortcutBvc; this.showShortcutBvc = !!settings.showShortcutBvc;
this.starredProjectIds = databaseUtil.parseJsonField(
settings.starredProjectIds,
[],
);
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings); this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
// Check onboarding status // Check onboarding status
@ -675,6 +704,43 @@ export default class HomeView extends Vue {
} }
} }
/**
* Loads new changes for starred projects
* Updates:
* - Number of new starred project changes
* - Rate limit status for starred project changes
*
* @internal
* Called by mounted() and initializeIdentity()
* @requires Active DID
*/
private async loadNewStarredProjectChanges() {
if (this.activeDid && this.starredProjectIds.length > 0) {
try {
const starredProjectChanges = await getStarredProjectsWithChanges(
this.axios,
this.apiServer,
this.activeDid,
this.starredProjectIds,
this.lastAckedStarredProjectChangesJwtId,
);
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) {
// Don't show errors for starred project changes as it's a secondary feature
logger.warn(
"[HomeView] Failed to load starred project changes:",
error,
);
this.numNewStarredProjectChanges = 0;
this.newStarredProjectChangesHitLimit = false;
}
} else {
this.numNewStarredProjectChanges = 0;
this.newStarredProjectChangesHitLimit = false;
}
}
/** /**
* Checks if user needs onboarding using ultra-concise mixin utilities * Checks if user needs onboarding using ultra-concise mixin utilities
* Opens onboarding dialog if not completed * Opens onboarding dialog if not completed

142
src/views/NewActivityView.vue

@ -144,6 +144,73 @@
</li> </li>
</ul> </ul>
</div> </div>
<!-- Starred Projects with Changes Section -->
<div
class="flex justify-between mt-6"
data-testId="showStarredProjectChanges"
>
<div>
<span class="text-lg font-medium"
>{{ newStarredProjectChanges.length
}}{{ newStarredProjectChangesHitLimit ? "+" : "" }}</span
>
<span class="text-lg font-medium ml-4"
>Starred Project{{
newStarredProjectChanges.length === 1 ? "" : "s"
}}
With Changes</span
>
<font-awesome
v-if="newStarredProjectChanges.length > 0"
:icon="
showStarredProjectChangesDetails ? 'chevron-down' : 'chevron-right'
"
class="cursor-pointer ml-4 mr-4 text-lg"
@click="expandStarredProjectChangesAndMarkRead()"
/>
</div>
</div>
<div v-if="showStarredProjectChangesDetails" class="ml-4 mt-4">
<ul class="list-disc ml-4">
<li
v-for="projectChange in newStarredProjectChanges"
:key="projectChange.plan.handleId"
class="mt-4 relative group"
>
<span class="font-medium">{{
projectChange.plan.name || "Unnamed Project"
}}</span>
<span v-if="projectChange.plan.description" class="text-gray-600">
- {{ projectChange.plan.description }}
</span>
<router-link
:to="{
path: '/plan/' + encodeURIComponent(projectChange.plan.handleId),
}"
class="text-blue-500"
>
<font-awesome
icon="external-link-alt"
class="pl-2 text-blue-500 cursor-pointer"
/>
</router-link>
<!-- New line that appears on hover -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click="
markStarredProjectChangesAsReadStartingWith(
projectChange.plan.handleId,
)
"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new changes
</div>
</li>
</ul>
</div>
</section> </section>
</template> </template>
@ -159,17 +226,20 @@ import { Router } from "vue-router";
import { import {
OfferSummaryRecord, OfferSummaryRecord,
OfferToPlanSummaryRecord, OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim,
} from "../interfaces/records"; } from "../interfaces/records";
import { import {
didInfo, didInfo,
displayAmount, displayAmount,
getNewOffersToUser, getNewOffersToUser,
getNewOffersToUserProjects, getNewOffersToUserProjects,
getStarredProjectsWithChanges,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import * as databaseUtil from "../db/databaseUtil";
@Component({ @Component({
components: { GiftedDialog, QuickNav, EntityIcon }, components: { GiftedDialog, QuickNav, EntityIcon },
@ -186,13 +256,18 @@ export default class NewActivityView extends Vue {
apiServer = ""; apiServer = "";
lastAckedOfferToUserJwtId = ""; lastAckedOfferToUserJwtId = "";
lastAckedOfferToUserProjectsJwtId = ""; lastAckedOfferToUserProjectsJwtId = "";
lastAckedStarredProjectChangesJwtId = "";
newOffersToUser: Array<OfferSummaryRecord> = []; newOffersToUser: Array<OfferSummaryRecord> = [];
newOffersToUserHitLimit = false; newOffersToUserHitLimit = false;
newOffersToUserProjects: Array<OfferToPlanSummaryRecord> = []; newOffersToUserProjects: Array<OfferToPlanSummaryRecord> = [];
newOffersToUserProjectsHitLimit = false; newOffersToUserProjectsHitLimit = false;
newStarredProjectChanges: Array<PlanSummaryAndPreviousClaim> = [];
newStarredProjectChangesHitLimit = false;
starredProjectIds: Array<string> = [];
showOffersDetails = false; showOffersDetails = false;
showOffersToUserProjectsDetails = false; showOffersToUserProjectsDetails = false;
showStarredProjectChangesDetails = false;
didInfo = didInfo; didInfo = didInfo;
displayAmount = displayAmount; displayAmount = displayAmount;
@ -206,6 +281,12 @@ export default class NewActivityView extends Vue {
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId || ""; this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId || "";
this.lastAckedOfferToUserProjectsJwtId = this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId || ""; settings.lastAckedOfferToUserProjectsJwtId || "";
this.lastAckedStarredProjectChangesJwtId =
settings.lastAckedStarredProjectChangesJwtId || "";
this.starredProjectIds = databaseUtil.parseJsonField(
settings.starredProjectIds,
[],
);
this.allContacts = await this.$getAllContacts(); this.allContacts = await this.$getAllContacts();
@ -229,6 +310,26 @@ export default class NewActivityView extends Vue {
this.newOffersToUserProjects = offersToUserProjectsData.data; this.newOffersToUserProjects = offersToUserProjectsData.data;
this.newOffersToUserProjectsHitLimit = offersToUserProjectsData.hitLimit; this.newOffersToUserProjectsHitLimit = offersToUserProjectsData.hitLimit;
// Load starred project changes if user has starred projects
if (this.starredProjectIds.length > 0) {
try {
const starredProjectChangesData = await getStarredProjectsWithChanges(
this.axios,
this.apiServer,
this.activeDid,
this.starredProjectIds,
this.lastAckedStarredProjectChangesJwtId,
);
this.newStarredProjectChanges = starredProjectChangesData.data;
this.newStarredProjectChangesHitLimit =
starredProjectChangesData.hitLimit;
} catch (error) {
logger.warn("Failed to load starred project changes:", error);
this.newStarredProjectChanges = [];
this.newStarredProjectChangesHitLimit = false;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
logger.error("Error retrieving settings & contacts:", err); logger.error("Error retrieving settings & contacts:", err);
@ -314,5 +415,46 @@ export default class NewActivityView extends Vue {
TIMEOUTS.STANDARD, TIMEOUTS.STANDARD,
); );
} }
async expandStarredProjectChangesAndMarkRead() {
this.showStarredProjectChangesDetails =
!this.showStarredProjectChangesDetails;
if (
this.showStarredProjectChangesDetails &&
this.newStarredProjectChanges.length > 0
) {
await this.$updateSettings({
lastAckedStarredProjectChangesJwtId:
this.newStarredProjectChanges[0].plan.jwtId,
});
this.notify.info(
"The starred project changes are now marked as viewed. Click in the list to keep them as new.",
TIMEOUTS.LONG,
);
}
}
async markStarredProjectChangesAsReadStartingWith(jwtId: string) {
const index = this.newStarredProjectChanges.findIndex(
(change) => change.plan.jwtId === jwtId,
);
if (index !== -1 && index < this.newStarredProjectChanges.length - 1) {
// Set to the next change's jwtId
await this.$updateSettings({
lastAckedStarredProjectChangesJwtId:
this.newStarredProjectChanges[index + 1].plan.jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await this.$updateSettings({
lastAckedStarredProjectChangesJwtId:
this.lastAckedStarredProjectChangesJwtId,
});
}
this.notify.info(
"All starred project changes above that line are marked as unread.",
TIMEOUTS.STANDARD,
);
}
} }
</script> </script>

5
src/views/ProjectViewView.vue

@ -1527,6 +1527,11 @@ export default class ProjectViewView extends Vue {
); );
} }
} }
if (!settings.lastAckedStarredProjectChangesJwtId) {
await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedStarredProjectChangesJwtId: settings.lastViewedClaimId,
});
}
this.isStarred = true; this.isStarred = true;
} else { } else {
// Remove from starred projects // Remove from starred projects

Loading…
Cancel
Save