Browse Source

feat: get the emojis to work with additions, removals, and multiple people

pull/209/head
Trent Larson 3 days ago
parent
commit
a4a9293bc2
  1. 192
      src/components/ActivityListItem.vue
  2. 16
      src/db-sql/migration.ts
  3. 4
      src/interfaces/common.ts
  4. 31
      src/interfaces/index.ts
  5. 13
      src/interfaces/records.ts
  6. 13
      src/libs/endorserServer.ts
  7. 26
      src/libs/util.ts
  8. 8
      src/services/platforms/WebPlatformService.ts
  9. 3
      src/views/HomeView.vue

192
src/components/ActivityListItem.vue

@ -79,24 +79,34 @@
<!-- Emoji Section -->
<div
class="float-right ml-3 mb-1 bg-white rounded border border-slate-300 px-1.5 py-0.5 max-w-[200px]"
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 mr-2">
<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': isUserEmoji(emoji) }"
:class="{
'bg-blue-50 border-blue-200': isUserEmojiWithoutLoading(emoji),
'opacity-75 cursor-wait': loadingEmojis,
}"
:title="
isUserEmoji(emoji)
loadingEmojis
? 'Loading...'
: !emojisOnActivity?.isResolved
? 'Click to load your emojis'
: isUserEmojiWithoutLoading(emoji)
? 'Click to remove your emoji'
: 'Click to add this emoji'
"
@click="toggleEmoji(emoji)"
:disabled="!isRegistered"
@click="toggleThisEmoji(emoji)"
>
<span class="text-sm leading-none">{{ emoji }}</span>
<!-- Show spinner when loading -->
<div v-if="loadingEmojis" class="animate-spin text-xs"></div>
<span v-else class="text-sm leading-none">{{ emoji }}</span>
<span class="text-xs text-slate-600 font-medium leading-none">{{
count
}}</span>
@ -105,11 +115,12 @@
<!-- Add Emoji Button -->
<button
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-auto"
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="showEmojiPicker = !showEmojiPicker"
@click="toggleEmojiPicker"
>
<span class="text-sm leading-none">{{
<span class="px-2 text-sm leading-none">{{
showEmojiPicker ? "x" : "😊"
}}</span>
</button>
@ -121,14 +132,20 @@
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-1 mt-1">
<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"
@click="selectEmoji(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)"
>
{{ emoji }}
<!-- Show spinner when loading -->
<div v-if="loadingEmojis" class="animate-spin text-sm"></div>
<span v-else>{{ emoji }}</span>
</button>
</div>
</div>
@ -323,8 +340,9 @@ import {
NOTIFY_PERSON_HIDDEN,
NOTIFY_UNKNOWN_PERSON,
} from "@/constants/notifications";
import { GenericVerifiableCredential } from "@/interfaces";
import { EmojiSummaryRecord, GenericVerifiableCredential } from "@/interfaces";
import { GiveRecordWithContactInfo } from "@/interfaces/give";
import { PromiseTracker } from "@/libs/util";
@Component({
components: {
@ -348,8 +366,9 @@ export default class ActivityListItem extends Vue {
// Emoji-related data
showEmojiPicker = false;
loadingEmojis = false; // Track if emojis are currently loading
userEmojis: string[] | null = null; // load this only when needed
emojisOnActivity: PromiseTracker<EmojiSummaryRecord[]> | null = null; // load this only when needed
created() {
this.notify = createNotifyHelpers(this.$notify);
@ -420,52 +439,87 @@ export default class ActivityListItem extends Vue {
return Object.keys(this.record.emojiCount).length > 0;
}
async loadUserEmojis(): Promise<void> {
try {
const response = await this.axios.get(
`${this.apiServer}/api/v2/emoji/userEmojis?parentHandleId=${this.record.jwtId}`,
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,
);
this.userEmojis = response.data;
} catch (error) {
logger.error(
"Error loading all emojis for parent handle id:",
this.record.jwtId,
error,
);
resolve(userEmojiRecords);
})
.catch((error) => {
logger.error("Error loading user emojis:", error);
resolve([]);
});
})();
});
this.emojisOnActivity = new PromiseTracker(promise);
}
return this.emojisOnActivity;
}
async getUserEmojis(): Promise<string[]> {
if (!this.userEmojis) {
await this.loadUserEmojis();
/**
*
* @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 this.userEmojis || [];
return false;
}
selectEmoji(emoji: string) {
this.showEmojiPicker = false;
this.submitEmoji(emoji);
async toggleEmojiPicker() {
this.triggerUserEmojiLoad(); // trigger it, but don't wait for it to complete
this.showEmojiPicker = !this.showEmojiPicker;
}
isUserEmoji(emoji: string): boolean {
return this.userEmojis?.includes(emoji) || false;
}
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
toggleEmoji(emoji: string) {
if (this.isUserEmoji(emoji)) {
this.removeEmoji(emoji);
const userHasEmoji: boolean = userEmojiList.some(
(record) => record.text === emoji,
);
if (userHasEmoji) {
// User already has this emoji, ask for confirmation to remove
const confirmed = confirm(`Do you want to remove your ${emoji} emoji?`);
if (confirmed) {
await this.removeEmoji(emoji);
}
} else {
this.submitEmoji(emoji);
// User doesn't have this emoji, add it
await this.submitEmoji(emoji);
}
} finally {
// Remove loading indicator
this.loadingEmojis = false;
}
}
async submitEmoji(emoji: string) {
try {
// Temporarily add to user emojis for UI feedback
if (!this.isUserEmoji(emoji)) {
this.record.emojiCount[emoji] = 0;
}
// Create an Emoji claim and send to the server
const emojiClaim: GenericVerifiableCredential = {
"@type": "Emoji",
@ -474,17 +528,30 @@ export default class ActivityListItem extends Vue {
};
const claim = await createAndSubmitClaim(
emojiClaim,
this.record.issuerDid,
this.activeDid,
this.apiServer,
this.axios,
);
if (
claim.success &&
!(claim.success as { embeddedRecordError?: string }).embeddedRecordError
) {
if (claim.success && !claim.embeddedRecordError) {
// Update emoji count
this.record.emojiCount[emoji] =
(this.record.emojiCount[emoji] || 0) + 1;
this.userEmojis = [...(this.userEmojis || []), emoji];
// 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);
}
@ -504,22 +571,31 @@ export default class ActivityListItem extends Vue {
};
const claim = await createAndSubmitClaim(
emojiClaim,
this.record.issuerDid,
this.activeDid,
this.apiServer,
this.axios,
);
if (claim.success) {
this.record.emojiCount[emoji] =
(this.record.emojiCount[emoji] || 0) - 1;
// Update local emoji count for immediate UI feedback
const newCount = Math.max(0, this.record.emojiCount[emoji]);
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;
}
this.userEmojis = this.userEmojis?.filter(e => e !== emoji) || [];
// 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);
}

16
src/db-sql/migration.ts

@ -234,32 +234,20 @@ export async function runMigrations<T>(
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
// Only log migration start in development
const isDevelopment = process.env.VITE_PLATFORM === "development";
if (isDevelopment) {
logger.debug("[Migration] Starting database migrations");
}
for (const migration of MIGRATIONS) {
if (isDevelopment) {
logger.debug("[Migration] Registering migration:", migration.name);
}
registerMigration(migration);
}
if (isDevelopment) {
logger.debug("[Migration] Running migration service");
}
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
if (isDevelopment) {
logger.debug("[Migration] Database migrations completed");
}
// Bootstrapping: Ensure active account is selected after migrations
if (isDevelopment) {
logger.debug("[Migration] Running bootstrapping hooks");
}
try {
// Check if we have accounts but no active selection
const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts");
@ -274,18 +262,14 @@ export async function runMigrations<T>(
activeDid = (extractSingleValue(activeResult) as string) || null;
} catch (error) {
// Table doesn't exist - migration 004 may not have run yet
if (isDevelopment) {
logger.debug(
"[Migration] active_identity table not found - migration may not have run",
);
}
activeDid = null;
}
if (accountsCount > 0 && (!activeDid || activeDid === "")) {
if (isDevelopment) {
logger.debug("[Migration] Auto-selecting first account as active");
}
const firstAccountResult = await sqlQuery(
"SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1",
);

4
src/interfaces/common.ts

@ -80,8 +80,10 @@ export interface UserInfo {
}
export interface CreateAndSubmitClaimResult {
success: boolean | { embeddedRecordError?: string; claimId?: string };
success: boolean;
embeddedRecordError?: string;
error?: string;
claimId?: string;
handleId?: string;
}

31
src/interfaces/index.ts

@ -1,34 +1,3 @@
export type {
// From common.ts
CreateAndSubmitClaimResult,
GenericCredWrapper,
GenericVerifiableCredential,
KeyMeta,
// Exclude types that are also exported from other files
// GiveVerifiableCredential,
// OfferVerifiableCredential,
// RegisterVerifiableCredential,
// PlanSummaryRecord,
// UserInfo,
} from "./common";
export type {
// From claims.ts
GiveActionClaim,
OfferClaim,
RegisterActionClaim,
} from "./claims";
export type {
// From records.ts
PlanSummaryRecord,
} from "./records";
export type {
// From user.ts
UserInfo,
} from "./user";
export * from "./limits";
export * from "./deepLinks";
export * from "./common";

13
src/interfaces/records.ts

@ -1,9 +1,20 @@
import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims";
import { GenericCredWrapper } from "./common";
export interface EmojiSummaryRecord {
issuerDid: string;
jwtId: string;
text: string;
parentHandleId: string;
}
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
[x: string]: PropertyKey | undefined | GiveActionClaim;
[x: string]:
| PropertyKey
| undefined
| GiveActionClaim
| Record<string, number>;
type?: string;
agentDid: string;
amount: number;

13
src/libs/endorserServer.ts

@ -630,11 +630,7 @@ async function performPlanRequest(
return cred;
} else {
// Use debug level for development to reduce console noise
const isDevelopment = process.env.VITE_PLATFORM === "development";
const log = isDevelopment ? logger.debug : logger.log;
log(
logger.debug(
"[Plan Loading] ⚠️ Plan cache is empty for handle",
handleId,
" Got data:",
@ -1226,7 +1222,12 @@ export async function createAndSubmitClaim(
timestamp: new Date().toISOString(),
});
return { success: true, handleId: response.data?.handleId };
return {
success: true,
claimId: response.data?.claimId,
handleId: response.data?.handleId,
embeddedRecordError: response.data?.embeddedRecordError,
};
} catch (error: unknown) {
// Enhanced error logging with comprehensive context
const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;

26
src/libs/util.ts

@ -1147,3 +1147,29 @@ export async function checkForDuplicateAccount(
return (existingAccount?.values?.length ?? 0) > 0;
}
export class PromiseTracker<T> {
private _promise: Promise<T>;
private _resolved = false;
private _value: T | undefined;
constructor(promise: Promise<T>) {
this._promise = promise.then((value) => {
this._resolved = true;
this._value = value;
return value;
});
}
get isResolved(): boolean {
return this._resolved;
}
get value(): T | undefined {
return this._value;
}
get promise(): Promise<T> {
return this._promise;
}
}

8
src/services/platforms/WebPlatformService.ts

@ -48,15 +48,11 @@ export class WebPlatformService implements PlatformService {
constructor() {
WebPlatformService.instanceCount++;
// Use debug level logging for development mode to reduce console noise
const isDevelopment = process.env.VITE_PLATFORM === "development";
const log = isDevelopment ? logger.debug : logger.log;
log("[WebPlatformService] Initializing web platform service");
logger.debug("[WebPlatformService] Initializing web platform service");
// Only initialize SharedArrayBuffer setup for web platforms
if (this.isWorker()) {
log("[WebPlatformService] Skipping initBackend call in worker context");
logger.debug("[WebPlatformService] Skipping initBackend call in worker context");
return;
}

3
src/views/HomeView.vue

@ -1235,7 +1235,6 @@ export default class HomeView extends Vue {
const recipientDid = this.extractRecipientDid(claim);
const fulfillsPlan = await this.getFulfillsPlan(record);
const emojiCount = await record.emojiCount;
// Log record details for debugging
logger.debug("[HomeView] 🔍 Processing record:", {
@ -1266,7 +1265,7 @@ export default class HomeView extends Vue {
provider,
fulfillsPlan,
providedByPlan,
emojiCount,
record.emojiCount,
);
}

Loading…
Cancel
Save