From 0c12f3f157056ad4b574a6e1b04f3745fb1c38af Mon Sep 17 00:00:00 2001 From: piotr-suwala <112620304+piotr-suwala@users.noreply.github.com> Date: Thu, 14 Dec 2023 10:32:33 +0100 Subject: [PATCH] feat(lib): add restoring deleted messages (#157) * feat(lib): add restoring deleted messages * feat(lib): disallow creating threads in deleted messages * PubNub SDK v0.5.0 release. --------- Co-authored-by: PubNub Release Bot <120067856+pubnub-release-bot@users.noreply.github.com> --- .pubnub.yml | 11 +- lib/package.json | 2 +- lib/src/entities/channel.ts | 6 +- lib/src/entities/chat.ts | 34 ++++- lib/src/entities/message.ts | 60 +++++++- lib/src/entities/user.ts | 2 + lib/src/types.ts | 1 + lib/tests/message.test.ts | 122 +++++++++++++++++ .../components/actions-menu/ActionsMenu.tsx | 129 +++++++++++------- .../components/message-text/MessageText.tsx | 16 ++- .../screens/ordinary/chat/Chat.tsx | 9 ++ .../ordinary/thread-reply/ThreadReply.tsx | 33 +++++ 12 files changed, 362 insertions(+), 63 deletions(-) diff --git a/.pubnub.yml b/.pubnub.yml index 9f2be5b..85c101c 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,11 +1,20 @@ --- name: pubnub-js-chat -version: v0.4.0 +version: v0.5.0 scm: github.com/pubnub/js-chat schema: 1 files: - lib/dist/index.js changelog: + - date: 2023-12-14 + version: v0.5.0 + changes: + - type: feature + text: "Add "restore" method to the Message entity." + - type: feature + text: "Add "reason" for user restrictions." + - type: feature + text: "Muted | banned | lifted)." - date: 2023-12-06 version: v0.4.0 changes: diff --git a/lib/package.json b/lib/package.json index 2805e08..591fa01 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,6 +1,6 @@ { "name": "@pubnub/chat", - "version": "0.4.0", + "version": "0.5.0", "description": "PubNub JavaScript Chat SDK", "author": "PubNub ", "license": "SEE LICENSE IN LICENSE", diff --git a/lib/src/entities/channel.ts b/lib/src/entities/channel.ts index d6e8355..a94eaed 100644 --- a/lib/src/entities/channel.ts +++ b/lib/src/entities/channel.ts @@ -685,13 +685,13 @@ export class Channel { * Moderation restrictions */ - async setRestrictions(user: User, params: { ban?: boolean; mute?: boolean }) { + async setRestrictions(user: User, params: { ban?: boolean; mute?: boolean; reason?: string }) { if (!(this.chat.sdk as any)._config.secretKey) throw "Moderation restrictions can only be set by clients initialized with a Secret Key." return this.chat.setRestrictions(user.id, this.id, params) } - /* @internal */ + /** @internal */ private async getRestrictions( user?: User, params?: Pick @@ -713,6 +713,7 @@ export class Channel { return { ban: !!restrictions?.ban, mute: !!restrictions?.mute, + reason: restrictions?.reason, } } @@ -730,6 +731,7 @@ export class Channel { restrictions: response.data.map(({ custom, uuid }) => ({ ban: !!custom?.ban, mute: !!custom?.mute, + reason: custom?.reason, userId: uuid.id, })), } diff --git a/lib/src/entities/chat.ts b/lib/src/entities/chat.ts index 76cd112..9bb54b4 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) @@ -468,6 +471,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 */ @@ -1084,7 +1114,7 @@ export class Chat { async setRestrictions( userId: string, channelId: string, - params: { ban?: boolean; mute?: boolean } + params: { ban?: boolean; mute?: boolean; reason?: string } ) { const channel = `${INTERNAL_MODERATION_PREFIX}${channelId}` @@ -1096,6 +1126,7 @@ export class Chat { payload: { channelId: channel, restriction: "lifted", + reason: params.reason, }, }) } else { @@ -1106,6 +1137,7 @@ export class Chat { payload: { channelId: channel, restriction: params.ban ? "banned" : "muted", + reason: params.reason, }, }) } diff --git a/lib/src/entities/message.ts b/lib/src/entities/message.ts index 318d867..4ee253e 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 || {} + 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/src/entities/user.ts b/lib/src/entities/user.ts index 437af86..9b34a5d 100644 --- a/lib/src/entities/user.ts +++ b/lib/src/entities/user.ts @@ -180,6 +180,7 @@ export class User { return { ban: !!restrictions?.ban, mute: !!restrictions?.mute, + reason: restrictions?.reason, } } @@ -197,6 +198,7 @@ export class User { restrictions: response.data.map(({ custom, channel }) => ({ ban: !!custom?.ban, mute: !!custom?.mute, + reason: custom?.reason, channelId: channel.id.replace(INTERNAL_MODERATION_PREFIX, ""), })), } diff --git a/lib/src/types.ts b/lib/src/types.ts index 75984a6..5182fe3 100644 --- a/lib/src/types.ts +++ b/lib/src/types.ts @@ -90,6 +90,7 @@ type InviteEventPayload = { type ModerationEventPayload = { channelId: string restriction: "muted" | "banned" | "lifted" + reason?: string } type CustomEventPayload = any diff --git a/lib/tests/message.test.ts b/lib/tests/message.test.ts index d1c084e..2f15292 100644 --- a/lib/tests/message.test.ts +++ b/lib/tests/message.test.ts @@ -112,6 +112,128 @@ 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 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 e5fb8ac..6f20b91 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 ) @@ -91,70 +95,95 @@ export function useActionsMenu({ Actions - - - {!removeThreadReply ? ( + {!currentlyFocusedMessage?.originalPnMessage.deleted ? ( <> + + {!removeThreadReply ? ( + <> + + + + ) : null} + + + ) : null} - - - - + variant="outlined" + onPress={() => { + if (currentlyFocusedMessage) { + onDeleteMessage(currentlyFocusedMessage.originalPnMessage) + setCurrentlyFocusedMessage(null) + bottomSheetModalRef.current?.dismiss() + } + }} + > + {currentlyFocusedMessage?.originalPnMessage.deleted + ? "Restore message" + : "Delete message"} + + ) : 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..a07aa36 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) { + await message.restore() + } else { + await message.delete({ soft: true }) + } + }, []) + const { ActionsMenuComponent, handlePresentModalPress } = useActionsMenu({ onQuote: handleQuote, onPinMessage: handlePin, onToggleEmoji: handleEmoji, + onDeleteMessage: handleDeleteMessage, }) useEffect(() => { diff --git a/samples/react-native-group-chat/screens/ordinary/thread-reply/ThreadReply.tsx b/samples/react-native-group-chat/screens/ordinary/thread-reply/ThreadReply.tsx index 9f64e46..8bc1336 100644 --- a/samples/react-native-group-chat/screens/ordinary/thread-reply/ThreadReply.tsx +++ b/samples/react-native-group-chat/screens/ordinary/thread-reply/ThreadReply.tsx @@ -30,6 +30,7 @@ export function ThreadReply({ route }: StackScreenProps([]) const [showSuggestedData, setShowSuggestedData] = useState(false) const [lastAffectedNameOccurrenceIndex, setLastAffectedNameOccurrenceIndex] = useState(-1) + const [showTextLinkBox, setShowTextLinkBox] = useState(false) const { renderFooter, renderMessageText, renderChatFooter } = useCommonChatRenderers({ typingData, @@ -41,6 +42,9 @@ export function ThreadReply({ route }: StackScreenProps { + const copiedMessages = [...giftedChatMappedMessages] + + const index = copiedMessages.findIndex( + (msg) => msg.originalPnMessage.timetoken === message.timetoken + ) + + if (index === -1) { + return + } + + copiedMessages[index].originalPnMessage = message + + setGiftedChatMappedMessages(copiedMessages) + }, + [giftedChatMappedMessages] + ) + + const handleDeleteMessage = useCallback(async (message: Message) => { + if (message.deleted) { + await message.restore() + } else { + await message.delete({ soft: true }) + } + }, []) + const { ActionsMenuComponent, handlePresentModalPress } = useActionsMenu({ onQuote: handleQuote, removeThreadReply: true, onPinMessage: handlePin, + onDeleteMessage: handleDeleteMessage, + onToggleEmoji: handleEmoji, }) useEffect(() => {