Skip to content

Commit 5cfe7e8

Browse files
authored
feat: Support enhanced chat retention options (#111)
* feat: Support enhanced chat retention options * chore: add changeset for v0.1.4
1 parent 23df175 commit 5cfe7e8

File tree

11 files changed

+231
-61
lines changed

11 files changed

+231
-61
lines changed

.changeset/amber-waves-flow.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@dittolive/ditto-chat-core': patch
3+
'@dittolive/ditto-chat-ui': patch
4+
---
5+
6+
Enhanced message retention capabilities with support for indefinite retention and flexible configuration at global, room, and query levels. Updated Room interface to use RetentionConfig type with retainIndefinitely flag and optional days field. UI components now support passing retention configuration overrides.

sdks/js/ditto-chat-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './types/ChatUser'
44
export * from './types/Message'
55
export * from './types/MessageWithUser'
66
export * from './types/RBAC'
7+
export * from './types/Retention'
78
export * from './types/Room'
89
export * from './useChat'
910
export { getChatStore, resetChatStore } from './useChat'

sdks/js/ditto-chat-core/src/slices/useMessages.ts

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { v4 as uuidv4 } from 'uuid'
1010
import ChatUser from '../types/ChatUser'
1111
import Message, { Mention, Reaction } from '../types/Message'
1212
import MessageWithUser from '../types/MessageWithUser'
13+
import type { RetentionConfig } from '../types/Retention'
1314
import Room from '../types/Room'
1415
import { ChatStore, CreateSlice } from '../useChat'
1516

@@ -18,15 +19,15 @@ export interface MessageSlice {
1819
messageObserversByRoom: Record<string, StoreObserver | null>
1920
messageSubscriptionsByRoom: Record<string, SyncSubscription | null>
2021
messagesLoading: boolean
21-
messagesPublisher: (room: Room, retentionDays?: number) => Promise<void>
22+
messagesPublisher: (room: Room, retention?: RetentionConfig) => Promise<void>
2223
/**
2324
* Subscribe to messages for a specific room on-demand.
2425
* Used for generated rooms (comment rooms) that need dynamic subscriptions.
2526
*/
2627
subscribeToRoomMessages: (
2728
roomId: string,
2829
messagesId: string,
29-
retentionDays?: number,
30+
retention?: RetentionConfig,
3031
) => Promise<void>
3132
/**
3233
* Unsubscribe from messages for a specific room.
@@ -94,7 +95,7 @@ export const createMessageSlice: CreateSlice<MessageSlice> = (
9495
ditto,
9596
userId,
9697
userCollectionKey,
97-
retentionDays: globalRetentionDays,
98+
retention: globalRetention,
9899
notificationHandler,
99100
},
100101
) => {
@@ -407,9 +408,9 @@ export const createMessageSlice: CreateSlice<MessageSlice> = (
407408
* 5. Triggers notifications for new messages from other users
408409
*
409410
* @param room - Room to subscribe to messages for
410-
* @param retentionDays - Optional override for message retention period
411+
* @param retention - Optional override for message retention configuration
411412
*/
412-
async messagesPublisher(room: Room, retentionDays?: number) {
413+
async messagesPublisher(room: Room, retention?: RetentionConfig) {
413414
if (!ditto) {
414415
return
415416
}
@@ -419,22 +420,40 @@ export const createMessageSlice: CreateSlice<MessageSlice> = (
419420
return
420421
}
421422

422-
const effectiveRetentionDays =
423-
retentionDays ??
424-
room.retentionDays ??
425-
globalRetentionDays ??
426-
DEFAULT_RETENTION_DAYS
427-
428-
const retentionDate = new Date(
429-
Date.now() - effectiveRetentionDays * 24 * 60 * 60 * 1000,
430-
)
431-
const query = `SELECT * FROM COLLECTION ${room.messagesId} (thumbnailImageToken ATTACHMENT, largeImageToken ATTACHMENT, fileAttachmentToken ATTACHMENT)
432-
WHERE roomId = :roomId AND createdOn >= :date AND isArchived = false
433-
ORDER BY createdOn ASC`
434-
435-
const args = {
436-
roomId: room._id,
437-
date: retentionDate.toISOString(),
423+
// Check if retention should be indefinite (priority: param > room > global)
424+
const retainIndefinitely =
425+
retention?.retainIndefinitely ??
426+
room.retention?.retainIndefinitely ??
427+
globalRetention?.retainIndefinitely ??
428+
false
429+
430+
let query: string
431+
let args: Record<string, string>
432+
433+
if (retainIndefinitely) {
434+
query = `SELECT * FROM COLLECTION ${room.messagesId} (thumbnailImageToken ATTACHMENT, largeImageToken ATTACHMENT, fileAttachmentToken ATTACHMENT)
435+
WHERE roomId = :roomId AND isArchived = false
436+
ORDER BY createdOn ASC`
437+
args = {
438+
roomId: room._id,
439+
}
440+
} else {
441+
const effectiveRetentionDays =
442+
retention?.days ??
443+
room.retention?.days ??
444+
globalRetention?.days ??
445+
DEFAULT_RETENTION_DAYS
446+
447+
const retentionDate = new Date(
448+
Date.now() - effectiveRetentionDays * 24 * 60 * 60 * 1000,
449+
)
450+
query = `SELECT * FROM COLLECTION ${room.messagesId} (thumbnailImageToken ATTACHMENT, largeImageToken ATTACHMENT, fileAttachmentToken ATTACHMENT)
451+
WHERE roomId = :roomId AND createdOn >= :date AND isArchived = false
452+
ORDER BY createdOn ASC`
453+
args = {
454+
roomId: room._id,
455+
date: retentionDate.toISOString(),
456+
}
438457
}
439458

440459
try {
@@ -501,12 +520,12 @@ export const createMessageSlice: CreateSlice<MessageSlice> = (
501520
*
502521
* @param roomId - Room ID to subscribe to
503522
* @param messagesId - Collection ID for messages ("messages" or "dm_messages")
504-
* @param retentionDays - Optional message retention override
523+
* @param retention - Optional message retention configuration override
505524
*/
506525
async subscribeToRoomMessages(
507526
roomId: string,
508527
messagesId: string,
509-
retentionDays?: number,
528+
retention?: RetentionConfig,
510529
) {
511530
if (!ditto) {
512531
return
@@ -517,20 +536,38 @@ export const createMessageSlice: CreateSlice<MessageSlice> = (
517536
return
518537
}
519538

520-
const effectiveRetentionDays =
521-
retentionDays ?? globalRetentionDays ?? DEFAULT_RETENTION_DAYS
522-
523-
const retentionDate = new Date(
524-
Date.now() - effectiveRetentionDays * 24 * 60 * 60 * 1000,
525-
)
526-
527-
const query = `SELECT * FROM COLLECTION ${messagesId} (thumbnailImageToken ATTACHMENT, largeImageToken ATTACHMENT, fileAttachmentToken ATTACHMENT)
528-
WHERE roomId = :roomId AND createdOn >= :date AND isArchived = false
529-
ORDER BY createdOn ASC`
539+
// Check if retention should be indefinite (priority: param > global)
540+
const retainIndefinitely =
541+
retention?.retainIndefinitely ??
542+
globalRetention?.retainIndefinitely ??
543+
false
544+
545+
let query: string
546+
let args: Record<string, string>
547+
548+
if (retainIndefinitely) {
549+
// Build query without date filter for indefinite retention
550+
query = `SELECT * FROM COLLECTION ${messagesId} (thumbnailImageToken ATTACHMENT, largeImageToken ATTACHMENT, fileAttachmentToken ATTACHMENT)
551+
WHERE roomId = :roomId AND isArchived = false
552+
ORDER BY createdOn ASC`
553+
args = {
554+
roomId,
555+
}
556+
} else {
557+
// Extract days with priority: param > global > default (30)
558+
const effectiveRetentionDays =
559+
retention?.days ?? globalRetention?.days ?? DEFAULT_RETENTION_DAYS
530560

531-
const args = {
532-
roomId,
533-
date: retentionDate.toISOString(),
561+
const retentionDate = new Date(
562+
Date.now() - effectiveRetentionDays * 24 * 60 * 60 * 1000,
563+
)
564+
query = `SELECT * FROM COLLECTION ${messagesId} (thumbnailImageToken ATTACHMENT, largeImageToken ATTACHMENT, fileAttachmentToken ATTACHMENT)
565+
WHERE roomId = :roomId AND createdOn >= :date AND isArchived = false
566+
ORDER BY createdOn ASC`
567+
args = {
568+
roomId,
569+
date: retentionDate.toISOString(),
570+
}
534571
}
535572

536573
try {

sdks/js/ditto-chat-core/src/slices/useRooms.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import { v4 as uuidv4 } from 'uuid'
99
import { StoreApi } from 'zustand'
1010

1111
import ChatUser from '../types/ChatUser'
12+
import type { RetentionConfig } from '../types/Retention'
1213
import Room from '../types/Room'
1314
import { ChatStore, CreateSlice, DittoConfParams } from '../useChat'
1415

1516
export interface CreateRoomOptions {
16-
retentionDays?: number
17+
retention?: RetentionConfig
1718
isGenerated?: boolean
1819
}
1920

@@ -112,7 +113,7 @@ function handleRoomsObserverResult(
112113
* @param params.collectionId - Collection to store the room ("rooms" or "dm_rooms")
113114
* @param params.messagesId - Collection for the room's messages ("messages" or "dm_messages")
114115
* @param params.participants - Optional array of user IDs (required for DM rooms)
115-
* @param params.retentionDays - Optional custom retention period for messages
116+
* @param params.retention - Optional custom retention configuration for messages
116117
* @param params.isGenerated - Optional flag marking room as generated (hidden from main list)
117118
* @param params.id - Optional custom room ID (defaults to UUID)
118119
* @returns The created room object, or undefined if creation fails
@@ -124,7 +125,7 @@ async function createRoomBase({
124125
collectionId,
125126
messagesId,
126127
participants = [],
127-
retentionDays,
128+
retention,
128129
isGenerated = false,
129130
id,
130131
}: {
@@ -134,7 +135,7 @@ async function createRoomBase({
134135
collectionId: 'rooms' | 'dm_rooms'
135136
messagesId: 'messages' | 'dm_messages'
136137
participants?: string[]
137-
retentionDays?: number
138+
retention?: RetentionConfig
138139
isGenerated?: boolean
139140
id?: string
140141
}) {
@@ -154,7 +155,7 @@ async function createRoomBase({
154155
createdBy: currentUserId,
155156
createdOn: new Date().toISOString(),
156157
participants: participants || undefined,
157-
...(retentionDays !== undefined && { retentionDays }),
158+
...(retention !== undefined && { retention }),
158159
}
159160

160161
const query = `INSERT INTO \`${collectionId}\` DOCUMENTS (:newDoc) ON ID CONFLICT DO UPDATE`
@@ -201,7 +202,7 @@ export const createRoomSlice: CreateSlice<RoomSlice> = (
201202
* - Delegates to createRoomBase with appropriate parameters
202203
* - Sets collectionId to "rooms" for regular rooms
203204
* - Sets messagesId to "messages" collection
204-
* - Passes through optional retentionDays parameter
205+
* - Passes through optional retention configuration
205206
*
206207
* This approach ensures:
207208
* - RBAC integration prevents unauthorized room creation
@@ -210,7 +211,7 @@ export const createRoomSlice: CreateSlice<RoomSlice> = (
210211
*
211212
* @param name - Display name for the new room
212213
* @param options - Optional configuration for the room
213-
* @param options.retentionDays - Optional custom message retention period (overrides global default)
214+
* @param options.retention - Optional custom message retention configuration (overrides global default)
214215
* @param options.isGenerated - Optional flag to mark room as generated (hidden from main list)
215216
* @returns Promise resolving to the created Room object, or undefined if permission denied
216217
*/
@@ -228,7 +229,7 @@ export const createRoomSlice: CreateSlice<RoomSlice> = (
228229
name,
229230
collectionId: 'rooms',
230231
messagesId: 'messages',
231-
retentionDays: options?.retentionDays,
232+
retention: options?.retention,
232233
isGenerated: options?.isGenerated ?? false,
233234
})
234235
},
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Configuration for message retention policies.
3+
*
4+
* Retention policies control how long messages are kept in chat rooms.
5+
* You can configure retention at three levels:
6+
* 1. Global level (via DittoConfParams)
7+
* 2. Per-room level (via Room.retention)
8+
* 3. Per-query level (via parameter overrides)
9+
*
10+
* Priority order: query parameter > room config > global config > default (30 days)
11+
*/
12+
export type RetentionConfig = {
13+
/**
14+
* If true, messages are retained indefinitely (no time-based eviction).
15+
* When set to true, the `days` field is ignored.
16+
*/
17+
retainIndefinitely: boolean
18+
19+
/**
20+
* Number of days to retain messages.
21+
* Only used when `retainIndefinitely` is false.
22+
* If not specified, defaults to 30 days.
23+
*/
24+
days?: number
25+
} | null | undefined

sdks/js/ditto-chat-core/src/types/Room.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { RetentionConfig } from './Retention'
2+
13
interface Room {
24
_id: string
35
name: string
@@ -7,7 +9,7 @@ interface Room {
79
createdOn: string
810
isGenerated: boolean
911
participants?: string[]
10-
retentionDays?: number
12+
retention?: RetentionConfig
1113
}
1214

1315
export default Room

sdks/js/ditto-chat-core/src/useChat.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ import {
1818
import { createRBACSlice, RBACSlice } from './slices/useRBAC'
1919
import { createRoomSlice, RoomSlice } from './slices/useRooms'
2020
import { RBACConfig } from './types/RBAC'
21+
import type { RetentionConfig } from './types/Retention'
2122

2223
export type DittoConfParams = {
2324
ditto: Ditto | null
2425
userId: string
2526
userCollectionKey: string
26-
retentionDays?: number
27+
retention?: RetentionConfig
2728
rbacConfig?: RBACConfig
2829
notificationHandler?: (title: string, description: string) => void
2930
}

sdks/js/ditto-chat-core/tests/useMessages.test.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -967,8 +967,10 @@ describe('useMessages Slice', () => {
967967
expect(messages[0].message.text).toBe('Incoming!')
968968
})
969969

970-
it('uses custom retention days', async () => {
971-
await store.getState().messagesPublisher(mockRoom, 7)
970+
it('uses custom retention config', async () => {
971+
await store
972+
.getState()
973+
.messagesPublisher(mockRoom, { retainIndefinitely: false, days: 7 })
972974

973975
expect(mockDitto.sync.registerSubscription).toHaveBeenCalledWith(
974976
expect.stringContaining(
@@ -981,8 +983,11 @@ describe('useMessages Slice', () => {
981983
)
982984
})
983985

984-
it('uses room retention days', async () => {
985-
const roomWithRetention = { ...mockRoom, retentionDays: 14 }
986+
it('uses room retention configuration', async () => {
987+
const roomWithRetention = {
988+
...mockRoom,
989+
retention: { retainIndefinitely: false, days: 14 },
990+
}
986991
mockDitto.store.execute.mockResolvedValueOnce({
987992
items: [{ value: roomWithRetention }],
988993
})
@@ -991,6 +996,44 @@ describe('useMessages Slice', () => {
991996
expect(mockDitto.sync.registerSubscription).toHaveBeenCalled()
992997
})
993998

999+
it('supports indefinite retention with retainIndefinitely flag', async () => {
1000+
mockDitto.sync.registerSubscription.mockClear()
1001+
1002+
const roomWithIndefiniteRetention = {
1003+
...mockRoom,
1004+
retention: { retainIndefinitely: true },
1005+
}
1006+
1007+
await store.getState().messagesPublisher(roomWithIndefiniteRetention)
1008+
1009+
const calls = mockDitto.sync.registerSubscription.mock.calls
1010+
const query = calls[calls.length - 1][0]
1011+
expect(query).not.toContain('createdOn >=')
1012+
expect(query).toContain('WHERE roomId = :roomId AND isArchived = false')
1013+
})
1014+
1015+
it('supports parameter override for indefinite retention', async () => {
1016+
mockDitto.sync.registerSubscription.mockClear()
1017+
1018+
await store
1019+
.getState()
1020+
.messagesPublisher(mockRoom, { retainIndefinitely: true })
1021+
1022+
const calls = mockDitto.sync.registerSubscription.mock.calls
1023+
const query = calls[calls.length - 1][0]
1024+
expect(query).not.toContain('createdOn >=')
1025+
})
1026+
1027+
it('defaults to 30 days when no retention config provided', async () => {
1028+
mockDitto.sync.registerSubscription.mockClear()
1029+
1030+
await store.getState().messagesPublisher(mockRoom)
1031+
1032+
const calls = mockDitto.sync.registerSubscription.mock.calls
1033+
const query = calls[calls.length - 1][0]
1034+
expect(query).toContain('createdOn >= :date')
1035+
})
1036+
9941037
it('does not create duplicate subscription', async () => {
9951038
mockDitto.sync.registerSubscription.mockClear()
9961039

0 commit comments

Comments
 (0)