From 8851d71a695b1c7348dc7460ea9634895d0a0f63 Mon Sep 17 00:00:00 2001 From: Piotr Suwala Date: Tue, 12 Dec 2023 15:47:59 +0100 Subject: [PATCH 1/6] feat(lib): add restoring deleted messages --- lib/src/entities/chat.ts | 27 ++++++ lib/src/entities/message.ts | 60 +++++++++++- lib/tests/message.test.ts | 96 +++++++++++++++++++ .../components/actions-menu/ActionsMenu.tsx | 27 +++++- .../components/message-text/MessageText.tsx | 16 ++-- .../screens/ordinary/chat/Chat.tsx | 9 ++ 6 files changed, 226 insertions(+), 9 deletions(-) diff --git a/lib/src/entities/chat.ts b/lib/src/entities/chat.ts index 76cd112..0f4764a 100644 --- a/lib/src/entities/chat.ts +++ b/lib/src/entities/chat.ts @@ -468,6 +468,33 @@ export class Chat { ]) } + /** @internal */ + async restoreThreadChannel(message: Message) { + const threadChannelId = this.getThreadId(message.channelId, message.timetoken) + + const threadChannel = await this.getChannel(threadChannelId) + if (!threadChannel) { + return + } + + const actionTimetoken = + message.actions?.threadRootId?.[this.getThreadId(message.channelId, message.timetoken)]?.[0] + ?.actionTimetoken + + if (actionTimetoken) { + throw "This thread is already restored" + } + + return this.sdk.addMessageAction({ + channel: message.channelId, + messageTimetoken: message.timetoken, + action: { + type: "threadRootId", + value: threadChannelId, + }, + }) + } + /** * Channels */ diff --git a/lib/src/entities/message.ts b/lib/src/entities/message.ts index 318d867..cc88af3 100644 --- a/lib/src/entities/message.ts +++ b/lib/src/entities/message.ts @@ -123,6 +123,9 @@ export class Message { const newActions = this.actions || {} newActions[type] ||= {} newActions[type][value] ||= [] + if (newActions[type][value].find((a) => a.actionTimetoken === actionTimetoken)) { + return newActions + } newActions[type][value] = [...newActions[type][value], { uuid, actionTimetoken }] return newActions } @@ -226,7 +229,7 @@ export class Message { */ get deleted() { const type = MessageActionType.DELETED - return !!this.actions?.[type] + return !!this.actions?.[type] && !!this.actions?.[type][type].length } async delete(params: DeleteParameters & { preserveFiles?: boolean } = {}) { @@ -265,6 +268,56 @@ export class Message { } } + async restore() { + if (!this.deleted) { + console.warn("This message has not been deleted") + return + } + const deletedActions = this.actions?.[MessageActionType.DELETED]?.[MessageActionType.DELETED] + if (!deletedActions) { + console.warn("Malformed data", deletedActions) + return + } + + // in practise it's possible to have a few soft deletions on a message + // so take care of it + for (let i = 0; i < deletedActions.length; i++) { + const deleteActionTimetoken = deletedActions[i].actionTimetoken + await this.chat.sdk.removeMessageAction({ + channel: this.channelId, + messageTimetoken: this.timetoken, + actionTimetoken: String(deleteActionTimetoken), + }) + } + const [{ data }, restoredThreadAction] = await Promise.all([ + this.chat.sdk.getMessageActions({ + channel: this.channelId, + start: this.timetoken, + end: this.timetoken, + }), + this.restoreThread(), + ]) + + let allActions = this.actions ? { ...this.actions } : {} + delete allActions[MessageActionType.DELETED] + + for (let i = 0; i < data.length; i++) { + const actions = this.assignAction(data[i]) + allActions = { + ...allActions, + ...actions, + } + } + if (restoredThreadAction) { + allActions = { + ...allActions, + ...this.assignAction(restoredThreadAction.data), + } + } + + return this.clone({ actions: allActions }) + } + /** * Reactions */ @@ -352,4 +405,9 @@ export class Message { await thread.delete(params) } } + + /** @internal */ + private async restoreThread() { + return this.chat.restoreThreadChannel(this) + } } diff --git a/lib/tests/message.test.ts b/lib/tests/message.test.ts index d1c084e..c28817f 100644 --- a/lib/tests/message.test.ts +++ b/lib/tests/message.test.ts @@ -112,6 +112,102 @@ describe("Send message test", () => { expect(deletedMessage).toBeUndefined() }, 30000) + test("should restore a soft deleted message", async () => { + await channel.sendText("Test message") + await sleep(150) // history calls have around 130ms of cache time + + const historyBeforeDelete = await channel.getHistory() + const messagesBeforeDelete = historyBeforeDelete.messages + const sentMessage = messagesBeforeDelete[messagesBeforeDelete.length - 1] + + await sentMessage.delete({ soft: true }) + await sleep(150) // history calls have around 130ms of cache time + + const historyAfterDelete = await channel.getHistory() + const messagesAfterDelete = historyAfterDelete.messages + + const deletedMessage = messagesAfterDelete.find( + (message: Message) => message.timetoken === sentMessage.timetoken + ) + + expect(deletedMessage.deleted).toBe(true) + + const restoredMessage = await deletedMessage.restore() + + expect(restoredMessage.deleted).toBe(false) + + const historyAfterRestore = await channel.getHistory() + const messagesAfterRestore = historyAfterRestore.messages + + const historicRestoredMessage = messagesAfterRestore.find( + (message: Message) => message.timetoken === sentMessage.timetoken + ) + + expect(historicRestoredMessage.deleted).toBe(false) + }) + + test("should restore a soft deleted message together with its thread", async () => { + await channel.sendText("Test message") + await sleep(150) // history calls have around 130ms of cache time + + let historyBeforeDelete = await channel.getHistory() + let messagesBeforeDelete = historyBeforeDelete.messages + let sentMessage = messagesBeforeDelete[messagesBeforeDelete.length - 1] + const messageThread = await sentMessage.createThread() + await messageThread.sendText("Some message in a thread") + await sleep(150) // history calls have around 130ms of cache time + historyBeforeDelete = await channel.getHistory() + messagesBeforeDelete = historyBeforeDelete.messages + sentMessage = messagesBeforeDelete[messagesBeforeDelete.length - 1] + + await sentMessage.delete({ soft: true }) + await sleep(200) // history calls have around 130ms of cache time + + const historyAfterDelete = await channel.getHistory() + const messagesAfterDelete = historyAfterDelete.messages + + const deletedMessage = messagesAfterDelete.find( + (message: Message) => message.timetoken === sentMessage.timetoken + ) + + expect(deletedMessage.deleted).toBe(true) + expect(deletedMessage.hasThread).toBe(false) + + const restoredMessage = await deletedMessage.restore() + + expect(restoredMessage.deleted).toBe(false) + expect(restoredMessage.hasThread).toBe(true) + expect(await restoredMessage.getThread()).toBeDefined() + expect((await restoredMessage.getThread()).id).toBe( + chat.getThreadId(restoredMessage.channelId, restoredMessage.timetoken) + ) + + const historyAfterRestore = await channel.getHistory() + const messagesAfterRestore = historyAfterRestore.messages + + const historicRestoredMessage = messagesAfterRestore.find( + (message: Message) => message.timetoken === sentMessage.timetoken + ) + + expect(historicRestoredMessage.deleted).toBe(false) + expect(await historicRestoredMessage.getThread()).toBeDefined() + expect((await historicRestoredMessage.getThread()).id).toBe( + chat.getThreadId(historicRestoredMessage.channelId, historicRestoredMessage.timetoken) + ) + }) + + test("should only log a warning if you try to restore an undeleted message", async () => { + await channel.sendText("Test message") + await sleep(150) // history calls have around 130ms of cache time + + const historicMessages = (await channel.getHistory()).messages + const sentMessage = historicMessages[historicMessages.length - 1] + const logSpy = jest.spyOn(console, "warn") + await sentMessage.restore() + expect(sentMessage.deleted).toBe(false) + expect(logSpy).toHaveBeenCalledWith("This message has not been deleted") + }) + test("should edit the message", async () => { await channel.sendText("Test message") await sleep(150) // history calls have around 130ms of cache time diff --git a/samples/react-native-group-chat/components/actions-menu/ActionsMenu.tsx b/samples/react-native-group-chat/components/actions-menu/ActionsMenu.tsx index e5fb8ac..046d47f 100644 --- a/samples/react-native-group-chat/components/actions-menu/ActionsMenu.tsx +++ b/samples/react-native-group-chat/components/actions-menu/ActionsMenu.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef, useState } from "react" +import React, { useCallback, useContext, useMemo, useRef, useState } from "react" import { StyleSheet, TouchableOpacity, View } from "react-native" import { BottomSheetModal, BottomSheetBackdrop } from "@gorhom/bottom-sheet" import { Gap, Text, colorPalette as colors, Button } from "../../ui-components" @@ -13,12 +13,14 @@ import { EnhancedIMessage } from "../../utils" import { HomeStackNavigation } from "../../types" import { Message, ThreadMessage } from "@pubnub/chat" import * as Clipboard from "expo-clipboard" +import { ChatContext } from "../../context" type UseActionsMenuParams = { onQuote: (message: Message) => void removeThreadReply?: boolean onPinMessage: (message: Message | ThreadMessage) => void onToggleEmoji: (message: Message) => void + onDeleteMessage: (message: Message) => void } export function useActionsMenu({ @@ -26,9 +28,11 @@ export function useActionsMenu({ removeThreadReply = false, onPinMessage, onToggleEmoji, + onDeleteMessage, }: UseActionsMenuParams) { const bottomSheetModalRef = useRef(null) const navigation = useNavigation() + const { chat } = useContext(ChatContext) const [currentlyFocusedMessage, setCurrentlyFocusedMessage] = useState( null ) @@ -155,6 +159,27 @@ export function useActionsMenu({ Pin message + {currentlyFocusedMessage?.originalPnMessage.userId === chat?.currentUser.id ? ( + + ) : null} ) diff --git a/samples/react-native-group-chat/components/message-text/MessageText.tsx b/samples/react-native-group-chat/components/message-text/MessageText.tsx index 8d544ff..40f47f9 100644 --- a/samples/react-native-group-chat/components/message-text/MessageText.tsx +++ b/samples/react-native-group-chat/components/message-text/MessageText.tsx @@ -154,13 +154,15 @@ export function MessageText({ onGoToMessage, messageProps }: MessageTextProps) { /> ) : null} - {messageElements.map((msgPart, index) => - renderMessagePart( - msgPart, - index, - messageProps.currentMessage?.originalPnMessage.userId || "" - ) - )} + {messageProps.currentMessage?.originalPnMessage.deleted + ? "(Message softly deleted)" + : messageElements.map((msgPart, index) => + renderMessagePart( + msgPart, + index, + messageProps.currentMessage?.originalPnMessage.userId || "" + ) + )} {renderImage()} {renderEmojis()} diff --git a/samples/react-native-group-chat/screens/ordinary/chat/Chat.tsx b/samples/react-native-group-chat/screens/ordinary/chat/Chat.tsx index c64848a..1981aeb 100644 --- a/samples/react-native-group-chat/screens/ordinary/chat/Chat.tsx +++ b/samples/react-native-group-chat/screens/ordinary/chat/Chat.tsx @@ -98,10 +98,19 @@ export function ChatScreen({}: StackScreenProps) { [giftedChatMappedMessages] ) + const handleDeleteMessage = useCallback(async (message: Message) => { + if (message.deleted) { + const restoredMessage = await message.restore() + } else { + await message.delete({ soft: true }) + } + }, []) + const { ActionsMenuComponent, handlePresentModalPress } = useActionsMenu({ onQuote: handleQuote, onPinMessage: handlePin, onToggleEmoji: handleEmoji, + onDeleteMessage: handleDeleteMessage, }) useEffect(() => { From 4805f74580736f0762608d415acdb0ffa0e6692e Mon Sep 17 00:00:00 2001 From: Piotr Suwala Date: Tue, 12 Dec 2023 16:53:48 +0100 Subject: [PATCH 2/6] feat(lib): disallow creating threads in deleted messages --- lib/src/entities/chat.ts | 3 ++ lib/tests/message.test.ts | 26 +++++++++++++++ .../components/actions-menu/ActionsMenu.tsx | 2 +- .../screens/ordinary/chat/Chat.tsx | 2 +- .../ordinary/thread-reply/ThreadReply.tsx | 33 +++++++++++++++++++ 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/lib/src/entities/chat.ts b/lib/src/entities/chat.ts index 0f4764a..c6570ec 100644 --- a/lib/src/entities/chat.ts +++ b/lib/src/entities/chat.ts @@ -373,6 +373,9 @@ export class Chat { if (message.channelId.startsWith(MESSAGE_THREAD_ID_PREFIX)) { throw "Only one level of thread nesting is allowed" } + if (message.deleted) { + throw "You cannot create threads on deleted messages" + } const threadChannelId = this.getThreadId(message.channelId, message.timetoken) diff --git a/lib/tests/message.test.ts b/lib/tests/message.test.ts index c28817f..2f15292 100644 --- a/lib/tests/message.test.ts +++ b/lib/tests/message.test.ts @@ -208,6 +208,32 @@ describe("Send message test", () => { expect(logSpy).toHaveBeenCalledWith("This message has not been deleted") }) + test("should throw an error if you try to create a thread on a deleted message", async () => { + await channel.sendText("Test message") + await sleep(150) // history calls have around 130ms of cache time + + const historyBeforeDelete = await channel.getHistory() + const messagesBeforeDelete = historyBeforeDelete.messages + const sentMessage = messagesBeforeDelete[messagesBeforeDelete.length - 1] + + await sentMessage.delete({ soft: true }) + await sleep(150) // history calls have around 130ms of cache time + + const historyAfterDelete = await channel.getHistory() + const messagesAfterDelete = historyAfterDelete.messages + + const deletedMessage = messagesAfterDelete.find( + (message: Message) => message.timetoken === sentMessage.timetoken + ) + let thrownExceptionString = "" + + await deletedMessage.createThread().catch((e) => { + thrownExceptionString = e + }) + + expect(thrownExceptionString).toBe("You cannot create threads on deleted messages") + }) + test("should edit the message", async () => { await channel.sendText("Test message") await sleep(150) // history calls have around 130ms of cache time diff --git a/samples/react-native-group-chat/components/actions-menu/ActionsMenu.tsx b/samples/react-native-group-chat/components/actions-menu/ActionsMenu.tsx index 046d47f..9e06dc8 100644 --- a/samples/react-native-group-chat/components/actions-menu/ActionsMenu.tsx +++ b/samples/react-native-group-chat/components/actions-menu/ActionsMenu.tsx @@ -109,7 +109,7 @@ export function useActionsMenu({ Copy message - {!removeThreadReply ? ( + {!removeThreadReply && !currentlyFocusedMessage?.originalPnMessage.deleted ? ( <> - - {!removeThreadReply && !currentlyFocusedMessage?.originalPnMessage.deleted ? ( + {!currentlyFocusedMessage?.originalPnMessage.deleted ? ( <> + + {!removeThreadReply ? ( + <> + + + + ) : null} + + + ) : null} - - - - {currentlyFocusedMessage?.originalPnMessage.userId === chat?.currentUser.id ? (