Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lib): add restoring deleted messages #157

Merged
merged 6 commits into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .pubnub.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pubnub/chat",
"version": "0.4.0",
"version": "0.5.0",
"description": "PubNub JavaScript Chat SDK",
"author": "PubNub <[email protected]>",
"license": "SEE LICENSE IN LICENSE",
Expand Down
6 changes: 4 additions & 2 deletions lib/src/entities/channel.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
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 || {}
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading
Loading