Skip to content

Commit

Permalink
feature(provider): comment tombstoning for threads
Browse files Browse the repository at this point in the history
  • Loading branch information
nperez0111 authored Nov 20, 2024
2 parents 1c18ee7 + efd04a9 commit 25d3884
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 13 deletions.
133 changes: 122 additions & 11 deletions packages/provider/src/TiptapCollabProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,27 @@ import {

import { TiptapCollabProviderWebsocket } from './TiptapCollabProviderWebsocket.js'
import type {
DeleteCommentOptions,
TCollabComment, TCollabThread, THistoryVersion,
} from './types.js'

const defaultDeleteCommentOptions: DeleteCommentOptions = {
deleteContent: false,
deleteThread: false,
}

export type TiptapCollabProviderConfiguration =
Required<Pick<HocuspocusProviderConfiguration, 'name'>> &
Partial<HocuspocusProviderConfiguration> &
(Required<Pick<AdditionalTiptapCollabProviderConfiguration, 'websocketProvider'>> |
Required<Pick<AdditionalTiptapCollabProviderConfiguration, 'appId'>>|
Required<Pick<AdditionalTiptapCollabProviderConfiguration, 'baseUrl'>>) &
Pick<AdditionalTiptapCollabProviderConfiguration, 'user'>
Pick<AdditionalTiptapCollabProviderConfiguration, 'user'> & {
/**
* Pass `true` if you want to delete a thread when the first comment is deleted.
*/
deleteThreadOnFirstCommentDelete?: boolean,
}

export interface AdditionalTiptapCollabProviderConfiguration {
/**
Expand Down Expand Up @@ -107,14 +118,27 @@ export class TiptapCollabProvider extends HocuspocusProvider {
return this.configuration.document.getMap<number>(`${this.tiptapCollabConfigurationPrefix}config`).set('autoVersioning', 0)
}

/**
* Returns all users in the document as Y.Map objects
* @returns An array of Y.Map objects
*/
private getYThreads() {
return this.configuration.document.getArray<Y.Map<any>>(`${this.tiptapCollabConfigurationPrefix}threads`)
}

/**
* Finds all threads in the document and returns them as JSON objects
* @returns An array of threads as JSON objects
*/
getThreads<Data, CommentData>(): TCollabThread<Data, CommentData>[] {
return this.getYThreads().toJSON() as TCollabThread<Data, CommentData>[]
}

/**
* Find the index of a thread by its id
* @param id The thread id
* @returns The index of the thread or null if not found
*/
private getThreadIndex(id: string): number | null {
let index = null

Expand All @@ -131,6 +155,11 @@ export class TiptapCollabProvider extends HocuspocusProvider {
return index
}

/**
* Gets a single thread by its id
* @param id The thread id
* @returns The thread as a JSON object or null if not found
*/
getThread<Data, CommentData>(id: string): TCollabThread<Data, CommentData> | null {
const index = this.getThreadIndex(id)

Expand All @@ -141,6 +170,11 @@ export class TiptapCollabProvider extends HocuspocusProvider {
return this.getYThreads().get(index).toJSON() as TCollabThread<Data, CommentData>
}

/**
* Gets a single thread by its id as a Y.Map object
* @param id The thread id
* @returns The thread as a Y.Map object or null if not found
*/
private getYThread(id: string) {
const index = this.getThreadIndex(id)

Expand All @@ -151,14 +185,20 @@ export class TiptapCollabProvider extends HocuspocusProvider {
return this.getYThreads().get(index)
}

createThread(data: Omit<TCollabThread, 'id' | 'createdAt' | 'updatedAt' | 'comments'>) {
/**
* Create a new thread
* @param data The thread data
* @returns The created thread
*/
createThread(data: Omit<TCollabThread, 'id' | 'createdAt' | 'updatedAt' | 'comments' | 'deletedComments'>) {
let createdThread: TCollabThread = {} as TCollabThread

this.document.transact(() => {
const thread = new Y.Map()
thread.set('id', uuidv4())
thread.set('createdAt', (new Date()).toISOString())
thread.set('comments', new Y.Array())
thread.set('deletedComments', new Y.Array())

this.getYThreads().push([thread])
createdThread = this.updateThread(String(thread.get('id')), data)
Expand All @@ -167,6 +207,12 @@ export class TiptapCollabProvider extends HocuspocusProvider {
return createdThread
}

/**
* Update a specific thread
* @param id The thread id
* @param data New data for the thread
* @returns The updated thread or null if the thread is not found
*/
updateThread(id: TCollabThread['id'], data: Partial<Pick<TCollabThread, 'data'> & {
resolvedAt: TCollabThread['resolvedAt'] | null
}>) {
Expand Down Expand Up @@ -195,6 +241,11 @@ export class TiptapCollabProvider extends HocuspocusProvider {
return updatedThread
}

/**
* Delete a specific thread and all its comments
* @param id The thread id
* @returns void
*/
deleteThread(id: TCollabThread['id']) {
const index = this.getThreadIndex(id)

Expand All @@ -205,26 +256,52 @@ export class TiptapCollabProvider extends HocuspocusProvider {
this.getYThreads().delete(index, 1)
}

getThreadComments(threadId: TCollabThread['id']): TCollabComment[] | null {
/**
* Returns comments from a thread, either deleted or not
* @param threadId The thread id
* @param includeDeleted If you want to include deleted comments, defaults to `false`
* @returns The comments or null if the thread is not found
*/
getThreadComments(threadId: TCollabThread['id'], includeDeleted?: boolean): TCollabComment[] | null {
const index = this.getThreadIndex(threadId)

if (index === null) {
return null
}

return this.getThread(threadId)?.comments ?? []
const comments = !includeDeleted ? this.getThread(threadId)?.comments : [...(this.getThread(threadId)?.comments || []), ...(this.getThread(threadId)?.deletedComments || [])].sort((a, b) => {
return a.createdAt.localeCompare(b.createdAt)
})

return comments ?? []
}

getThreadComment(threadId: TCollabThread['id'], commentId: TCollabComment['id']): TCollabComment | null {
/**
* Get a single comment from a specific thread
* @param threadId The thread id
* @param commentId The comment id
* @param includeDeleted If you want to include deleted comments in the search
* @returns The comment or null if not found
*/
getThreadComment(threadId: TCollabThread['id'], commentId: TCollabComment['id'], includeDeleted?: boolean): TCollabComment | null {
const index = this.getThreadIndex(threadId)

if (index === null) {
return null
}

return this.getThread(threadId)?.comments.find(comment => comment.id === commentId) ?? null
const comments = this.getThreadComments(threadId, includeDeleted)

return comments?.find(comment => comment.id === commentId) ?? null
}

/**
* Adds a comment to a thread
* @param threadId The thread id
* @param data The comment data
* @returns The updated thread or null if the thread is not found
* @example addComment('123', { content: 'Hello world', data: { author: 'Maria Doe' } })
*/
addComment(threadId: TCollabThread['id'], data: Omit<TCollabComment, 'id' | 'updatedAt' | 'createdAt'>) {
let updatedThread: TCollabThread = {} as TCollabThread

Expand All @@ -246,6 +323,14 @@ export class TiptapCollabProvider extends HocuspocusProvider {
return updatedThread
}

/**
* Update a comment in a thread
* @param threadId The thread id
* @param commentId The comment id
* @param data The new comment data
* @returns The updated thread or null if the thread or comment is not found
* @example updateComment('123', { content: 'The new content', data: { attachments: ['file1.jpg'] }})
*/
updateComment(threadId: TCollabThread['id'], commentId: TCollabComment['id'], data: Partial<Pick<TCollabComment, 'data' | 'content'>>) {
let updatedThread: TCollabThread = {} as TCollabThread

Expand Down Expand Up @@ -281,7 +366,16 @@ export class TiptapCollabProvider extends HocuspocusProvider {
return updatedThread
}

deleteComment(threadId: TCollabThread['id'], commentId: TCollabComment['id']) {
/**
* Deletes a comment from a thread
* @param threadId The thread id
* @param commentId The comment id
* @param options A set of options that control how the comment is deleted
* @returns The updated thread or null if the thread or comment is not found
*/
deleteComment(threadId: TCollabThread['id'], commentId: TCollabComment['id'], options: DeleteCommentOptions) {
const { deleteContent, deleteThread } = { ...defaultDeleteCommentOptions, ...options }

const thread = this.getYThread(threadId)

if (thread === null) return null
Expand All @@ -297,22 +391,39 @@ export class TiptapCollabProvider extends HocuspocusProvider {

// if the first comment of a thread is deleted we also
// delete the thread itself as the source comment is gone
if (commentIndex === 0) {
if (commentIndex === 0 && (deleteThread || (this.configuration as TiptapCollabProviderConfiguration).deleteThreadOnFirstCommentDelete)) {
this.deleteThread(threadId)
return
}

if (commentIndex > 0) {
thread.get('comments').delete(commentIndex)
}
const comment = thread.get('comments').get(commentIndex)
const newComment = new Y.Map()

newComment.set('id', comment.get('id'))
newComment.set('createdAt', comment.get('createdAt'))
newComment.set('updatedAt', (new Date()).toISOString())
newComment.set('deletedAt', (new Date()).toISOString())
newComment.set('data', comment.get('data'))
newComment.set('content', deleteContent ? null : comment.get('content'))

thread.get('deletedComments').push([newComment])
thread.get('comments').delete(commentIndex)

return thread.toJSON() as TCollabThread
}

/**
* Start watching threads for changes
* @param callback The callback function to be called when a thread changes
*/
watchThreads(callback: () => void) {
this.getYThreads().observeDeep(callback)
}

/**
* Stop watching threads for changes
* @param callback The callback function to be removed
*/
unwatchThreads(callback: () => void) {
this.getYThreads().unobserveDeep(callback)
}
Expand Down
18 changes: 16 additions & 2 deletions packages/provider/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,15 @@ export type TCollabThread<Data = any, CommentData = any> = {
updatedAt: number;
resolvedAt?: string; // (new Date()).toISOString()
comments: TCollabComment<CommentData>[];
deletedComments: TCollabComment<CommentData>[];
data: Data
}

export type TCollabComment<Data = any> = {
id: string;
createdAt: number;
updatedAt: number;
createdAt: string;
updatedAt: string;
deletedAt?: string;
data: Data
content: any
}
Expand Down Expand Up @@ -183,3 +185,15 @@ export type THistoryDocumentRevertedEvent = {
event: 'document.reverted';
version: number;
};

export type DeleteCommentOptions = {
/**
* If `true`, the thread will also be deleted if the deleted comment was the first comment in the thread.
*/
deleteThread?: boolean

/**
* If `true`, will remove the content of the deleted comment
*/
deleteContent?: boolean
}

0 comments on commit 25d3884

Please sign in to comment.