Merge pull request 'emojis' (#209) from emojis into master

Reviewed-on: trent_larson/crowd-funder-for-time-pwa#209
This commit is contained in:
2025-10-22 21:19:58 -04:00
12 changed files with 371 additions and 89 deletions

View File

@@ -77,12 +77,86 @@
</a>
</div>
<!-- Description -->
<p class="font-medium overflow-hidden">
<a
class="block cursor-pointer overflow-hidden text-ellipsis"
@click="emitLoadClaim(record.jwtId)"
<!-- Emoji Section -->
<div
v-if="hasEmojis || isRegistered"
class="float-right ml-3 mb-1 bg-white rounded border border-slate-300 px-1.5 py-0.5 max-w-[240px]"
>
<div class="flex items-center justify-between gap-1">
<!-- Existing Emojis Display -->
<div v-if="hasEmojis" class="flex flex-wrap gap-1">
<button
v-for="(count, emoji) in record.emojiCount"
:key="emoji"
class="inline-flex items-center gap-0.5 px-1 py-0.5 text-xs bg-slate-50 hover:bg-slate-100 rounded border border-slate-200 transition-colors cursor-pointer"
:class="{
'bg-blue-50 border-blue-200': isUserEmojiWithoutLoading(emoji),
'opacity-75 cursor-wait': loadingEmojis,
}"
:title="
loadingEmojis
? 'Loading...'
: !emojisOnActivity?.isResolved
? 'Click to load your emojis'
: isUserEmojiWithoutLoading(emoji)
? 'Click to remove your emoji'
: 'Click to add this emoji'
"
:disabled="!isRegistered"
@click="toggleThisEmoji(emoji)"
>
<!-- Show spinner when loading -->
<div v-if="loadingEmojis" class="animate-spin text-xs">
<font-awesome icon="spinner" class="fa-spin" />
</div>
<span v-else class="text-sm leading-none">{{ emoji }}</span>
<span class="text-xs text-slate-600 font-medium leading-none">{{
count
}}</span>
</button>
</div>
<!-- Add Emoji Button -->
<button
v-if="isRegistered"
class="inline-flex px-1 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded border border-slate-300 transition-colors items-center justify-center ml-2 ml-auto"
:title="showEmojiPicker ? 'Close emoji picker' : 'Add emoji'"
@click="toggleEmojiPicker"
>
<span class="px-2 text-sm leading-none">{{
showEmojiPicker ? "x" : "😊"
}}</span>
</button>
</div>
<!-- Emoji Picker (placeholder for now) -->
<div
v-if="showEmojiPicker"
class="mt-1 p-1.5 bg-slate-50 rounded border border-slate-300"
>
<!-- Temporary emoji buttons for testing -->
<div class="flex flex-wrap gap-3 mt-1">
<button
v-for="emoji in QUICK_EMOJIS"
:key="emoji"
class="p-0.5 hover:bg-slate-200 rounded text-base transition-opacity"
:class="{
'opacity-75 cursor-wait': loadingEmojis,
}"
:disabled="loadingEmojis"
@click="toggleThisEmoji(emoji)"
>
<!-- Show spinner when loading -->
<div v-if="loadingEmojis" class="animate-spin text-sm">⟳</div>
<span v-else>{{ emoji }}</span>
</button>
</div>
</div>
</div>
<!-- Description -->
<p class="font-medium">
<a class="block cursor-pointer" @click="emitLoadClaim(record.jwtId)">
<vue-markdown
:source="truncatedDescription"
class="markdown-content"
@@ -91,7 +165,7 @@
</p>
<div
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
class="clear-right relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
>
<!-- Source -->
<div
@@ -254,17 +328,24 @@
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import { GiveRecordWithContactInfo } from "@/interfaces/give";
import VueMarkdown from "vue-markdown-render";
import { logger } from "../utils/logger";
import {
createAndSubmitClaim,
getHeaders,
isHiddenDid,
} from "../libs/endorserServer";
import EntityIcon from "./EntityIcon.vue";
import { isHiddenDid } from "../libs/endorserServer";
import ProjectIcon from "./ProjectIcon.vue";
import { createNotifyHelpers, NotifyFunction } from "@/utils/notify";
import { createNotifyHelpers, NotifyFunction, TIMEOUTS } from "@/utils/notify";
import {
NOTIFY_PERSON_HIDDEN,
NOTIFY_UNKNOWN_PERSON,
} from "@/constants/notifications";
import { TIMEOUTS } from "@/utils/notify";
import VueMarkdown from "vue-markdown-render";
import { EmojiSummaryRecord, GenericVerifiableCredential } from "@/interfaces";
import { GiveRecordWithContactInfo } from "@/interfaces/give";
import { PromiseTracker } from "@/libs/util";
@Component({
components: {
@@ -274,15 +355,24 @@ import VueMarkdown from "vue-markdown-render";
},
})
export default class ActivityListItem extends Vue {
readonly QUICK_EMOJIS = ["👍", "👏", "❤️", "🎉", "😊", "😆", "🔥"];
@Prop() record!: GiveRecordWithContactInfo;
@Prop() lastViewedClaimId?: string;
@Prop() isRegistered!: boolean;
@Prop() activeDid!: string;
@Prop() apiServer!: string;
isHiddenDid = isHiddenDid;
notify!: ReturnType<typeof createNotifyHelpers>;
$notify!: NotifyFunction;
// Emoji-related data
showEmojiPicker = false;
loadingEmojis = false; // Track if emojis are currently loading
emojisOnActivity: PromiseTracker<EmojiSummaryRecord[]> | null = null; // load this only when needed
created() {
this.notify = createNotifyHelpers(this.$notify);
}
@@ -346,5 +436,186 @@ export default class ActivityListItem extends Vue {
day: "numeric",
});
}
// Emoji-related computed properties and methods
get hasEmojis(): boolean {
return Object.keys(this.record.emojiCount).length > 0;
}
triggerUserEmojiLoad(): PromiseTracker<EmojiSummaryRecord[]> {
if (!this.emojisOnActivity) {
const promise = new Promise<EmojiSummaryRecord[]>((resolve) => {
(async () => {
this.axios
.get(
`${this.apiServer}/api/v2/report/emoji?parentHandleId=${encodeURIComponent(this.record.handleId)}`,
{ headers: await getHeaders(this.activeDid) },
)
.then((response) => {
const userEmojiRecords = response.data.data.filter(
(e: EmojiSummaryRecord) => e.issuerDid === this.activeDid,
);
resolve(userEmojiRecords);
})
.catch((error) => {
logger.error("Error loading user emojis:", error);
resolve([]);
});
})();
});
this.emojisOnActivity = new PromiseTracker(promise);
}
return this.emojisOnActivity;
}
/**
*
* @param emoji - The emoji to check.
* @returns True if the emoji is in the user's emojis, false otherwise.
*
* @note This method is quick and synchronous, and can check resolved emojis
* without triggering a server request. Returns false if emojis haven't been loaded yet.
*/
isUserEmojiWithoutLoading(emoji: string): boolean {
if (this.emojisOnActivity?.isResolved && this.emojisOnActivity.value) {
return this.emojisOnActivity.value.some(
(record) => record.text === emoji,
);
}
return false;
}
async toggleEmojiPicker() {
this.triggerUserEmojiLoad(); // trigger it, but don't wait for it to complete
this.showEmojiPicker = !this.showEmojiPicker;
}
async toggleThisEmoji(emoji: string) {
// Start loading indicator
this.loadingEmojis = true;
this.showEmojiPicker = false; // always close the picker when an emoji is clicked
try {
this.triggerUserEmojiLoad(); // trigger just in case
const userEmojiList = await this.emojisOnActivity!.promise; // must wait now that they've chosen
const userHasEmoji: boolean = userEmojiList.some(
(record) => record.text === emoji,
);
if (userHasEmoji) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Remove Emoji",
text: `Do you want to remove your ${emoji} ?`,
yesText: "Remove",
onYes: async () => {
await this.removeEmoji(emoji);
},
},
TIMEOUTS.MODAL,
);
} else {
// User doesn't have this emoji, add it
await this.submitEmoji(emoji);
}
} finally {
// Remove loading indicator
this.loadingEmojis = false;
}
}
async submitEmoji(emoji: string) {
try {
// Create an Emoji claim and send to the server
const emojiClaim: GenericVerifiableCredential = {
"@context": "https://endorser.ch",
"@type": "Emoji",
text: emoji,
parentItem: { lastClaimId: this.record.jwtId },
};
const claim = await createAndSubmitClaim(
emojiClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (claim.success && !claim.embeddedRecordError) {
// Update emoji count
this.record.emojiCount[emoji] =
(this.record.emojiCount[emoji] || 0) + 1;
// Create a new emoji record (we'll get the actual jwtId from the server response later)
const newEmojiRecord: EmojiSummaryRecord = {
issuerDid: this.activeDid,
jwtId: claim.claimId || "",
text: emoji,
parentHandleId: this.record.jwtId,
};
// Update user emojis list by creating a new promise with the updated data
// (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly)
this.triggerUserEmojiLoad();
const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one
this.emojisOnActivity = new PromiseTracker(
Promise.resolve([...currentEmojis, newEmojiRecord]),
);
} else {
this.notify.error("Failed to add emoji.", TIMEOUTS.STANDARD);
}
} catch (error) {
logger.error("Error submitting emoji:", error);
this.notify.error("Got error adding emoji.", TIMEOUTS.STANDARD);
}
}
async removeEmoji(emoji: string) {
try {
// Create an Emoji claim and send to the server
const emojiClaim: GenericVerifiableCredential = {
"@context": "https://endorser.ch",
"@type": "Emoji",
text: emoji,
parentItem: { lastClaimId: this.record.jwtId },
};
const claim = await createAndSubmitClaim(
emojiClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (claim.success && !claim.embeddedRecordError) {
// Update emoji count
const newCount = Math.max(0, (this.record.emojiCount[emoji] || 0) - 1);
if (newCount === 0) {
delete this.record.emojiCount[emoji];
} else {
this.record.emojiCount[emoji] = newCount;
}
// Update user emojis list by creating a new promise with the updated data
// (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly)
this.triggerUserEmojiLoad();
const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one
this.emojisOnActivity = new PromiseTracker(
Promise.resolve(
currentEmojis.filter(
(record) =>
record.issuerDid === this.activeDid && record.text !== emoji,
),
),
);
} else {
this.notify.error("Failed to remove emoji.", TIMEOUTS.STANDARD);
}
} catch (error) {
logger.error("Error removing emoji:", error);
this.notify.error("Got error removing emoji.", TIMEOUTS.STANDARD);
}
}
}
</script>