From 4e84b820c3602a5b8aa9779f151dd95e07f52aba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:01:40 +0000 Subject: [PATCH 1/3] fix gun sync write bursts and listener cleanup Agent-Logs-Url: https://github.com/theEndless11/decentralised/sessions/8b490b38-664b-42ac-b504-12397fd0436e Co-authored-by: thegoodduck <163307030+thegoodduck@users.noreply.github.com> --- src/composables/copilot-composables.md | 2 +- src/composables/useChainSync.ts | 12 ++- src/services/chatService.ts | 48 +++++++-- src/services/commentService.ts | 138 +++++++++++++------------ src/services/communityService.ts | 20 ++++ src/services/copilot-services.md | 16 +-- src/services/gunService.ts | 6 +- src/services/pollService.ts | 37 ++----- src/services/postService.ts | 46 ++++----- src/services/snapshotService.ts | 49 ++++++--- src/services/userService.ts | 68 +++++++++--- src/stores/communityStore.ts | 15 ++- src/stores/copilot-stores.md | 1 + 13 files changed, 286 insertions(+), 172 deletions(-) diff --git a/src/composables/copilot-composables.md b/src/composables/copilot-composables.md index c82c23a..d283d4d 100644 --- a/src/composables/copilot-composables.md +++ b/src/composables/copilot-composables.md @@ -8,7 +8,7 @@ Vue 3 composables that bridge stores/services with component logic. | File | Export | Purpose | |---|---|---| -| `useChainSync.ts` | `useChainSync()` | Polls `chainStore.chainHead` every 10 seconds and calls `checkForDowngrade()`. Returns `downgradeDetected` (ref), `lastSync` (ref), `resetDowngradeAlert()`. Mount in `App.vue` or a top-level layout component. | +| `useChainSync.ts` | `useChainSync()` | Polls `chainStore.chainHead` every 10 seconds and calls `checkForDowngrade()`. The polling interval is created on mount and cleared on unmount so repeated route/component mounts do not leak background sync timers. Returns `downgradeDetected` (ref), `lastSync` (ref), `resetDowngradeAlert()`. Mount in `App.vue` or a top-level layout component. | | `useChat.ts` | `useChat()` | Manages a `ChatService` instance for the current user. Handles init, message sending, and reactive message list. | | `useFingerprint.ts` | `useFingerprint()` | Wraps `CryptoService.generateFingerprint()`. Returns `fingerprint` (ref), `isLoading` (ref), `generateFingerprint()`. | | `useModerationFilter.ts` | `useModerationFilter()` | Exposes moderation settings and filter functions. Uses `ModerationService` + `userStore`. `shouldShow(item)` checks both karma and content score. `getContentAction(text)` returns `blur`/`hide`/`flag`/`show`. | diff --git a/src/composables/useChainSync.ts b/src/composables/useChainSync.ts index f2bb4b2..3773484 100644 --- a/src/composables/useChainSync.ts +++ b/src/composables/useChainSync.ts @@ -1,8 +1,9 @@ -import { ref, onMounted } from 'vue'; +import { ref, onMounted, onUnmounted } from 'vue'; import { useChainStore } from '../stores/chainStore'; export function useChainSync() { const chainStore = useChainStore(); + let interval: ReturnType | null = null; const downgradeDetected = ref(false); const peerCount = ref(0); @@ -12,7 +13,7 @@ export function useChainSync() { // Since Supabase was removed, // this is now local-only chain monitoring. - const interval = setInterval(async () => { + interval = setInterval(async () => { const head = chainStore.chainHead; if (!head) return; @@ -37,6 +38,13 @@ export function useChainSync() { startSync(); }); + onUnmounted(() => { + if (interval) { + clearInterval(interval); + interval = null; + } + }); + const resetDowngradeAlert = () => { downgradeDetected.value = false; }; diff --git a/src/services/chatService.ts b/src/services/chatService.ts index e46f5c8..5c60315 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -28,6 +28,7 @@ class ChatService { private recipientKeys: Map = new Map(); private connected: boolean = false; private reconnectTimer: number | null = null; + private pendingReadFlushes: Map = new Map(); public onMessage: ((msg: ChatMessage) => void) | null = null; public onTyping: ((data: { from: string; isTyping: boolean }) => void) | null = null; @@ -348,23 +349,56 @@ class ChatService { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'chat-read', recipientId })); } - // Mark in GunDB too - const gun = GunService.getGun(); const roomId = this.getRoomId(this.userId, recipientId); - gun.get('chats').get(roomId).map().once((msg: any, msgId: string) => { - if (msg && msg.recipientId === this.userId && !msg.readAt) { - gun.get('chats').get(roomId).get(msgId).get('readAt').put(Date.now()); - } + const existingTimer = this.pendingReadFlushes.get(roomId); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = window.setTimeout(() => { + this.pendingReadFlushes.delete(roomId); + this.flushReadMarkers(roomId); + }, 150); + this.pendingReadFlushes.set(roomId, timer); + } + + private flushReadMarkers(roomId: string): void { + const gun = GunService.getGun(); + const readAt = Date.now(); + gun.get('chats').get(roomId).once((room: any) => { + if (!room || typeof room !== 'object') return; + const unreadIds = Object.entries(room) + .filter(([msgId, msg]) => { + if (msgId === '_') return false; + return Boolean(msg && typeof msg === 'object' && (msg as any).recipientId === this.userId && !(msg as any).readAt); + }) + .map(([msgId]) => msgId); + if (unreadIds.length === 0) return; + void this.writeReadMarkers(roomId, unreadIds, readAt); }); } + private async writeReadMarkers(roomId: string, unreadIds: string[], readAt: number): Promise { + const gun = GunService.getGun(); + const batchSize = 20; + for (let index = 0; index < unreadIds.length; index += batchSize) { + unreadIds.slice(index, index + batchSize).forEach((msgId) => { + gun.get('chats').get(roomId).get(msgId).get('readAt').put(readAt); + }); + if (index + batchSize < unreadIds.length) { + await new Promise((resolve) => window.setTimeout(resolve, 25)); + } + } + } + isConnected(): boolean { return this.connected; } disconnect(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + this.pendingReadFlushes.forEach((timer) => clearTimeout(timer)); + this.pendingReadFlushes.clear(); if (this.ws) { this.ws.close(); this.ws = null; } this.connected = false; } } -export default ChatService; \ No newline at end of file +export default ChatService; diff --git a/src/services/commentService.ts b/src/services/commentService.ts index ff3e92c..1e0f391 100644 --- a/src/services/commentService.ts +++ b/src/services/commentService.ts @@ -107,87 +107,89 @@ export async function createComment(data: CreateCommentData): Promise { return new Promise((resolve, reject) => { const commentNode = getGun().get('comments').get(commentId); - - // Set each field individually (Gun.js prefers this approach) - commentNode.get('id').put(commentId); - commentNode.get('postId').put(data.postId); - commentNode.get('communityId').put(data.communityId); - commentNode.get('authorId').put(data.authorId); - commentNode.get('authorName').put(data.authorName); - commentNode.get('authorShowRealName').put(data.authorShowRealName || false); - commentNode.get('content').put(data.content); - + const commentRecord: Record = { + id: commentId, + postId: data.postId, + communityId: data.communityId, + authorId: data.authorId, + authorName: data.authorName, + authorShowRealName: data.authorShowRealName || false, + content: data.content, + createdAt: timestamp, + upvotes: 0, + downvotes: 0, + score: 0, + edited: false, + }; if (data.parentId) { - commentNode.get('parentId').put(data.parentId); + commentRecord.parentId = data.parentId; } - - commentNode.get('createdAt').put(timestamp); - commentNode.get('upvotes').put(0); - commentNode.get('downvotes').put(0); - commentNode.get('score').put(0); - commentNode.get('edited').put(false); - if (comment.authorPubkey) { - commentNode.get('authorPubkey').put(comment.authorPubkey); + commentRecord.authorPubkey = comment.authorPubkey; } if (comment.contentSignature) { - commentNode.get('contentSignature').put(comment.contentSignature); + commentRecord.contentSignature = comment.contentSignature; } - if (comment.isEncrypted) { - commentNode.get('isEncrypted').put(true); - commentNode.get('encryptedContent').put(comment.encryptedContent); - commentNode.get('authTag').put(comment.authTag); - // Replace plaintext with placeholder - commentNode.get('content').put('πŸ”’ Encrypted comment'); - commentNode.get('authorName').put('encrypted'); + commentRecord.isEncrypted = true; + commentRecord.encryptedContent = comment.encryptedContent; + commentRecord.authTag = comment.authTag; + commentRecord.content = 'πŸ”’ Encrypted comment'; + commentRecord.authorName = 'encrypted'; } - // Add to post's comments index - getGun().get('posts') - .get(data.postId) - .get('comments') - .set({ commentId, createdAt: timestamp }); - - setTimeout(() => { - // Audit receipt (fire-and-forget) - (async () => { - try { - const contentHash = CryptoService.hash( - JSON.stringify({ - id: comment.id, + commentNode.put(commentRecord, (ack: any) => { + if (ack?.err) { + reject(ack.err); + return; + } + + // Add to post's comments index + getGun().get('posts') + .get(data.postId) + .get('comments') + .set({ commentId, createdAt: timestamp }); + + setTimeout(() => { + // Audit receipt (fire-and-forget) + (async () => { + try { + const contentHash = CryptoService.hash( + JSON.stringify({ + id: comment.id, + postId: comment.postId, + communityId: comment.communityId, + authorId: comment.authorId, + createdAt: comment.createdAt, + content: comment.content, + }) + ); + + await AuditService.logReceipt('comment', { + commentId: comment.id, postId: comment.postId, communityId: comment.communityId, authorId: comment.authorId, createdAt: comment.createdAt, - content: comment.content, - }) - ); - - await AuditService.logReceipt('comment', { - commentId: comment.id, - postId: comment.postId, - communityId: comment.communityId, - authorId: comment.authorId, - createdAt: comment.createdAt, - contentHash, - }); - } catch (_error) { - // Non-fatal: audit logging failed - } - })(); - - // Bump comment count on the associated post (best-effort) - (async () => { - try { - await PostService.incrementCommentCount(data.postId, data.communityId); - } catch (_err) { - // Non-fatal: comment count increment failed - } - })(); + contentHash, + }); + } catch (_error) { + // Non-fatal: audit logging failed + } + })(); + + // Bump comment count on the associated post (best-effort) + (async () => { + try { + await PostService.incrementCommentCount(data.postId, data.communityId); + } catch (_err) { + // Non-fatal: comment count increment failed + } + })(); - resolve(comment); - }, 100); + resolve(comment); + }, 100); + }); }); } @@ -609,4 +611,4 @@ export const CommentService = { getCommentCount, verifyCommentSignature, decryptComment -}; \ No newline at end of file +}; diff --git a/src/services/communityService.ts b/src/services/communityService.ts index b6e2a35..cdabd6c 100644 --- a/src/services/communityService.ts +++ b/src/services/communityService.ts @@ -33,6 +33,7 @@ export class CommunityService { private static readonly rulesSubscriptions = new Map(); private static readonly communityDataCache = new Map(); private static readonly liveCallbacks = new Set<(community: Community) => void>(); + private static readonly emittedCommunitySignatures = new Map(); private static liveCommunityListener: any = null; // ─── Create ──────────────────────────────────────────────────────────────── @@ -453,6 +454,24 @@ export class CommunityService { } private static emitCommunity(community: Community): void { + const nextSignature = JSON.stringify({ + id: community.id, + name: community.name, + displayName: community.displayName, + description: community.description, + rules: community.rules, + creatorId: community.creatorId, + createdAt: community.createdAt, + memberCount: community.memberCount, + postCount: community.postCount ?? 0, + creatorPubkey: community.creatorPubkey ?? null, + creatorSignature: community.creatorSignature ?? null, + isEncrypted: Boolean(community.isEncrypted), + encryptionHint: community.encryptionHint ?? null, + encryptedMeta: community.encryptedMeta ?? null, + }); + if (this.emittedCommunitySignatures.get(community.id) === nextSignature) return; + this.emittedCommunitySignatures.set(community.id, nextSignature); for (const callback of this.liveCallbacks) { callback(community); } @@ -477,6 +496,7 @@ export class CommunityService { this.rulesLoadPromises.clear(); this.rulesLoaded.clear(); this.communityDataCache.clear(); + this.emittedCommunitySignatures.clear(); } private static parseRules(data: unknown): string[] { diff --git a/src/services/copilot-services.md b/src/services/copilot-services.md index 3b7091c..c4b5468 100644 --- a/src/services/copilot-services.md +++ b/src/services/copilot-services.md @@ -8,7 +8,7 @@ All services are **static classes** β€” never instantiated with `new`. Initializ | File | Class | Purpose | |---|---|---| -| `gunService.ts` | `GunService` | GunDB wrapper. `initialize()` called in `main.ts`. All data roots (`posts`, `polls`, `communities`, `users`, `comments`, `events`, `chatrooms`, `server-config`) are transparently namespaced under `v3` via a Proxy. Use `GunService.getGun()` to get the proxied instance. Adding a new root requires adding it to `NAMESPACED_ROOTS`. | +| `gunService.ts` | `GunService` | GunDB wrapper. `initialize()` called in `main.ts`. All data roots (`posts`, `polls`, `communities`, `users`, `comments`, `events`, `chatrooms`, `server-config`) are transparently namespaced under `v3` via a Proxy. Use `GunService.getGun()` to get the proxied instance. `subscribe(path, cb)` now returns an unsubscribe function so long-lived `.on()` listeners can be cleaned up. Adding a new root requires adding it to `NAMESPACED_ROOTS`. | | `storageService.ts` | `StorageService` | IndexedDB wrapper (`idb`). Stores: `blocks`, `votes`, `receipts`, `polls`, `metadata`, `encryption-keys`. DB name: `interpoll-db` v2. The `metadata` store is a generic key-value bag used by many other services. The `encryption-keys` store holds `StoredEncryptionKey` entries keyed by `id`. | | `websocketService.ts` | `WebSocketService` | WebSocket peer connection to the relay server. Handles reconnection (exponential backoff, infinite retries), peer discovery, server list sharing, and message queuing when disconnected. Subscribe to message types via `.subscribe(type, callback)`. Also supports encrypted chat room message relay: `broadcastChatRoomMessage(roomId, data)` sends an opaque encrypted blob via the relay, and `subscribeToChatRoom(roomId, callback)` receives them with per-room multiplexing. At startup/reconnect it also publishes and consumes signed Gun discovery announcements via `DiscoveryService`, so known servers can be sourced from `v3/server-config/discovery` in addition to WSS `server-list` broadcasts. Known-server entries track source (`local`, `peer`, `gun`) and explicit trust metadata (`signatureValid`, `lastVerifiedAt`, `expiresAt`) without inferring signature trust from labels/source strings; local self-seeded entries are stored unsigned by default. Incoming `server-list` entries are only accepted when the relay envelope passes `IntegrityService.verifySealedPayload()` and endpoint protocol safety checks. `broadcast()` is async and automatically attaches proof-of-work for content messages (via dynamic import of `PowService`), then seals the message with `IntegrityService.seal()` (hash, signature, hashcash PoW, replay nonce) before sending β€” seal failure drops the message. `sendRaw(message)` sends a raw message bypassing PoW/broadcast wrapping. | | `discoveryService.ts` | `DiscoveryService` | Gun-based relay discovery registry at `v3/server-config/discovery`. Publishes signed relay announcements (`nodeId`, `peerId`, `websocket`, `gun`, `api`, `capabilities`, `timestamp`, `ttlMs`, `signerPubkey`, `signature`), verifies Schnorr signatures before accepting, enforces TTL expiry and max-entry caps, and exposes normalized validated discovery entries for other services. | @@ -34,12 +34,12 @@ All services are **static classes** β€” never instantiated with `new`. Initializ | File | Class | Purpose | |---|---|---| -| `pollService.ts` | `PollService` | Poll CRUD, invite code generation/validation/consumption, vote recording in GunDB. Schnorr-signs polls on create (`authorPubkey`, `contentSignature`). Community/all-poll subscriptions use hydration-first loading with batch processing plus soft/hard timeouts to avoid startup hangs while preventing startup content from being treated as "new". `loadPoll()` is Gun-first for live vote totals; API fallback is metadata-only (question/options labels) and does **not** ingest API vote counts, preventing stale backend totals from bouncing local/Gun-confirmed votes. When global poll options are missing during reload, it now also falls back to community-scoped poll options before dropping the poll, which prevents empty poll lists caused by partial Gun hydration ordering; if Gun still yields only a partial shell, read flows can fall back to a fresh offline local backup instead of returning null. Community subscriptions now normalize missing `communityId` fields to the subscribed community id before emitting polls, preventing transient partial root records from moving polls out of community feeds. Poll option voter lists are normalized from Gun-safe object storage back into arrays for the app, and voting rewrites the full options map (global + community copy) so option order and vote counts stay aligned. Private poll invite codes are stored both by index and uppercase code key; `validateInviteCode()` checks availability, `consumeInviteCode()` creates a short-lived reservation token and verifies ownership before voting, `finalizeInviteCode()` permanently marks the code used after chain success, `releaseInviteCode()` clears only the caller's own reservation if voting fails before the chain write, and `queueInviteCodeFinalization()` / `flushPendingInviteCodeFinalizations()` persist best-effort finalize retries in localStorage so a post-chain finalize glitch does not silently reopen the code forever. Poll create/read/vote now include same-device local durability fallback (IndexedDB metadata) for degraded relay conditions, but local fallback reads are now offline-only with backup freshness TTL and deletion tombstones to avoid stale/deleted poll resurrection while online. Local backup fallback is disabled for mutating paths (like vote) so writes cannot proceed from stale cached-only poll state. Poll creation now embeds options directly in the root/community poll shell write as a redundancy path, treats dedicated options-child write timeouts as soft failures, and `loadPollOptions()` falls back to inline shell options only after trying live options hydration; this prevents poll creation failures caused by flaky Gun ACKs on `...get('options').put(...)` while reducing stale reads. Root and community poll-shell writes now use timeout-with-verification fallback plus multi-attempt repair retries: after timeout, writes are retried with merged current state and re-verified before failing, which significantly improves propagation on slow relays without clobbering concurrent updates. Poll creation and vote confirmation time budgets were increased further for high-latency relays, while still requiring explicit post-write confirmation before reporting success. Vote writes now normalize selected option IDs against known poll options before write/confirmation and use timeout-tolerant writes with explicit confirmation and retry writes on both root and community paths, using fresh options snapshots per retry so transient lag does not fail otherwise-valid votes or overwrite concurrent voters; once root confirmation succeeds, slower community-path reconciliation continues in the background so UX is not blocked by secondary-path lag. Timeout-tolerant writes no longer emit unconditional console warnings when an ACK is late; failures are now surfaced only if post-write confirmation cannot be established. Private invite-code list writes and `inviteCodesByCode` writes now use timeout-tolerant writes plus explicit read-back confirmation retries (with chunking for by-code writes), so high-latency relays cannot silently drop entries during large private poll creation; search indexing is timeboxed and uses credentials only for same-origin API calls so indexing outages/CORS mismatches cannot block poll creation success. Local poll backup persistence is serialized through a write queue and skips unchanged snapshots using a normalized signature check to avoid repeated metadata rewrites from read-heavy paths. Poll creation diagnostics can be enabled from console via `localStorage.setItem('interpoll_poll_debug', 'all')` (or categories: `create,writes,index,ui`) to log write ACK timing, indexing lifecycle, and create flow checkpoints. Encrypts poll content (question, options, description) via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptPoll()` reverses at read time. Also calls `indexForSearch()` to push data to relay for full-text search. | -| `communityService.ts` | `CommunityService` | Community CRUD in GunDB. IDs are derived from lowercased name: `c-{slug}`. Signs community creation with Schnorr (via `CryptoService`/`KeyService`) for anti-sabotage; includes `verifyCommunitySignature()` to check integrity. Supports private encrypted communities via `createPrivateCommunity()` (AES-256-GCM encrypted metadata, password-derived or invite-only keys), `decryptCommunityMeta()` (decrypt using stored key), and `joinPrivateCommunity()` (join with invite key or password). `subscribeToCommunitiesLive()` now forwards repeat updates so member-count and metadata changes are not dropped after first load, but avoids repeated per-update `rules` child lookups by reusing inline/cached rules and keeping a single per-community `rules` subscription for cache sync. Rules loading only accepts numeric-key string entries, which avoids mis-parsing Gun relation/link metadata as rules in partially hydrated nodes. Uses `EncryptionService`, `KeyVaultService`, and `InviteLinkService`. | -| `postService.ts` | `PostService` | Post CRUD in GunDB, image upload via `IPFSService`. Community subscriptions now hydrate full community post IDs (no initial 50-post cap), use `map().on` for live community-index updates, and wrap per-post `once()` reads with a short timeout so stale/missing post pointers cannot block initial load completion after reload. API post fetches and search indexing use `config.relay.api`, so Settings/runtime relay overrides also apply in fresh and incognito sessions instead of silently calling the default production host. Signs post content with Schnorr (via `CryptoService`/`KeyService`) for anti-sabotage verification; exposes `verifyPostSignature()` returning `'verified' | 'unverified' | 'unsigned'`. Encrypts post content (title, body, author info, images, signature) via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptPost()` reverses at read time with HMAC authTag verification and type-validated decryption. | -| `commentService.ts` | `CommentService` | Comment CRUD in GunDB. Schnorr-signs comment content on create/edit for anti-sabotage verification (`authorPubkey`, `contentSignature`). Encrypts comment content via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptComment()` reverses at read time. `verifyCommentSignature()` returns `'verified' | 'unverified' | 'unsigned'`. | -| `userService.ts` | `UserService` | User profile CRUD in GunDB, keyed by device ID. Exposes Schnorr public key for identity. Supports `customUsername`, `showRealName` toggle, and avatar images (`avatarIPFS`/`avatarThumbnail`). | -| `chatService.ts` | `ChatService` | **Instance-based** (not static). P2P DM chat over GunDB + WebSocket. Uses RSA-OAEP for message encryption between users. Each chat session needs `new ChatService(wsUrl, userId)`. | +| `pollService.ts` | `PollService` | Poll CRUD, invite code generation/validation/consumption, vote recording in GunDB. Schnorr-signs polls on create (`authorPubkey`, `contentSignature`). Community/all-poll subscriptions use hydration-first loading with batch processing plus soft/hard timeouts to avoid startup hangs while preventing startup content from being treated as "new". `loadPoll()` is Gun-first for live vote totals; API fallback is metadata-only (question/options labels) and does **not** ingest API vote counts, preventing stale backend totals from bouncing local/Gun-confirmed votes. When global poll options are missing during reload, it now also falls back to community-scoped poll options before dropping the poll, which prevents empty poll lists caused by partial Gun hydration ordering; if Gun still yields only a partial shell, read flows can fall back to a fresh offline local backup instead of returning null. Community subscriptions now normalize missing `communityId` fields to the subscribed community id before emitting polls, preventing transient partial root records from moving polls out of community feeds. Poll option voter lists are normalized from Gun-safe object storage back into arrays for the app, and voting rewrites the full options map (global + community copy) so option order and vote counts stay aligned. Private poll invite codes are stored both by index and uppercase code key; `validateInviteCode()` checks availability, `consumeInviteCode()` creates a short-lived reservation token and verifies ownership before voting, `finalizeInviteCode()` permanently marks the code used after chain success, `releaseInviteCode()` clears only the caller's own reservation if voting fails before the chain write, and `queueInviteCodeFinalization()` / `flushPendingInviteCodeFinalizations()` persist best-effort finalize retries in localStorage so a post-chain finalize glitch does not silently reopen the code forever. Poll create/read/vote now include same-device local durability fallback (IndexedDB metadata) for degraded relay conditions, but local fallback reads are now offline-only with backup freshness TTL and deletion tombstones to avoid stale/deleted poll resurrection while online. Local backup fallback is disabled for mutating paths (like vote) so writes cannot proceed from stale cached-only poll state. Poll creation now embeds options directly in the root/community poll shell write as a redundancy path, includes signatures/encryption metadata in the initial shell write instead of follow-up field-by-field puts, and treats dedicated options-child write timeouts as soft failures. `loadPollOptions()` falls back to inline shell options only after trying live options hydration; this prevents poll creation failures caused by flaky Gun ACKs on `...get('options').put(...)` while reducing stale reads. Root and community poll-shell writes now use timeout-with-verification fallback plus multi-attempt repair retries: after timeout, writes are retried with merged current state and re-verified before failing, which significantly improves propagation on slow relays without clobbering concurrent updates. Poll creation and vote confirmation time budgets were increased further for high-latency relays, while still requiring explicit post-write confirmation before reporting success. Vote writes now normalize selected option IDs against known poll options before write/confirmation and use timeout-tolerant writes with explicit confirmation and retry writes on both root and community paths, using fresh options snapshots per retry so transient lag does not fail otherwise-valid votes or overwrite concurrent voters; once root confirmation succeeds, slower community-path reconciliation continues in the background so UX is not blocked by secondary-path lag. Timeout-tolerant writes no longer emit unconditional console warnings when an ACK is late; failures are now surfaced only if post-write confirmation cannot be established. Private invite-code list writes and `inviteCodesByCode` writes now use timeout-tolerant writes plus explicit read-back confirmation retries (with chunking for by-code writes), so high-latency relays cannot silently drop entries during large private poll creation; search indexing is timeboxed and uses credentials only for same-origin API calls so indexing outages/CORS mismatches cannot block poll creation success. Local poll backup persistence is serialized through a write queue and skips unchanged snapshots using a normalized signature check to avoid repeated metadata rewrites from read-heavy paths. Poll creation diagnostics can be enabled from console via `localStorage.setItem('interpoll_poll_debug', 'all')` (or categories: `create,writes,index,ui`) to log write ACK timing, indexing lifecycle, and create flow checkpoints. Encrypts poll content (question, options, description) via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptPoll()` reverses at read time. Also calls `indexForSearch()` to push data to relay for full-text search. | +| `communityService.ts` | `CommunityService` | Community CRUD in GunDB. IDs are derived from lowercased name: `c-{slug}`. Signs community creation with Schnorr (via `CryptoService`/`KeyService`) for anti-sabotage; includes `verifyCommunitySignature()` to check integrity. Supports private encrypted communities via `createPrivateCommunity()` (AES-256-GCM encrypted metadata, password-derived or invite-only keys), `decryptCommunityMeta()` (decrypt using stored key), and `joinPrivateCommunity()` (join with invite key or password). `subscribeToCommunitiesLive()` now forwards repeat updates so member-count and metadata changes are not dropped after first load, but avoids repeated identical re-emits by signature-checking the fully mapped community payload before notifying callbacks. It also reuses inline/cached rules and keeps a single per-community `rules` subscription for cache sync. Rules loading only accepts numeric-key string entries, which avoids mis-parsing Gun relation/link metadata as rules in partially hydrated nodes. Uses `EncryptionService`, `KeyVaultService`, and `InviteLinkService`. | +| `postService.ts` | `PostService` | Post CRUD in GunDB, image upload via `IPFSService`. Community subscriptions now hydrate full community post IDs (no initial 50-post cap), use `map().on` for live community-index updates, and wrap per-post `once()` reads with a short timeout so stale/missing post pointers cannot block initial load completion after reload. Global all-post subscriptions now also use `map().on` instead of root `.on`, which reduces full-root rescan churn when large batches of posts are written into Gun. API post fetches and search indexing use `config.relay.api`, so Settings/runtime relay overrides also apply in fresh and incognito sessions instead of silently calling the default production host. Signs post content with Schnorr (via `CryptoService`/`KeyService`) for anti-sabotage verification; exposes `verifyPostSignature()` returning `'verified' | 'unverified' | 'unsigned'`. Encrypts post content (title, body, author info, images, signature) via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptPost()` reverses at read time with HMAC authTag verification and type-validated decryption. | +| `commentService.ts` | `CommentService` | Comment CRUD in GunDB. Schnorr-signs comment content on create/edit for anti-sabotage verification (`authorPubkey`, `contentSignature`). New comments now write a single consolidated comment object to Gun instead of many field-level `.put()` calls, which reduces sync fan-out when encrypted comments are created. Encrypts comment content via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptComment()` reverses at read time. `verifyCommentSignature()` returns `'verified' | 'unverified' | 'unsigned'`. | +| `userService.ts` | `UserService` | User profile CRUD in GunDB, keyed by device ID. Exposes Schnorr public key for identity. Per-user write operations now run through a small local queue so counter-style updates (`postCount`, `commentCount`, `karma`) do not race each other on the same client, and those counters now update only the specific Gun field instead of rewriting the whole profile object. Supports `customUsername`, `showRealName` toggle, and avatar images (`avatarIPFS`/`avatarThumbnail`). | +| `chatService.ts` | `ChatService` | **Instance-based** (not static). P2P DM chat over GunDB + WebSocket. Uses RSA-OAEP for message encryption between users. `markAsRead()` now debounces room-level read flushing and writes unread `readAt` markers in small batches instead of doing one immediate `.put()` per message callback. Each chat session needs `new ChatService(wsUrl, userId)`. | ## Media @@ -74,7 +74,7 @@ All services are **static classes** β€” never instantiated with `new`. Initializ | `feedPreferencesService.ts` | `FeedPreferencesService` | Local-only personalized feed settings (mode, include/exclude keywords, muted/favorite communities, content-type toggles, ranking weights). Persists in localStorage (`interpoll_feed_preferences`). | | `pinningService.ts` | `PinningService` | Storage quota and local caching policies for GunDB data. | | `storageManager.ts` | β€” | Higher-level storage orchestration. | -| `snapshotService.ts` | `SnapshotService` | Full network snapshot export/import. Collects IndexedDB chain data (blocks, votes, receipts, polls) and GunDB data (posts, communities, comments, users, events) into a single `NetworkSnapshot` JSON. Import writes data back to both stores. Includes `downloadSnapshot()` for browser file download and `parseSnapshotFile()` for validated file upload. | +| `snapshotService.ts` | `SnapshotService` | Full network snapshot export/import. Collects IndexedDB chain data (blocks, votes, receipts, polls) and GunDB data (posts, communities, comments, users, events) into a single `NetworkSnapshot` JSON. Import writes data back to both stores, but Gun writes are now replayed in small batches to avoid sync storms when importing large snapshots. Includes `downloadSnapshot()` for browser file download and `parseSnapshotFile()` for validated file upload. | | `snapshotSyncService.ts` | `SnapshotSyncService` | Peer-to-peer snapshot transfer over WebSocket. Implements a chunked (32 KB) offerβ†’acceptβ†’chunksβ†’complete protocol with SHA-256 integrity verification, structural validation, size limits (50 MB), transfer timeout (2 min), and progress callbacks. Uses dynamic imports for `WebSocketService` to avoid circular deps. | | `mnemonicService.ts` | β€” | Mnemonic receipt lookup helpers. | | `webrtcService.ts` | β€” | WebRTC peer connection utilities (direct P2P). | diff --git a/src/services/gunService.ts b/src/services/gunService.ts index 27150c1..fc0bef0 100644 --- a/src/services/gunService.ts +++ b/src/services/gunService.ts @@ -130,10 +130,12 @@ export class GunService { }); } - static subscribe(path: string, callback: (data: any) => void): void { + static subscribe(path: string, callback: (data: any) => void): () => void { + let listener: any; try { - this.getGun().get(path).on(callback); + listener = this.getGun().get(path).on(callback); } catch { } + return () => listener?.off?.(); } // Throttled map β€” prevents 1K+ records/sec DOM warning diff --git a/src/services/pollService.ts b/src/services/pollService.ts index a0fa5c1..e5ef049 100644 --- a/src/services/pollService.ts +++ b/src/services/pollService.ts @@ -485,23 +485,6 @@ export class PollService { } return null; } - - private static warmPollCache(pollData: any, optionsData?: any) { - if (!pollData?.id) return; - const pollNode = this.getPollPath(pollData.id); - pollNode.put(pollData); - if (optionsData && typeof optionsData === 'object') { - pollNode.get('options').put(optionsData); - } - if (pollData.communityId) { - const communityNode = this.getCommunityPollPath(pollData.communityId, pollData.id); - communityNode.put(pollData); - if (optionsData && typeof optionsData === 'object') { - communityNode.get('options').put(optionsData); - } - } - } - static subscribeToPollsInCommunity( communityId: string, onPoll: (poll: Poll) => void, @@ -782,7 +765,7 @@ export class PollService { const optionsMap = this.buildOptionsMap(pollOptions); - const gunPoll = { + const gunPoll: Record = { id: poll.id, communityId: poll.communityId, authorId: poll.authorId, authorName: poll.authorName, authorShowRealName: poll.authorShowRealName, question: poll.question, description: poll.description, createdAt: poll.createdAt, @@ -801,6 +784,8 @@ export class PollService { const signature = CryptoService.sign(contentHash, keyPair.privateKey); poll.authorPubkey = keyPair.publicKey; poll.contentSignature = signature; + gunPoll.authorPubkey = keyPair.publicKey; + gunPoll.contentSignature = signature; logPollDebug('create', 'Poll signing completed', { pollId }); } catch (err) { console.warn('Failed to sign poll:', err); } @@ -813,6 +798,11 @@ export class PollService { poll.encryptedContent = await EncryptionService.encrypt(JSON.stringify(encryptableData), aesKey); poll.authTag = await EncryptionService.generateAuthTag(aesKey, poll.id, String(poll.createdAt), poll.authorId); poll.isEncrypted = true; + gunPoll.question = 'πŸ”’ Encrypted Poll'; + gunPoll.description = ''; + gunPoll.encryptedContent = poll.encryptedContent; + gunPoll.authTag = poll.authTag; + gunPoll.isEncrypted = true; logPollDebug('create', 'Poll encryption completed', { pollId }); } catch (err) { console.warn('Failed to encrypt poll:', err); } } else { @@ -907,17 +897,6 @@ export class PollService { }); } - if (poll.isEncrypted && poll.encryptedContent) { - const node = this.getPollPath(pollId); - node.get('question').put('πŸ”’ Encrypted Poll'); - node.get('description').put(''); - node.get('encryptedContent').put(poll.encryptedContent); - node.get('authTag').put(poll.authTag); - node.get('isEncrypted').put(true); - if (poll.authorPubkey) node.get('authorPubkey').put(poll.authorPubkey); - if (poll.contentSignature) node.get('contentSignature').put(poll.contentSignature); - } - if (poll.isPrivate) { const rawInviteCount = Number(data.inviteCodeCount); const safeInviteCount = Number.isFinite(rawInviteCount) ? rawInviteCount : 20; diff --git a/src/services/postService.ts b/src/services/postService.ts index 2d1b1f7..c9eb46d 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -389,19 +389,16 @@ export class PostService { }); }); - subscription = postsNode.on((allPosts: any) => { - if (!allPosts) return; - Object.keys(allPosts).forEach(postId => { - if (postId === '_' || initialSeenIds.has(postId) || inFlightIds.has(postId)) return; - inFlightIds.add(postId); - void onceWithTimeout(gun.get('posts').get(postId)).then((postData) => { - if (postData && postData.id) { - initialSeenIds.add(postData.id); - onPost({ ...postData, dataVersion: GUN_NAMESPACE }); - } - }).finally(() => { - inFlightIds.delete(postId); - }); + subscription = postsNode.map().on((_: any, postId: string) => { + if (!postId || postId === '_' || initialSeenIds.has(postId) || inFlightIds.has(postId)) return; + inFlightIds.add(postId); + void onceWithTimeout(gun.get('posts').get(postId)).then((postData) => { + if (postData && postData.id) { + initialSeenIds.add(postData.id); + onPost({ ...postData, dataVersion: GUN_NAMESPACE }); + } + }).finally(() => { + inFlightIds.delete(postId); }); }); @@ -429,19 +426,16 @@ export class PostService { checkLoadComplete(); }); }); - v1Subscription = v1PostsNode.on((allPosts: any) => { - if (!allPosts) return; - Object.keys(allPosts).forEach(postId => { - if (postId === '_' || initialSeenIds.has(postId) || inFlightIds.has(postId)) return; - inFlightIds.add(postId); - void onceWithTimeout(rawGun.get('posts').get(postId)).then((postData) => { - if (postData && postData.id) { - initialSeenIds.add(postData.id); - onPost({ ...postData, dataVersion: 'v1' }); - } - }).finally(() => { - inFlightIds.delete(postId); - }); + v1Subscription = v1PostsNode.map().on((_: any, postId: string) => { + if (!postId || postId === '_' || initialSeenIds.has(postId) || inFlightIds.has(postId)) return; + inFlightIds.add(postId); + void onceWithTimeout(rawGun.get('posts').get(postId)).then((postData) => { + if (postData && postData.id) { + initialSeenIds.add(postData.id); + onPost({ ...postData, dataVersion: 'v1' }); + } + }).finally(() => { + inFlightIds.delete(postId); }); }); } diff --git a/src/services/snapshotService.ts b/src/services/snapshotService.ts index 8a318a2..ab251d8 100644 --- a/src/services/snapshotService.ts +++ b/src/services/snapshotService.ts @@ -60,6 +60,19 @@ function sanitizeObject(data: any): Record { } export class SnapshotService { + private static async writeGunEntriesInBatches( + entries: Array<() => void>, + batchSize = 20, + pauseMs = 25, + ): Promise { + for (let index = 0; index < entries.length; index += batchSize) { + entries.slice(index, index + batchSize).forEach((write) => write()); + if (index + batchSize < entries.length) { + await new Promise((resolve) => setTimeout(resolve, pauseMs)); + } + } + } + private static async enumerateGunNode(rootPath: string, timeout = 5000): Promise { return new Promise((resolve) => { const items: T[] = []; @@ -212,44 +225,54 @@ export class SnapshotService { const totalGun = posts.length + communities.length + comments.length + users.length + events.length; let gunProgress = 0; + const gunWrites: Array<() => void> = []; for (const post of posts) { - gun.get('posts').get(post.id).put(post); - // Also write to community-specific path so subscriptions pick it up - if (post.communityId) { - gun.get('communities').get(post.communityId).get('posts').get(post.id).put(post); - } + gunWrites.push(() => { + gun.get('posts').get(post.id).put(post); + if (post.communityId) { + gun.get('communities').get(post.communityId).get('posts').get(post.id).put(post); + } + }); result.imported.posts++; gunProgress++; onProgress?.('gun', gunProgress, totalGun); } for (const community of communities) { - gun.get('communities').get(community.id).put(community); + gunWrites.push(() => { + gun.get('communities').get(community.id).put(community); + }); result.imported.communities++; gunProgress++; onProgress?.('gun', gunProgress, totalGun); } for (const comment of comments) { - gun.get('comments').get(comment.id).put(comment); - // Also write to post-specific path - if (comment.postId) { - gun.get('posts').get(comment.postId).get('comments').get(comment.id).put(comment); - } + gunWrites.push(() => { + gun.get('comments').get(comment.id).put(comment); + if (comment.postId) { + gun.get('posts').get(comment.postId).get('comments').get(comment.id).put(comment); + } + }); result.imported.comments++; gunProgress++; onProgress?.('gun', gunProgress, totalGun); } for (const user of users) { - gun.get('users').get(user.id).put(user); + gunWrites.push(() => { + gun.get('users').get(user.id).put(user); + }); result.imported.users++; gunProgress++; onProgress?.('gun', gunProgress, totalGun); } for (const event of events) { - gun.get('events').get(event.id).put(event); + gunWrites.push(() => { + gun.get('events').get(event.id).put(event); + }); result.imported.events++; gunProgress++; onProgress?.('gun', gunProgress, totalGun); } + await this.writeGunEntriesInBatches(gunWrites); return result; } diff --git a/src/services/userService.ts b/src/services/userService.ts index 3dbb23d..845c0d4 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -30,6 +30,24 @@ export interface UserStats { export class UserService { private static currentUser: UserProfile | null = null; + private static writeQueues = new Map>(); + + private static async enqueueWrite(userId: string, task: () => Promise): Promise { + const previous = this.writeQueues.get(userId) ?? Promise.resolve(); + let release!: () => void; + const next = new Promise((resolve) => { release = resolve; }); + this.writeQueues.set(userId, previous.then(() => next)); + + try { + await previous; + return await task(); + } finally { + release(); + if (this.writeQueues.get(userId) === next) { + this.writeQueues.delete(userId); + } + } + } static async getCurrentUser(forceRefresh = false): Promise { if (this.currentUser && !forceRefresh) return this.currentUser; @@ -90,11 +108,16 @@ export class UserService { const gun = GunService.getGun(); const currentUser = await this.getCurrentUser(); - const updatedProfile = { ...currentUser, ...updates }; - await gun.get('users').get(currentUser.id).put(updatedProfile); + return this.enqueueWrite(currentUser.id, async () => { + const baseProfile = this.currentUser?.id === currentUser.id + ? this.currentUser + : await this.getUser(currentUser.id) ?? currentUser; + const updatedProfile = { ...baseProfile, ...updates }; + await gun.get('users').get(currentUser.id).put(updatedProfile); - this.currentUser = updatedProfile; - return updatedProfile; + this.currentUser = updatedProfile; + return updatedProfile; + }); } static async getUser(userId: string): Promise { @@ -104,20 +127,41 @@ export class UserService { static async incrementPostCount() { const user = await this.getCurrentUser(true); - await this.updateProfile({ postCount: (user.postCount || 0) + 1 }); + await this.enqueueWrite(user.id, async () => { + const latestUser = await this.getUser(user.id) ?? user; + const nextPostCount = (latestUser.postCount || 0) + 1; + await GunService.getGun().get('users').get(user.id).get('postCount').put(nextPostCount); + this.currentUser = { + ...(this.currentUser?.id === user.id ? this.currentUser : latestUser), + postCount: nextPostCount, + }; + }); } static async incrementCommentCount() { const user = await this.getCurrentUser(true); - await this.updateProfile({ commentCount: (user.commentCount || 0) + 1 }); + await this.enqueueWrite(user.id, async () => { + const latestUser = await this.getUser(user.id) ?? user; + const nextCommentCount = (latestUser.commentCount || 0) + 1; + await GunService.getGun().get('users').get(user.id).get('commentCount').put(nextCommentCount); + this.currentUser = { + ...(this.currentUser?.id === user.id ? this.currentUser : latestUser), + commentCount: nextCommentCount, + }; + }); } static async incrementKarma(authorId: string, points: number = 1) { - const gun = GunService.getGun(); - const user = await this.getUser(authorId); - if (user) { - await gun.get('users').get(authorId).get('karma').put(user.karma + points); - } + if (!points) return; + await this.enqueueWrite(authorId, async () => { + const user = await this.getUser(authorId); + if (!user) return; + const nextKarma = (user.karma || 0) + points; + await GunService.getGun().get('users').get(authorId).get('karma').put(nextKarma); + if (this.currentUser?.id === authorId) { + this.currentUser = { ...this.currentUser, karma: nextKarma }; + } + }); } static async getUserStats(userId: string): Promise { @@ -147,4 +191,4 @@ export class UserService { setTimeout(() => resolve(users), 1000); }); } -} \ No newline at end of file +} diff --git a/src/stores/communityStore.ts b/src/stores/communityStore.ts index 57191ee..b7f0795 100644 --- a/src/stores/communityStore.ts +++ b/src/stores/communityStore.ts @@ -221,10 +221,17 @@ export const useCommunityStore = defineStore('community', () => { // Warm up Gun's local cache by putting data back into it so existing // postService subscriptions fire correctly const gun = (await import('../services/gunService')).GunService.getGun(); - for (const row of json.results || []) { - const d = row.data; - if (!d?.id || !d?.title) continue; // only full post nodes - gun.get('posts').get(d.id).put(d); + const writes = (json.results || []) + .map((row) => row.data) + .filter((d): d is Record & { id: string; title: string } => Boolean(d?.id && d?.title)); + const batchSize = 25; + for (let index = 0; index < writes.length; index += batchSize) { + writes.slice(index, index + batchSize).forEach((d) => { + gun.get('posts').get(d.id).put(d); + }); + if (index + batchSize < writes.length) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } } } catch (err) { console.warn('⚠️ MySQL posts warmup failed:', err); diff --git a/src/stores/copilot-stores.md b/src/stores/copilot-stores.md index 8eb3e48..49d43c5 100644 --- a/src/stores/copilot-stores.md +++ b/src/stores/copilot-stores.md @@ -40,6 +40,7 @@ Key computed: `polls`, `sortedPolls` - `selectCommunity()` follows the same rule: `/db/soul` fallback is v2-only, so v3+ does not trigger cross-origin fallback requests when Gun has no matching community yet. - The fallback relay base URL is derived from runtime config (`config.relay.gun`), not hardcoded, so Settings relay overrides and localhost/dev relays are respected. - Fallback `/db/search` and `/db/soul` reads are timeboxed to avoid hanging community navigation when fallback relay requests are slow or blocked. +- MySQL post warmup writes are now replayed back into Gun in small batches rather than a single tight loop, reducing startup sync spikes while still waking existing Gun subscriptions on cold relays. - Deduplicates with a `seen: Set`. - `joinedCommunities` is a `Set` persisted in localStorage (`joined-communities`), then backfilled from the key vault so private invite/password joins survive refresh. - Joined state is also synced from stored community encryption keys, so invite/password-joined private communities behave like normal joined communities after refresh. From 6a447c8b730ef2c2cb296d6aa5aa2165791093a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:17:32 +0000 Subject: [PATCH 2/3] fix chat namespace and batched dm read flush Agent-Logs-Url: https://github.com/theEndless11/decentralised/sessions/8b490b38-664b-42ac-b504-12397fd0436e Co-authored-by: thegoodduck <163307030+thegoodduck@users.noreply.github.com> --- src/services/chatService.ts | 38 ++++++++++++++++++++++++-------- src/services/copilot-services.md | 2 +- src/services/gunService.ts | 2 +- src/services/userService.ts | 13 +++++------ 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/services/chatService.ts b/src/services/chatService.ts index 5c60315..bc6ef20 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -364,19 +364,39 @@ class ChatService { private flushReadMarkers(roomId: string): void { const gun = GunService.getGun(); const readAt = Date.now(); - gun.get('chats').get(roomId).once((room: any) => { + const roomNode = gun.get('chats').get(roomId); + roomNode.once((room: any) => { if (!room || typeof room !== 'object') return; - const unreadIds = Object.entries(room) - .filter(([msgId, msg]) => { - if (msgId === '_') return false; - return Boolean(msg && typeof msg === 'object' && (msg as any).recipientId === this.userId && !(msg as any).readAt); - }) - .map(([msgId]) => msgId); - if (unreadIds.length === 0) return; - void this.writeReadMarkers(roomId, unreadIds, readAt); + const messageIds = Object.keys(room).filter((msgId) => msgId !== '_' && msgId.startsWith('msg-')); + if (messageIds.length === 0) return; + void this.readMessagesInBatches(roomNode, messageIds).then((messages) => { + const unreadIds = messages + .filter(([, msg]) => Boolean(msg && msg.recipientId === this.userId && !msg.readAt)) + .map(([msgId]) => msgId); + if (unreadIds.length === 0) return; + return this.writeReadMarkers(roomId, unreadIds, readAt); + }); }); } + private async readMessagesInBatches(roomNode: any, messageIds: string[]): Promise> { + const results: Array<[string, any]> = []; + const batchSize = 20; + for (let index = 0; index < messageIds.length; index += batchSize) { + const batch = messageIds.slice(index, index + batchSize); + const messages = await Promise.all(batch.map((msgId) => ( + new Promise<[string, any]>((resolve) => { + roomNode.get(msgId).once((msg: any) => resolve([msgId, msg])); + }) + ))); + results.push(...messages); + if (index + batchSize < messageIds.length) { + await new Promise((resolve) => window.setTimeout(resolve, 25)); + } + } + return results; + } + private async writeReadMarkers(roomId: string, unreadIds: string[], readAt: number): Promise { const gun = GunService.getGun(); const batchSize = 20; diff --git a/src/services/copilot-services.md b/src/services/copilot-services.md index c4b5468..6491def 100644 --- a/src/services/copilot-services.md +++ b/src/services/copilot-services.md @@ -8,7 +8,7 @@ All services are **static classes** β€” never instantiated with `new`. Initializ | File | Class | Purpose | |---|---|---| -| `gunService.ts` | `GunService` | GunDB wrapper. `initialize()` called in `main.ts`. All data roots (`posts`, `polls`, `communities`, `users`, `comments`, `events`, `chatrooms`, `server-config`) are transparently namespaced under `v3` via a Proxy. Use `GunService.getGun()` to get the proxied instance. `subscribe(path, cb)` now returns an unsubscribe function so long-lived `.on()` listeners can be cleaned up. Adding a new root requires adding it to `NAMESPACED_ROOTS`. | +| `gunService.ts` | `GunService` | GunDB wrapper. `initialize()` called in `main.ts`. All data roots (`posts`, `polls`, `communities`, `users`, `comments`, `events`, `chats`, `chatrooms`, `server-config`) are transparently namespaced under `v3` via a Proxy. Use `GunService.getGun()` to get the proxied instance. `subscribe(path, cb)` now returns an unsubscribe function so long-lived `.on()` listeners can be cleaned up. Adding a new root requires adding it to `NAMESPACED_ROOTS`. | | `storageService.ts` | `StorageService` | IndexedDB wrapper (`idb`). Stores: `blocks`, `votes`, `receipts`, `polls`, `metadata`, `encryption-keys`. DB name: `interpoll-db` v2. The `metadata` store is a generic key-value bag used by many other services. The `encryption-keys` store holds `StoredEncryptionKey` entries keyed by `id`. | | `websocketService.ts` | `WebSocketService` | WebSocket peer connection to the relay server. Handles reconnection (exponential backoff, infinite retries), peer discovery, server list sharing, and message queuing when disconnected. Subscribe to message types via `.subscribe(type, callback)`. Also supports encrypted chat room message relay: `broadcastChatRoomMessage(roomId, data)` sends an opaque encrypted blob via the relay, and `subscribeToChatRoom(roomId, callback)` receives them with per-room multiplexing. At startup/reconnect it also publishes and consumes signed Gun discovery announcements via `DiscoveryService`, so known servers can be sourced from `v3/server-config/discovery` in addition to WSS `server-list` broadcasts. Known-server entries track source (`local`, `peer`, `gun`) and explicit trust metadata (`signatureValid`, `lastVerifiedAt`, `expiresAt`) without inferring signature trust from labels/source strings; local self-seeded entries are stored unsigned by default. Incoming `server-list` entries are only accepted when the relay envelope passes `IntegrityService.verifySealedPayload()` and endpoint protocol safety checks. `broadcast()` is async and automatically attaches proof-of-work for content messages (via dynamic import of `PowService`), then seals the message with `IntegrityService.seal()` (hash, signature, hashcash PoW, replay nonce) before sending β€” seal failure drops the message. `sendRaw(message)` sends a raw message bypassing PoW/broadcast wrapping. | | `discoveryService.ts` | `DiscoveryService` | Gun-based relay discovery registry at `v3/server-config/discovery`. Publishes signed relay announcements (`nodeId`, `peerId`, `websocket`, `gun`, `api`, `capabilities`, `timestamp`, `ttlMs`, `signerPubkey`, `signature`), verifies Schnorr signatures before accepting, enforces TTL expiry and max-entry caps, and exposes normalized validated discovery entries for other services. | diff --git a/src/services/gunService.ts b/src/services/gunService.ts index fc0bef0..602060f 100644 --- a/src/services/gunService.ts +++ b/src/services/gunService.ts @@ -7,7 +7,7 @@ export const GUN_NAMESPACE = 'v3'; // Roots that get namespaced under GUN_NAMESPACE β€” Gun is now live-updates only, // not the initial load source. These namespaced paths are still written to on // createPost/createPoll so Gun relay peers can pick up new content in real time. -const NAMESPACED_ROOTS = new Set(['posts', 'communities', 'polls', 'postVotes', 'users', 'comments', 'events', 'chatrooms', 'server-config']); +const NAMESPACED_ROOTS = new Set(['posts', 'communities', 'polls', 'postVotes', 'users', 'comments', 'events', 'chats', 'chatrooms', 'server-config']); function createNamespacedProxy(gun: any, nsNode: any): any { return new Proxy(gun, { diff --git a/src/services/userService.ts b/src/services/userService.ts index 845c0d4..81b8ca7 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -34,16 +34,13 @@ export class UserService { private static async enqueueWrite(userId: string, task: () => Promise): Promise { const previous = this.writeQueues.get(userId) ?? Promise.resolve(); - let release!: () => void; - const next = new Promise((resolve) => { release = resolve; }); - this.writeQueues.set(userId, previous.then(() => next)); - + const run = previous.then(task, task); + const queueTail = run.then(() => undefined, () => undefined); + this.writeQueues.set(userId, queueTail); try { - await previous; - return await task(); + return await run; } finally { - release(); - if (this.writeQueues.get(userId) === next) { + if (this.writeQueues.get(userId) === queueTail) { this.writeQueues.delete(userId); } } From 164d2060077cc5ed7cfee96aebc7f6e878a29c6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:41:59 +0000 Subject: [PATCH 3/3] finalize gun sync loop stability fixes Agent-Logs-Url: https://github.com/theEndless11/decentralised/sessions/8b490b38-664b-42ac-b504-12397fd0436e Co-authored-by: thegoodduck <163307030+thegoodduck@users.noreply.github.com> --- src/services/chatService.ts | 100 ++++++++++++++++++++----------- src/services/commentService.ts | 55 ++++++++++++++--- src/services/copilot-services.md | 8 +-- src/services/gunService.ts | 2 +- src/services/userService.ts | 78 ++++++++++++++++++++++-- src/views/HomePage.vue | 71 +++++++++++++++++----- src/views/copilot-views.md | 2 +- 7 files changed, 245 insertions(+), 71 deletions(-) diff --git a/src/services/chatService.ts b/src/services/chatService.ts index bc6ef20..5fa3e64 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -1,6 +1,6 @@ // chatService.ts - P2P Chat Service for Vue -import { GunService } from './gunService'; +import { GunService, GUN_NAMESPACE } from './gunService'; export interface ChatMessage { id: string; @@ -148,6 +148,18 @@ class ChatService { return [userA, userB].sort().join(':'); } + private getChatRoots(): any[] { + const rawGun = GunService.getRawGun(); + return [ + rawGun.get('chats'), + rawGun.get(GUN_NAMESPACE).get('chats'), + ]; + } + + private getRoomNodes(roomId: string): any[] { + return this.getChatRoots().map((root) => root.get(roomId)); + } + private async storeMessageInGun( roomId: string, messageId: string, @@ -157,8 +169,7 @@ class ChatService { encryptedForSender: string, timestamp: number ): Promise { - const gun = GunService.getGun(); - gun.get('chats').get(roomId).get(messageId).put({ + const messageRecord = { id: messageId, senderId, recipientId, @@ -166,6 +177,9 @@ class ChatService { encryptedForSender, // decryptable by sender timestamp, readAt: null, + }; + this.getRoomNodes(roomId).forEach((roomNode) => { + roomNode.get(messageId).put(messageRecord); }); } @@ -173,28 +187,23 @@ class ChatService { * Load and decrypt all messages for a conversation from GunDB. */ async loadHistory(recipientId: string): Promise { - const gun = GunService.getGun(); const roomId = this.getRoomId(this.userId, recipientId); - const raw: any[] = []; - - await new Promise((resolve) => { - const timer = setTimeout(resolve, 3000); - gun.get('chats').get(roomId).once((room: any) => { - clearTimeout(timer); - if (!room) { resolve(); return; } - const keys = Object.keys(room).filter(k => k !== '_' && k.startsWith('msg-')); - if (keys.length === 0) { resolve(); return; } - - let loaded = 0; - keys.forEach((msgId) => { - gun.get('chats').get(roomId).get(msgId).once((msg: any) => { - if (msg && msg.senderId) raw.push(msg); - loaded++; - if (loaded === keys.length) resolve(); - }); + const roomMessages = await Promise.all(this.getRoomNodes(roomId).map((roomNode) => this.readRoomMessages(roomNode))); + const raw = Array.from(roomMessages.reduce((merged, messages) => { + messages.forEach((msg, msgId) => { + const existing = merged.get(msgId); + if (!existing) { + merged.set(msgId, msg); + return; + } + merged.set(msgId, { + ...existing, + ...msg, + readAt: existing.readAt ?? msg.readAt, }); }); - }); + return merged; + }, new Map()).values()); // Sort by timestamp raw.sort((a, b) => a.timestamp - b.timestamp); @@ -362,21 +371,39 @@ class ChatService { } private flushReadMarkers(roomId: string): void { - const gun = GunService.getGun(); const readAt = Date.now(); - const roomNode = gun.get('chats').get(roomId); - roomNode.once((room: any) => { - if (!room || typeof room !== 'object') return; - const messageIds = Object.keys(room).filter((msgId) => msgId !== '_' && msgId.startsWith('msg-')); - if (messageIds.length === 0) return; - void this.readMessagesInBatches(roomNode, messageIds).then((messages) => { - const unreadIds = messages - .filter(([, msg]) => Boolean(msg && msg.recipientId === this.userId && !msg.readAt)) - .map(([msgId]) => msgId); - if (unreadIds.length === 0) return; - return this.writeReadMarkers(roomId, unreadIds, readAt); + void Promise.all(this.getRoomNodes(roomId).map((roomNode) => this.readRoomMessages(roomNode))).then((messageSets) => { + const unreadIds = Array.from(messageSets.reduce((ids, messages) => { + messages.forEach((msg, msgId) => { + if (msg && msg.recipientId === this.userId && !msg.readAt) { + ids.add(msgId); + } + }); + return ids; + }, new Set())); + if (unreadIds.length === 0) return; + return this.writeReadMarkers(roomId, unreadIds, readAt); + }); + } + + private async readRoomMessages(roomNode: any): Promise> { + const room = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), 3000); + roomNode.once((data: any) => { + clearTimeout(timer); + resolve(data); }); }); + if (!room || typeof room !== 'object') return new Map(); + const messageIds = Object.keys(room).filter((msgId) => msgId !== '_' && msgId.startsWith('msg-')); + if (messageIds.length === 0) return new Map(); + const messages = await this.readMessagesInBatches(roomNode, messageIds); + return messages.reduce((map, [msgId, msg]) => { + if (msg && msg.senderId) { + map.set(msgId, msg); + } + return map; + }, new Map()); } private async readMessagesInBatches(roomNode: any, messageIds: string[]): Promise> { @@ -398,11 +425,12 @@ class ChatService { } private async writeReadMarkers(roomId: string, unreadIds: string[], readAt: number): Promise { - const gun = GunService.getGun(); const batchSize = 20; for (let index = 0; index < unreadIds.length; index += batchSize) { unreadIds.slice(index, index + batchSize).forEach((msgId) => { - gun.get('chats').get(roomId).get(msgId).get('readAt').put(readAt); + this.getRoomNodes(roomId).forEach((roomNode) => { + roomNode.get(msgId).get('readAt').put(readAt); + }); }); if (index + batchSize < unreadIds.length) { await new Promise((resolve) => window.setTimeout(resolve, 25)); diff --git a/src/services/commentService.ts b/src/services/commentService.ts index 1e0f391..64a8f59 100644 --- a/src/services/commentService.ts +++ b/src/services/commentService.ts @@ -10,6 +10,48 @@ function getGun() { return GunService.getGun(); } +function putNodeWithTimeout( + node: any, + value: any, + verify: (data: any) => boolean, + timeoutMs = 5000, + verifyTimeoutMs = 1500, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + let verified = false; + node.once((data: any) => { + if (settled || verified) return; + verified = true; + settled = true; + if (verify(data)) { + resolve(); + return; + } + reject(new Error('Comment write timed out and could not be verified')); + }); + setTimeout(() => { + if (settled || verified) return; + verified = true; + settled = true; + reject(new Error('Comment write timed out and could not be verified')); + }, verifyTimeoutMs); + }, timeoutMs); + node.put(value, (ack: any) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (ack?.err) { + reject(ack.err); + return; + } + resolve(); + }); + }); +} + export interface Comment { id: string; postId: string; @@ -138,12 +180,11 @@ export async function createComment(data: CreateCommentData): Promise { commentRecord.authorName = 'encrypted'; } - commentNode.put(commentRecord, (ack: any) => { - if (ack?.err) { - reject(ack.err); - return; - } - + putNodeWithTimeout( + commentNode, + commentRecord, + (stored) => Boolean(stored?.id === commentId && stored?.postId === data.postId), + ).then(() => { // Add to post's comments index getGun().get('posts') .get(data.postId) @@ -189,7 +230,7 @@ export async function createComment(data: CreateCommentData): Promise { resolve(comment); }, 100); - }); + }).catch(reject); }); } diff --git a/src/services/copilot-services.md b/src/services/copilot-services.md index 6491def..80c4c15 100644 --- a/src/services/copilot-services.md +++ b/src/services/copilot-services.md @@ -8,7 +8,7 @@ All services are **static classes** β€” never instantiated with `new`. Initializ | File | Class | Purpose | |---|---|---| -| `gunService.ts` | `GunService` | GunDB wrapper. `initialize()` called in `main.ts`. All data roots (`posts`, `polls`, `communities`, `users`, `comments`, `events`, `chats`, `chatrooms`, `server-config`) are transparently namespaced under `v3` via a Proxy. Use `GunService.getGun()` to get the proxied instance. `subscribe(path, cb)` now returns an unsubscribe function so long-lived `.on()` listeners can be cleaned up. Adding a new root requires adding it to `NAMESPACED_ROOTS`. | +| `gunService.ts` | `GunService` | GunDB wrapper. `initialize()` called in `main.ts`. All data roots (`posts`, `polls`, `communities`, `users`, `comments`, `events`, `chatrooms`, `server-config`) are transparently namespaced under `v3` via a Proxy. Use `GunService.getGun()` to get the proxied instance. `subscribe(path, cb)` now returns an unsubscribe function so long-lived `.on()` listeners can be cleaned up. Adding a new root requires adding it to `NAMESPACED_ROOTS`. | | `storageService.ts` | `StorageService` | IndexedDB wrapper (`idb`). Stores: `blocks`, `votes`, `receipts`, `polls`, `metadata`, `encryption-keys`. DB name: `interpoll-db` v2. The `metadata` store is a generic key-value bag used by many other services. The `encryption-keys` store holds `StoredEncryptionKey` entries keyed by `id`. | | `websocketService.ts` | `WebSocketService` | WebSocket peer connection to the relay server. Handles reconnection (exponential backoff, infinite retries), peer discovery, server list sharing, and message queuing when disconnected. Subscribe to message types via `.subscribe(type, callback)`. Also supports encrypted chat room message relay: `broadcastChatRoomMessage(roomId, data)` sends an opaque encrypted blob via the relay, and `subscribeToChatRoom(roomId, callback)` receives them with per-room multiplexing. At startup/reconnect it also publishes and consumes signed Gun discovery announcements via `DiscoveryService`, so known servers can be sourced from `v3/server-config/discovery` in addition to WSS `server-list` broadcasts. Known-server entries track source (`local`, `peer`, `gun`) and explicit trust metadata (`signatureValid`, `lastVerifiedAt`, `expiresAt`) without inferring signature trust from labels/source strings; local self-seeded entries are stored unsigned by default. Incoming `server-list` entries are only accepted when the relay envelope passes `IntegrityService.verifySealedPayload()` and endpoint protocol safety checks. `broadcast()` is async and automatically attaches proof-of-work for content messages (via dynamic import of `PowService`), then seals the message with `IntegrityService.seal()` (hash, signature, hashcash PoW, replay nonce) before sending β€” seal failure drops the message. `sendRaw(message)` sends a raw message bypassing PoW/broadcast wrapping. | | `discoveryService.ts` | `DiscoveryService` | Gun-based relay discovery registry at `v3/server-config/discovery`. Publishes signed relay announcements (`nodeId`, `peerId`, `websocket`, `gun`, `api`, `capabilities`, `timestamp`, `ttlMs`, `signerPubkey`, `signature`), verifies Schnorr signatures before accepting, enforces TTL expiry and max-entry caps, and exposes normalized validated discovery entries for other services. | @@ -37,9 +37,9 @@ All services are **static classes** β€” never instantiated with `new`. Initializ | `pollService.ts` | `PollService` | Poll CRUD, invite code generation/validation/consumption, vote recording in GunDB. Schnorr-signs polls on create (`authorPubkey`, `contentSignature`). Community/all-poll subscriptions use hydration-first loading with batch processing plus soft/hard timeouts to avoid startup hangs while preventing startup content from being treated as "new". `loadPoll()` is Gun-first for live vote totals; API fallback is metadata-only (question/options labels) and does **not** ingest API vote counts, preventing stale backend totals from bouncing local/Gun-confirmed votes. When global poll options are missing during reload, it now also falls back to community-scoped poll options before dropping the poll, which prevents empty poll lists caused by partial Gun hydration ordering; if Gun still yields only a partial shell, read flows can fall back to a fresh offline local backup instead of returning null. Community subscriptions now normalize missing `communityId` fields to the subscribed community id before emitting polls, preventing transient partial root records from moving polls out of community feeds. Poll option voter lists are normalized from Gun-safe object storage back into arrays for the app, and voting rewrites the full options map (global + community copy) so option order and vote counts stay aligned. Private poll invite codes are stored both by index and uppercase code key; `validateInviteCode()` checks availability, `consumeInviteCode()` creates a short-lived reservation token and verifies ownership before voting, `finalizeInviteCode()` permanently marks the code used after chain success, `releaseInviteCode()` clears only the caller's own reservation if voting fails before the chain write, and `queueInviteCodeFinalization()` / `flushPendingInviteCodeFinalizations()` persist best-effort finalize retries in localStorage so a post-chain finalize glitch does not silently reopen the code forever. Poll create/read/vote now include same-device local durability fallback (IndexedDB metadata) for degraded relay conditions, but local fallback reads are now offline-only with backup freshness TTL and deletion tombstones to avoid stale/deleted poll resurrection while online. Local backup fallback is disabled for mutating paths (like vote) so writes cannot proceed from stale cached-only poll state. Poll creation now embeds options directly in the root/community poll shell write as a redundancy path, includes signatures/encryption metadata in the initial shell write instead of follow-up field-by-field puts, and treats dedicated options-child write timeouts as soft failures. `loadPollOptions()` falls back to inline shell options only after trying live options hydration; this prevents poll creation failures caused by flaky Gun ACKs on `...get('options').put(...)` while reducing stale reads. Root and community poll-shell writes now use timeout-with-verification fallback plus multi-attempt repair retries: after timeout, writes are retried with merged current state and re-verified before failing, which significantly improves propagation on slow relays without clobbering concurrent updates. Poll creation and vote confirmation time budgets were increased further for high-latency relays, while still requiring explicit post-write confirmation before reporting success. Vote writes now normalize selected option IDs against known poll options before write/confirmation and use timeout-tolerant writes with explicit confirmation and retry writes on both root and community paths, using fresh options snapshots per retry so transient lag does not fail otherwise-valid votes or overwrite concurrent voters; once root confirmation succeeds, slower community-path reconciliation continues in the background so UX is not blocked by secondary-path lag. Timeout-tolerant writes no longer emit unconditional console warnings when an ACK is late; failures are now surfaced only if post-write confirmation cannot be established. Private invite-code list writes and `inviteCodesByCode` writes now use timeout-tolerant writes plus explicit read-back confirmation retries (with chunking for by-code writes), so high-latency relays cannot silently drop entries during large private poll creation; search indexing is timeboxed and uses credentials only for same-origin API calls so indexing outages/CORS mismatches cannot block poll creation success. Local poll backup persistence is serialized through a write queue and skips unchanged snapshots using a normalized signature check to avoid repeated metadata rewrites from read-heavy paths. Poll creation diagnostics can be enabled from console via `localStorage.setItem('interpoll_poll_debug', 'all')` (or categories: `create,writes,index,ui`) to log write ACK timing, indexing lifecycle, and create flow checkpoints. Encrypts poll content (question, options, description) via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptPoll()` reverses at read time. Also calls `indexForSearch()` to push data to relay for full-text search. | | `communityService.ts` | `CommunityService` | Community CRUD in GunDB. IDs are derived from lowercased name: `c-{slug}`. Signs community creation with Schnorr (via `CryptoService`/`KeyService`) for anti-sabotage; includes `verifyCommunitySignature()` to check integrity. Supports private encrypted communities via `createPrivateCommunity()` (AES-256-GCM encrypted metadata, password-derived or invite-only keys), `decryptCommunityMeta()` (decrypt using stored key), and `joinPrivateCommunity()` (join with invite key or password). `subscribeToCommunitiesLive()` now forwards repeat updates so member-count and metadata changes are not dropped after first load, but avoids repeated identical re-emits by signature-checking the fully mapped community payload before notifying callbacks. It also reuses inline/cached rules and keeps a single per-community `rules` subscription for cache sync. Rules loading only accepts numeric-key string entries, which avoids mis-parsing Gun relation/link metadata as rules in partially hydrated nodes. Uses `EncryptionService`, `KeyVaultService`, and `InviteLinkService`. | | `postService.ts` | `PostService` | Post CRUD in GunDB, image upload via `IPFSService`. Community subscriptions now hydrate full community post IDs (no initial 50-post cap), use `map().on` for live community-index updates, and wrap per-post `once()` reads with a short timeout so stale/missing post pointers cannot block initial load completion after reload. Global all-post subscriptions now also use `map().on` instead of root `.on`, which reduces full-root rescan churn when large batches of posts are written into Gun. API post fetches and search indexing use `config.relay.api`, so Settings/runtime relay overrides also apply in fresh and incognito sessions instead of silently calling the default production host. Signs post content with Schnorr (via `CryptoService`/`KeyService`) for anti-sabotage verification; exposes `verifyPostSignature()` returning `'verified' | 'unverified' | 'unsigned'`. Encrypts post content (title, body, author info, images, signature) via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptPost()` reverses at read time with HMAC authTag verification and type-validated decryption. | -| `commentService.ts` | `CommentService` | Comment CRUD in GunDB. Schnorr-signs comment content on create/edit for anti-sabotage verification (`authorPubkey`, `contentSignature`). New comments now write a single consolidated comment object to Gun instead of many field-level `.put()` calls, which reduces sync fan-out when encrypted comments are created. Encrypts comment content via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptComment()` reverses at read time. `verifyCommentSignature()` returns `'verified' | 'unverified' | 'unsigned'`. | -| `userService.ts` | `UserService` | User profile CRUD in GunDB, keyed by device ID. Exposes Schnorr public key for identity. Per-user write operations now run through a small local queue so counter-style updates (`postCount`, `commentCount`, `karma`) do not race each other on the same client, and those counters now update only the specific Gun field instead of rewriting the whole profile object. Supports `customUsername`, `showRealName` toggle, and avatar images (`avatarIPFS`/`avatarThumbnail`). | -| `chatService.ts` | `ChatService` | **Instance-based** (not static). P2P DM chat over GunDB + WebSocket. Uses RSA-OAEP for message encryption between users. `markAsRead()` now debounces room-level read flushing and writes unread `readAt` markers in small batches instead of doing one immediate `.put()` per message callback. Each chat session needs `new ChatService(wsUrl, userId)`. | +| `commentService.ts` | `CommentService` | Comment CRUD in GunDB. Schnorr-signs comment content on create/edit for anti-sabotage verification (`authorPubkey`, `contentSignature`). New comments now write a single consolidated comment object to Gun instead of many field-level `.put()` calls, which reduces sync fan-out when encrypted comments are created. The write path is still bounded by a timeout, but if the ACK is late it now performs a short read-back verification before indexing the comment under the post or bumping counts, preventing orphan comment refs on failed writes. Encrypts comment content via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptComment()` reverses at read time. `verifyCommentSignature()` returns `'verified' | 'unverified' | 'unsigned'`. | +| `userService.ts` | `UserService` | User profile CRUD in GunDB, keyed by device ID. Exposes Schnorr public key for identity. Per-user write operations now run through a small local queue so counter-style updates (`postCount`, `commentCount`, `karma`) do not race each other on the same client, and those counters now update only the specific Gun field instead of rewriting the whole profile object. Timeout-bounded writes now perform a short read-back verification before treating a late ACK as success, so flaky relays do not silently drop profile/public-key/counter writes while the UI assumes they landed. Supports `customUsername`, `showRealName` toggle, and avatar images (`avatarIPFS`/`avatarThumbnail`). | +| `chatService.ts` | `ChatService` | **Instance-based** (not static). P2P DM chat over GunDB + WebSocket. Uses RSA-OAEP for message encryption between users. During the DM-root cleanup it now mirrors messages/read markers across both legacy `chats/*` and mirrored `v3/chats/*` roots and merges reads from both, so old history stays visible while newer clients converge on the namespaced copy. `markAsRead()` now debounces room-level read flushing, batches the message reads, and writes unread `readAt` markers in small batches instead of doing one immediate `.put()` per message callback. Each chat session needs `new ChatService(wsUrl, userId)`. | ## Media diff --git a/src/services/gunService.ts b/src/services/gunService.ts index 602060f..fc0bef0 100644 --- a/src/services/gunService.ts +++ b/src/services/gunService.ts @@ -7,7 +7,7 @@ export const GUN_NAMESPACE = 'v3'; // Roots that get namespaced under GUN_NAMESPACE β€” Gun is now live-updates only, // not the initial load source. These namespaced paths are still written to on // createPost/createPoll so Gun relay peers can pick up new content in real time. -const NAMESPACED_ROOTS = new Set(['posts', 'communities', 'polls', 'postVotes', 'users', 'comments', 'events', 'chats', 'chatrooms', 'server-config']); +const NAMESPACED_ROOTS = new Set(['posts', 'communities', 'polls', 'postVotes', 'users', 'comments', 'events', 'chatrooms', 'server-config']); function createNamespacedProxy(gun: any, nsNode: any): any { return new Proxy(gun, { diff --git a/src/services/userService.ts b/src/services/userService.ts index 81b8ca7..31415c7 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -32,6 +32,48 @@ export class UserService { private static currentUser: UserProfile | null = null; private static writeQueues = new Map>(); + private static putNode( + node: any, + value: any, + verify: (stored: any) => boolean, + timeoutMs = 5000, + verifyTimeoutMs = 1500, + ): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + let verified = false; + node.once((stored: any) => { + if (settled || verified) return; + verified = true; + settled = true; + if (verify(stored)) { + resolve(); + return; + } + reject(new Error('User write timed out and could not be verified')); + }); + setTimeout(() => { + if (settled || verified) return; + verified = true; + settled = true; + reject(new Error('User write timed out and could not be verified')); + }, verifyTimeoutMs); + }, timeoutMs); + node.put(value, (ack: any) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (ack?.err) { + reject(new Error(ack.err)); + return; + } + resolve(); + }); + }); + } + private static async enqueueWrite(userId: string, task: () => Promise): Promise { const previous = this.writeQueues.get(userId) ?? Promise.resolve(); const run = previous.then(task, task); @@ -73,7 +115,11 @@ export class UserService { if (existingProfile) { // Backfill publicKey if it's missing from an older profile if (!existingProfile.publicKey) { - await gun.get('users').get(deviceId).get('publicKey').put(publicKey); + await this.putNode( + gun.get('users').get(deviceId).get('publicKey'), + publicKey, + (stored) => stored === publicKey, + ); existingProfile.publicKey = publicKey; } this.currentUser = existingProfile; @@ -95,7 +141,11 @@ export class UserService { publicKey, // ← stored in GunDB so other users can fetch it }; - await gun.get('users').get(deviceId).put(newProfile); + await this.putNode( + gun.get('users').get(deviceId), + newProfile, + (stored) => Boolean(stored?.id === deviceId && stored?.publicKey === publicKey), + ); this.currentUser = newProfile; return newProfile; @@ -110,7 +160,11 @@ export class UserService { ? this.currentUser : await this.getUser(currentUser.id) ?? currentUser; const updatedProfile = { ...baseProfile, ...updates }; - await gun.get('users').get(currentUser.id).put(updatedProfile); + await this.putNode( + gun.get('users').get(currentUser.id), + updatedProfile, + (stored) => Object.entries(updates).every(([key, fieldValue]) => stored?.[key] === fieldValue), + ); this.currentUser = updatedProfile; return updatedProfile; @@ -127,7 +181,11 @@ export class UserService { await this.enqueueWrite(user.id, async () => { const latestUser = await this.getUser(user.id) ?? user; const nextPostCount = (latestUser.postCount || 0) + 1; - await GunService.getGun().get('users').get(user.id).get('postCount').put(nextPostCount); + await this.putNode( + GunService.getGun().get('users').get(user.id).get('postCount'), + nextPostCount, + (stored) => stored === nextPostCount, + ); this.currentUser = { ...(this.currentUser?.id === user.id ? this.currentUser : latestUser), postCount: nextPostCount, @@ -140,7 +198,11 @@ export class UserService { await this.enqueueWrite(user.id, async () => { const latestUser = await this.getUser(user.id) ?? user; const nextCommentCount = (latestUser.commentCount || 0) + 1; - await GunService.getGun().get('users').get(user.id).get('commentCount').put(nextCommentCount); + await this.putNode( + GunService.getGun().get('users').get(user.id).get('commentCount'), + nextCommentCount, + (stored) => stored === nextCommentCount, + ); this.currentUser = { ...(this.currentUser?.id === user.id ? this.currentUser : latestUser), commentCount: nextCommentCount, @@ -154,7 +216,11 @@ export class UserService { const user = await this.getUser(authorId); if (!user) return; const nextKarma = (user.karma || 0) + points; - await GunService.getGun().get('users').get(authorId).get('karma').put(nextKarma); + await this.putNode( + GunService.getGun().get('users').get(authorId).get('karma'), + nextKarma, + (stored) => stored === nextKarma, + ); if (this.currentUser?.id === authorId) { this.currentUser = { ...this.currentUser, karma: nextKarma }; } diff --git a/src/views/HomePage.vue b/src/views/HomePage.vue index 613a2bb..8b28940 100644 --- a/src/views/HomePage.vue +++ b/src/views/HomePage.vue @@ -439,7 +439,7 @@ import PostCard from '../components/PostCard.vue'; import PollCard from '../components/PollCard.vue'; import { Post } from '../services/postService'; import { Poll } from '../services/pollService'; -import { GunService } from '../services/gunService'; +import { GunService, GUN_NAMESPACE } from '../services/gunService'; import { UserService } from '../services/userService'; import ChatService from '../services/chatService'; import { warmupFromDB } from '../services/dbWarmup'; @@ -627,40 +627,73 @@ function getRoomId(a: string, b: string) { return [a, b].sort().join(':'); } +function getChatRoots() { + const rawGun = GunService.getRawGun(); + return [ + rawGun.get('chats'), + rawGun.get(GUN_NAMESPACE).get('chats'), + ]; +} + +function getChatRoomNodes(roomId: string) { + return getChatRoots().map((root) => root.get(roomId)); +} + const unreadDebounceTimers = new Map>(); +const subscribedChatRooms = new Set(); function recomputeUnread(roomId: string, otherUserId: string) { // Debounce per room β€” only compute after 500ms of no new messages const existing = unreadDebounceTimers.get(roomId); if (existing) clearTimeout(existing); unreadDebounceTimers.set(roomId, setTimeout(() => { - const gun = GunService.getGun(); - let unread = 0; - gun.get('chats').get(roomId).map().once((msg: any) => { - if (msg && msg.recipientId === currentUserId && !msg.readAt) unread++; + const unreadIds = new Set(); + getChatRoomNodes(roomId).forEach((roomNode) => { + roomNode.map().once((msg: any, msgId: string) => { + if (msg && msgId && msg.recipientId === currentUserId && !msg.readAt) unreadIds.add(msgId); + }); }); setTimeout(() => { const entry = chatList.value.find(c => c.userId === otherUserId); - if (entry) entry.unreadCount = unread; + if (entry) entry.unreadCount = unreadIds.size; chatList.value = [...chatList.value].sort((a, b) => b.lastMessageTime - a.lastMessageTime); }, 300); }, 500)); } function subscribeToRoom(otherUserId: string, otherName: string, otherPublicKey: string) { - const gun = GunService.getGun(); const roomId = getRoomId(currentUserId, otherUserId); + const seenMessageSignatures = new Map(); + const existingEntry = chatList.value.find(c => c.userId === otherUserId); - if (!chatList.value.find(c => c.userId === otherUserId)) { + if (!existingEntry) { chatList.value.push({ userId: otherUserId, name: otherName, lastMessage: '', lastMessageTime: 0, unreadCount: 0, publicKey: otherPublicKey, }); + } else { + existingEntry.name = otherName; + existingEntry.publicKey = otherPublicKey; } - const listener = gun.get('chats').get(roomId).map().on((msg: any) => { + if (subscribedChatRooms.has(roomId)) { + return; + } + subscribedChatRooms.add(roomId); + + const handleMessage = (msg: any, msgId: string) => { if (!msg || !msg.senderId || !msg.timestamp) return; + if (msgId) { + const signature = JSON.stringify({ + senderId: msg.senderId, + recipientId: msg.recipientId, + timestamp: msg.timestamp, + readAt: msg.readAt ?? null, + }); + if (seenMessageSignatures.get(msgId) === signature) return; + seenMessageSignatures.set(msgId, signature); + } const entry = chatList.value.find(c => c.userId === otherUserId); if (!entry) return; if (msg.timestamp > entry.lastMessageTime) { @@ -668,18 +701,22 @@ function subscribeToRoom(otherUserId: string, otherName: string, otherPublicKey: entry.lastMessage = msg.senderId === currentUserId ? 'You: [Encrypted]' : '[Encrypted message]'; } recomputeUnread(roomId, otherUserId); - }); + }; - gunListeners.push(() => listener?.off?.()); + const listeners = getChatRoomNodes(roomId).map((roomNode) => roomNode.map().on(handleMessage)); + gunListeners.push(() => listeners.forEach((listener) => listener?.off?.())); } async function loadChatList() { const gun = GunService.getGun(); - gun.get('chats').once((rooms: any) => { - if (!rooms) return; - Object.keys(rooms) - .filter(k => k !== '_' && k.includes(currentUserId)) - .forEach((roomId) => { + const roomIds = new Set(); + getChatRoots().forEach((rootNode) => { + rootNode.once((rooms: any) => { + if (!rooms) return; + Object.keys(rooms) + .filter(k => k !== '_' && k.includes(currentUserId)) + .forEach((roomId) => roomIds.add(roomId)); + roomIds.forEach((roomId) => { const otherUserId = roomId.split(':').find(id => id !== currentUserId); if (!otherUserId) return; gun.get('users').get(otherUserId).once((userData: any) => { @@ -690,6 +727,7 @@ async function loadChatList() { ); }); }); + }); }); } @@ -1079,6 +1117,7 @@ onMounted(async () => { onUnmounted(() => { bgChatService?.disconnect(); gunListeners.forEach(off => off()); + subscribedChatRooms.clear(); unreadDebounceTimers.forEach(t => clearTimeout(t)); unreadDebounceTimers.clear(); }); diff --git a/src/views/copilot-views.md b/src/views/copilot-views.md index 9e12fbf..eb863fa 100644 --- a/src/views/copilot-views.md +++ b/src/views/copilot-views.md @@ -8,7 +8,7 @@ Route-level pages built with Vue 3 Composition API + Ionic. Views compose stores | File | Purpose | |---|---| -| `HomePage.vue` | Mixed home feed that combines communities, posts, and polls with new-content banners and runtime relay-aware loading. On mount it expands visible feed size up to an initial 50-item target, and it auto-expands again when newly loaded posts/polls arrive (up to that same cap), so fetched content is visible without first scrolling. It still uses incremental infinite-scroll loading beyond the cap. Includes detailed `[FeedDebug]` console diagnostics for warmup/subscription/render count tracing (enabled only when `localStorage.interpoll_feed_debug === 'true'`). | +| `HomePage.vue` | Mixed home feed that combines communities, posts, and polls with new-content banners and runtime relay-aware loading. On mount it expands visible feed size up to an initial 50-item target, and it auto-expands again when newly loaded posts/polls arrive (up to that same cap), so fetched content is visible without first scrolling. It still uses incremental infinite-scroll loading beyond the cap. The background DM preview list now merges room discovery, listeners, and unread counting across both legacy `chats/*` and mirrored `v3/chats/*` roots so direct-message history is not split during migration. Includes detailed `[FeedDebug]` console diagnostics for warmup/subscription/render count tracing (enabled only when `localStorage.interpoll_feed_debug === 'true'`). | | `CommunityPage.vue` | Community detail page with feed, metadata, and join/share actions. In the mixed `all` filter, it now promotes one poll near the top when polls are present but would otherwise be buried below many posts, so polls are visible without deep scrolling. On direct route reloads it now performs a short `communityStore.loadCommunities()` bootstrap before selecting the community to reduce cold-start misses, and emits detailed `[CommunityDebug]` loading logs when `localStorage.interpoll_community_debug === 'true'`. | | `PollDetailPage.vue` | Full poll page with inline vote submission, results gating, duplicate-vote checks, and receipt-first submission flow. After `chainStore.addVote()` succeeds it marks the vote locally, routes to the receipt immediately, and lets backend confirm plus Gun/MySQL follow-up sync continue in the background so slow or outdated relays do not bounce the user back into a second submission attempt. | | `VotePage.vue` | Standalone vote route that loads a poll by id and renders `VoteForm.vue`; used for direct links and receipt-oriented vote flows. |