Skip to content

Commit

Permalink
feat(lib): add restoring deleted messages (#157)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
piotr-suwala and pubnub-release-bot committed Dec 14, 2023
1 parent dbbe034 commit 0c12f3f
Show file tree
Hide file tree
Showing 12 changed files with 362 additions and 63 deletions.
11 changes: 10 additions & 1 deletion .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:
Expand Down
2 changes: 1 addition & 1 deletion 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 <support@pubnub.com>",
"license": "SEE LICENSE IN LICENSE",
Expand Down
6 changes: 4 additions & 2 deletions lib/src/entities/channel.ts
Expand Up @@ -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<PubNub.GetChannelMembersParameters, "limit" | "page" | "sort">
Expand All @@ -713,6 +713,7 @@ export class Channel {
return {
ban: !!restrictions?.ban,
mute: !!restrictions?.mute,
reason: restrictions?.reason,
}
}

Expand All @@ -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,
})),
}
Expand Down
34 changes: 33 additions & 1 deletion lib/src/entities/chat.ts
Expand Up @@ -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)

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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}`

Expand All @@ -1096,6 +1126,7 @@ export class Chat {
payload: {
channelId: channel,
restriction: "lifted",
reason: params.reason,
},
})
} else {
Expand All @@ -1106,6 +1137,7 @@ export class Chat {
payload: {
channelId: channel,
restriction: params.ban ? "banned" : "muted",
reason: params.reason,
},
})
}
Expand Down
60 changes: 59 additions & 1 deletion lib/src/entities/message.ts
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 || {}
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)
}
}
2 changes: 2 additions & 0 deletions lib/src/entities/user.ts
Expand Up @@ -180,6 +180,7 @@ export class User {
return {
ban: !!restrictions?.ban,
mute: !!restrictions?.mute,
reason: restrictions?.reason,
}
}

Expand All @@ -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, ""),
})),
}
Expand Down
1 change: 1 addition & 0 deletions lib/src/types.ts
Expand Up @@ -90,6 +90,7 @@ type InviteEventPayload = {
type ModerationEventPayload = {
channelId: string
restriction: "muted" | "banned" | "lifted"
reason?: string
}
type CustomEventPayload = any

Expand Down
122 changes: 122 additions & 0 deletions lib/tests/message.test.ts
Expand Up @@ -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
Expand Down

0 comments on commit 0c12f3f

Please sign in to comment.