Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/composables/copilot-composables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
12 changes: 10 additions & 2 deletions src/composables/useChainSync.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | null = null;

const downgradeDetected = ref(false);
const peerCount = ref(0);
Expand All @@ -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;
Expand All @@ -37,6 +38,13 @@ export function useChainSync() {
startSync();
});

onUnmounted(() => {
if (interval) {
clearInterval(interval);
interval = null;
}
});

const resetDowngradeAlert = () => {
downgradeDetected.value = false;
};
Expand Down
140 changes: 111 additions & 29 deletions src/services/chatService.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -28,6 +28,7 @@ class ChatService {
private recipientKeys: Map<string, CryptoKey> = new Map();
private connected: boolean = false;
private reconnectTimer: number | null = null;
private pendingReadFlushes: Map<string, number> = new Map();

public onMessage: ((msg: ChatMessage) => void) | null = null;
public onTyping: ((data: { from: string; isTyping: boolean }) => void) | null = null;
Expand Down Expand Up @@ -147,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,
Expand All @@ -156,44 +169,41 @@ class ChatService {
encryptedForSender: string,
timestamp: number
): Promise<void> {
const gun = GunService.getGun();
gun.get('chats').get(roomId).get(messageId).put({
const messageRecord = {
id: messageId,
senderId,
recipientId,
encryptedForRecipient, // decryptable by recipient
encryptedForSender, // decryptable by sender
timestamp,
readAt: null,
};
this.getRoomNodes(roomId).forEach((roomNode) => {
roomNode.get(messageId).put(messageRecord);
});
}

/**
* Load and decrypt all messages for a conversation from GunDB.
*/
async loadHistory(recipientId: string): Promise<ChatMessage[]> {
const gun = GunService.getGun();
const roomId = this.getRoomId(this.userId, recipientId);
const raw: any[] = [];

await new Promise<void>((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<string, any>()).values());

// Sort by timestamp
raw.sort((a, b) => a.timestamp - b.timestamp);
Expand Down Expand Up @@ -348,23 +358,95 @@ 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 readAt = Date.now();
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<string>()));
if (unreadIds.length === 0) return;
return this.writeReadMarkers(roomId, unreadIds, readAt);
});
}

private async readRoomMessages(roomNode: any): Promise<Map<string, any>> {
const room = await new Promise<any>((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<string, any>());
}

private async readMessagesInBatches(roomNode: any, messageIds: string[]): Promise<Array<[string, any]>> {
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<void> {
const batchSize = 20;
for (let index = 0; index < unreadIds.length; index += batchSize) {
unreadIds.slice(index, index + batchSize).forEach((msgId) => {
this.getRoomNodes(roomId).forEach((roomNode) => {
Comment on lines +429 to +431
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeReadMarkers calls this.getRoomNodes(roomId) inside the per-message loop, recreating the room-node array for every unread message. Pull const roomNodes = this.getRoomNodes(roomId) out of the loop to avoid extra allocations/work when marking many messages as read.

Suggested change
for (let index = 0; index < unreadIds.length; index += batchSize) {
unreadIds.slice(index, index + batchSize).forEach((msgId) => {
this.getRoomNodes(roomId).forEach((roomNode) => {
const roomNodes = this.getRoomNodes(roomId);
for (let index = 0; index < unreadIds.length; index += batchSize) {
unreadIds.slice(index, index + batchSize).forEach((msgId) => {
roomNodes.forEach((roomNode) => {

Copilot uses AI. Check for mistakes.
roomNode.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;
export default ChatService;
Loading