Skip to content

Commit 76581b6

Browse files
author
code4bones
committed
Add local PWA chat reset
1 parent b420784 commit 76581b6

7 files changed

Lines changed: 94 additions & 8 deletions

File tree

‎pwa/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pwa/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@code4bones/branch-pwa",
3-
"version": "0.1.0-beta.140",
3+
"version": "0.1.0-beta.141",
44
"private": true,
55
"type": "module",
66
"scripts": {

‎pwa/src/app/PocDebugPanel.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export function PocDebugPanel({ contactId, contactName, enabled, attachedRelayEn
4141
const [selectedTraceCategories, setSelectedTraceCategories] = useState<TraceCategory[]>(defaultTraceCategories);
4242
const outbox = useAppStore((state) => state.outbox);
4343
const settleOutboxMessage = useAppStore((state) => state.settleOutboxMessage);
44+
const clearLocalConversation = useAppStore((state) => state.clearLocalConversation);
4445
const recordTransportTrace = useAppStore((state) => state.recordTransportTrace);
4546
const { awaitingDeliveryCount, deliveredAwaitingReadCount } = useMemo(() => {
4647
const localOutbox = outbox.filter((entry) => entry.contactId === contactId);
@@ -69,6 +70,10 @@ export function PocDebugPanel({ contactId, contactName, enabled, attachedRelayEn
6970
}
7071
if (purgeableMessageIds.length > 0) recordTransportTrace(`outbox: delivery_mapping_purged ${String(purgeableMessageIds.length)}`);
7172
};
73+
const resetChat = (): void => {
74+
clearLocalConversation(contactId);
75+
recordTransportTrace("outbox: local_chat_reset");
76+
};
7277
const recentTrace = transportTrace
7378
.filter((entry) => selectedTraceCategories.includes(traceCategory(entry.detail)))
7479
.slice(-12)
@@ -112,6 +117,16 @@ export function PocDebugPanel({ contactId, contactName, enabled, attachedRelayEn
112117
>
113118
<Button danger disabled={purgeableMessageIds.length === 0} icon={<DeleteOutlined />} size="small">Purge mapping</Button>
114119
</Popconfirm>
120+
<Popconfirm
121+
cancelText="Keep"
122+
description="This device only: removes this chat's messages, local outbox, Read work and delivery mappings. Contact, identity and relay stay intact."
123+
okButtonProps={{ danger: true }}
124+
okText="Reset local chat"
125+
onConfirm={resetChat}
126+
title={`Reset all local test messages with ${contactName}?`}
127+
>
128+
<Button danger icon={<DeleteOutlined />} size="small">Reset chat</Button>
129+
</Popconfirm>
115130
</div>
116131
<p className="pwa-poc-debug-note">Burst is local pacing into the normal outbox; it creates no special relay traffic or retry policy.</p>
117132
<Checkbox.Group

‎pwa/src/state/slices/conversations-slice.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import type { StateCreator } from "zustand";
22

33
import type { AppStore } from "../store.js";
4-
import { deleteStoredMessagesLocally, maxConversationTimelineEntries, replaceStoredMessageForRetry, saveStoredMessage } from "../../storage/messages-store.js";
5-
import { deleteStoredImageMessages } from "../../storage/image-media-store.js";
4+
import { deleteStoredMessagesForContact, deleteStoredMessagesLocally, maxConversationTimelineEntries, replaceStoredMessageForRetry, saveStoredMessage } from "../../storage/messages-store.js";
5+
import { deleteStoredImageMessages, deleteStoredImageMessagesForContact } from "../../storage/image-media-store.js";
6+
import { deleteStoredMessageDeliveryTargetsForContact } from "../../storage/message-delivery-target-store.js";
7+
import { deleteStoredOutboxForContact } from "../../storage/message-outbox-store.js";
8+
import { deleteStoredReadReceiptsForContact } from "../../storage/read-receipt-outbox-store.js";
9+
import { deleteStoredReadState } from "../../storage/read-state-store.js";
610

711
// `received` remains an inbound/local compatibility value. Remote user-facing
812
// outcomes for an outgoing message are explicit: a relay may say Relayed, but
@@ -33,6 +37,9 @@ export interface ConversationsSlice {
3337
readonly setMessageDeliveryState: (contactId: string, messageId: string, deliveryState: MessageDeliveryState) => void;
3438
// UI-initiated device-local removal. It has no transport/control side effect.
3539
readonly deleteMessagesLocally: (contactId: string, messageIds: readonly string[]) => readonly MessageSummary[];
40+
// Debug-only local test cleanup. It preserves the contact, identity, and
41+
// relay attachment while clearing this chat's visible history and work.
42+
readonly clearLocalConversation: (contactId: string) => void;
3643
}
3744

3845
export const createConversationsSlice: StateCreator<AppStore, [], [], ConversationsSlice> = (set, get) => ({
@@ -141,6 +148,37 @@ export const createConversationsSlice: StateCreator<AppStore, [], [], Conversati
141148
// removal can retry local byte cleanup when IndexedDB was unavailable.
142149
});
143150
return deleted;
151+
},
152+
clearLocalConversation: (contactId) => {
153+
// Do not reuse contact removal: a test reset must leave the user-owned
154+
// contact card and all live relay attachment state intact.
155+
get().clearImageMessageProjectionsForContact(contactId);
156+
set((state) => {
157+
const { [contactId]: removedMessages, ...messagesByContactId } = state.messagesByContactId;
158+
const { [contactId]: removedReadState, ...lastReadAtByContactId } = state.lastReadAtByContactId;
159+
const { [contactId]: removedTyping, ...typingExpiresAtByContactId } = state.typingExpiresAtByContactId;
160+
void removedMessages;
161+
void removedReadState;
162+
void removedTyping;
163+
return {
164+
messagesByContactId,
165+
outbox: state.outbox.filter((entry) => entry.contactId !== contactId),
166+
readReceiptOutbox: state.readReceiptOutbox.filter((entry) => entry.contactId !== contactId),
167+
lastReadAtByContactId,
168+
typingExpiresAtByContactId
169+
};
170+
});
171+
void Promise.all([
172+
deleteStoredMessagesForContact(contactId),
173+
deleteStoredReadState(contactId),
174+
deleteStoredOutboxForContact(contactId),
175+
deleteStoredReadReceiptsForContact(contactId),
176+
deleteStoredMessageDeliveryTargetsForContact(contactId),
177+
deleteStoredImageMessagesForContact(contactId)
178+
]).catch(() => {
179+
// The current tab has already reset its local projection. No reset
180+
// action emits transport traffic or retries storage failures.
181+
});
144182
}
145183
});
146184

‎pwa/src/storage/messages-store.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,21 +216,35 @@ export async function replaceStoredMessageForRetry(previousMessageId: string, re
216216
}
217217
}
218218

219-
export async function deleteStoredMessagesForContact(contactId: string): Promise<void> {
219+
export async function deleteStoredMessagesForContact(contactId: string, deletedAt: number = Date.now()): Promise<void> {
220220
const db = await openDatabase();
221221
try {
222222
await new Promise<void>((resolve, reject) => {
223-
const transaction = db.transaction([MESSAGES_STORE, CHAT_TIMELINE_STORE], "readwrite");
223+
const transaction = db.transaction([MESSAGES_STORE, CHAT_TIMELINE_STORE, LOCALLY_DELETED_MESSAGES_STORE], "readwrite");
224224
const request = transaction.objectStore(MESSAGES_STORE).index("byContactId").openCursor(IDBKeyRange.only(contactId));
225225
const timeline = transaction.objectStore(CHAT_TIMELINE_STORE);
226+
const tombstones = transaction.objectStore(LOCALLY_DELETED_MESSAGES_STORE);
226227
request.onsuccess = () => {
227228
const cursor = request.result;
228229
if (cursor !== null) {
229-
if (typeof cursor.primaryKey === "string") timeline.delete(cursor.primaryKey);
230+
if (typeof cursor.primaryKey === "string") {
231+
timeline.delete(cursor.primaryKey);
232+
tombstones.put({ messageId: cursor.primaryKey, deletedAt });
233+
}
230234
cursor.delete();
231235
cursor.continue();
232236
}
233237
};
238+
let retained = 0;
239+
const trimRequest = tombstones.index("byDeletedAt").openCursor(null, "prev");
240+
trimRequest.onsuccess = () => {
241+
const cursor = trimRequest.result;
242+
if (cursor === null) return;
243+
retained += 1;
244+
if (retained > maxStoredLocalDeletionTombstones) cursor.delete();
245+
cursor.continue();
246+
};
247+
trimRequest.onerror = () => { reject(trimRequest.error ?? new Error("failed to trim local deletion markers")); };
234248
request.onerror = () => { reject(request.error ?? new Error("failed to delete contact messages")); };
235249
transaction.oncomplete = () => { resolve(); };
236250
transaction.onerror = () => { reject(transaction.error ?? new Error("failed to delete contact messages")); };

‎pwa/tests/app-shell.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,18 @@ void test("PWA persists active-chat read presentation locally and drains opted-i
826826
assert.match(transport, /stopReadReceiptRuntime\(\)/);
827827
});
828828

829+
void test("PWA debug chat reset clears only one local conversation and preserves its contact boundary", async () => {
830+
const conversations = await readFile(conversationsSlicePath, "utf8");
831+
const panel = await readFile(resolve(process.cwd(), "src/app/PocDebugPanel.tsx"), "utf8");
832+
assert.match(conversations, /clearLocalConversation/);
833+
assert.match(conversations, /deleteStoredReadReceiptsForContact/);
834+
assert.match(conversations, /deleteStoredMessageDeliveryTargetsForContact/);
835+
assert.match(conversations, /deleteStoredMessagesForContact/);
836+
assert.match(conversations, /identity, and[\s\S]*relay attachment state intact/);
837+
assert.match(panel, /Reset chat/);
838+
assert.match(panel, /Contact, identity and relay stay intact/);
839+
});
840+
829841
void test("PWA persists sender receipt targets beyond outbox settlement", async () => {
830842
const database = await readFile(databasePath, "utf8");
831843
const targets = await readFile(messageDeliveryTargetStorePath, "utf8");

‎pwa/tests/poc-burst.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ void test("PoC purge is explicitly labelled as dropping the local correlation, n
7979
assert.doesNotMatch(panel, /Purge delivered/);
8080
});
8181

82+
void test("PoC reset chat is an explicit local test cleanup", async () => {
83+
const panel = await readFile(resolve(process.cwd(), "src/app/PocDebugPanel.tsx"), "utf8");
84+
assert.match(panel, /clearLocalConversation\(contactId\)/);
85+
assert.match(panel, /outbox: local_chat_reset/);
86+
assert.match(panel, /Reset local chat/);
87+
});
88+
8289
class Timers implements PocBurstTimerPort {
8390
readonly callbacks: Array<() => void> = [];
8491

0 commit comments

Comments
 (0)