diff --git a/build.gradle.kts b/build.gradle.kts index 10633c3e..33ae92f8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -78,7 +78,6 @@ kotlin { compilerOptions { target.set("es2015") -// moduleKind.set(JsModuleKind.MODULE_UMD) } binaries.library() } diff --git a/js-chat/main.mjs b/js-chat/main.mjs index b8325cb0..c9d62c85 100644 --- a/js-chat/main.mjs +++ b/js-chat/main.mjs @@ -1,4 +1,8 @@ export * from "../build/dist/js/productionLibrary/pubnub-chat.mjs" export const INTERNAL_MODERATION_PREFIX = "PUBNUB_INTERNAL_MODERATION_" +export const MESSAGE_THREAD_ID_PREFIX = "PUBNUB_INTERNAL_THREAD"; +export const INTERNAL_ADMIN_CHANNEL = "PUBNUB_INTERNAL_ADMIN_CHANNEL"; +export const ERROR_LOGGER_KEY_PREFIX = "PUBNUB_INTERNAL_ERROR_LOGGER"; + import PubNub from "pubnub" export let CryptoModule = PubNub.CryptoModule \ No newline at end of file diff --git a/js-chat/tests/message-draft-v2.test.ts b/js-chat/tests/message-draft-v2.test.ts new file mode 100644 index 00000000..6d79fbff --- /dev/null +++ b/js-chat/tests/message-draft-v2.test.ts @@ -0,0 +1,445 @@ +import { Channel, Chat, MessageDraftV2, MixedTextTypedElement } from "../dist" +import { + createChatInstance, + createRandomChannel, + createRandomUser, + renderMessagePart, + sleep, +} from "./utils" +import { jest } from "@jest/globals" + +describe("MessageDraft", function () { + jest.retryTimes(2) + let chat: Chat + let channel: Channel + let messageDraft: MessageDraftV2 + + beforeAll(async () => { + chat = await createChatInstance() + }) + + beforeEach(async () => { + channel = await createRandomChannel() + messageDraft = channel.createMessageDraftV2({ userSuggestionSource: "global" }) + }) + + test("should mention 2 users", async () => { + const [user1, user2] = await Promise.all([createRandomUser(), createRandomUser()]) + + messageDraft.update("Hello @user1 and @user2") + messageDraft.addMention(6, 6, "mention", user1.id) + messageDraft.addMention(17, 6, "mention", user2.id) + const messagePreview = messageDraft.getMessagePreview() + expect(messagePreview.length).toBe(4) + expect(messagePreview[0].type).toBe("text") + expect(messagePreview[1].type).toBe("mention") + expect(messagePreview[2].type).toBe("text") + expect(messagePreview[3].type).toBe("mention") + expect(messageDraft.value).toBe(`Hello @user1 and @user2`) + expect(messagePreview.map(renderMessagePart).join("")).toBe( + `Hello @user1 and @user2` + ) + await Promise.all([user1.delete({ soft: false }), user2.delete({ soft: false })]) + }) + + test.only("should mention 2 - 3 users next to each other", async () => { + const [user1, user2, user3] = await Promise.all([ + createRandomUser(), + createRandomUser(), + createRandomUser(), + ]) + + let elements: MixedTextTypedElement[][] = [] + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + messageDraft.addChangeListener(async function(state) { + elements.push(state.messageElements) + if (elements.length == 3) { + resolve() + return + } + let mentions = await state.suggestedMentions + messageDraft.insertSuggestedMention(mentions[0], mentions[0].replaceWith) + }) + + messageDraft.update("Hello @Te @Tes @Test") + await promise + + const messagePreview = messageDraft.getMessagePreview() + expect(messagePreview.length).toBe(4) + expect(messagePreview[0].type).toBe("text") + expect(messagePreview[1].type).toBe("mention") + expect(messagePreview[2].type).toBe("text") + expect(messagePreview[3].type).toBe("mention") + expect(messagePreview.map(renderMessagePart).join("")).toBe( + elements[2].map(renderMessagePart).join("") + ) + await Promise.all([ + user1.delete({ soft: false }), + user2.delete({ soft: false }), + user3.delete({ soft: false }), + ]) + }) + + test("should mention 2 - 3 users with words between second and third", async () => { + const [user1, user2, user3] = await Promise.all([ + createRandomUser(), + createRandomUser(), + createRandomUser(), + ]) + + messageDraft.onChange("Hello @user1 @user2 and @user3") + messageDraft.addMentionedUser(user1, 0) + messageDraft.addMentionedUser(user2, 1) + messageDraft.addMentionedUser(user3, 2) + const messagePreview = messageDraft.getMessagePreview() + expect(messageDraft.value).toBe(`Hello @${user1.name} @${user2.name} and @${user3.name}`) + expect(messagePreview.length).toBe(6) + expect(messagePreview[0].type).toBe("text") + expect(messagePreview[1].type).toBe("mention") + expect(messagePreview[2].type).toBe("text") + expect(messagePreview[3].type).toBe("mention") + expect(messagePreview[4].type).toBe("text") + expect(messagePreview[4].content.text).toBe(" and ") + expect(messagePreview[5].type).toBe("mention") + expect(messagePreview.map(renderMessagePart).join("")).toBe( + `Hello @${user1.name} @${user2.name} and @${user3.name}` + ) + expect(messageDraft.value).toBe(`Hello @${user1.name} @${user2.name} and @${user3.name}`) + await Promise.all([ + user1.delete({ soft: false }), + user2.delete({ soft: false }), + user3.delete({ soft: false }), + ]) + }) + + test("should reference 2 channels", async () => { + const [channel1, channel2] = await Promise.all([createRandomChannel(), createRandomChannel()]) + + messageDraft.onChange("Hello #channel1 and #channl2") + messageDraft.addReferencedChannel(channel1, 0) + messageDraft.addReferencedChannel(channel2, 1) + const messagePreview = messageDraft.getMessagePreview() + + expect(messagePreview.length).toBe(4) + expect(messagePreview[0].type).toBe("text") + expect(messagePreview[1].type).toBe("channelReference") + expect(messagePreview[2].type).toBe("text") + expect(messagePreview[3].type).toBe("channelReference") + expect(messagePreview.map(renderMessagePart).join("")).toBe( + `Hello #${channel1.name} and #${channel2.name}` + ) + expect(messageDraft.value).toBe(`Hello #${channel1.name} and #${channel2.name}`) + await Promise.all([channel1.delete({ soft: false }), channel2.delete({ soft: false })]) + }) + + test("should reference 2 channels and 2 mentions", async () => { + const [channel1, channel2] = await Promise.all([createRandomChannel(), createRandomChannel()]) + const [user1, user2] = await Promise.all([createRandomUser(), createRandomUser()]) + + messageDraft.update("Hello #channel1 and @brad and #channel2 or @jasmine.") + messageDraft.addReferencedChannel(channel1, 0) + messageDraft.addReferencedChannel(channel2, 1) + messageDraft.addMentionedUser(user1, 0) + messageDraft.addMentionedUser(user2, 1) + const messagePreview = messageDraft.getMessagePreview() + + expect(messagePreview.length).toBe(9) + expect(messagePreview[0].type).toBe("text") + expect(messagePreview[1].type).toBe("channelReference") + expect(messagePreview[2].type).toBe("text") + expect(messagePreview[3].type).toBe("mention") + expect(messagePreview[4].type).toBe("text") + expect(messagePreview[5].type).toBe("channelReference") + expect(messagePreview[6].type).toBe("text") + expect(messagePreview[7].type).toBe("mention") + expect(messagePreview.map(renderMessagePart).join("")).toBe( + `Hello #${channel1.name} and @${user1.name} and #${channel2.name} or @${user2.name}.` + ) + expect(messageDraft.value).toBe( + `Hello #${channel1.name} and @${user1.name} and #${channel2.name} or @${user2.name}.` + ) + await Promise.all([channel1.delete({ soft: false }), channel2.delete({ soft: false })]) + await Promise.all([user1.delete({ soft: false }), user2.delete({ soft: false })]) + }) + + test("should reference 2 channels and 2 mentions with commas", async () => { + const [channel1, channel2] = await Promise.all([createRandomChannel(), createRandomChannel()]) + const [user1, user2] = await Promise.all([createRandomUser(), createRandomUser()]) + + messageDraft.onChange("Hello #channel1, @brad, #channel2 or @jasmine") + messageDraft.addReferencedChannel(channel1, 0) + messageDraft.addReferencedChannel(channel2, 1) + messageDraft.addMentionedUser(user1, 0) + messageDraft.addMentionedUser(user2, 1) + const messagePreview = messageDraft.getMessagePreview() + + expect(messagePreview.length).toBe(8) + expect(messagePreview[0].type).toBe("text") + expect(messagePreview[1].type).toBe("channelReference") + expect(messagePreview[2].type).toBe("text") + expect(messagePreview[3].type).toBe("mention") + expect(messagePreview[4].type).toBe("text") + expect(messagePreview[5].type).toBe("channelReference") + expect(messagePreview[6].type).toBe("text") + expect(messagePreview[7].type).toBe("mention") + expect(messagePreview.map(renderMessagePart).join("")).toBe( + `Hello #${channel1.name}, @${user1.name}, #${channel2.name} or @${user2.name}` + ) + expect(messageDraft.value).toBe( + `Hello #${channel1.name}, @${user1.name}, #${channel2.name} or @${user2.name}` + ) + await Promise.all([channel1.delete({ soft: false }), channel2.delete({ soft: false })]) + await Promise.all([user1.delete({ soft: false }), user2.delete({ soft: false })]) + }) + + test("should reference 2 channels and 2 mentions with commas - another variation", async () => { + const [channel1, channel2, channel3] = await Promise.all([ + createRandomChannel(), + createRandomChannel(), + createRandomChannel(), + ]) + const [user1, user2] = await Promise.all([createRandomUser(), createRandomUser()]) + + messageDraft.onChange("Hello #channel1, @brad, #channel2, #some-random-channel, @jasmine") + messageDraft.addReferencedChannel(channel1, 0) + messageDraft.addReferencedChannel(channel2, 1) + messageDraft.addReferencedChannel(channel2, 2) + messageDraft.addMentionedUser(user1, 0) + messageDraft.addMentionedUser(user2, 1) + const messagePreview = messageDraft.getMessagePreview() + + expect(messagePreview.length).toBe(10) + expect(messagePreview[0].type).toBe("text") + expect(messagePreview[1].type).toBe("channelReference") + expect(messagePreview[2].type).toBe("text") + expect(messagePreview[3].type).toBe("mention") + expect(messagePreview[4].type).toBe("text") + expect(messagePreview[5].type).toBe("channelReference") + expect(messagePreview[6].type).toBe("text") + expect(messagePreview[7].type).toBe("channelReference") + expect(messagePreview[8].type).toBe("text") + expect(messagePreview[9].type).toBe("mention") + expect(messagePreview.map(renderMessagePart).join("")).toBe( + `Hello #${channel1.name}, @${user1.name}, #${channel2.name}, #${channel3.name}, @${user2.name}` + ) + expect(messageDraft.value).toBe( + `Hello #${channel1.name}, @${user1.name}, #${channel2.name}, #${channel3.name}, @${user2.name}` + ) + await Promise.all([ + channel1.delete({ soft: false }), + channel2.delete({ soft: false }), + channel3.delete({ soft: false }), + ]) + await Promise.all([user1.delete({ soft: false }), user2.delete({ soft: false })]) + }) + + test("should add 2 text links and 2 plain links", async () => { + messageDraft.onChange("Hello https://pubnub.com, https://google.com and ") + messageDraft.addLinkedText({ + text: "pubnub", + link: "https://pubnub.com", + positionInInput: messageDraft.value.length, + }) + messageDraft.onChange("Hello https://pubnub.com, https://google.com and pubnub, ") + messageDraft.addLinkedText({ + text: "google", + link: "https://google.com", + positionInInput: messageDraft.value.length, + }) + messageDraft.onChange("Hello https://pubnub.com, https://google.com and pubnub, google.") + const messagePreview = messageDraft.getMessagePreview() + expect(messagePreview.length).toBe(9) + expect(messagePreview[0].type).toBe("text") + expect(messagePreview[1].type).toBe("plainLink") + expect(messagePreview[2].type).toBe("text") + expect(messagePreview[3].type).toBe("plainLink") + expect(messagePreview[4].type).toBe("text") + expect(messagePreview[5].type).toBe("textLink") + expect(messagePreview[6].type).toBe("text") + expect(messagePreview[7].type).toBe("textLink") + expect(messagePreview[8].type).toBe("text") + expect(messagePreview.map(renderMessagePart).join("")).toBe( + "Hello https://pubnub.com, https://google.com and pubnub, google." + ) + expect(messageDraft.value).toBe( + "Hello https://pubnub.com, https://google.com and pubnub, google." + ) + }) + + test("should mix every type of message part", async () => { + const [channel1, channel2] = await Promise.all([createRandomChannel(), createRandomChannel()]) + const [user1, user2, user4, user5] = await Promise.all([ + createRandomUser(), + createRandomUser(), + createRandomUser(), + createRandomUser(), + ]) + messageDraft.onChange("Hello ") + messageDraft.addLinkedText({ + text: "pubnub", + link: "https://pubnub.com", + positionInInput: messageDraft.value.length, + }) + messageDraft.onChange("Hello pubnub at https://pubnub.com! Hello to ") + messageDraft.addLinkedText({ + text: "google", + link: "https://google.com", + positionInInput: messageDraft.value.length, + }) + messageDraft.onChange( + "Hello pubnub at https://pubnub.com! Hello to google at https://google.com. Referencing #channel1, #channel2, #blankchannel, @user1, @user2, and mentioning @blankuser3 @user4 @user5" + ) + messageDraft.addReferencedChannel(channel1, 0) + messageDraft.addReferencedChannel(channel2, 1) + messageDraft.addMentionedUser(user1, 0) + messageDraft.addMentionedUser(user2, 1) + messageDraft.addMentionedUser(user4, 3) + messageDraft.addMentionedUser(user5, 4) + const messagePreview = messageDraft.getMessagePreview() + + expect(messagePreview.length).toBe(20) + expect(messagePreview[0].type).toBe("text") + expect(messagePreview[1].type).toBe("textLink") + expect(messagePreview[2].type).toBe("text") + expect(messagePreview[3].type).toBe("plainLink") + expect(messagePreview[4].type).toBe("text") + expect(messagePreview[5].type).toBe("textLink") + expect(messagePreview[6].type).toBe("text") + expect(messagePreview[7].type).toBe("plainLink") + expect(messagePreview[8].type).toBe("text") + expect(messagePreview[9].type).toBe("channelReference") + expect(messagePreview[10].type).toBe("text") + expect(messagePreview[11].type).toBe("channelReference") + expect(messagePreview[12].type).toBe("text") + expect(messagePreview[13].type).toBe("mention") + expect(messagePreview[14].type).toBe("text") + expect(messagePreview[15].type).toBe("mention") + expect(messagePreview[16].type).toBe("text") + expect(messagePreview[17].type).toBe("mention") + expect(messagePreview[18].type).toBe("text") + expect(messagePreview[19].type).toBe("mention") + expect(messagePreview.map(renderMessagePart).join("")).toBe( + `Hello pubnub at https://pubnub.com! Hello to google at https://google.com. Referencing #${channel1.name}, #${channel2.name}, #blankchannel, @${user1.name}, @${user2.name}, and mentioning @blankuser3 @${user4.name} @${user5.name}` + ) + expect(messageDraft.value).toBe( + `Hello pubnub at https://pubnub.com! Hello to google at https://google.com. Referencing #${channel1.name}, #${channel2.name}, #blankchannel, @${user1.name}, @${user2.name}, and mentioning @blankuser3 @${user4.name} @${user5.name}` + ) + await Promise.all([channel1.delete({ soft: false }), channel2.delete({ soft: false })]) + await Promise.all([ + user1.delete({ soft: false }), + user2.delete({ soft: false }), + user4.delete({ soft: false }), + ]) + }) + + test("should mix every type of message part - variant 2", async () => { + const [channel1, channel2] = await Promise.all([createRandomChannel(), createRandomChannel()]) + const [user1, user2, user4, user5] = await Promise.all([ + createRandomUser(), + createRandomUser(), + createRandomUser(), + createRandomUser(), + ]) + messageDraft.onChange("Hello @user1 #channel1 ") + messageDraft.addMentionedUser(user1, 0) + messageDraft.addReferencedChannel(channel1, 0) + messageDraft.onChange(`${messageDraft.value} `) + messageDraft.addLinkedText({ + text: "pubnub", + link: "https://pubnub.com", + positionInInput: messageDraft.value.length, + }) + messageDraft.onChange(`${messageDraft.value} at https://pubnub.com. `) + messageDraft.addLinkedText({ + text: "google", + link: "https://google.com", + positionInInput: messageDraft.value.length, + }) + messageDraft.onChange( + `${messageDraft.value} at https://google.com, @user2 @blankuser3 #channel2, random text @user4, @user5.` + ) + messageDraft.addReferencedChannel(channel2, 1) + messageDraft.addMentionedUser(user2, 1) + messageDraft.addMentionedUser(user4, 3) + messageDraft.addMentionedUser(user5, 4) + const messagePreview = messageDraft.getMessagePreview() + + expect(messagePreview.length).toBe(21) + expect(messagePreview[0].type).toBe("text") + expect(messagePreview[1].type).toBe("mention") + expect(messagePreview[2].type).toBe("text") + expect(messagePreview[3].type).toBe("channelReference") + expect(messagePreview[4].type).toBe("text") + expect(messagePreview[5].type).toBe("textLink") + expect(messagePreview[6].type).toBe("text") + expect(messagePreview[7].type).toBe("plainLink") + expect(messagePreview[8].type).toBe("text") + expect(messagePreview[9].type).toBe("textLink") + expect(messagePreview[10].type).toBe("text") + expect(messagePreview[11].type).toBe("plainLink") + expect(messagePreview[12].type).toBe("text") + expect(messagePreview[13].type).toBe("mention") + expect(messagePreview[14].type).toBe("text") + expect(messagePreview[15].type).toBe("channelReference") + expect(messagePreview[16].type).toBe("text") + expect(messagePreview[17].type).toBe("mention") + expect(messagePreview[18].type).toBe("text") + expect(messagePreview[19].type).toBe("mention") + expect(messagePreview.map(renderMessagePart).join("")).toBe( + `Hello @Test User #Test Channel pubnub at https://pubnub.com. google at https://google.com, @Test User @blankuser3 #Test Channel, random text @Test User, @Test User.` + ) + expect(messageDraft.value).toBe( + `Hello @Test User #Test Channel pubnub at https://pubnub.com. google at https://google.com, @Test User @blankuser3 #Test Channel, random text @Test User, @Test User.` + ) + await Promise.all([channel1.delete({ soft: false }), channel2.delete({ soft: false })]) + await Promise.all([ + user1.delete({ soft: false }), + user2.delete({ soft: false }), + user4.delete({ soft: false }), + ]) + }) + + test("should reference 3 channels and 3 mentions with no order", async () => { + const [channel1, channel2, channel3] = await Promise.all([ + createRandomChannel(), + createRandomChannel(), + createRandomChannel(), + ]) + const [user1, user2, user3] = await Promise.all([ + createRandomUser(), + createRandomUser(), + createRandomUser(), + ]) + + messageDraft.onChange( + `Hello @real #real #fake @fake @real #fake #fake #real @real #fake #real @@@ @@@@ @ #fake #fake` + ) + messageDraft.addReferencedChannel(channel1, 0) + messageDraft.addReferencedChannel(channel2, 4) + messageDraft.addReferencedChannel(channel3, 6) + messageDraft.addMentionedUser(user1, 0) + messageDraft.addMentionedUser(user2, 2) + messageDraft.addMentionedUser(user3, 3) + const messagePreview = messageDraft.getMessagePreview() + + expect(messagePreview.map(renderMessagePart).join("")).toBe( + "Hello @Test User #Test Channel #fake @fake @Test User #fake #fake #Test Channel @Test User #fake #Test Channel @@@ @@@@ @ #fake #fake" + ) + + await Promise.all([ + channel1.delete({ soft: false }), + channel2.delete({ soft: false }), + channel3.delete({ soft: false }), + ]) + await Promise.all([ + user1.delete({ soft: false }), + user2.delete({ soft: false }), + user3.delete({ soft: false }), + ]) + }) +}) diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt index 28cafa06..9848cb2c 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt @@ -845,7 +845,7 @@ class ChatImpl( val customMap: Map = buildMap { membership.custom?.let { putAll(it) } - put(METADATA_LAST_READ_MESSAGE_TIMETOKEN, relevantLastMessageTimeToken) + put(METADATA_LAST_READ_MESSAGE_TIMETOKEN, relevantLastMessageTimeToken.toString()) } PNChannelMembership.Partial( diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/UuidTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/UuidTest.kt new file mode 100644 index 00000000..196969fd --- /dev/null +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/UuidTest.kt @@ -0,0 +1,13 @@ +package com.pubnub.kmp + +import com.pubnub.chat.internal.generateRandomUuid +import kotlin.test.Test +import kotlin.test.assertTrue + +class UuidTest { + @Test + fun generateUuid() { + val uuid = generateRandomUuid() + assertTrue { uuid.matches(Regex("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}")) } + } +} diff --git a/pubnub-chat-impl/src/jsMain/kotlin/ChannelJs.kt b/pubnub-chat-impl/src/jsMain/kotlin/ChannelJs.kt index abe175af..7c09519c 100644 --- a/pubnub-chat-impl/src/jsMain/kotlin/ChannelJs.kt +++ b/pubnub-chat-impl/src/jsMain/kotlin/ChannelJs.kt @@ -200,6 +200,7 @@ open class ChannelJs internal constructor(internal val channel: Channel, interna fun createMessageDraftV2(config: MessageDraftConfig?): MessageDraftV2Js { return MessageDraftV2Js( + this.chatJs, MessageDraftImpl( this.channel, config?.userSuggestionSource?.let { @@ -209,7 +210,12 @@ open class ChannelJs internal constructor(internal val channel: Channel, interna config?.userLimit ?: 10, config?.channelLimit ?: 10 ), - config + createJsObject { + this.userSuggestionSource = config?.userSuggestionSource ?: "channel" + this.isTypingIndicatorTriggered = config?.isTypingIndicatorTriggered ?: (channel.type != ChannelType.PUBLIC) + this.userLimit = config?.userLimit ?: 10 + this.channelLimit = config?.channelLimit ?: 10 + } ) } diff --git a/pubnub-chat-impl/src/jsMain/kotlin/MessageDraftV2Js.kt b/pubnub-chat-impl/src/jsMain/kotlin/MessageDraftV2Js.kt index bc15f1ea..fcbc5fb0 100644 --- a/pubnub-chat-impl/src/jsMain/kotlin/MessageDraftV2Js.kt +++ b/pubnub-chat-impl/src/jsMain/kotlin/MessageDraftV2Js.kt @@ -1,12 +1,14 @@ @file:OptIn(ExperimentalJsExport::class) import com.pubnub.chat.MentionTarget +import com.pubnub.chat.MessageDraftChangeListener import com.pubnub.chat.MessageElement +import com.pubnub.chat.SuggestedMention import com.pubnub.chat.internal.MessageDraftImpl import com.pubnub.chat.types.InputFile import com.pubnub.kmp.JsMap +import com.pubnub.kmp.PNFuture import com.pubnub.kmp.UploadableImpl -import com.pubnub.kmp.createJsObject import com.pubnub.kmp.then import com.pubnub.kmp.toMap import kotlin.js.Promise @@ -14,9 +16,11 @@ import kotlin.js.Promise @JsExport @JsName("MessageDraftV2") class MessageDraftV2Js internal constructor( + private val chat: ChatJs, private val messageDraft: MessageDraftImpl, - val config: MessageDraftConfig?, + val config: MessageDraftConfig, ) { + val channel: ChannelJs get() = messageDraft.channel.asJs(chat) val value: String get() = messageDraft.value.toString() var quotedMessage: MessageJs? = null var files: Any? = null @@ -41,40 +45,8 @@ class MessageDraftV2Js internal constructor( messageDraft.removeMention(positionOnInput) } - fun getMessagePreview(): Array { - return messageDraft.getMessageElements().map { element -> - when (element) { - is MessageElement.Link -> when (val target = element.target) { - is MentionTarget.Channel -> createJsObject { - this.type = "channelReference" - this.content = createJsObject { - this.name = element.text.substring(1) - this.id = target.channelId - } - } - is MentionTarget.Url -> createJsObject { - this.type = "textLink" - this.content = createJsObject { - this.text = element.text - this.link = target.url - } - } - is MentionTarget.User -> createJsObject { - this.type = "mention" - this.content = createJsObject { - this.name = element.text.substring(1) - this.id = target.userId - } - } - } - is MessageElement.PlainText -> createJsObject { - this.type = "text" - this.content = createJsObject { - this.text = element.text - } - } - } - }.toTypedArray() + fun getMessagePreview(): Array { + return messageDraft.getMessageElements().toJs() } fun send(options: PubNub.PublishParameters?): Promise { @@ -96,30 +68,120 @@ class MessageDraftV2Js internal constructor( options?.ttl?.toInt() ).then { it.toPublishResponse() }.asPromise() } -} -external interface MessageElementJs { - var type: String - var content: MessageElementPayloadJs -} + fun addChangeListener(listener: (MessageDraftState) -> Unit) { + messageDraft.addChangeListener(MessageDraftListenerJs(listener)) + } -external interface MessageElementPayloadJs { - interface Text : MessageElementPayloadJs { - var text: String + fun removeChangeListener(listener: (MessageDraftState) -> Unit) { + messageDraft.removeChangeListener(MessageDraftListenerJs(listener)) } - interface User : MessageElementPayloadJs { - var name: String - var id: String + fun insertText(offset: Int, text: String) = messageDraft.insertText(offset, text) + + fun removeText(offset: Int, length: Int) = messageDraft.removeText(offset, length) + + fun insertSuggestedMention(mention: SuggestedMentionJs, text: String) { + return messageDraft.insertSuggestedMention( + SuggestedMention( + mention.offset, + mention.replaceFrom, + mention.replaceWith, + when (mention.type) { + TYPE_MENTION -> MentionTarget.User(mention.target) + TYPE_CHANNEL_REFERENCE -> MentionTarget.Channel(mention.target) + TYPE_TEXT_LINK -> MentionTarget.Url(mention.target) + else -> throw IllegalStateException("Unknown target type") + } + ), + text + ) } - interface Link : MessageElementPayloadJs { - var text: String - var link: String + fun addMention(offset: Int, length: Int, mentionType: String, mentionTarget: String) { + return messageDraft.addMention( + offset, + length, + when (mentionType) { + TYPE_MENTION -> MentionTarget.User(mentionTarget) + TYPE_CHANNEL_REFERENCE -> MentionTarget.Channel(mentionTarget) + TYPE_TEXT_LINK -> MentionTarget.Url(mentionTarget) + else -> throw IllegalStateException("Unknown target type") + } + ) } - interface Channel : MessageElementPayloadJs { - var name: String - var id: String + fun removeMention(offset: Int) = messageDraft.removeMention(offset) + + fun update(text: String) = messageDraft.update(text) +} + +@JsExport +class MessageDraftState internal constructor( + val messageElements: Array, + suggestedMentionsFuture: PNFuture> +) { + val suggestedMentions: Promise> by lazy { + suggestedMentionsFuture.then { + it.map { + SuggestedMentionJs( + it.offset, + it.replaceFrom, + it.replaceWith, + when (it.target) { + is MentionTarget.Channel -> TYPE_CHANNEL_REFERENCE + is MentionTarget.Url -> TYPE_TEXT_LINK + is MentionTarget.User -> TYPE_MENTION + }, + when (val link = it.target) { + is MentionTarget.Channel -> link.channelId + is MentionTarget.Url -> link.url + is MentionTarget.User -> link.userId + } + ) + }.toTypedArray() + }.asPromise() + } +} + +data class MessageDraftListenerJs(val listener: (MessageDraftState) -> Unit) : MessageDraftChangeListener { + override fun onChange( + messageElements: List, + suggestedMentions: PNFuture>, + ) { + listener( + MessageDraftState( + messageElements.toJs(), + suggestedMentions + ) + ) } } + +@JsExport +@JsName("SuggestedMention") +class SuggestedMentionJs( + val offset: Int, + val replaceFrom: String, + val replaceWith: String, + val type: String, + val target: String, +) + +private const val TYPE_CHANNEL_REFERENCE = "channelReference" +private const val TYPE_TEXT_LINK = "textLink" +private const val TYPE_MENTION = "mention" +private const val TYPE_TEXT = "text" + +fun List.toJs() = map { element -> + when (element) { + is MessageElement.Link -> when (val target = element.target) { + is MentionTarget.Channel -> MixedTextTypedElement.ChannelReference( + ChannelReferenceContent(target.channelId, element.text.substring(1)) + ) + is MentionTarget.Url -> MixedTextTypedElement.TextLink(TextLinkContent(target.url, element.text)) + is MentionTarget.User -> MixedTextTypedElement.Mention(MentionContent(target.userId, element.text.substring(1))) + } + is MessageElement.PlainText -> MixedTextTypedElement.Text(TextContent(element.text)) + } +}.toTypedArray() diff --git a/pubnub-chat-impl/src/jsMain/kotlin/MessageJs.kt b/pubnub-chat-impl/src/jsMain/kotlin/MessageJs.kt index f1a8dd20..a808a23b 100644 --- a/pubnub-chat-impl/src/jsMain/kotlin/MessageJs.kt +++ b/pubnub-chat-impl/src/jsMain/kotlin/MessageJs.kt @@ -3,6 +3,7 @@ import com.pubnub.api.PubNubError import com.pubnub.api.adjustCollectionTypes import com.pubnub.chat.Message +import com.pubnub.chat.internal.MessageDraftImpl import com.pubnub.chat.internal.message.BaseMessage import com.pubnub.chat.types.EventContent import com.pubnub.chat.types.MessageMentionedUser @@ -67,13 +68,24 @@ open class MessageJs internal constructor(internal val message: Message, interna return message.streamUpdates { it.asJs(chatJs) }::close } + fun getLinkedText() = getMessageElements() + fun getMessageElements(): Array { - return MessageElementsUtils.getMessageElements( - text, - mentionedUsers?.toMap()?.mapKeys { it.key.toInt() } ?: emptyMap(), - textLinks?.toList() ?: emptyList(), - referencedChannels?.toMap()?.mapKeys { it.key.toInt() } ?: emptyMap(), - ) + // data from v1 message draft + if (mentionedUsers?.toMap()?.isNotEmpty() == true || + textLinks?.isNotEmpty() == true || + referencedChannels?.toMap()?.isNotEmpty() == true + ) { + return MessageElementsUtils.getMessageElements( + text, + mentionedUsers?.toMap()?.mapKeys { it.key.toInt() } ?: emptyMap(), + textLinks?.toList() ?: emptyList(), + referencedChannels?.toMap()?.mapKeys { it.key.toInt() } ?: emptyMap(), + ) + } else { + // use v2 message draft + return MessageDraftImpl.getMessageElements(text).toJs() + } } fun editText(newText: String): Promise { diff --git a/pubnub-chat-impl/src/jsMain/kotlin/com/pubnub/chat/internal/ChatImpl.js.kt b/pubnub-chat-impl/src/jsMain/kotlin/com/pubnub/chat/internal/ChatImpl.js.kt index e734ab91..8300bea1 100644 --- a/pubnub-chat-impl/src/jsMain/kotlin/com/pubnub/chat/internal/ChatImpl.js.kt +++ b/pubnub-chat-impl/src/jsMain/kotlin/com/pubnub/chat/internal/ChatImpl.js.kt @@ -1,16 +1,26 @@ package com.pubnub.chat.internal +import kotlin.experimental.and +import kotlin.experimental.or +import kotlin.math.floor +import kotlin.random.Random import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid external val globalThis: dynamic @OptIn(ExperimentalUuidApi::class) actual fun generateRandomUuid(): String { - val process = js("process") - if (process !== undefined && process.versions && process.versions.node && globalThis.crypto === undefined) { - // Node.js environment detected - globalThis.crypto = js("require('crypto')") + val uuid = ByteArray(32) + for (i in 0 until 32) { + uuid[i] = floor(Random.nextDouble() * 16).toInt().toByte() } - return Uuid.random().toString() + uuid[12] = 4; // set bits 12-15 of time-high-and-version to 0100 + uuid[16] = uuid[19] and (1 shl 2).inv().toByte() // set bit 6 of clock-seq-and-reserved to zero + uuid[16] = uuid[19] or (1 shl 3).toByte(); // set bit 7 of clock-seq-and-reserved to one + val uuidString = uuid.joinToString("") { it.toString(16) } + return uuidString.substring(0, 8) + + "-" + uuidString.substring(8, 12) + + "-" + uuidString.substring(12, 16) + + "-" + uuidString.substring(16, 20) + + "-" + uuidString.substring(20) } diff --git a/src/jsMain/resources/index.d.ts b/src/jsMain/resources/index.d.ts index be0c3e00..27ea9eb1 100644 --- a/src/jsMain/resources/index.d.ts +++ b/src/jsMain/resources/index.d.ts @@ -421,6 +421,43 @@ type AddLinkedTextParams = { link: string; positionInInput: number; }; + +export declare class MessageDraftV2 { + get channel(): Channel; + get value(): string; + quotedMessage: Message | undefined; + readonly config: MessageDraftConfig; + files?: FileList | File[] | SendFileParameters["file"][]; + addQuote(message: Message): void; + removeQuote(): void; + addLinkedText(params: AddLinkedTextParams): void; + removeLinkedText(positionInInput: number): void; + getMessagePreview(): MixedTextTypedElement[]; + send(params?: MessageDraftOptions): Promise; + addChangeListener(listener: (p0: MessageDraftState) => void): void; + removeChangeListener(listener: (p0: MessageDraftState) => void): void; + insertText(offset: number, text: string): void; + removeText(offset: number, length: number): void; + insertSuggestedMention(mention: SuggestedMention, text: string): void; + addMention(offset: number, length: number, mentionType: TextTypes, mentionTarget: string): void; + removeMention(offset: number): void; + update(text: string): void; +} + +export declare class MessageDraftState { + private constructor(); + get messageElements(): Array; + get suggestedMentions(): Promise>; +} + +export declare class SuggestedMention { + offset: number; + replaceFrom: string; + replaceWith: string; + type: TextTypes; + target: string; +} + declare class MessageDraft { private chat; value: string; @@ -527,6 +564,7 @@ declare class Channel { limit: number; }): Promise; createMessageDraft(config?: Partial): MessageDraft; + createMessageDraftV2(config?: Partial): MessageDraftV2; registerForPush(): Promise; unregisterFromPush(): Promise; streamReadReceipts(callback: (receipts: {