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. 202
      src/components/ActivityListItem.vue
  2. 34
      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

202
src/components/ActivityListItem.vue

@ -79,24 +79,34 @@
<!-- Emoji Section --> <!-- Emoji Section -->
<div <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"> <div class="flex items-center justify-between gap-1">
<!-- Existing Emojis Display --> <!-- 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 <button
v-for="(count, emoji) in record.emojiCount" v-for="(count, emoji) in record.emojiCount"
:key="emoji" :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="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=" :title="
isUserEmoji(emoji) loadingEmojis
? 'Click to remove your emoji' ? 'Loading...'
: 'Click to add this emoji' : !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">{{ <span class="text-xs text-slate-600 font-medium leading-none">{{
count count
}}</span> }}</span>
@ -105,11 +115,12 @@
<!-- Add Emoji Button --> <!-- Add Emoji Button -->
<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'" :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" : "😊" showEmojiPicker ? "x" : "😊"
}}</span> }}</span>
</button> </button>
@ -121,14 +132,20 @@
class="mt-1 p-1.5 bg-slate-50 rounded border border-slate-300" class="mt-1 p-1.5 bg-slate-50 rounded border border-slate-300"
> >
<!-- Temporary emoji buttons for testing --> <!-- Temporary emoji buttons for testing -->
<div class="flex flex-wrap gap-1 mt-1"> <div class="flex flex-wrap gap-3 mt-1">
<button <button
v-for="emoji in QUICK_EMOJIS" v-for="emoji in QUICK_EMOJIS"
:key="emoji" :key="emoji"
class="p-0.5 hover:bg-slate-200 rounded text-base" class="p-0.5 hover:bg-slate-200 rounded text-base transition-opacity"
@click="selectEmoji(emoji)" :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> </button>
</div> </div>
</div> </div>
@ -323,8 +340,9 @@ import {
NOTIFY_PERSON_HIDDEN, NOTIFY_PERSON_HIDDEN,
NOTIFY_UNKNOWN_PERSON, NOTIFY_UNKNOWN_PERSON,
} from "@/constants/notifications"; } from "@/constants/notifications";
import { GenericVerifiableCredential } from "@/interfaces"; import { EmojiSummaryRecord, GenericVerifiableCredential } from "@/interfaces";
import { GiveRecordWithContactInfo } from "@/interfaces/give"; import { GiveRecordWithContactInfo } from "@/interfaces/give";
import { PromiseTracker } from "@/libs/util";
@Component({ @Component({
components: { components: {
@ -348,8 +366,9 @@ export default class ActivityListItem extends Vue {
// Emoji-related data // Emoji-related data
showEmojiPicker = false; 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() { created() {
this.notify = createNotifyHelpers(this.$notify); this.notify = createNotifyHelpers(this.$notify);
@ -420,52 +439,87 @@ export default class ActivityListItem extends Vue {
return Object.keys(this.record.emojiCount).length > 0; return Object.keys(this.record.emojiCount).length > 0;
} }
async loadUserEmojis(): Promise<void> { triggerUserEmojiLoad(): PromiseTracker<EmojiSummaryRecord[]> {
try { if (!this.emojisOnActivity) {
const response = await this.axios.get( const promise = new Promise<EmojiSummaryRecord[]>((resolve) => {
`${this.apiServer}/api/v2/emoji/userEmojis?parentHandleId=${this.record.jwtId}`, (async () => {
{ headers: await getHeaders(this.activeDid) }, this.axios
); .get(
this.userEmojis = response.data; `${this.apiServer}/api/v2/report/emoji?parentHandleId=${encodeURIComponent(this.record.handleId)}`,
} catch (error) { { headers: await getHeaders(this.activeDid) },
logger.error( )
"Error loading all emojis for parent handle id:", .then((response) => {
this.record.jwtId, const userEmojiRecords = response.data.data.filter(
error, (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;
} }
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) { async toggleEmojiPicker() {
this.showEmojiPicker = false; this.triggerUserEmojiLoad(); // trigger it, but don't wait for it to complete
this.submitEmoji(emoji); this.showEmojiPicker = !this.showEmojiPicker;
} }
isUserEmoji(emoji: string): boolean { async toggleThisEmoji(emoji: string) {
return this.userEmojis?.includes(emoji) || false; // 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
toggleEmoji(emoji: string) { const userEmojiList = await this.emojisOnActivity!.promise; // must wait now that they've chosen
if (this.isUserEmoji(emoji)) {
this.removeEmoji(emoji); const userHasEmoji: boolean = userEmojiList.some(
} else { (record) => record.text === emoji,
this.submitEmoji(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 {
// User doesn't have this emoji, add it
await this.submitEmoji(emoji);
}
} finally {
// Remove loading indicator
this.loadingEmojis = false;
} }
} }
async submitEmoji(emoji: string) { async submitEmoji(emoji: string) {
try { 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 // Create an Emoji claim and send to the server
const emojiClaim: GenericVerifiableCredential = { const emojiClaim: GenericVerifiableCredential = {
"@type": "Emoji", "@type": "Emoji",
@ -474,17 +528,30 @@ export default class ActivityListItem extends Vue {
}; };
const claim = await createAndSubmitClaim( const claim = await createAndSubmitClaim(
emojiClaim, emojiClaim,
this.record.issuerDid, this.activeDid,
this.apiServer, this.apiServer,
this.axios, this.axios,
); );
if ( if (claim.success && !claim.embeddedRecordError) {
claim.success && // Update emoji count
!(claim.success as { embeddedRecordError?: string }).embeddedRecordError
) {
this.record.emojiCount[emoji] = this.record.emojiCount[emoji] =
(this.record.emojiCount[emoji] || 0) + 1; (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 { } else {
this.notify.error("Failed to add emoji.", TIMEOUTS.STANDARD); this.notify.error("Failed to add emoji.", TIMEOUTS.STANDARD);
} }
@ -504,22 +571,31 @@ export default class ActivityListItem extends Vue {
}; };
const claim = await createAndSubmitClaim( const claim = await createAndSubmitClaim(
emojiClaim, emojiClaim,
this.record.issuerDid, this.activeDid,
this.apiServer, this.apiServer,
this.axios, this.axios,
); );
if (claim.success) { if (claim.success && !claim.embeddedRecordError) {
this.record.emojiCount[emoji] = // Update emoji count
(this.record.emojiCount[emoji] || 0) - 1; const newCount = Math.max(0, (this.record.emojiCount[emoji] || 0) - 1);
// Update local emoji count for immediate UI feedback
const newCount = Math.max(0, this.record.emojiCount[emoji]);
if (newCount === 0) { if (newCount === 0) {
delete this.record.emojiCount[emoji]; delete this.record.emojiCount[emoji];
} else { } else {
this.record.emojiCount[emoji] = newCount; 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 { } else {
this.notify.error("Failed to remove emoji.", TIMEOUTS.STANDARD); this.notify.error("Failed to remove emoji.", TIMEOUTS.STANDARD);
} }

34
src/db-sql/migration.ts

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

13
src/interfaces/records.ts

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

13
src/libs/endorserServer.ts

@ -630,11 +630,7 @@ async function performPlanRequest(
return cred; return cred;
} else { } else {
// Use debug level for development to reduce console noise logger.debug(
const isDevelopment = process.env.VITE_PLATFORM === "development";
const log = isDevelopment ? logger.debug : logger.log;
log(
"[Plan Loading] ⚠️ Plan cache is empty for handle", "[Plan Loading] ⚠️ Plan cache is empty for handle",
handleId, handleId,
" Got data:", " Got data:",
@ -1226,7 +1222,12 @@ export async function createAndSubmitClaim(
timestamp: new Date().toISOString(), 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) { } catch (error: unknown) {
// Enhanced error logging with comprehensive context // Enhanced error logging with comprehensive context
const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 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; 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() { constructor() {
WebPlatformService.instanceCount++; WebPlatformService.instanceCount++;
// Use debug level logging for development mode to reduce console noise logger.debug("[WebPlatformService] Initializing web platform service");
const isDevelopment = process.env.VITE_PLATFORM === "development";
const log = isDevelopment ? logger.debug : logger.log;
log("[WebPlatformService] Initializing web platform service");
// Only initialize SharedArrayBuffer setup for web platforms // Only initialize SharedArrayBuffer setup for web platforms
if (this.isWorker()) { if (this.isWorker()) {
log("[WebPlatformService] Skipping initBackend call in worker context"); logger.debug("[WebPlatformService] Skipping initBackend call in worker context");
return; return;
} }

3
src/views/HomeView.vue

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

Loading…
Cancel
Save