Browse Source

feat: add claim confirmation functionality to activity feed

- Add confirm button functionality to ActivityListItem
- Implement confirmation logic in HomeView
- Add proper button state handling and validation

Technical Changes:
- Add canConfirm computed property to validate confirmation ability
- Add handleConfirmClick method with proper error handling
- Pass required props (isRegistered, activeDid, confirmerIdList)
- Add confirmation dialog with user verification
- Implement claim submission with proper cleanup
- Add visual feedback for button states
- Update feed after successful confirmation

UI/UX Improvements:
- Add disabled state styling for confirm button
- Show proper error messages for invalid confirmation attempts
- Add loading and success notifications
- Improve button accessibility with proper states

Bug Fixes:
- Make apiServer optional in settings type
- Fix settings update during registration
- Add proper type checking for claim confirmation

This adds the ability to confirm claims directly from the
activity feed with proper validation, error handling, and
user feedback. The confirmation flow matches the existing
claim view confirmation functionality.
homeview-refresh-2025-02
Matthew Raymer 4 weeks ago
parent
commit
fa7d6317b9
  1. 146
      src/components/ActivityListItem.vue
  2. 2
      src/db/tables/settings.ts
  3. 62
      src/views/HomeView.vue

146
src/components/ActivityListItem.vue

@ -19,14 +19,14 @@
/></a> /></a>
<div> <div>
<h3 class="font-semibold"><a href="">[POSTER_NAME]</a></h3> <h3 class="font-semibold">
<p class="ms-auto text-xs text-slate-500 italic">[TIMESTAMP]</p> <a href="" class="hover:underline">
<!-- <p {{ record.giver.known ? record.giver.displayName : 'Anonymous Giver' }}
class="ms-auto text-xs text-slate-500 italic" </a>
:title="record.timestamp" </h3>
> <p class="ms-auto text-xs text-slate-500 italic">
{{ formattedTimestamp }} {{ friendlyDate }}
</p> --> </p>
</div> </div>
</div> </div>
@ -55,34 +55,35 @@
class="w-28 sm:w-48 text-center bg-white border border-slate-200 rounded p-2 sm:p-3" class="w-28 sm:w-48 text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
> >
<div class="relative w-fit mx-auto"> <div class="relative w-fit mx-auto">
<!-- <template v-if="record.giver.profileImageUrl">
If unknown user, icon="person-circle-question"
If project with no photo, icon="image"
-->
<fa
v-if="!record.giver.profileImageUrl"
icon="person-circle-question"
class="text-slate-300 text-5xl sm:text-8xl"
/>
<EntityIcon <EntityIcon
v-else
:profile-image-url="record.giver.profileImageUrl" :profile-image-url="record.giver.profileImageUrl"
:class=" :class="[
record.giver.known record.giver.known
? 'rounded-full size-12 sm:size-24 object-cover' ? 'rounded-full size-12 sm:size-24 object-cover'
: 'rounded size-12 sm:size-24 object-cover' : 'rounded size-12 sm:size-24 object-cover'
" ]"
/> />
<!-- </template>
<span <template v-else>
class="absolute -end-3 -bottom-2 bg-slate-400 rounded-full leading-1.25 p-1 sm:px-1.5 -mt-6 border sm:border-2 border-white text-xs sm:text-base" <!-- Identicon for DIDs -->
> <img
v-if="record.giver.did"
:src="`https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg`"
:class="[
record.giver.known
? 'rounded-full size-12 sm:size-24'
: 'rounded size-12 sm:size-24'
]"
alt="Identicon"
/>
<!-- Fallback icon for projects -->
<fa <fa
:icon="record.giver.known ? 'user' : 'hammer'" v-else
class="fa-fw text-white" icon="person-circle-question"
class="text-slate-300 text-5xl sm:text-8xl"
/> />
</span> </template>
-->
</div> </div>
<div class="text-xs mt-2 line-clamp-2"> <div class="text-xs mt-2 line-clamp-2">
<fa <fa
@ -109,34 +110,35 @@
class="w-28 sm:w-48 text-center bg-white border border-slate-200 rounded p-2 sm:p-3" class="w-28 sm:w-48 text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
> >
<div class="relative w-fit mx-auto"> <div class="relative w-fit mx-auto">
<!-- <template v-if="record.receiver.profileImageUrl">
If unknown user, icon="person-circle-question"
If project with no photo, icon="image"
-->
<fa
v-if="!record.receiver.profileImageUrl"
icon="image"
class="text-slate-300 text-5xl sm:text-8xl"
/>
<EntityIcon <EntityIcon
v-else
:profile-image-url="record.receiver.profileImageUrl" :profile-image-url="record.receiver.profileImageUrl"
:class=" :class="[
record.receiver.known record.receiver.known
? 'rounded-full size-12 sm:size-24 object-cover' ? 'rounded-full size-12 sm:size-24 object-cover'
: 'rounded size-12 sm:size-24 object-cover' : 'rounded size-12 sm:size-24 object-cover'
" ]"
/> />
<!-- </template>
<span <template v-else>
class="absolute -end-3 -bottom-2 bg-slate-400 rounded-full leading-1.25 p-1 sm:px-1.5 -mt-6 border sm:border-2 border-white text-xs sm:text-base" <!-- Identicon for DIDs -->
> <img
v-if="record.receiver.did"
:src="`https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg`"
:class="[
record.receiver.known
? 'rounded-full size-12 sm:size-24'
: 'rounded size-12 sm:size-24'
]"
alt="Identicon"
/>
<!-- Fallback icon for projects -->
<fa <fa
:icon="record.receiver.known ? 'user' : 'hammer'" v-else
class="fa-fw text-white" icon="image"
class="text-slate-300 text-5xl sm:text-8xl"
/> />
</span> </template>
-->
</div> </div>
<div class="text-xs mt-2 line-clamp-2"> <div class="text-xs mt-2 line-clamp-2">
<fa <fa
@ -164,7 +166,14 @@
<fa icon="circle-info" class="fa-fw text-slate-500" /> <fa icon="circle-info" class="fa-fw text-slate-500" />
</a> </a>
<button <button
class="text-sm text-white 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 ms-auto rounded-md" @click="handleConfirmClick"
:disabled="!canConfirm"
class="text-sm text-white px-3 py-1.5 ms-auto rounded-md"
:class="[
canConfirm
? 'bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)]'
: 'bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] opacity-50'
]"
> >
Confirm Confirm
</button> </button>
@ -176,6 +185,8 @@
import { Component, Prop, Vue } from "vue-facing-decorator"; import { Component, Prop, Vue } from "vue-facing-decorator";
import { GiveRecordWithContactInfo } from "../types"; import { GiveRecordWithContactInfo } from "../types";
import EntityIcon from "./EntityIcon.vue"; import EntityIcon from "./EntityIcon.vue";
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
import { containsHiddenDid } from "../libs/endorserServer";
@Component({ @Component({
components: { components: {
@ -185,6 +196,9 @@ import EntityIcon from "./EntityIcon.vue";
export default class ActivityListItem extends Vue { export default class ActivityListItem extends Vue {
@Prop() record!: GiveRecordWithContactInfo; @Prop() record!: GiveRecordWithContactInfo;
@Prop() lastViewedClaimId?: string; @Prop() lastViewedClaimId?: string;
@Prop() isRegistered!: boolean;
@Prop() activeDid!: string;
@Prop() confirmerIdList?: string[];
private formatAmount(claim: unknown): string { private formatAmount(claim: unknown): string {
const amount = claim.object?.amountOfThisGood const amount = claim.object?.amountOfThisGood
@ -274,5 +288,39 @@ export default class ActivityListItem extends Vue {
// Add your timestamp formatting logic here // Add your timestamp formatting logic here
return this.record.timestamp; return this.record.timestamp;
} }
get canConfirm(): boolean {
if (!this.isRegistered) return false;
if (!isGiveClaimType(this.record.fullClaim?.["@type"])) return false;
if (this.confirmerIdList?.includes(this.activeDid)) return false;
if (this.record.issuerDid === this.activeDid) return false;
if (containsHiddenDid(this.record.fullClaim)) return false;
return true;
}
handleConfirmClick() {
if (!this.canConfirm) {
notifyWhyCannotConfirm(
this.$notify,
this.isRegistered,
this.record.fullClaim?.["@type"],
this.record,
this.activeDid,
this.confirmerIdList
);
return;
}
this.$emit('confirmClaim', this.record);
}
get friendlyDate(): string {
const date = new Date(this.record.issuedAt);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
} }
</script> </script>

2
src/db/tables/settings.ts

@ -20,7 +20,7 @@ export type Settings = {
// active Decentralized ID // active Decentralized ID
activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry
apiServer: string; // API server URL apiServer?: string; // API server URL
filterFeedByNearby?: boolean; // filter by nearby filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden

62
src/views/HomeView.vue

@ -255,9 +255,13 @@
:key="record.jwtId" :key="record.jwtId"
:record="record" :record="record"
:lastViewedClaimId="feedLastViewedClaimId" :lastViewedClaimId="feedLastViewedClaimId"
:isRegistered="isRegistered"
:activeDid="activeDid"
:confirmerIdList="record.confirmerIdList"
@loadClaim="onClickLoadClaim" @loadClaim="onClickLoadClaim"
@viewImage="openImageViewer" @viewImage="openImageViewer"
@cacheImage="cacheImageData" @cacheImage="cacheImageData"
@confirmClaim="confirmClaim"
/> />
</ul> </ul>
</InfiniteScroll> </InfiniteScroll>
@ -336,6 +340,7 @@ import {
OnboardPage, OnboardPage,
registerSaveAndActivatePasskey, registerSaveAndActivatePasskey,
} from "../libs/util"; } from "../libs/util";
import * as serverUtil from "../libs/endorserServer";
// import { fa0 } from "@fortawesome/free-solid-svg-icons"; // import { fa0 } from "@fortawesome/free-solid-svg-icons";
interface GiveRecordWithContactInfo extends GiveSummaryRecord { interface GiveRecordWithContactInfo extends GiveSummaryRecord {
@ -461,6 +466,7 @@ export default class HomeView extends Vue {
if (resp.status === 200) { if (resp.status === 200) {
await updateAccountSettings(this.activeDid, { await updateAccountSettings(this.activeDid, {
isRegistered: true, isRegistered: true,
...await retrieveSettingsForActiveAccount()
}); });
this.isRegistered = true; this.isRegistered = true;
} }
@ -904,5 +910,61 @@ export default class HomeView extends Vue {
this.selectedImage = imageUrl; this.selectedImage = imageUrl;
this.isImageViewerOpen = true; 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 {
console.error("Error submitting confirmation:", result);
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation."
}, 5000);
}
}
},
-1
);
}
} }
</script> </script>

Loading…
Cancel
Save