Skip to content
Merged
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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jank",
"version": "26.14.1",
"version": "26.14.2",
"description": "A TweetDeck-style multi-column deck for Nostr — just another nostr klient",
"type": "module",
"author": "DocNR",
Expand Down
22 changes: 2 additions & 20 deletions src/providers/ColumnsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1097,27 +1097,9 @@ export function ColumnsProvider({ children }: { children: ReactNode }) {
recentlyDeletedRef.current = null

const current = storage.getWorkspacesByAccount()
const workspace = current[snap.workspaceKey]
if (!workspace) return false

// Re-insert at the original index. If last-deck guard fired (workspace
// has a single Untitled+empty deck post-delete), drop it; otherwise keep.
let nextDecks = [...workspace.decks]
const lastDeckGuardDeck = nextDecks.find(
(d) => d.name === 'Untitled deck' && d.columns.length === 0 && d.savedColumns.length === 0
)
if (lastDeckGuardDeck && nextDecks.length === 1) {
nextDecks = []
}
nextDecks.splice(snap.restorationIndex, 0, snap.deck)
if (!current[snap.workspaceKey]) return false

storage.setWorkspacesByAccount({
...current,
[snap.workspaceKey]: {
decks: nextDecks,
activeDeckId: snap.deck.id
}
})
storage.restoreDeck(snap.workspaceKey, snap.deck, snap.restorationIndex)
refreshWorkspacesByAccount()
setColumns(storage.getColumns())
void deckSyncService.publishWorkspace(snap.workspaceKey)
Expand Down
7 changes: 7 additions & 0 deletions src/release-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export type ReleaseNote = {
}

export const RELEASE_NOTES: ReleaseNote[] = [
{
version: '26.14.2',
date: '2026-06-15',
highlights: [
'Deleting a deck now sticks across your devices. A deck you remove on one device no longer reappears on another after it syncs, and the brief Undo still brings it back if you change your mind.'
]
},
{
version: '26.14.1',
date: '2026-06-14',
Expand Down
53 changes: 53 additions & 0 deletions src/services/__tests__/deck-sync-codec.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,56 @@ describe('deck-sync-codec', () => {
expect(d.columns[0].config).toEqual(d.savedColumns[0].config)
})
})

describe('deck-sync-codec — deletedDecks tombstones', () => {
it('round-trips deletedDecks', () => {
const decoded = decodeWorkspace(encodeWorkspace(workspace({ deletedDecks: { x: 111, y: 222 } })))
expect(decoded.ok).toBe(true)
if (!decoded.ok) return
expect(decoded.workspace.deletedDecks).toEqual({ x: 111, y: 222 })
})

it('omits deletedDecks from the wire when empty', () => {
const json = encodeWorkspace(workspace({ deletedDecks: {} }))
expect(json).not.toContain('deletedDecks')
const decoded = decodeWorkspace(json)
expect(decoded.ok).toBe(true)
if (!decoded.ok) return
expect(decoded.workspace.deletedDecks).toBeUndefined()
})

it('decodes a blob without deletedDecks (back-compat) and keeps version 1', () => {
const json = encodeWorkspace(workspace())
expect(JSON.parse(json).version).toBe(1)
const decoded = decodeWorkspace(json)
expect(decoded.ok).toBe(true)
if (!decoded.ok) return
expect(decoded.workspace.deletedDecks).toBeUndefined()
})

it('ignores a non-object deletedDecks (array) rather than producing index keys', () => {
const json = JSON.stringify({
version: 1,
activeDeckId: 'd1',
decks: [{ id: 'd1', name: 'My Deck', createdAt: 1, updatedAt: 1, lastSavedAt: 1, columns: [] }],
deletedDecks: [123, 456]
})
const decoded = decodeWorkspace(json)
expect(decoded.ok).toBe(true)
if (!decoded.ok) return
expect(decoded.workspace.deletedDecks).toBeUndefined()
})

it('sanitizes malformed deletedDecks (non-number values dropped) rather than failing', () => {
const json = JSON.stringify({
version: 1,
activeDeckId: 'd1',
decks: [{ id: 'd1', name: 'My Deck', createdAt: 1, updatedAt: 1, lastSavedAt: 1, columns: [] }],
deletedDecks: { good: 123, bad: 'oops', alsoBad: null }
})
const decoded = decodeWorkspace(json)
expect(decoded.ok).toBe(true)
if (!decoded.ok) return
expect(decoded.workspace.deletedDecks).toEqual({ good: 123 })
})
})
124 changes: 122 additions & 2 deletions src/services/__tests__/deck-sync-merge.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { TAccountWorkspace, TColumn, TDeck, TPairedAgent } from '@/types/column'
import { mergePairedAgents, mergeRemoteWorkspace } from '@/services/deck-sync-merge'
import { DECK_TOMBSTONE_TTL_MS, mergePairedAgents, mergeRemoteWorkspace } from '@/services/deck-sync-merge'

const col = (id = 'c'): TColumn => ({ id, viewContext: 'pk', signingIdentity: 'pk', type: 'home' })
const deck = (over: Partial<TDeck> & { id: string }): TDeck => ({
Expand Down Expand Up @@ -49,7 +49,7 @@ describe('mergeRemoteWorkspace', () => {
expect(conflicts.map((d) => d.id)).toEqual(['a'])
})

it('keeps a local-only deck not present remotely (no delete propagation)', () => {
it('keeps a local-only deck with no tombstone (newly created, not yet synced)', () => {
const local = ws('a', [deck({ id: 'a' }), deck({ id: 'local-only' })])
const remote = ws('a', [deck({ id: 'a' })])
expect(mergeRemoteWorkspace(local, remote).merged.decks.map((d) => d.id)).toContain('local-only')
Expand All @@ -60,6 +60,126 @@ describe('mergeRemoteWorkspace', () => {
const remote = ws('a', [deck({ id: 'a' })])
expect(mergeRemoteWorkspace(local, remote).merged.activeDeckId).toBe('a')
})

const NOW = 1_700_000_000_000 // fixed "now" for deterministic GC

it('drops a tombstoned deck that was deleted after its last save (delete propagates)', () => {
const local: TAccountWorkspace = {
activeDeckId: 'a',
decks: [deck({ id: 'a' })],
deletedDecks: { b: NOW - 1000 }
}
const remote = ws('a', [deck({ id: 'a' }), deck({ id: 'b', lastSavedAt: NOW - 5000 })])
const { merged } = mergeRemoteWorkspace(local, remote, NOW)
expect(merged.decks.map((d) => d.id)).toEqual(['a'])
expect(merged.deletedDecks).toEqual({ b: NOW - 1000 })
})

it('does not resurrect: remote re-introduces a deck the local tombstone covers', () => {
const local: TAccountWorkspace = {
activeDeckId: 'a',
decks: [deck({ id: 'a' }), deck({ id: 'b', lastSavedAt: NOW - 5000 })],
deletedDecks: { b: NOW - 1000 }
}
const remote = ws('a', [deck({ id: 'a' })])
const { merged } = mergeRemoteWorkspace(local, remote, NOW)
expect(merged.decks.map((d) => d.id)).toEqual(['a'])
})

it('resurrects a tombstoned deck saved AFTER the delete (LWW) and clears its tombstone', () => {
const local: TAccountWorkspace = {
activeDeckId: 'a',
decks: [deck({ id: 'a' })],
deletedDecks: { b: NOW - 1000 }
}
const remote = ws('a', [deck({ id: 'a' }), deck({ id: 'b', lastSavedAt: NOW })])
const { merged } = mergeRemoteWorkspace(local, remote, NOW)
expect(merged.decks.map((d) => d.id)).toEqual(['a', 'b'])
expect(merged.deletedDecks).toBeUndefined()
})

it('keeps a tombstoned-but-dirty local deck and reports it as a conflict', () => {
const dirty = deck({
id: 'b',
columns: [col('x'), col('y')],
savedColumns: [col('x')],
lastSavedAt: NOW - 5000
})
const local: TAccountWorkspace = {
activeDeckId: 'b',
decks: [deck({ id: 'a' }), dirty],
deletedDecks: { b: NOW - 1000 }
}
const remote = ws('a', [deck({ id: 'a' })])
const { merged, conflicts } = mergeRemoteWorkspace(local, remote, NOW)
expect(merged.decks.map((d) => d.id)).toContain('b')
expect(conflicts.map((d) => d.id)).toContain('b')
expect(merged.deletedDecks).toBeUndefined()
})

it('keeps a local-only deck that is tombstoned but dirty, reporting it as a conflict', () => {
const dirty = deck({
id: 'b',
columns: [col('x'), col('y')],
savedColumns: [col('x')],
lastSavedAt: NOW - 5000
})
const local: TAccountWorkspace = {
activeDeckId: 'a',
decks: [deck({ id: 'a' }), dirty], // 'b' is local-only (absent from remote) + tombstoned
deletedDecks: { b: NOW - 1000 }
}
const remote = ws('a', [deck({ id: 'a' })])
const { merged, conflicts } = mergeRemoteWorkspace(local, remote, NOW)
expect(merged.decks.map((d) => d.id)).toContain('b')
expect(conflicts.map((d) => d.id)).toContain('b')
expect(merged.deletedDecks).toBeUndefined()
})

it('unions tombstones taking the max timestamp', () => {
const local: TAccountWorkspace = {
activeDeckId: 'a',
decks: [deck({ id: 'a' })],
deletedDecks: { x: NOW - 100, y: NOW - 9000 }
}
const remote: TAccountWorkspace = {
activeDeckId: 'a',
decks: [deck({ id: 'a' })],
deletedDecks: { x: NOW - 5000, z: NOW - 7000 }
}
const { merged } = mergeRemoteWorkspace(local, remote, NOW)
expect(merged.deletedDecks).toEqual({ x: NOW - 100, y: NOW - 9000, z: NOW - 7000 })
})

it('garbage-collects tombstones older than the TTL (using injected now)', () => {
const local: TAccountWorkspace = {
activeDeckId: 'a',
decks: [deck({ id: 'a' })],
deletedDecks: { fresh: NOW - 1000, stale: NOW - DECK_TOMBSTONE_TTL_MS - 1000 }
}
const remote = ws('a', [deck({ id: 'a' })])
const { merged } = mergeRemoteWorkspace(local, remote, NOW)
expect(merged.deletedDecks).toEqual({ fresh: NOW - 1000 })
})

it('never returns zero decks: keeps the newest candidate even if all are tombstoned', () => {
const local: TAccountWorkspace = {
activeDeckId: 'a',
decks: [deck({ id: 'a', lastSavedAt: 1 }), deck({ id: 'b', lastSavedAt: 9 })],
deletedDecks: { a: NOW, b: NOW }
}
const remote = ws('a', [])
const { merged } = mergeRemoteWorkspace(local, remote, NOW)
expect(merged.decks.map((d) => d.id)).toEqual(['b'])
})

it('treats a remote workspace with no deletedDecks as no tombstones (back-compat)', () => {
const local: TAccountWorkspace = { activeDeckId: 'a', decks: [deck({ id: 'a' })] }
const remote = ws('a', [deck({ id: 'a' }), deck({ id: 'b' })])
const { merged } = mergeRemoteWorkspace(local, remote, NOW)
expect(merged.decks.map((d) => d.id)).toEqual(['a', 'b'])
expect(merged.deletedDecks).toBeUndefined()
})
})

const agent = (
Expand Down
Loading
Loading