Skip to content

Commit

Permalink
feat(lib): add restoring deleted messages
Browse files Browse the repository at this point in the history
  • Loading branch information
piotr-suwala committed Dec 12, 2023
1 parent dbbe034 commit 8851d71
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 9 deletions.
27 changes: 27 additions & 0 deletions lib/src/entities/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
60 changes: 59 additions & 1 deletion lib/src/entities/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 } = {}) {
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -352,4 +405,9 @@ export class Message {
await thread.delete(params)
}
}

/** @internal */
private async restoreThread() {
return this.chat.restoreThreadChannel(this)
}
}
96 changes: 96 additions & 0 deletions lib/tests/message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Check failure on line 178 in lib/tests/message.test.ts

View workflow job for this annotation

GitHub Actions / Test results

Send message test ► Send message test should restore a soft deleted message together with its thread ► Send message test should restore a soft deleted message together with its thread

Failed test found in: lib/junit.xml Error: Error: expect(received).toBe(expected) // Object.is equality
Raw output
Error: expect(received).toBe(expected) // Object.is equality

Expected: false
Received: true
    at Object.toBe (/home/runner/work/js-chat/js-chat/lib/tests/message.test.ts:178:37)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,22 +13,26 @@ 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({
onQuote,
removeThreadReply = false,
onPinMessage,
onToggleEmoji,
onDeleteMessage,
}: UseActionsMenuParams) {
const bottomSheetModalRef = useRef<BottomSheetModal>(null)
const navigation = useNavigation<HomeStackNavigation>()
const { chat } = useContext(ChatContext)
const [currentlyFocusedMessage, setCurrentlyFocusedMessage] = useState<EnhancedIMessage | null>(
null
)
Expand Down Expand Up @@ -155,6 +159,27 @@ export function useActionsMenu({
Pin message
</Button>
<Gap value={16} />
{currentlyFocusedMessage?.originalPnMessage.userId === chat?.currentUser.id ? (
<Button
size="md"
align="left"
icon={
currentlyFocusedMessage?.originalPnMessage.deleted ? "restore-from-trash" : "delete"
}
variant="outlined"
onPress={() => {
if (currentlyFocusedMessage) {
onDeleteMessage(currentlyFocusedMessage.originalPnMessage)
setCurrentlyFocusedMessage(null)
bottomSheetModalRef.current?.dismiss()
}
}}
>
{currentlyFocusedMessage?.originalPnMessage.deleted
? "Restore message"
: "Delete message"}
</Button>
) : null}
</BottomSheetModal>
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,15 @@ export function MessageText({ onGoToMessage, messageProps }: MessageTextProps) {
/>
) : null}
<Text variant="body">
{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 || ""
)
)}
</Text>
{renderImage()}
{renderEmojis()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,19 @@ export function ChatScreen({}: StackScreenProps<HomeStackParamList, "Chat">) {
[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(() => {
Expand Down

0 comments on commit 8851d71

Please sign in to comment.