diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 0786c53ee4..83a7ca9c3e 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,4 +1,11 @@ -import { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat'; +import { useEffect } from 'react'; +import { + ChannelFilters, + ChannelOptions, + ChannelSort, + LocalMessage, + TextComposerMiddleware, +} from 'stream-chat'; import { AIStateIndicator, Channel, @@ -8,13 +15,20 @@ import { Chat, ChatView, MessageInput, - StreamMessage, Thread, ThreadList, + useChatContext, useCreateChatClient, VirtualizedMessageList as MessageList, Window, + SendButtonProps, + useMessageComposer, } from 'stream-chat-react'; +import { createTextComposerEmojiMiddleware } from 'stream-chat-react/emojis'; +import { init, SearchIndex } from 'emoji-mart'; +import data from '@emoji-mart/data'; + +init({ data }); const params = new Proxy(new URLSearchParams(window.location.search), { get: (searchParams, property) => searchParams.get(property as string), @@ -35,12 +49,27 @@ const userId = parseUserIdFromToken(userToken); const filters: ChannelFilters = { members: { $in: [userId] }, type: 'messaging', - archived: false, + // archived: false, }; const options: ChannelOptions = { limit: 5, presence: true, state: true }; const sort: ChannelSort = { pinned_at: 1, last_message_at: -1, updated_at: -1 }; -const isMessageAIGenerated = (message: StreamMessage) => !!message?.ai_generated; +// @ts-ignore +const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated; + +const Btn = ({ sendMessage }: SendButtonProps) => { + const messageComposer = useMessageComposer(); + return ( + <button + onClick={(e) => { + messageComposer.customDataManager.setData({ a: 'b' }); + sendMessage(e); + }} + > + Submit + </button> + ); +}; const App = () => { const chatClient = useCreateChatClient({ @@ -49,6 +78,24 @@ const App = () => { userData: { id: userId }, }); + useEffect(() => { + if (!chatClient) return; + + chatClient.setMessageComposerSetupFunction(({ composer }) => { + composer.textComposer.middlewareExecutor.insert({ + middleware: [ + createTextComposerEmojiMiddleware(SearchIndex) as TextComposerMiddleware, + ], + position: { before: 'stream-io/text-composer/mentions-middleware' }, + unique: true, + }); + composer.attachmentManager.setCustomUploadFn(async (fileLike) => { + return composer.attachmentManager.doDefaultUploadRequest(fileLike); + }); + // composer.customDataManager = + }); + }, [chatClient]); + if (!chatClient) return <>Loading...</>; return ( @@ -64,12 +111,12 @@ const App = () => { showChannelSearch additionalChannelSearchProps={{ searchForChannels: true }} /> - <Channel> + <Channel emojiSearchIndex={SearchIndex} SendButton={Btn}> <Window> <ChannelHeader Avatar={ChannelAvatar} /> <MessageList returnAllReadData /> <AIStateIndicator /> - <MessageInput focus /> + <MessageInput focus audioRecordingEnabled /> </Window> <Thread virtualized /> </Channel> diff --git a/examples/vite/yarn.lock b/examples/vite/yarn.lock index 449ad7796b..f91794a282 100644 --- a/examples/vite/yarn.lock +++ b/examples/vite/yarn.lock @@ -450,18 +450,23 @@ dependencies: "@types/ms" "*" +"@types/estree-jsx@^1.0.0": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz#858a88ea20f34fe65111f005a689fa1ebf70dc18" + integrity sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg== + dependencies: + "@types/estree" "*" + +"@types/estree@*", "@types/estree@^1.0.0": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" + integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== + "@types/estree@1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== -"@types/hast@^2.0.0": - version "2.3.10" - resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.10.tgz#5c9d9e0b304bbb8879b857225c5ebab2d81d7643" - integrity sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw== - dependencies: - "@types/unist" "^2" - "@types/hast@^3.0.0": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" @@ -488,12 +493,19 @@ dependencies: "@types/unist" "^2" +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + "@types/ms@*": version "0.7.34" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== -"@types/prop-types@*", "@types/prop-types@^15.0.0": +"@types/prop-types@*": version "15.7.12" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== @@ -514,11 +526,6 @@ version "0.0.0" uid "" -"@types/scheduler@*": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.23.0.tgz#0a6655b3e2708eaabca00b7372fafd7a792a7b09" - integrity sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw== - "@types/semver@^7.5.8": version "7.5.8" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" @@ -620,6 +627,11 @@ "@typescript-eslint/types" "7.8.0" eslint-visitor-keys "^3.4.3" +"@ungap/structured-clone@^1.0.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== + "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" @@ -759,11 +771,21 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + character-entities-legacy@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA== +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + character-entities@^1.0.0: version "1.2.4" resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.4.tgz#e12c3939b7eaf4e5b15e7ad4c5e28e1d48c5b16b" @@ -779,6 +801,11 @@ character-reference-invalid@^1.0.0: resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== +character-reference-invalid@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9" + integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== + "chokidar@>=3.0.0 <4.0.0": version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -869,10 +896,12 @@ dequal@^2.0.0: resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== -diff@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" - integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== +devlop@^1.0.0, devlop@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" dir-glob@^3.0.1: version "3.0.1" @@ -1062,6 +1091,11 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-util-is-identifier-name@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz#0b5ef4c4ff13508b34dcd01ecfa945f61fce5dbd" + integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg== + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -1235,10 +1269,33 @@ hast-util-is-element@^3.0.0: dependencies: "@types/hast" "^3.0.0" -hast-util-whitespace@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz#0ec64e257e6fc216c7d14c8a1b74d27d650b4557" - integrity sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng== +hast-util-to-jsx-runtime@^2.0.0: + version "2.3.6" + resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz#ff31897aae59f62232e21594eac7ef6b63333e98" + integrity sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg== + dependencies: + "@types/estree" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + comma-separated-tokens "^2.0.0" + devlop "^1.0.0" + estree-util-is-identifier-name "^3.0.0" + hast-util-whitespace "^3.0.0" + mdast-util-mdx-expression "^2.0.0" + mdast-util-mdx-jsx "^3.0.0" + mdast-util-mdxjs-esm "^2.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + style-to-js "^1.0.0" + unist-util-position "^5.0.0" + vfile-message "^4.0.0" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" html-to-react@^1.3.4: version "1.7.0" @@ -1249,6 +1306,11 @@ html-to-react@^1.3.4: htmlparser2 "^9.0" lodash.camelcase "^4.3.0" +html-url-attributes@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87" + integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ== + htmlparser2@^9.0: version "9.1.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23" @@ -1314,16 +1376,21 @@ inherits@2: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -inline-style-parser@0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" - integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== +inline-style-parser@0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.4.tgz#f4af5fe72e612839fcd453d989a586566d695f22" + integrity sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q== is-alphabetical@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" integrity sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg== +is-alphabetical@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz#01072053ea7c1036df3c7d19a6daaec7f19e789b" + integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ== + is-alphanumerical@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz#7eb9a2431f855f6b1ef1a78e326df515696c4dbf" @@ -1332,6 +1399,14 @@ is-alphanumerical@^1.0.0: is-alphabetical "^1.0.0" is-decimal "^1.0.0" +is-alphanumerical@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz#7c03fbe96e3e931113e57f964b0a368cc2dfd875" + integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw== + dependencies: + is-alphabetical "^2.0.0" + is-decimal "^2.0.0" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -1349,6 +1424,11 @@ is-decimal@^1.0.0: resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.4.tgz#65a3a5958a1c5b63a706e1b333d7cd9f630d3fa5" integrity sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw== +is-decimal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-2.0.1.tgz#9469d2dc190d0214fd87d78b78caecc0cc14eef7" + integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -1366,6 +1446,11 @@ is-hexadecimal@^1.0.0: resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== +is-hexadecimal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz#86b5bf668fca307498d319dfc03289d781a90027" + integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg== + is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -1425,11 +1510,6 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" -kleur@^4.0.3: - version "4.1.5" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" - integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== - levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -1505,7 +1585,7 @@ longest-streak@^3.0.0: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -1524,24 +1604,15 @@ mdast-add-list-metadata@1.0.1: dependencies: unist-util-visit-parents "1.1.2" -mdast-util-definitions@^5.0.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz#9910abb60ac5d7115d6819b57ae0bcef07a3f7a7" - integrity sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA== - dependencies: - "@types/mdast" "^3.0.0" - "@types/unist" "^2.0.0" - unist-util-visit "^4.0.0" - -mdast-util-find-and-replace@^2.0.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-2.2.2.tgz#cc2b774f7f3630da4bd592f61966fecade8b99b1" - integrity sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw== +mdast-util-find-and-replace@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz#70a3174c894e14df722abf43bc250cbae44b11df" + integrity sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg== dependencies: - "@types/mdast" "^3.0.0" + "@types/mdast" "^4.0.0" escape-string-regexp "^5.0.0" - unist-util-is "^5.0.0" - unist-util-visit-parents "^5.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" mdast-util-from-markdown@^0.8.0: version "0.8.5" @@ -1554,116 +1625,167 @@ mdast-util-from-markdown@^0.8.0: parse-entities "^2.0.0" unist-util-stringify-position "^2.0.0" -mdast-util-from-markdown@^1.0.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz#9421a5a247f10d31d2faed2a30df5ec89ceafcf0" - integrity sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww== +mdast-util-from-markdown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a" + integrity sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA== dependencies: - "@types/mdast" "^3.0.0" - "@types/unist" "^2.0.0" + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" decode-named-character-reference "^1.0.0" - mdast-util-to-string "^3.1.0" - micromark "^3.0.0" - micromark-util-decode-numeric-character-reference "^1.0.0" - micromark-util-decode-string "^1.0.0" - micromark-util-normalize-identifier "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" - unist-util-stringify-position "^3.0.0" - uvu "^0.5.0" - -mdast-util-gfm-autolink-literal@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-1.0.3.tgz#67a13abe813d7eba350453a5333ae1bc0ec05c06" - integrity sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA== + devlop "^1.0.0" + mdast-util-to-string "^4.0.0" + micromark "^4.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-decode-string "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + unist-util-stringify-position "^4.0.0" + +mdast-util-gfm-autolink-literal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz#abd557630337bd30a6d5a4bd8252e1c2dc0875d5" + integrity sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ== dependencies: - "@types/mdast" "^3.0.0" + "@types/mdast" "^4.0.0" ccount "^2.0.0" - mdast-util-find-and-replace "^2.0.0" - micromark-util-character "^1.0.0" + devlop "^1.0.0" + mdast-util-find-and-replace "^3.0.0" + micromark-util-character "^2.0.0" -mdast-util-gfm-footnote@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-1.0.2.tgz#ce5e49b639c44de68d5bf5399877a14d5020424e" - integrity sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ== +mdast-util-gfm-footnote@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz#7778e9d9ca3df7238cc2bd3fa2b1bf6a65b19403" + integrity sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ== dependencies: - "@types/mdast" "^3.0.0" - mdast-util-to-markdown "^1.3.0" - micromark-util-normalize-identifier "^1.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.1.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" -mdast-util-gfm-strikethrough@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-1.0.3.tgz#5470eb105b483f7746b8805b9b989342085795b7" - integrity sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ== +mdast-util-gfm-strikethrough@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz#d44ef9e8ed283ac8c1165ab0d0dfd058c2764c16" + integrity sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg== dependencies: - "@types/mdast" "^3.0.0" - mdast-util-to-markdown "^1.3.0" + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" -mdast-util-gfm-table@^1.0.0: - version "1.0.7" - resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-1.0.7.tgz#3552153a146379f0f9c4c1101b071d70bbed1a46" - integrity sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg== +mdast-util-gfm-table@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz#7a435fb6223a72b0862b33afbd712b6dae878d38" + integrity sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg== dependencies: - "@types/mdast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" markdown-table "^3.0.0" - mdast-util-from-markdown "^1.0.0" - mdast-util-to-markdown "^1.3.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" -mdast-util-gfm-task-list-item@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-1.0.2.tgz#b280fcf3b7be6fd0cc012bbe67a59831eb34097b" - integrity sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ== +mdast-util-gfm-task-list-item@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz#e68095d2f8a4303ef24094ab642e1047b991a936" + integrity sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ== dependencies: - "@types/mdast" "^3.0.0" - mdast-util-to-markdown "^1.3.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" -mdast-util-gfm@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-2.0.2.tgz#e92f4d8717d74bdba6de57ed21cc8b9552e2d0b6" - integrity sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg== - dependencies: - mdast-util-from-markdown "^1.0.0" - mdast-util-gfm-autolink-literal "^1.0.0" - mdast-util-gfm-footnote "^1.0.0" - mdast-util-gfm-strikethrough "^1.0.0" - mdast-util-gfm-table "^1.0.0" - mdast-util-gfm-task-list-item "^1.0.0" - mdast-util-to-markdown "^1.0.0" - -mdast-util-phrasing@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz#c7c21d0d435d7fb90956038f02e8702781f95463" - integrity sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg== +mdast-util-gfm@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz#2cdf63b92c2a331406b0fb0db4c077c1b0331751" + integrity sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ== + dependencies: + mdast-util-from-markdown "^2.0.0" + mdast-util-gfm-autolink-literal "^2.0.0" + mdast-util-gfm-footnote "^2.0.0" + mdast-util-gfm-strikethrough "^2.0.0" + mdast-util-gfm-table "^2.0.0" + mdast-util-gfm-task-list-item "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-mdx-expression@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz#43f0abac9adc756e2086f63822a38c8d3c3a5096" + integrity sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ== dependencies: - "@types/mdast" "^3.0.0" - unist-util-is "^5.0.0" + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" -mdast-util-to-hast@^12.1.0: - version "12.3.0" - resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-12.3.0.tgz#045d2825fb04374e59970f5b3f279b5700f6fb49" - integrity sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw== +mdast-util-mdx-jsx@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz#fd04c67a2a7499efb905a8a5c578dddc9fdada0d" + integrity sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q== dependencies: - "@types/hast" "^2.0.0" - "@types/mdast" "^3.0.0" - mdast-util-definitions "^5.0.0" - micromark-util-sanitize-uri "^1.1.0" + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + devlop "^1.1.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + parse-entities "^4.0.0" + stringify-entities "^4.0.0" + unist-util-stringify-position "^4.0.0" + vfile-message "^4.0.0" + +mdast-util-mdxjs-esm@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz#019cfbe757ad62dd557db35a695e7314bcc9fa97" + integrity sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-phrasing@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3" + integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w== + dependencies: + "@types/mdast" "^4.0.0" + unist-util-is "^6.0.0" + +mdast-util-to-hast@^13.0.0: + version "13.2.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz#5ca58e5b921cc0a3ded1bc02eed79a4fe4fe41f4" + integrity sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" trim-lines "^3.0.0" - unist-util-generated "^2.0.0" - unist-util-position "^4.0.0" - unist-util-visit "^4.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" -mdast-util-to-markdown@^1.0.0, mdast-util-to-markdown@^1.3.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-1.5.0.tgz#c13343cb3fc98621911d33b5cd42e7d0731171c6" - integrity sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A== +mdast-util-to-markdown@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b" + integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA== dependencies: - "@types/mdast" "^3.0.0" - "@types/unist" "^2.0.0" + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" longest-streak "^3.0.0" - mdast-util-phrasing "^3.0.0" - mdast-util-to-string "^3.0.0" - micromark-util-decode-string "^1.0.0" - unist-util-visit "^4.0.0" + mdast-util-phrasing "^4.0.0" + mdast-util-to-string "^4.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-decode-string "^2.0.0" + unist-util-visit "^5.0.0" zwitch "^2.0.0" mdast-util-to-string@^2.0.0: @@ -1671,12 +1793,12 @@ mdast-util-to-string@^2.0.0: resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz#b8cfe6a713e1091cb5b728fc48885a4767f8b97b" integrity sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w== -mdast-util-to-string@^3.0.0, mdast-util-to-string@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz#66f7bb6324756741c5f47a53557f0cbf16b6f789" - integrity sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg== +mdast-util-to-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814" + integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg== dependencies: - "@types/mdast" "^3.0.0" + "@types/mdast" "^4.0.0" memoize-one@^5.1.1: version "5.2.1" @@ -1688,278 +1810,278 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -micromark-core-commonmark@^1.0.0, micromark-core-commonmark@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8" - integrity sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw== +micromark-core-commonmark@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4" + integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg== dependencies: decode-named-character-reference "^1.0.0" - micromark-factory-destination "^1.0.0" - micromark-factory-label "^1.0.0" - micromark-factory-space "^1.0.0" - micromark-factory-title "^1.0.0" - micromark-factory-whitespace "^1.0.0" - micromark-util-character "^1.0.0" - micromark-util-chunked "^1.0.0" - micromark-util-classify-character "^1.0.0" - micromark-util-html-tag-name "^1.0.0" - micromark-util-normalize-identifier "^1.0.0" - micromark-util-resolve-all "^1.0.0" - micromark-util-subtokenize "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.1" - uvu "^0.5.0" - -micromark-extension-gfm-autolink-literal@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-1.0.5.tgz#5853f0e579bbd8ef9e39a7c0f0f27c5a063a66e7" - integrity sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg== + devlop "^1.0.0" + micromark-factory-destination "^2.0.0" + micromark-factory-label "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-factory-title "^2.0.0" + micromark-factory-whitespace "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-html-tag-name "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-autolink-literal@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz#6286aee9686c4462c1e3552a9d505feddceeb935" + integrity sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw== dependencies: - micromark-util-character "^1.0.0" - micromark-util-sanitize-uri "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-extension-gfm-footnote@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-1.1.2.tgz#05e13034d68f95ca53c99679040bc88a6f92fe2e" - integrity sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q== - dependencies: - micromark-core-commonmark "^1.0.0" - micromark-factory-space "^1.0.0" - micromark-util-character "^1.0.0" - micromark-util-normalize-identifier "^1.0.0" - micromark-util-sanitize-uri "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" - uvu "^0.5.0" - -micromark-extension-gfm-strikethrough@^1.0.0: - version "1.0.7" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-1.0.7.tgz#c8212c9a616fa3bf47cb5c711da77f4fdc2f80af" - integrity sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw== - dependencies: - micromark-util-chunked "^1.0.0" - micromark-util-classify-character "^1.0.0" - micromark-util-resolve-all "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" - uvu "^0.5.0" - -micromark-extension-gfm-table@^1.0.0: - version "1.0.7" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-1.0.7.tgz#dcb46074b0c6254c3fc9cc1f6f5002c162968008" - integrity sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw== - dependencies: - micromark-factory-space "^1.0.0" - micromark-util-character "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" - uvu "^0.5.0" - -micromark-extension-gfm-tagfilter@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-1.0.2.tgz#aa7c4dd92dabbcb80f313ebaaa8eb3dac05f13a7" - integrity sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g== +micromark-extension-gfm-footnote@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz#4dab56d4e398b9853f6fe4efac4fc9361f3e0750" + integrity sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw== + dependencies: + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-strikethrough@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz#86106df8b3a692b5f6a92280d3879be6be46d923" + integrity sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw== dependencies: - micromark-util-types "^1.0.0" + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-extension-gfm-task-list-item@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-1.0.5.tgz#b52ce498dc4c69b6a9975abafc18f275b9dde9f4" - integrity sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ== +micromark-extension-gfm-table@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz#fac70bcbf51fe65f5f44033118d39be8a9b5940b" + integrity sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg== dependencies: - micromark-factory-space "^1.0.0" - micromark-util-character "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" - uvu "^0.5.0" + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-extension-gfm@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-2.0.3.tgz#e517e8579949a5024a493e49204e884aa74f5acf" - integrity sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ== - dependencies: - micromark-extension-gfm-autolink-literal "^1.0.0" - micromark-extension-gfm-footnote "^1.0.0" - micromark-extension-gfm-strikethrough "^1.0.0" - micromark-extension-gfm-table "^1.0.0" - micromark-extension-gfm-tagfilter "^1.0.0" - micromark-extension-gfm-task-list-item "^1.0.0" - micromark-util-combine-extensions "^1.0.0" - micromark-util-types "^1.0.0" - -micromark-factory-destination@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz#eb815957d83e6d44479b3df640f010edad667b9f" - integrity sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg== +micromark-extension-gfm-tagfilter@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz#f26d8a7807b5985fba13cf61465b58ca5ff7dc57" + integrity sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg== dependencies: - micromark-util-character "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" + micromark-util-types "^2.0.0" -micromark-factory-label@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz#cc95d5478269085cfa2a7282b3de26eb2e2dec68" - integrity sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w== +micromark-extension-gfm-task-list-item@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz#bcc34d805639829990ec175c3eea12bb5b781f2c" + integrity sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw== dependencies: - micromark-util-character "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" - uvu "^0.5.0" + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-factory-space@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz#c8f40b0640a0150751d3345ed885a080b0d15faf" - integrity sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ== +micromark-extension-gfm@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz#3e13376ab95dd7a5cfd0e29560dfe999657b3c5b" + integrity sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w== + dependencies: + micromark-extension-gfm-autolink-literal "^2.0.0" + micromark-extension-gfm-footnote "^2.0.0" + micromark-extension-gfm-strikethrough "^2.0.0" + micromark-extension-gfm-table "^2.0.0" + micromark-extension-gfm-tagfilter "^2.0.0" + micromark-extension-gfm-task-list-item "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-destination@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639" + integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA== dependencies: - micromark-util-character "^1.0.0" - micromark-util-types "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-factory-title@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz#dd0fe951d7a0ac71bdc5ee13e5d1465ad7f50ea1" - integrity sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ== +micromark-factory-label@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1" + integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg== dependencies: - micromark-factory-space "^1.0.0" - micromark-util-character "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" + devlop "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-factory-whitespace@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz#798fb7489f4c8abafa7ca77eed6b5745853c9705" - integrity sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ== +micromark-factory-space@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc" + integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg== dependencies: - micromark-factory-space "^1.0.0" - micromark-util-character "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-types "^2.0.0" -micromark-util-character@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-1.2.0.tgz#4fedaa3646db249bc58caeb000eb3549a8ca5dcc" - integrity sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg== +micromark-factory-title@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94" + integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw== dependencies: - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-util-chunked@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz#37a24d33333c8c69a74ba12a14651fd9ea8a368b" - integrity sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ== +micromark-factory-whitespace@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1" + integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ== dependencies: - micromark-util-symbol "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-util-classify-character@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz#6a7f8c8838e8a120c8e3c4f2ae97a2bff9190e9d" - integrity sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw== +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== dependencies: - micromark-util-character "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-util-combine-extensions@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz#192e2b3d6567660a85f735e54d8ea6e3952dbe84" - integrity sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA== +micromark-util-chunked@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051" + integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA== dependencies: - micromark-util-chunked "^1.0.0" - micromark-util-types "^1.0.0" + micromark-util-symbol "^2.0.0" -micromark-util-decode-numeric-character-reference@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz#b1e6e17009b1f20bc652a521309c5f22c85eb1c6" - integrity sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw== +micromark-util-classify-character@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629" + integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q== dependencies: - micromark-util-symbol "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-util-decode-string@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz#dc12b078cba7a3ff690d0203f95b5d5537f2809c" - integrity sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ== +micromark-util-combine-extensions@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9" + integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg== + dependencies: + micromark-util-chunked "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-decode-numeric-character-reference@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5" + integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-decode-string@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2" + integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ== dependencies: decode-named-character-reference "^1.0.0" - micromark-util-character "^1.0.0" - micromark-util-decode-numeric-character-reference "^1.0.0" - micromark-util-symbol "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-symbol "^2.0.0" -micromark-util-encode@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz#92e4f565fd4ccb19e0dcae1afab9a173bbeb19a5" - integrity sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw== +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== -micromark-util-html-tag-name@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz#48fd7a25826f29d2f71479d3b4e83e94829b3588" - integrity sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q== +micromark-util-html-tag-name@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825" + integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA== -micromark-util-normalize-identifier@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz#7a73f824eb9f10d442b4d7f120fecb9b38ebf8b7" - integrity sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q== +micromark-util-normalize-identifier@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d" + integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q== dependencies: - micromark-util-symbol "^1.0.0" + micromark-util-symbol "^2.0.0" -micromark-util-resolve-all@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz#4652a591ee8c8fa06714c9b54cd6c8e693671188" - integrity sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA== +micromark-util-resolve-all@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b" + integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg== dependencies: - micromark-util-types "^1.0.0" + micromark-util-types "^2.0.0" -micromark-util-sanitize-uri@^1.0.0, micromark-util-sanitize-uri@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz#613f738e4400c6eedbc53590c67b197e30d7f90d" - integrity sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A== +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== dependencies: - micromark-util-character "^1.0.0" - micromark-util-encode "^1.0.0" - micromark-util-symbol "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" -micromark-util-subtokenize@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz#941c74f93a93eaf687b9054aeb94642b0e92edb1" - integrity sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A== +micromark-util-subtokenize@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee" + integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA== dependencies: - micromark-util-chunked "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.0" - uvu "^0.5.0" + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromark-util-symbol@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz#813cd17837bdb912d069a12ebe3a44b6f7063142" - integrity sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag== +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== -micromark-util-types@^1.0.0, micromark-util-types@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-1.1.0.tgz#e6676a8cae0bb86a2171c498167971886cb7e283" - integrity sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg== +micromark-util-types@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== -micromark@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/micromark/-/micromark-3.2.0.tgz#1af9fef3f995ea1ea4ac9c7e2f19c48fd5c006e9" - integrity sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA== +micromark@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb" + integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA== dependencies: "@types/debug" "^4.0.0" debug "^4.0.0" decode-named-character-reference "^1.0.0" - micromark-core-commonmark "^1.0.1" - micromark-factory-space "^1.0.0" - micromark-util-character "^1.0.0" - micromark-util-chunked "^1.0.0" - micromark-util-combine-extensions "^1.0.0" - micromark-util-decode-numeric-character-reference "^1.0.0" - micromark-util-encode "^1.0.0" - micromark-util-normalize-identifier "^1.0.0" - micromark-util-resolve-all "^1.0.0" - micromark-util-sanitize-uri "^1.0.0" - micromark-util-subtokenize "^1.0.0" - micromark-util-symbol "^1.0.0" - micromark-util-types "^1.0.1" - uvu "^0.5.0" + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" micromark@~2.11.0: version "2.11.4" @@ -2005,11 +2127,6 @@ mml-react@^0.4.7: react-markdown "^5.0.3" react-virtuoso "^2.10.2" -mri@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" - integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== - ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -2087,6 +2204,19 @@ parse-entities@^2.0.0: is-decimal "^1.0.0" is-hexadecimal "^1.0.0" +parse-entities@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-4.0.2.tgz#61d46f5ed28e4ee62e9ddc43d6b010188443f159" + integrity sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw== + dependencies: + "@types/unist" "^2.0.0" + character-entities-legacy "^3.0.0" + character-reference-invalid "^2.0.0" + decode-named-character-reference "^1.0.0" + is-alphanumerical "^2.0.0" + is-decimal "^2.0.0" + is-hexadecimal "^2.0.0" + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -2131,7 +2261,7 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prop-types@^15.0.0, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -2140,10 +2270,10 @@ prop-types@^15.0.0, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" -property-information@^6.0.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.5.0.tgz#6212fbb52ba757e92ef4fb9d657563b933b7ffec" - integrity sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig== +property-information@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.0.0.tgz#3508a6d6b0b8eb3ca6eb2c6623b164d2ed2ab112" + integrity sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg== punycode@^2.1.0: version "2.3.1" @@ -2183,11 +2313,6 @@ react-is@^16.13.1, react-is@^16.8.6: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^18.0.0, react-is@^18.1.0: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" - integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== - react-markdown@^5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-5.0.3.tgz#41040ea7a9324b564b328fb81dd6c04f2a5373ac" @@ -2204,26 +2329,22 @@ react-markdown@^5.0.3: unist-util-visit "^2.0.0" xtend "^4.0.1" -react-markdown@^8.0.7: - version "8.0.7" - resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-8.0.7.tgz#c8dbd1b9ba5f1c5e7e5f2a44de465a3caafdf89b" - integrity sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ== +react-markdown@^9.0.3: + version "9.1.0" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.1.0.tgz#606bd74c6af131ba382a7c1282ff506708ed2e26" + integrity sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw== dependencies: - "@types/hast" "^2.0.0" - "@types/prop-types" "^15.0.0" - "@types/unist" "^2.0.0" - comma-separated-tokens "^2.0.0" - hast-util-whitespace "^2.0.0" - prop-types "^15.0.0" - property-information "^6.0.0" - react-is "^18.0.0" - remark-parse "^10.0.0" - remark-rehype "^10.0.0" - space-separated-tokens "^2.0.0" - style-to-object "^0.4.0" - unified "^10.0.0" - unist-util-visit "^4.0.0" - vfile "^5.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + hast-util-to-jsx-runtime "^2.0.0" + html-url-attributes "^3.0.0" + mdast-util-to-hast "^13.0.0" + remark-parse "^11.0.0" + remark-rehype "^11.0.0" + unified "^11.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" react-player@2.10.1: version "2.10.1" @@ -2277,24 +2398,27 @@ regenerator-runtime@^0.14.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== -remark-gfm@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-3.0.1.tgz#0b180f095e3036545e9dddac0e8df3fa5cfee54f" - integrity sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig== +remark-gfm@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b" + integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg== dependencies: - "@types/mdast" "^3.0.0" - mdast-util-gfm "^2.0.0" - micromark-extension-gfm "^2.0.0" - unified "^10.0.0" + "@types/mdast" "^4.0.0" + mdast-util-gfm "^3.0.0" + micromark-extension-gfm "^3.0.0" + remark-parse "^11.0.0" + remark-stringify "^11.0.0" + unified "^11.0.0" -remark-parse@^10.0.0: - version "10.0.2" - resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-10.0.2.tgz#ca241fde8751c2158933f031a4e3efbaeb8bc262" - integrity sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw== +remark-parse@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1" + integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA== dependencies: - "@types/mdast" "^3.0.0" - mdast-util-from-markdown "^1.0.0" - unified "^10.0.0" + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + micromark-util-types "^2.0.0" + unified "^11.0.0" remark-parse@^9.0.0: version "9.0.0" @@ -2303,15 +2427,25 @@ remark-parse@^9.0.0: dependencies: mdast-util-from-markdown "^0.8.0" -remark-rehype@^10.0.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-10.1.0.tgz#32dc99d2034c27ecaf2e0150d22a6dcccd9a6279" - integrity sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw== +remark-rehype@^11.0.0: + version "11.1.1" + resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-11.1.1.tgz#f864dd2947889a11997c0a2667cd6b38f685bca7" + integrity sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ== dependencies: - "@types/hast" "^2.0.0" - "@types/mdast" "^3.0.0" - mdast-util-to-hast "^12.1.0" - unified "^10.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + mdast-util-to-hast "^13.0.0" + unified "^11.0.0" + vfile "^6.0.0" + +remark-stringify@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3" + integrity sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-to-markdown "^2.0.0" + unified "^11.0.0" resolve-from@^4.0.0: version "4.0.0" @@ -2362,13 +2496,6 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -sade@^1.7.3: - version "1.8.1" - resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" - integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A== - dependencies: - mri "^1.1.0" - sass@^1.75.0: version "1.75.0" resolved "https://registry.yarnpkg.com/sass/-/sass-1.75.0.tgz#91bbe87fb02dfcc34e052ddd6ab80f60d392be6c" @@ -2378,12 +2505,10 @@ sass@^1.75.0: immutable "^4.0.0" source-map-js ">=0.6.2 <2.0.0" -scheduler@^0.23.0: - version "0.23.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" - integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== - dependencies: - loose-envify "^1.1.0" +scheduler@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" + integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== semver@^7.6.0: version "7.6.3" @@ -2421,6 +2546,14 @@ space-separated-tokens@^2.0.0: version "0.0.0" uid "" +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -2433,12 +2566,19 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -style-to-object@^0.4.0: - version "0.4.4" - resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.4.4.tgz#266e3dfd56391a7eefb7770423612d043c3f33ec" - integrity sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg== +style-to-js@^1.0.0: + version "1.1.16" + resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.16.tgz#e6bd6cd29e250bcf8fa5e6591d07ced7575dbe7a" + integrity sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw== dependencies: - inline-style-parser "0.1.1" + style-to-object "1.0.8" + +style-to-object@1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.8.tgz#67a29bca47eaa587db18118d68f9d95955e81292" + integrity sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g== + dependencies: + inline-style-parser "0.2.4" supports-color@^7.1.0: version "7.2.0" @@ -2506,18 +2646,18 @@ typescript@^5.4.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== -unified@^10.0.0: - version "10.1.2" - resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df" - integrity sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q== +unified@^11.0.0: + version "11.0.5" + resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1" + integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA== dependencies: - "@types/unist" "^2.0.0" + "@types/unist" "^3.0.0" bail "^2.0.0" + devlop "^1.0.0" extend "^3.0.0" - is-buffer "^2.0.0" is-plain-obj "^4.0.0" trough "^2.0.0" - vfile "^5.0.0" + vfile "^6.0.0" unified@^9.0.0: version "9.2.2" @@ -2531,30 +2671,18 @@ unified@^9.0.0: trough "^1.0.0" vfile "^4.0.0" -unist-builder@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-3.0.1.tgz#258b89dcadd3c973656b2327b347863556907f58" - integrity sha512-gnpOw7DIpCA0vpr6NqdPvTWnlPTApCTRzr+38E6hCWx3rz/cjo83SsKIlS1Z+L5ttScQ2AwutNnb8+tAvpb6qQ== +unist-builder@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-4.0.0.tgz#817b326c015a6f9f5e92bb55b8e8bc5e578fe243" + integrity sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg== dependencies: - "@types/unist" "^2.0.0" - -unist-util-generated@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/unist-util-generated/-/unist-util-generated-2.0.1.tgz#e37c50af35d3ed185ac6ceacb6ca0afb28a85cae" - integrity sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A== + "@types/unist" "^3.0.0" unist-util-is@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-4.1.0.tgz#976e5f462a7a5de73d94b706bac1b90671b57797" integrity sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg== -unist-util-is@^5.0.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-5.2.1.tgz#b74960e145c18dcb6226bc57933597f5486deae9" - integrity sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw== - dependencies: - "@types/unist" "^2.0.0" - unist-util-is@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" @@ -2562,12 +2690,12 @@ unist-util-is@^6.0.0: dependencies: "@types/unist" "^3.0.0" -unist-util-position@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-4.0.4.tgz#93f6d8c7d6b373d9b825844645877c127455f037" - integrity sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg== +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== dependencies: - "@types/unist" "^2.0.0" + "@types/unist" "^3.0.0" unist-util-stringify-position@^2.0.0: version "2.0.3" @@ -2576,12 +2704,12 @@ unist-util-stringify-position@^2.0.0: dependencies: "@types/unist" "^2.0.2" -unist-util-stringify-position@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz#03ad3348210c2d930772d64b489580c13a7db39d" - integrity sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg== +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== dependencies: - "@types/unist" "^2.0.0" + "@types/unist" "^3.0.0" unist-util-visit-parents@1.1.2: version "1.1.2" @@ -2596,14 +2724,6 @@ unist-util-visit-parents@^3.0.0: "@types/unist" "^2.0.0" unist-util-is "^4.0.0" -unist-util-visit-parents@^5.0.0, unist-util-visit-parents@^5.1.1: - version "5.1.3" - resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz#b4520811b0ca34285633785045df7a8d6776cfeb" - integrity sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg== - dependencies: - "@types/unist" "^2.0.0" - unist-util-is "^5.0.0" - unist-util-visit-parents@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" @@ -2621,15 +2741,6 @@ unist-util-visit@^2.0.0: unist-util-is "^4.0.0" unist-util-visit-parents "^3.0.0" -unist-util-visit@^4.0.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.2.tgz#125a42d1eb876283715a3cb5cceaa531828c72e2" - integrity sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg== - dependencies: - "@types/unist" "^2.0.0" - unist-util-is "^5.0.0" - unist-util-visit-parents "^5.1.1" - unist-util-visit@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" @@ -2663,15 +2774,10 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" -uvu@^0.5.0: - version "0.5.6" - resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df" - integrity sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA== - dependencies: - dequal "^2.0.0" - diff "^5.0.0" - kleur "^4.0.3" - sade "^1.7.3" +use-sync-external-store@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz#adbc795d8eeb47029963016cefdf89dc799fcebc" + integrity sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw== vfile-message@^2.0.0: version "2.0.4" @@ -2681,13 +2787,13 @@ vfile-message@^2.0.0: "@types/unist" "^2.0.0" unist-util-stringify-position "^2.0.0" -vfile-message@^3.0.0: - version "3.1.4" - resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-3.1.4.tgz#15a50816ae7d7c2d1fa87090a7f9f96612b59dea" - integrity sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw== +vfile-message@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" + integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw== dependencies: - "@types/unist" "^2.0.0" - unist-util-stringify-position "^3.0.0" + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" vfile@^4.0.0: version "4.2.1" @@ -2699,15 +2805,13 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" -vfile@^5.0.0: - version "5.3.7" - resolved "https://registry.yarnpkg.com/vfile/-/vfile-5.3.7.tgz#de0677e6683e3380fafc46544cfe603118826ab7" - integrity sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g== +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== dependencies: - "@types/unist" "^2.0.0" - is-buffer "^2.0.0" - unist-util-stringify-position "^3.0.0" - vfile-message "^3.0.0" + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" vite@^5.2.10: version "5.2.10" diff --git a/jest.env.setup.js b/jest.env.setup.js index e1725578d9..b3c6d0fe88 100644 --- a/jest.env.setup.js +++ b/jest.env.setup.js @@ -3,6 +3,46 @@ const crypto = require('crypto'); Object.defineProperty(globalThis, 'crypto', { value: { - getRandomValues: (arr) => crypto.randomBytes(arr.length), + getRandomValues: (arr) => { + for (let i = 0; i < arr.length; i++) { + arr[i] = Math.floor(Math.random() * 256); + } + return arr; + }, }, }); + +// Mock proper File API behavior +if (typeof File === 'undefined') { + class File extends Blob { + constructor(bits, name, options = {}) { + super(bits, options); + this.name = name; + this.lastModified = options.lastModified || Date.now(); + } + } + global.File = File; +} + +// Ensure FileReader is available +if (typeof FileReader === 'undefined') { + class FileReader { + readAsDataURL(blob) { + const result = `data:${blob.type};base64,${Buffer.from(blob).toString('base64')}`; + setTimeout(() => { + this.result = result; + this.onload?.(); + }, 0); + } + } + global.FileReader = FileReader; +} + +// Mock URL.createObjectURL +if (typeof URL.createObjectURL === 'undefined') { + URL.createObjectURL = (file) => `blob:${file.name}`; +} +// Mock URL.createObjectURL +if (typeof URL.revokeObjectURL === 'undefined') { + URL.revokeObjectURL = () => null; +} diff --git a/package.json b/package.json index 15580769bd..db98b101fd 100644 --- a/package.json +++ b/package.json @@ -239,7 +239,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "semantic-release": "^24.2.3", - "stream-chat": "^9.0.0-rc.8", + "stream-chat": "https://github.com/GetStream/stream-chat-js.git#feat/message-composer", "ts-jest": "^29.2.5", "typescript": "^5.4.5", "typescript-eslint": "^8.17.0" diff --git a/src/components/Attachment/Attachment.tsx b/src/components/Attachment/Attachment.tsx index 93f8b76425..351fadd53b 100644 --- a/src/components/Attachment/Attachment.tsx +++ b/src/components/Attachment/Attachment.tsx @@ -1,15 +1,11 @@ import React, { useMemo } from 'react'; -import type { ReactPlayerProps } from 'react-player'; -import type { Attachment as StreamAttachment } from 'stream-chat'; - import { isAudioAttachment, - isFileAttachment, - isMediaAttachment, + isImageAttachment, isScrapedContent, - isUploadedImage, isVoiceRecordingAttachment, -} from './utils'; +} from 'stream-chat'; + import { AudioContainer, CardContainer, @@ -20,6 +16,10 @@ import { UnsupportedAttachmentContainer, VoiceRecordingContainer, } from './AttachmentContainer'; +import { isFileAttachment, isMediaAttachment } from './utils'; + +import type { ReactPlayerProps } from 'react-player'; +import type { Attachment as StreamAttachment } from 'stream-chat'; import type { AttachmentActionsProps } from './AttachmentActions'; import type { AudioProps } from './Audio'; import type { VoiceRecordingProps } from './VoiceRecording'; @@ -104,11 +104,11 @@ const renderGroupedAttachments = ({ ...rest }: AttachmentProps): GroupedRenderedAttachment => { const uploadedImages: StreamAttachment[] = attachments.filter((attachment) => - isUploadedImage(attachment), + isImageAttachment(attachment), ); const containers = attachments - .filter((attachment) => !isUploadedImage(attachment)) + .filter((attachment) => !isImageAttachment(attachment)) .reduce<GroupedRenderedAttachment>( (typeMap, attachment) => { const attachmentType = getAttachmentType(attachment); diff --git a/src/components/Attachment/AttachmentContainer.tsx b/src/components/Attachment/AttachmentContainer.tsx index 3d1611d0e0..158d14b87b 100644 --- a/src/components/Attachment/AttachmentContainer.tsx +++ b/src/components/Attachment/AttachmentContainer.tsx @@ -3,7 +3,7 @@ import React, { useLayoutEffect, useRef, useState } from 'react'; import ReactPlayer from 'react-player'; import clsx from 'clsx'; import * as linkify from 'linkifyjs'; -import type { Attachment } from 'stream-chat'; +import type { Attachment, LocalAttachment } from 'stream-chat'; import { AttachmentActions as DefaultAttachmentActions } from './AttachmentActions'; import { Audio as DefaultAudio } from './Audio'; @@ -20,7 +20,6 @@ import type { } from './utils'; import { isGalleryAttachmentType, isSvgAttachment } from './utils'; import { useChannelStateContext } from '../../context/ChannelStateContext'; -import type { LocalAttachment } from '../MessageInput'; import type { ImageAttachmentConfiguration, VideoAttachmentConfiguration, diff --git a/src/components/Attachment/utils.tsx b/src/components/Attachment/utils.tsx index 31165e1951..a9852153f4 100644 --- a/src/components/Attachment/utils.tsx +++ b/src/components/Attachment/utils.tsx @@ -1,16 +1,15 @@ import type { ReactNode } from 'react'; -import type { Attachment } from 'stream-chat'; - -import type { ATTACHMENT_GROUPS_ORDER, AttachmentProps } from './Attachment'; import type { + Attachment, LocalAttachment, LocalAudioAttachment, - LocalFileAttachment, LocalImageAttachment, - LocalVideoAttachment, LocalVoiceRecordingAttachment, VoiceRecordingAttachment, -} from '../MessageInput'; +} from 'stream-chat'; + +import type { LocalFileAttachment, LocalVideoAttachment } from 'stream-chat'; +import type { ATTACHMENT_GROUPS_ORDER, AttachmentProps } from './Attachment'; export const SUPPORTED_VIDEO_FORMATS = [ 'video/mp4', diff --git a/src/components/AutoCompleteTextarea/Item.jsx b/src/components/AutoCompleteTextarea/Item.jsx deleted file mode 100644 index 4945d682d5..0000000000 --- a/src/components/AutoCompleteTextarea/Item.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useCallback } from 'react'; -import clsx from 'clsx'; - -export const Item = React.forwardRef(function Item(props, innerRef) { - const { - className, - component: Component, - item, - onClickHandler, - onSelectHandler, - selected, - style, - } = props; - - const handleSelect = useCallback(() => onSelectHandler(item), [item, onSelectHandler]); - const handleClick = useCallback( - (event) => onClickHandler(event, item), - [item, onClickHandler], - ); - - return ( - <li - className={clsx(className, { 'str-chat__suggestion-item--selected': selected })} - style={style} - > - <a - href='' - onClick={handleClick} - onFocus={handleSelect} - onMouseEnter={handleSelect} - ref={innerRef} - > - <Component entity={item} selected={selected} /> - </a> - </li> - ); -}); diff --git a/src/components/AutoCompleteTextarea/List.jsx b/src/components/AutoCompleteTextarea/List.jsx deleted file mode 100644 index 73c275ae1d..0000000000 --- a/src/components/AutoCompleteTextarea/List.jsx +++ /dev/null @@ -1,167 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import clsx from 'clsx'; - -import { useComponentContext } from '../../context/ComponentContext'; - -import { Item } from './Item'; -import { escapeRegExp } from '../Message/renderText'; - -export const List = ({ - className, - component, - currentTrigger, - dropdownScroll, - getSelectedItem, - getTextToReplace, - itemClassName, - itemStyle, - onSelect, - selectionEnd, - style, - SuggestionItem: PropSuggestionItem, - value: propValue, - values, -}) => { - const { AutocompleteSuggestionItem } = useComponentContext('SuggestionList'); - const SuggestionItem = PropSuggestionItem || AutocompleteSuggestionItem || Item; - - const [selectedItemIndex, setSelectedItemIndex] = useState(undefined); - - const itemsRef = []; - - const isSelected = (item) => - selectedItemIndex === values.findIndex((value) => getId(value) === getId(item)); - - const getId = (item) => { - const textToReplace = getTextToReplace(item); - if (textToReplace.key) { - return textToReplace.key; - } - - if (typeof item === 'string' || !item.key) { - return textToReplace.text; - } - - return item.key; - }; - - const findItemIndex = useCallback( - (item) => - values.findIndex((value) => - value.id ? value.id === item.id : value.name === item.name, - ), - [values], - ); - - const modifyText = (value) => { - if (!value) return; - - onSelect(getTextToReplace(value)); - if (getSelectedItem) getSelectedItem(value); - }; - - const handleClick = useCallback( - (e, item) => { - e?.preventDefault(); - - const index = findItemIndex(item); - - modifyText(values[index]); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [modifyText, findItemIndex], - ); - - const selectItem = useCallback( - (item) => { - const index = findItemIndex(item); - setSelectedItemIndex(index); - }, - [findItemIndex], - ); - - const handleKeyDown = useCallback( - (event) => { - if (event.key === 'ArrowUp') { - setSelectedItemIndex((prevSelected) => { - if (prevSelected === undefined) return 0; - const newIndex = prevSelected === 0 ? values.length - 1 : prevSelected - 1; - dropdownScroll(itemsRef[newIndex]); - return newIndex; - }); - } - - if (event.key === 'ArrowDown') { - setSelectedItemIndex((prevSelected) => { - if (prevSelected === undefined) return 0; - const newIndex = prevSelected === values.length - 1 ? 0 : prevSelected + 1; - dropdownScroll(itemsRef[newIndex]); - return newIndex; - }); - } - - if ( - (event.key === 'Enter' || event.key === 'Tab') && - selectedItemIndex !== undefined - ) { - handleClick(event, values[selectedItemIndex]); - } - - return null; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedItemIndex, values], - ); - - useEffect(() => { - document.addEventListener('keydown', handleKeyDown, false); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [handleKeyDown]); - - useEffect(() => { - if (values?.length) selectItem(values[0]); - }, [values]); // eslint-disable-line - - const restructureItem = useCallback( - (item) => { - const matched = item.name || item.id; - - const textBeforeCursor = propValue.slice(0, selectionEnd); - const triggerIndex = textBeforeCursor.lastIndexOf(currentTrigger); - const editedPropValue = escapeRegExp(textBeforeCursor.slice(triggerIndex + 1)); - - const parts = matched.split(new RegExp(`(${editedPropValue})`, 'gi')); - - const itemNameParts = { match: editedPropValue, parts }; - - return { ...item, itemNameParts }; - }, - [propValue, selectionEnd, currentTrigger], - ); - - const restructuredValues = useMemo( - () => values.map(restructureItem), - [values, restructureItem], - ); - - return ( - <ul className={clsx('str-chat__suggestion-list', className)} style={style}> - {restructuredValues.map((item, i) => ( - <SuggestionItem - className={itemClassName} - component={component} - item={item} - key={getId(item)} - onClickHandler={handleClick} - onSelectHandler={selectItem} - ref={(ref) => { - itemsRef[i] = ref; - }} - selected={isSelected(item)} - style={itemStyle} - value={propValue} - /> - ))} - </ul> - ); -}; diff --git a/src/components/AutoCompleteTextarea/Textarea.jsx b/src/components/AutoCompleteTextarea/Textarea.jsx deleted file mode 100644 index 65356bc327..0000000000 --- a/src/components/AutoCompleteTextarea/Textarea.jsx +++ /dev/null @@ -1,811 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Textarea from 'react-textarea-autosize'; -import getCaretCoordinates from 'textarea-caret'; -import clsx from 'clsx'; - -import { List as DefaultSuggestionList } from './List'; -import { - DEFAULT_CARET_POSITION, - defaultScrollToItem, - errorMessage, - triggerPropsCheck, -} from './utils'; - -import { CommandItem } from '../CommandItem'; -import { UserItem } from '../UserItem'; -import { isSafari } from '../../utils/browsers'; - -export class ReactTextareaAutocomplete extends React.Component { - static defaultProps = { - closeOnClickOutside: true, - maxRows: 10, - minChar: 1, - movePopupAsYouType: false, - scrollToItem: true, - value: '', - }; - - constructor(props) { - super(props); - - const { loadingComponent, trigger, value } = this.props; - - // TODO: it would be better to have the parent control state... - // if (value) this.state.value = value; - - if (!loadingComponent) { - throw new Error('RTA: loadingComponent is not defined'); - } - - if (!trigger) { - throw new Error('RTA: trigger is not defined'); - } - - this.state = { - actualToken: '', - component: null, - currentTrigger: null, - data: null, - dataLoading: false, - isComposing: false, - left: null, - selectionEnd: 0, - selectionStart: 0, - top: null, - value: value || '', - }; - } - - // FIXME: unused method - getSelectionPosition = () => { - if (!this.textareaRef) return null; - - return { - selectionEnd: this.textareaRef.selectionEnd, - selectionStart: this.textareaRef.selectionStart, - }; - }; - - // FIXME: unused method - getSelectedText = () => { - if (!this.textareaRef) return null; - const { selectionEnd, selectionStart } = this.textareaRef; - - if (selectionStart === selectionEnd) return null; - - return this.state.value.substr(selectionStart, selectionEnd - selectionStart); - }; - - setCaretPosition = (position = 0) => { - if (!this.textareaRef) return; - - this.textareaRef.focus(); - this.textareaRef.setSelectionRange(position, position); - }; - - getCaretPosition = () => { - if (!this.textareaRef) return 0; - - return this.textareaRef.selectionEnd; - }; - - /** - * isComposing prevents double submissions in Korean and other languages. - * starting point for a read: - * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/isComposing - * In the long term, the fix should happen by handling keypress, but changing this has unknown implications. - * @param event React.KeyboardEvent - */ - _defaultShouldSubmit = (event) => - event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing; - - _handleKeyDown = (event) => { - const { shouldSubmit = this._defaultShouldSubmit } = this.props; - - // prevent default behaviour when the selection list is rendered - if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && this.dropdownRef) - event.preventDefault(); - - if (shouldSubmit?.(event)) return this._onEnter(event); - if (event.key === ' ') return this._onSpace(event); - if (event.key === 'Escape') return this._closeAutocomplete(); - }; - - _onEnter = async (event) => { - if (!this.textareaRef) return; - - const trigger = this.state.currentTrigger; - - if (!trigger || !this.state.data) { - // https://legacy.reactjs.org/docs/legacy-event-pooling.html - event.persist(); - // trigger a submit - await this._replaceWord(); - if (this.textareaRef) { - this.textareaRef.selectionEnd = 0; - } - this.props.handleSubmit(event); - this._closeAutocomplete(); - } - }; - - _onSpace = () => { - if (!this.props.replaceWord || !this.textareaRef) return; - - // don't change characters if the element doesn't have focus - const hasFocus = this.textareaRef.matches(':focus'); - if (!hasFocus) return; - - this._replaceWord(); - }; - - _replaceWord = async () => { - const { value } = this.state; - - const lastWordRegex = /([^\s]+)(\s*)$/; - const match = lastWordRegex.exec(value.slice(0, this.getCaretPosition())); - const lastWord = match && match[1]; - - if (!lastWord) return; - - const spaces = match[2]; - - const newWord = await this.props.replaceWord(lastWord); - if (newWord == null) return; - - const textBeforeWord = value.slice(0, this.getCaretPosition() - match[0].length); - const textAfterCaret = value.slice(this.getCaretPosition(), -1); - const newText = textBeforeWord + newWord + spaces + textAfterCaret; - - this.setState( - { - value: newText, - }, - () => { - // fire onChange event after successful selection - const e = new CustomEvent('change', { bubbles: true }); - this.textareaRef.dispatchEvent(e); - if (this.props.onChange) this.props.onChange(e); - }, - ); - }; - - _onSelect = (newToken) => { - const { - closeCommandsList, - closeMentionsList, - onChange, - showCommandsList, - showMentionsList, - } = this.props; - const { - currentTrigger: stateTrigger, - selectionEnd, - value: textareaValue, - } = this.state; - - const currentTrigger = showCommandsList ? '/' : showMentionsList ? '@' : stateTrigger; - - if (!currentTrigger) return; - - const computeCaretPosition = (position, token, startToken) => { - switch (position) { - case 'start': - return startToken; - case 'next': - case 'end': - return startToken + token.length; - default: - if (!Number.isInteger(position)) { - throw new Error( - 'RTA: caretPosition should be "start", "next", "end" or number.', - ); - } - - return position; - } - }; - - const textToModify = showCommandsList - ? '/' - : showMentionsList - ? '@' - : textareaValue.slice(0, selectionEnd); - - const startOfTokenPosition = textToModify.lastIndexOf(currentTrigger); - - // we add space after emoji is selected if a caret position is next - const newTokenString = - newToken.caretPosition === 'next' ? `${newToken.text} ` : newToken.text; - - const newCaretPosition = computeCaretPosition( - newToken.caretPosition, - newTokenString, - startOfTokenPosition, - ); - - const modifiedText = textToModify.substring(0, startOfTokenPosition) + newTokenString; - const valueToReplace = textareaValue.replace(textToModify, modifiedText); - - // set the new textarea value and after that set the caret back to its position - this.setState( - { - dataLoading: false, - value: valueToReplace, - }, - () => { - // fire onChange event after successful selection - const e = new CustomEvent('change', { bubbles: true }); - this.textareaRef.dispatchEvent(e); - if (onChange) onChange(e); - - this.setCaretPosition(newCaretPosition); - }, - ); - - this._closeAutocomplete(); - if (showCommandsList) closeCommandsList(); - if (showMentionsList) closeMentionsList(); - }; - - _getItemOnSelect = (paramTrigger) => { - const { currentTrigger: stateTrigger } = this.state; - const triggerSettings = this._getCurrentTriggerSettings(paramTrigger); - - const currentTrigger = paramTrigger || stateTrigger; - - if (!currentTrigger || !triggerSettings) return null; - - const { callback } = triggerSettings; - - if (!callback) return null; - - return (item) => { - if (typeof callback !== 'function') { - throw new Error( - 'Output functor is not defined! You have to define "output" function. https://github.com/webscopeio/react-textarea-autocomplete#trigger-type', - ); - } - if (callback) { - return callback(item, currentTrigger); - } - return null; - }; - }; - - _getTextToReplace = (paramTrigger) => { - const { actualToken, currentTrigger: stateTrigger } = this.state; - const triggerSettings = this._getCurrentTriggerSettings(paramTrigger); - - const currentTrigger = paramTrigger || stateTrigger; - - if (!currentTrigger || !triggerSettings) return null; - - const { output } = triggerSettings; - - return (item) => { - if (typeof item === 'object' && (!output || typeof output !== 'function')) { - throw new Error( - 'Output functor is not defined! If you are using items as object you have to define "output" function. https://github.com/webscopeio/react-textarea-autocomplete#trigger-type', - ); - } - - if (output) { - const textToReplace = output(item, currentTrigger); - - if (!textToReplace || typeof textToReplace === 'number') { - throw new Error( - `Output functor should return string or object in shape {text: string, caretPosition: string | number}.\nGot "${String( - textToReplace, - )}". Check the implementation for trigger "${currentTrigger}" and its token "${actualToken}"\n\nSee https://github.com/webscopeio/react-textarea-autocomplete#trigger-type for more informations.\n`, - ); - } - - if (typeof textToReplace === 'string') { - return { - caretPosition: DEFAULT_CARET_POSITION, - text: textToReplace, - }; - } - - if (!textToReplace.text && currentTrigger !== ':') { - throw new Error( - `Output "text" is not defined! Object should has shape {text: string, caretPosition: string | number}. Check the implementation for trigger "${currentTrigger}" and its token "${actualToken}"\n`, - ); - } - - if (!textToReplace.caretPosition) { - throw new Error( - `Output "caretPosition" is not defined! Object should has shape {text: string, caretPosition: string | number}. Check the implementation for trigger "${currentTrigger}" and its token "${actualToken}"\n`, - ); - } - - return textToReplace; - } - - if (typeof item !== 'string') { - throw new Error('Output item should be string\n'); - } - - return { - caretPosition: DEFAULT_CARET_POSITION, - text: `${currentTrigger}${item}${currentTrigger}`, - }; - }; - }; - - _getCurrentTriggerSettings = (paramTrigger) => { - const { currentTrigger: stateTrigger } = this.state; - - const currentTrigger = paramTrigger || stateTrigger; - - if (!currentTrigger) return null; - - return this.props.trigger[currentTrigger]; - }; - - _getValuesFromProvider = () => { - const { actualToken, currentTrigger } = this.state; - const triggerSettings = this._getCurrentTriggerSettings(); - - if (!currentTrigger || !triggerSettings) return; - - const { component, dataProvider } = triggerSettings; - - if (typeof dataProvider !== 'function') { - throw new Error('Trigger provider has to be a function!'); - } - - this.setState({ dataLoading: true }); - - // Modified: send the full text to support / style commands - dataProvider(actualToken, this.state.value, (data, token) => { - // Make sure that the result is still relevant for current query - if (token !== this.state.actualToken) return; - - if (!Array.isArray(data)) { - throw new Error('Trigger provider has to provide an array!'); - } - - // throw away if we resolved old trigger - if (currentTrigger !== this.state.currentTrigger) return; - - // if we haven't resolved any data let's close the autocomplete - if (!data.length) { - this._closeAutocomplete(); - return; - } - - this.setState({ - component, - data, - dataLoading: false, - }); - }); - }; - - _getSuggestions = (paramTrigger) => { - const { currentTrigger: stateTrigger, data } = this.state; - - const currentTrigger = paramTrigger || stateTrigger; - - if (!currentTrigger || !data || (data && !data.length)) return null; - - return data; - }; - - /** - * setup to emulate the UNSAFE_componentWillReceiveProps - */ - static getDerivedStateFromProps(props, state) { - if (props.value !== state.propsValue || !state.value) { - return { propsValue: props.value, value: props.value }; - } else { - return null; - } - } - /** - * Close autocomplete, also clean up trigger (to avoid slow promises) - */ - _closeAutocomplete = () => { - this.setState({ - currentTrigger: null, - data: null, - dataLoading: false, - left: null, - top: null, - }); - }; - - _cleanUpProps = () => { - const props = { ...this.props }; - const notSafe = [ - 'additionalTextareaProps', - 'className', - 'closeCommandsList', - 'closeMentionsList', - 'closeOnClickOutside', - 'containerClassName', - 'containerStyle', - 'disableMentions', - 'dropdownClassName', - 'dropdownStyle', - 'grow', - 'handleSubmit', - 'innerRef', - 'itemClassName', - 'itemStyle', - 'listClassName', - 'listStyle', - 'loaderClassName', - 'loaderStyle', - 'loadingComponent', - 'minChar', - 'movePopupAsYouType', - 'onCaretPositionChange', - 'onChange', - 'ref', - 'replaceWord', - 'scrollToItem', - 'shouldSubmit', - 'showCommandsList', - 'showMentionsList', - 'SuggestionItem', - 'SuggestionList', - 'trigger', - 'value', - ]; - - for (const prop in props) { - if (notSafe.includes(prop)) delete props[prop]; - } - - return props; - }; - - _isCommand = (text) => { - if (text[0] !== '/') return false; - - const tokens = text.split(' '); - - return tokens.length <= 1; - }; - - _changeHandler = (e) => { - const { minChar, movePopupAsYouType, onCaretPositionChange, onChange, trigger } = - this.props; - const { left, top } = this.state; - - const textarea = e.target; - const { selectionEnd, selectionStart, value } = textarea; - - if (onChange) { - e.persist(); - onChange(e); - } - - if (onCaretPositionChange) onCaretPositionChange(this.getCaretPosition()); - - this.setState({ value }); - - let currentTrigger; - let lastToken; - - if (this._isCommand(value)) { - currentTrigger = '/'; - lastToken = value; - } else { - const triggerTokens = Object.keys(trigger).join().replace('/', ''); - const triggerNorWhitespace = `[^\\s${triggerTokens}]*`; - const regex = new RegExp( - `(?!^|\\W)?[${triggerTokens}]${triggerNorWhitespace}\\s?${triggerNorWhitespace}$`, - 'g', - ); - const tokenMatch = value.slice(0, selectionEnd).match(regex); - - lastToken = tokenMatch && tokenMatch[tokenMatch.length - 1].trim(); - - currentTrigger = - (lastToken && Object.keys(trigger).find((a) => a === lastToken[0])) || null; - } - - /* - if we lost the trigger token or there is no following character we want to close - the autocomplete - */ - if (!lastToken || lastToken.length <= minChar) { - this._closeAutocomplete(); - return; - } - - const actualToken = lastToken.slice(1); - - // if trigger is not configured step out from the function, otherwise proceed - if (!currentTrigger) return; - - if ( - movePopupAsYouType || - (top === null && left === null) || - // if we have single char - trigger it means we want to re-position the autocomplete - lastToken.length === 1 - ) { - const { left: newLeft, top: newTop } = getCaretCoordinates(textarea, selectionEnd); - - this.setState({ - // make position relative to textarea - left: newLeft, - top: newTop - this.textareaRef.scrollTop || 0, - }); - } - this.setState( - { - actualToken, - currentTrigger, - selectionEnd, - selectionStart, - }, - () => { - try { - this._getValuesFromProvider(); - } catch (err) { - errorMessage(err.message); - } - }, - ); - }; - - _selectHandler = (e) => { - const { onCaretPositionChange, onSelect } = this.props; - - if (onCaretPositionChange) onCaretPositionChange(this.getCaretPosition()); - - if (onSelect) { - e.persist(); - onSelect(e); - } - }; - - // The textarea itself is outside the auto-select dropdown. - _onClickAndBlurHandler = (e) => { - const { closeOnClickOutside, onBlur } = this.props; - - // If this is a click: e.target is the textarea, and e.relatedTarget is the thing - // that was actually clicked. If we clicked inside the auto-select dropdown, then - // that's not a blur, from the auto-select point of view, so then do nothing. - const el = e.relatedTarget; - // If this is a blur event in Safari, then relatedTarget is never a dropdown item, but a common parent - // of textarea and dropdown container. That means that dropdownRef will not contain its parent and the - // autocomplete will be closed before onclick handler can be invoked selecting an item. - // It seems that Safari has different implementation determining the relatedTarget node than Chrome and Firefox. - // Therefore, if focused away in Safari, the dropdown will be kept rendered until pressing Esc or selecting and item from it. - const focusedAwayInSafari = isSafari() && e.type === 'blur'; - if ( - (this.dropdownRef && el instanceof Node && this.dropdownRef.contains(el)) || - focusedAwayInSafari - ) { - return; - } - - if (closeOnClickOutside) this._closeAutocomplete(); - - if (onBlur) { - e.persist(); - onBlur(e); - } - }; - - _onScrollHandler = () => this._closeAutocomplete(); - - _dropdownScroll = (item) => { - const { scrollToItem } = this.props; - - if (!scrollToItem) return; - - if (scrollToItem === true) { - defaultScrollToItem(this.dropdownRef, item); - return; - } - - if (typeof scrollToItem !== 'function' || scrollToItem.length !== 2) { - throw new Error( - '`scrollToItem` has to be boolean (true for default implementation) or function with two parameters: container, item.', - ); - } - - scrollToItem(this.dropdownRef, item); - }; - - getTriggerProps = () => { - const { showCommandsList, showMentionsList, trigger } = this.props; - const { component, currentTrigger, selectionEnd, value } = this.state; - - const selectedItem = this._getItemOnSelect(); - const suggestionData = this._getSuggestions(); - const textToReplace = this._getTextToReplace(); - - const triggerProps = { - component, - currentTrigger, - getSelectedItem: selectedItem, - getTextToReplace: textToReplace, - selectionEnd, - value, - values: suggestionData, - }; - - if ((showCommandsList && trigger['/']) || (showMentionsList && trigger['@'])) { - let currentCommands; - const getCommands = trigger[showCommandsList ? '/' : '@'].dataProvider; - - getCommands?.('', showCommandsList ? '/' : '@', (data) => { - currentCommands = data; - }); - - triggerProps.component = showCommandsList ? CommandItem : UserItem; - triggerProps.currentTrigger = showCommandsList ? '/' : '@'; - triggerProps.getTextToReplace = this._getTextToReplace( - showCommandsList ? '/' : '@', - ); - triggerProps.getSelectedItem = this._getItemOnSelect(showCommandsList ? '/' : '@'); - triggerProps.selectionEnd = 1; - triggerProps.value = showCommandsList ? '/' : '@'; - triggerProps.values = currentCommands; - } - - return triggerProps; - }; - - setDropdownRef = (element) => { - this.dropdownRef = element; - }; - - renderSuggestionListContainer() { - const { - disableMentions, - dropdownClassName, - dropdownStyle, - itemClassName, - itemStyle, - listClassName, - SuggestionItem, - SuggestionList = DefaultSuggestionList, - } = this.props; - - const { isComposing } = this.state; - - const triggerProps = this.getTriggerProps(); - - if ( - isComposing || - !triggerProps.values || - !triggerProps.currentTrigger || - (disableMentions && triggerProps.currentTrigger === '@') - ) - return null; - - return ( - <div - className={clsx('str-chat__suggestion-list-container', dropdownClassName)} - ref={this.setDropdownRef} - style={dropdownStyle} - > - <SuggestionList - className={listClassName} - dropdownScroll={this._dropdownScroll} - itemClassName={clsx('str-chat__suggestion-list-item', itemClassName)} - itemStyle={itemStyle} - onSelect={this._onSelect} - SuggestionItem={SuggestionItem} - {...triggerProps} - /> - </div> - ); - } - - render() { - const { className, containerClassName, containerStyle, style } = this.props; - const { - onBlur, - onChange, - onClick, - onFocus, - onKeyDown, - onScroll, - onSelect, - ...restAdditionalTextareaProps - } = this.props.additionalTextareaProps || {}; - - let { maxRows } = this.props; - - const { dataLoading, value } = this.state; - - if (!this.props.grow) maxRows = 1; - - // By setting defaultValue to undefined, avoid error: - // ForwardRef(TextareaAutosize) contains a textarea with both value and defaultValue props. - // Textarea elements must be either controlled or uncontrolled - - return ( - <div - className={clsx('rta', containerClassName, { - ['rta--loading']: dataLoading, - })} - style={containerStyle} - > - {this.renderSuggestionListContainer()} - <Textarea - data-testid='message-input' - {...this._cleanUpProps()} - className={clsx('rta__textarea', className)} - maxRows={maxRows} - onBlur={(e) => { - this._onClickAndBlurHandler(e); - onBlur?.(e); - }} - onChange={(e) => { - this._changeHandler(e); - onChange?.(e); - }} - onClick={(e) => { - this._onClickAndBlurHandler(e); - onClick?.(e); - }} - onCompositionEnd={() => this.setState((pv) => ({ ...pv, isComposing: false }))} - onCompositionStart={() => this.setState((pv) => ({ ...pv, isComposing: true }))} - onFocus={(e) => { - this.props.onFocus?.(e); - onFocus?.(e); - }} - onKeyDown={(e) => { - this._handleKeyDown(e); - onKeyDown?.(e); - }} - onScroll={(e) => { - this._onScrollHandler(e); - onScroll?.(e); - }} - onSelect={(e) => { - this._selectHandler(e); - onSelect?.(e); - }} - ref={(ref) => { - this.props?.innerRef(ref); - this.textareaRef = ref; - }} - style={style} - value={value} - {...restAdditionalTextareaProps} - defaultValue={undefined} - /> - </div> - ); - } -} - -ReactTextareaAutocomplete.propTypes = { - className: PropTypes.string, - closeOnClickOutside: PropTypes.bool, - containerClassName: PropTypes.string, - containerStyle: PropTypes.object, - disableMentions: PropTypes.bool, - dropdownClassName: PropTypes.string, - dropdownStyle: PropTypes.object, - itemClassName: PropTypes.string, - itemStyle: PropTypes.object, - listClassName: PropTypes.string, - listStyle: PropTypes.object, - loaderClassName: PropTypes.string, - loaderStyle: PropTypes.object, - loadingComponent: PropTypes.elementType, - minChar: PropTypes.number, - onBlur: PropTypes.func, - onCaretPositionChange: PropTypes.func, - onChange: PropTypes.func, - onSelect: PropTypes.func, - shouldSubmit: PropTypes.func, - style: PropTypes.object, - SuggestionList: PropTypes.elementType, - trigger: triggerPropsCheck, - value: PropTypes.string, -}; diff --git a/src/components/AutoCompleteTextarea/index.ts b/src/components/AutoCompleteTextarea/index.ts index a816294a0b..accf652408 100644 --- a/src/components/AutoCompleteTextarea/index.ts +++ b/src/components/AutoCompleteTextarea/index.ts @@ -1,4 +1 @@ -export { Item as DefaultSuggestionListItem } from './Item'; -export { List as DefaultSuggestionList } from './List'; -export { ReactTextareaAutocomplete as AutoCompleteTextarea } from './Textarea'; export { defaultScrollToItem } from './utils'; diff --git a/src/components/AutoCompleteTextarea/types.ts b/src/components/AutoCompleteTextarea/types.ts deleted file mode 100644 index eac1f96c7d..0000000000 --- a/src/components/AutoCompleteTextarea/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type React from 'react'; - -export interface TriggerMap { - [triggerChar: string]: { - component: React.ComponentType<unknown>; - dataProvider: ( - q: string, - text: string, - onReady: (data: unknown[], token: string) => void, - ) => Promise<void> | Array<Record<string, unknown> | string>; - callback?: (item: Record<string, unknown>) => void; - output?: ( - item: { [key: string]: unknown }, - trigger?: string, - ) => - | { - caretPosition: 'start' | 'end' | 'next' | number; - text: string; - key?: string; - } - | string - | null; - }; -} diff --git a/src/components/AutoCompleteTextarea/utils.js b/src/components/AutoCompleteTextarea/utils.js index 54a32d8011..9a4dedbd41 100644 --- a/src/components/AutoCompleteTextarea/utils.js +++ b/src/components/AutoCompleteTextarea/utils.js @@ -5,14 +5,14 @@ export function defaultScrollToItem(container, item) { const itemHeight = parseInt(getComputedStyle(item).getPropertyValue('height'), 10); - const containerHight = + const containerHeight = parseInt(getComputedStyle(container).getPropertyValue('height'), 10) - itemHeight; const actualScrollTop = container.scrollTop; const itemOffsetTop = item.offsetTop; if ( - itemOffsetTop < actualScrollTop + containerHight && + itemOffsetTop < actualScrollTop + containerHeight && actualScrollTop < itemOffsetTop ) { return; diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 38c688dec7..a237b334f3 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps, PropsWithChildren } from 'react'; import React, { useCallback, useEffect, @@ -7,12 +8,11 @@ import React, { useRef, useState, } from 'react'; -import { nanoid } from 'nanoid'; import clsx from 'clsx'; import debounce from 'lodash.debounce'; import defaultsDeep from 'lodash.defaultsdeep'; import throttle from 'lodash.throttle'; -import type { ComponentProps, PropsWithChildren } from 'react'; +import { localMessageToNewMessagePayload } from 'stream-chat'; import type { APIErrorResponse, ChannelAPIResponse, @@ -22,13 +22,14 @@ import type { ErrorFromResponse, Event, EventAPIResponse, + LocalMessage, Message, MessageResponse, SendMessageAPIResponse, + SendMessageOptions, Channel as StreamChannel, StreamChat, - UpdatedMessage, - UserResponse, + UpdateMessageOptions, } from 'stream-chat'; import { initialState, makeChannelReducer } from './channelState'; @@ -49,8 +50,6 @@ import type { ChannelNotifications, ComponentContextValue, MarkReadWrapperOptions, - MessageToSend, - StreamMessage, } from '../../context'; import { ChannelActionProvider, @@ -83,18 +82,14 @@ import { getChannel } from '../../utils'; import type { MessageInputProps } from '../MessageInput'; import type { ChannelUnreadUiState, - CustomTrigger, GiphyVersions, ImageAttachmentSizeHandler, - SendMessageOptions, - UpdateMessageOptions, VideoAttachmentSizeHandler, } from '../../types/types'; import { getImageAttachmentConfiguration, getVideoAttachmentConfiguration, } from '../Attachment/attachment-sizing'; -import type { URLEnrichmentConfig } from '../MessageInput/hooks/useLinkPreviews'; import { useSearchFocusedMessage } from '../../experimental/Search/hooks'; type ChannelPropsForwardedToComponentContext = Pick< @@ -154,7 +149,6 @@ type ChannelPropsForwardedToComponentContext = Pick< | 'ThreadHeader' | 'ThreadStart' | 'Timestamp' - | 'TriggerProvider' | 'TypingIndicator' | 'UnreadMessagesNotification' | 'UnreadMessagesSeparator' @@ -163,87 +157,77 @@ type ChannelPropsForwardedToComponentContext = Pick< | 'StreamedMessageText' >; -const isUserResponseArray = ( - output: string[] | UserResponse[], -): output is UserResponse[] => (output as UserResponse[])[0]?.id != null; - -export type ChannelProps<V extends CustomTrigger = CustomTrigger> = - ChannelPropsForwardedToComponentContext & { - /** List of accepted file types */ - acceptedFiles?: string[]; - /** Custom handler function that runs when the active channel has unread messages and the app is running on a separate browser tab */ - activeUnreadHandler?: (unread: number, documentTitle: string) => void; - /** The connected and active channel */ - channel?: StreamChannel; - /** - * Optional configuration parameters used for the initial channel query. - * Applied only if the value of channel.initialized is false. - * If the channel instance has already been initialized (channel has been queried), - * then the channel query will be skipped and channelQueryOptions will not be applied. - */ - channelQueryOptions?: ChannelQueryOptions; - /** Custom action handler to override the default `client.deleteMessage(message.id)` function */ - doDeleteMessageRequest?: (message: StreamMessage) => Promise<MessageResponse>; - /** Custom action handler to override the default `channel.markRead` request function (advanced usage only) */ - doMarkReadRequest?: ( - channel: StreamChannel, - setChannelUnreadUiState?: (state: ChannelUnreadUiState) => void, - ) => Promise<EventAPIResponse> | void; - /** Custom action handler to override the default `channel.sendMessage` request function (advanced usage only) */ - doSendMessageRequest?: ( - channel: StreamChannel, - message: Message, - options?: SendMessageOptions, - ) => ReturnType<StreamChannel['sendMessage']> | void; - /** Custom action handler to override the default `client.updateMessage` request function (advanced usage only) */ - doUpdateMessageRequest?: ( - cid: string, - updatedMessage: UpdatedMessage, - options?: UpdateMessageOptions, - ) => ReturnType<StreamChat['updateMessage']>; - /** If true, chat users will be able to drag and drop file uploads to the entire channel window */ - dragAndDropWindow?: boolean; - /** Custom UI component to be shown if no active channel is set, defaults to null and skips rendering the Channel component */ - EmptyPlaceholder?: React.ReactElement; - /** - * A global flag to toggle the URL enrichment and link previews in `MessageInput` components. - * By default, the feature is disabled. Can be overridden on Thread, MessageList level through additionalMessageInputProps - * or directly on MessageInput level through urlEnrichmentConfig. - */ - enrichURLForPreview?: URLEnrichmentConfig['enrichURLForPreview']; - /** Global configuration for link preview generation in all the MessageInput components */ - enrichURLForPreviewConfig?: Omit<URLEnrichmentConfig, 'enrichURLForPreview'>; - /** The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default */ - giphyVersion?: GiphyVersions; - /** A custom function to provide size configuration for image attachments */ - imageAttachmentSizeHandler?: ImageAttachmentSizeHandler; - /** - * Allows to prevent triggering the channel.watch() call when mounting the component. - * That means that no channel data from the back-end will be received neither channel WS events will be delivered to the client. - * Preventing to initialize the channel on mount allows us to postpone the channel creation to a later point in time. - */ - initializeOnMount?: boolean; - /** Custom UI component to be shown if the channel query fails, defaults to and accepts same props as: [LoadingErrorIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingErrorIndicator.tsx) */ - LoadingErrorIndicator?: React.ComponentType<LoadingErrorIndicatorProps>; - /** Configuration parameter to mark the active channel as read when mounted (opened). By default, the channel is marked read on mount. */ - markReadOnMount?: boolean; - /** Maximum number of attachments allowed per message */ - maxNumberOfFiles?: number; - /** Whether to allow multiple attachment uploads */ - multipleUploads?: boolean; - /** Custom action handler function to run on click of an @mention in a message */ - onMentionsClick?: OnMentionAction; - /** Custom action handler function to run on hover of an @mention in a message */ - onMentionsHover?: OnMentionAction; - /** If `dragAndDropWindow` prop is true, the props to pass to the MessageInput component (overrides props placed directly on MessageInput) */ - optionalMessageInputProps?: MessageInputProps<V>; - /** You can turn on/off thumbnail generation for video attachments */ - shouldGenerateVideoThumbnail?: boolean; - /** If true, skips the message data string comparison used to memoize the current channel messages (helpful for channels with 1000s of messages) */ - skipMessageDataMemoization?: boolean; - /** A custom function to provide size configuration for video attachments */ - videoAttachmentSizeHandler?: VideoAttachmentSizeHandler; - }; +export type ChannelProps = ChannelPropsForwardedToComponentContext & { + // todo: X document the use of config.attachments.fileUploadFilter to replace acceptedFiles prop + /** List of accepted file types */ + acceptedFiles?: string[]; + /** Custom handler function that runs when the active channel has unread messages and the app is running on a separate browser tab */ + activeUnreadHandler?: (unread: number, documentTitle: string) => void; + /** The connected and active channel */ + channel?: StreamChannel; + /** + * Optional configuration parameters used for the initial channel query. + * Applied only if the value of channel.initialized is false. + * If the channel instance has already been initialized (channel has been queried), + * then the channel query will be skipped and channelQueryOptions will not be applied. + */ + channelQueryOptions?: ChannelQueryOptions; + /** Custom action handler to override the default `client.deleteMessage(message.id)` function */ + doDeleteMessageRequest?: (message: LocalMessage) => Promise<MessageResponse>; + /** Custom action handler to override the default `channel.markRead` request function (advanced usage only) */ + doMarkReadRequest?: ( + channel: StreamChannel, + setChannelUnreadUiState?: (state: ChannelUnreadUiState) => void, + ) => Promise<EventAPIResponse> | void; + /** Custom action handler to override the default `channel.sendMessage` request function (advanced usage only) */ + doSendMessageRequest?: ( + channel: StreamChannel, + message: Message, + options?: SendMessageOptions, + ) => ReturnType<StreamChannel['sendMessage']> | void; + /** Custom action handler to override the default `client.updateMessage` request function (advanced usage only) */ + doUpdateMessageRequest?: ( + cid: string, + updatedMessage: LocalMessage | MessageResponse, + options?: UpdateMessageOptions, + ) => ReturnType<StreamChat['updateMessage']>; + /** If true, chat users will be able to drag and drop file uploads to the entire channel window */ + dragAndDropWindow?: boolean; + /** Custom UI component to be shown if no active channel is set, defaults to null and skips rendering the Channel component */ + EmptyPlaceholder?: React.ReactElement; + /** The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default */ + giphyVersion?: GiphyVersions; + /** A custom function to provide size configuration for image attachments */ + imageAttachmentSizeHandler?: ImageAttachmentSizeHandler; + /** + * Allows to prevent triggering the channel.watch() call when mounting the component. + * That means that no channel data from the back-end will be received neither channel WS events will be delivered to the client. + * Preventing to initialize the channel on mount allows us to postpone the channel creation to a later point in time. + */ + initializeOnMount?: boolean; + /** Custom UI component to be shown if the channel query fails, defaults to and accepts same props as: [LoadingErrorIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingErrorIndicator.tsx) */ + LoadingErrorIndicator?: React.ComponentType<LoadingErrorIndicatorProps>; + /** Configuration parameter to mark the active channel as read when mounted (opened). By default, the channel is marked read on mount. */ + markReadOnMount?: boolean; + // todo: X document how maxNumberOfFiles can be customized with message composer + /** Maximum number of attachments allowed per message */ + maxNumberOfFiles?: number; + // todo: X document that multipleUploads is redundant and ignored with message composer + /** Whether to allow multiple attachment uploads */ + multipleUploads?: boolean; + /** Custom action handler function to run on click of an @mention in a message */ + onMentionsClick?: OnMentionAction; + /** Custom action handler function to run on hover of an @mention in a message */ + onMentionsHover?: OnMentionAction; + /** If `dragAndDropWindow` prop is true, the props to pass to the MessageInput component (overrides props placed directly on MessageInput) */ + optionalMessageInputProps?: MessageInputProps; + /** You can turn on/off thumbnail generation for video attachments */ + shouldGenerateVideoThumbnail?: boolean; + /** If true, skips the message data string comparison used to memoize the current channel messages (helpful for channels with 1000s of messages) */ + skipMessageDataMemoization?: boolean; + /** A custom function to provide size configuration for video attachments */ + videoAttachmentSizeHandler?: VideoAttachmentSizeHandler; +}; const ChannelContainer = ({ children, @@ -262,9 +246,7 @@ const ChannelContainer = ({ ); }; -const UnMemoizedChannel = <V extends CustomTrigger = CustomTrigger>( - props: PropsWithChildren<ChannelProps<V>>, -) => { +const UnMemoizedChannel = (props: PropsWithChildren<ChannelProps>) => { const { channel: propsChannel, EmptyPlaceholder = null, @@ -299,9 +281,9 @@ const UnMemoizedChannel = <V extends CustomTrigger = CustomTrigger>( return <ChannelInner {...props} channel={channel} key={channel.cid} />; }; -const ChannelInner = <V extends CustomTrigger = CustomTrigger>( +const ChannelInner = ( props: PropsWithChildren< - ChannelProps<V> & { + ChannelProps & { channel: StreamChannel; key: string; } @@ -318,7 +300,6 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( doSendMessageRequest, doUpdateMessageRequest, dragAndDropWindow = false, - enrichURLForPreviewConfig, initializeOnMount = true, LoadingErrorIndicator = DefaultLoadingErrorIndicator, LoadingIndicator = DefaultLoadingIndicator, @@ -349,13 +330,13 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( const thread = useThreadContext(); const [channelConfig, setChannelConfig] = useState(channel.getConfig()); + // FIXME: Create a proper notification service in the LLC. const [notifications, setNotifications] = useState<ChannelNotifications>([]); - const [quotedMessage, setQuotedMessage] = useState<StreamMessage>(); + const notificationTimeouts = useRef<Array<NodeJS.Timeout>>([]); + const [channelUnreadUiState, _setChannelUnreadUiState] = useState<ChannelUnreadUiState>(); - const notificationTimeouts = useRef<Array<NodeJS.Timeout>>([]); - const channelReducer = useMemo(() => makeChannelReducer(), []); const [state, dispatch] = useReducer( @@ -572,6 +553,11 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( } } + if (maxNumberOfFiles) { + // todo: X this has to be configured via a template + channel.messageComposer.attachmentManager.config.maxNumberOfFilesPerMessage = + maxNumberOfFiles; + } done = true; originalTitle.current = document.title; @@ -933,7 +919,7 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( ); const deleteMessage = useCallback( - async (message: StreamMessage): Promise<MessageResponse> => { + async (message: LocalMessage): Promise<MessageResponse> => { if (!message?.id) { throw new Error('Cannot delete a message - missing message ID.'); } @@ -950,9 +936,9 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( [client, doDeleteMessageRequest], ); - const updateMessage = (updatedMessage: MessageToSend | StreamMessage) => { + const updateMessage = (updatedMessage: MessageResponse | LocalMessage) => { // add the message to the local channel state - channel.state.addMessageSorted(updatedMessage as MessageResponse, true); + channel.state.addMessageSorted(updatedMessage, true); dispatch({ channel, @@ -961,42 +947,28 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( }); }; - const doSendMessage = async ( - message: MessageToSend | StreamMessage, - customMessageData?: Partial<Message>, - options?: SendMessageOptions, - ) => { - const { attachments, id, mentioned_users = [], parent_id, text } = message; - - // channel.sendMessage expects an array of user id strings - const mentions = isUserResponseArray(mentioned_users) - ? mentioned_users.map(({ id }) => id) - : mentioned_users; - - const messageData = { - attachments, - id, - mentioned_users: mentions, - parent_id, - quoted_message_id: - parent_id === quotedMessage?.parent_id ? quotedMessage?.id : undefined, - text, - ...customMessageData, - } as Message; - + const doSendMessage = async ({ + localMessage, + message, + options, + }: { + localMessage: LocalMessage; + message: Message; + options?: SendMessageOptions; + }) => { try { let messageResponse: void | SendMessageAPIResponse; if (doSendMessageRequest) { - messageResponse = await doSendMessageRequest(channel, messageData, options); + messageResponse = await doSendMessageRequest(channel, message, options); } else { - messageResponse = await channel.sendMessage(messageData, options); + messageResponse = await channel.sendMessage(message, options); } - let existingMessage; + let existingMessage: LocalMessage | undefined = undefined; for (let i = channel.state.messages.length - 1; i >= 0; i--) { const msg = channel.state.messages[i]; - if (msg.id && msg.id === messageData.id) { + if (msg.id && msg.id === message.id) { existingMessage = msg; break; } @@ -1020,9 +992,6 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( status: 'received', }); } - - if (quotedMessage && parent_id === quotedMessage?.parent_id) - setQuotedMessage(undefined); } catch (error) { // error response isn't usable so needs to be stringified then parsed const stringError = JSON.stringify(error); @@ -1043,23 +1012,20 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( error.message.includes('already exists') ) { updateMessage({ - ...message, + ...localMessage, status: 'received', }); } else { updateMessage({ - ...message, + ...localMessage, error: parsedError, - errorStatusCode: parsedError.status || undefined, status: 'failed', }); thread?.upsertReplyLocally({ message: { - ...message, - // @ts-expect-error error is local + ...localMessage, error: parsedError, - errorStatusCode: parsedError.status || undefined, status: 'failed', }, }); @@ -1067,55 +1033,41 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( } }; - const sendMessage = async ( - { attachments = [], mentioned_users = [], parent, text = '' }: MessageToSend, - customMessageData?: Partial<Message>, - options?: SendMessageOptions, - ) => { + // BREAKING: sendMessage now requires a params object instead of message + const sendMessage = async ({ + localMessage, + message, + options, + }: { + localMessage: LocalMessage; + message: Message; + options?: SendMessageOptions; + }) => { channel.state.filterErrorMessages(); - const messagePreview = { - attachments, - created_at: new Date(), - html: text, - id: customMessageData?.id ?? `${client.userID}-${nanoid()}`, - mentioned_users, - parent_id: parent?.id, - reactions: [], - status: 'sending', - text, - type: 'regular', - user: client.user, - }; - thread?.upsertReplyLocally({ - // @ts-expect-error message type mismatch - message: messagePreview, + message: localMessage, }); - updateMessage(messagePreview); + updateMessage(localMessage); - await doSendMessage(messagePreview, customMessageData, options); + await doSendMessage({ localMessage, message, options }); }; - const retrySendMessage = async (message: StreamMessage) => { + const retrySendMessage = async (localMessage: LocalMessage) => { updateMessage({ - ...message, - errorStatusCode: undefined, + ...localMessage, + error: undefined, status: 'sending', }); - if (message.attachments) { - // remove scraped attachments added during the message composition in MessageInput to prevent sync issues - message.attachments = message.attachments.filter( - (attachment) => !attachment.og_scrape_url, - ); - } - - await doSendMessage(message); + await doSendMessage({ + localMessage, + message: localMessageToNewMessagePayload(localMessage), + }); }; - const removeMessage = (message: StreamMessage) => { + const removeMessage = (message: LocalMessage) => { channel.state.removeMessage(message); dispatch({ @@ -1127,15 +1079,8 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( /** THREAD */ - const openThread = (message: StreamMessage, event?: React.BaseSyntheticEvent) => { + const openThread = (message: LocalMessage, event?: React.BaseSyntheticEvent) => { event?.preventDefault(); - setQuotedMessage((current) => { - if (current?.parent_id !== message?.parent_id) { - return undefined; - } else { - return current; - } - }); dispatch({ channel, message, type: 'openThread' }); }; @@ -1209,10 +1154,7 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( channelCapabilitiesArray, channelConfig, channelUnreadUiState, - debounceURLEnrichmentMs: enrichURLForPreviewConfig?.debounceURLEnrichmentMs, dragAndDropWindow, - enrichURLForPreview: props.enrichURLForPreview, - findURLFn: enrichURLForPreviewConfig?.findURLFn, giphyVersion: props.giphyVersion || 'fixed_height', imageAttachmentSizeHandler: props.imageAttachmentSizeHandler || getImageAttachmentConfiguration, @@ -1220,8 +1162,6 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( multipleUploads, mutes, notifications, - onLinkPreviewDismissed: enrichURLForPreviewConfig?.onLinkPreviewDismissed, - quotedMessage, shouldGenerateVideoThumbnail: props.shouldGenerateVideoThumbnail || true, videoAttachmentSizeHandler: props.videoAttachmentSizeHandler || getVideoAttachmentConfiguration, @@ -1249,7 +1189,6 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( retrySendMessage, sendMessage, setChannelUnreadUiState, - setQuotedMessage, skipMessageDataMemoization, updateMessage, }), @@ -1257,12 +1196,9 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( [ channel.cid, deleteMessage, - enrichURLForPreviewConfig?.findURLFn, - enrichURLForPreviewConfig?.onLinkPreviewDismissed, loadMore, loadMoreNewer, markRead, - quotedMessage, jumpToFirstUnreadMessage, jumpToMessage, jumpToLatestMessage, @@ -1329,7 +1265,6 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( ThreadHeader: props.ThreadHeader, ThreadStart: props.ThreadStart, Timestamp: props.Timestamp, - TriggerProvider: props.TriggerProvider, TypingIndicator: props.TypingIndicator, UnreadMessagesNotification: props.UnreadMessagesNotification, UnreadMessagesSeparator: props.UnreadMessagesSeparator, @@ -1392,7 +1327,6 @@ const ChannelInner = <V extends CustomTrigger = CustomTrigger>( props.ThreadHeader, props.ThreadStart, props.Timestamp, - props.TriggerProvider, props.TypingIndicator, props.UnreadMessagesNotification, props.UnreadMessagesSeparator, diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js index b4dc0c853b..fff894a092 100644 --- a/src/components/Channel/__tests__/Channel.test.js +++ b/src/components/Channel/__tests__/Channel.test.js @@ -88,15 +88,6 @@ const ActiveChannelSetter = ({ activeChannel }) => { return null; }; -const user = generateUser({ custom: 'custom-value', id: 'id', name: 'name' }); - -// create a full message state so that we can properly test `loadMore` -const messages = Array.from({ length: 25 }, (_, i) => - generateMessage({ created_at: new Date((i + 1) * 1000000), user }), -); - -const pinnedMessages = [generateMessage({ pinned: true, user })]; - const renderComponent = async (props = {}, callback = () => {}) => { const { channel: channelFromProps, @@ -118,9 +109,13 @@ const renderComponent = async (props = {}, callback = () => {}) => { return result; }; -const initClient = async () => { +const initClient = async ({ channelId, channelType, messages, pinnedMessages, user }) => { const members = [generateMember({ user })]; const mockedChannel = generateChannel({ + channel: { + id: channelId, + type: channelType, + }, members, messages, pinnedMessages, @@ -133,15 +128,52 @@ const initClient = async () => { jest.spyOn(channel, 'getConfig').mockImplementation(() => mockedChannel.channel.config); return { channel, chatClient }; }; -describe('Channel', () => { - const MockMessageList = () => { - const { messages: channelMessages } = useChannelStateContext(); - return channelMessages.map( - ({ id, status, text }) => - status !== 'failed' && <div key={id || nanoid()}>{text}</div>, +const MockMessageList = () => { + const { messages: channelMessages } = useChannelStateContext(); + + return channelMessages.map( + ({ id, status, text }) => + status !== 'failed' && <div key={id || nanoid()}>{text}</div>, + ); +}; + +describe('Channel', () => { + const user = generateUser({ custom: 'custom-value', id: 'id', name: 'name' }); + const channelType = 'messaging'; + let channelId; + let channel; + let chatClient; + let messages; + + beforeEach(async () => { + channelId = nanoid(); + + // create a full message state so that we can properly test `loadMore` + messages = Array.from({ length: 25 }, (_, i) => + generateMessage({ + cid: `${channelType}:${channelId}`, + created_at: new Date((i + 1) * 1000000), + user, + }), ); - }; + + const pinnedMessages = [ + generateMessage({ + cid: `${channelType}:${channelId}`, + pinned: true, + user, + }), + ]; + + ({ channel, chatClient } = await initClient({ + channelId, + channelType, + messages, + pinnedMessages, + user, + })); + }); afterEach(() => { jest.clearAllMocks(); @@ -169,7 +201,6 @@ describe('Channel', () => { }); it('should render channel content if channels query loads more channels', async () => { - const { channel, chatClient } = await initClient(); const childrenContent = 'Channel children'; await channel.watch(); render( @@ -211,7 +242,6 @@ describe('Channel', () => { }); it('should render empty channel container if channel does not have cid', async () => { - const { channel } = await initClient(); const childrenContent = 'Channel children'; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { cid, ...channelWithoutCID } = channel; @@ -297,7 +327,6 @@ describe('Channel', () => { }); it('should watch the current channel on mount', async () => { - const { channel, chatClient } = await initClient(); const watchSpy = jest.spyOn(channel, 'watch'); await renderComponent({ channel, chatClient }); @@ -309,7 +338,6 @@ describe('Channel', () => { }); it('should apply channelQueryOptions to channel watch call', async () => { - const { channel, chatClient } = await initClient(); const watchSpy = jest.spyOn(channel, 'watch'); const channelQueryOptions = { messages: { limit: 20 }, @@ -323,7 +351,6 @@ describe('Channel', () => { }); it('should set hasMore state to false if the initial channel query returns less messages than the default initial page size', async () => { - const { channel, chatClient } = await initClient(); useMockedApis(chatClient, [ queryChannelWithNewMessages([generateMessage()], channel), ]); @@ -343,7 +370,6 @@ describe('Channel', () => { // switch back to channel A (reset hasMore) // switch back to channel B - messages are already cached and there's more than page size amount it('should set hasMore state to true if the initial channel query returns more messages than the default initial page size', async () => { - const { channel, chatClient } = await initClient(); useMockedApis(chatClient, [ queryChannelWithNewMessages(Array.from({ length: 26 }, generateMessage), channel), ]); @@ -360,7 +386,6 @@ describe('Channel', () => { }); it('should set hasMore state to true if the initial channel query returns count of messages equal to the default initial page size', async () => { - const { channel, chatClient } = await initClient(); useMockedApis(chatClient, [ queryChannelWithNewMessages(Array.from({ length: 25 }, generateMessage), channel), ]); @@ -375,7 +400,6 @@ describe('Channel', () => { }); it('should set hasMore state to false if the initial channel query returns less messages than the custom query channels options message limit', async () => { - const { channel, chatClient } = await initClient(); useMockedApis(chatClient, [ queryChannelWithNewMessages([generateMessage()], channel), ]); @@ -396,7 +420,6 @@ describe('Channel', () => { }); it('should set hasMore state to true if the initial channel query returns count of messages equal custom query channels options message limit', async () => { - const { channel, chatClient } = await initClient(); const equalCount = 10; useMockedApis(chatClient, [ queryChannelWithNewMessages( @@ -421,7 +444,6 @@ describe('Channel', () => { }); it('should not call watch the current channel on mount if channel is initialized', async () => { - const { channel, chatClient } = await initClient(); const watchSpy = jest.spyOn(channel, 'watch'); channel.initialized = true; await renderComponent({ channel, chatClient }); @@ -429,7 +451,6 @@ describe('Channel', () => { }); it('should set an error if watching the channel goes wrong, and render a LoadingErrorIndicator', async () => { - const { channel, chatClient } = await initClient(); const watchError = new Error('watching went wrong'); jest.spyOn(channel, 'watch').mockImplementationOnce(() => Promise.reject(watchError)); @@ -446,7 +467,6 @@ describe('Channel', () => { }); it('should render a LoadingIndicator if it is loading', async () => { - const { channel, chatClient } = await initClient(); const watchPromise = new Promise(() => {}); jest.spyOn(channel, 'watch').mockImplementationOnce(() => watchPromise); const result = await renderComponent({ channel, chatClient }); @@ -455,7 +475,6 @@ describe('Channel', () => { }); it('should provide context and render children if channel is set and the component is not loading or errored', async () => { - const { channel, chatClient } = await initClient(); const { findByText } = await renderComponent({ channel, chatClient, @@ -466,7 +485,6 @@ describe('Channel', () => { }); it('should store pinned messages as an array in the channel context', async () => { - const { channel, chatClient } = await initClient(); let ctxPins; const { getByText } = await renderComponent( @@ -488,7 +506,6 @@ describe('Channel', () => { // should these 'on' tests actually test if the handler works? it('should add a connection recovery handler on the client on mount', async () => { - const { channel, chatClient } = await initClient(); const clientOnSpy = jest.spyOn(chatClient, 'on'); await renderComponent({ channel, chatClient }); @@ -502,7 +519,6 @@ describe('Channel', () => { }); it('should add an `on` handler to the channel on mount', async () => { - const { channel, chatClient } = await initClient(); const channelOnSpy = jest.spyOn(channel, 'on'); await renderComponent({ channel, chatClient }); @@ -510,7 +526,6 @@ describe('Channel', () => { }); it('should mark the channel as read when the channel is mounted', async () => { - const { channel, chatClient } = await initClient(); jest.spyOn(channel, 'countUnread').mockImplementationOnce(() => 1); const markReadSpy = jest.spyOn(channel, 'markRead'); @@ -520,7 +535,6 @@ describe('Channel', () => { }); it('should not mark the channel as read if the count of unread messages is higher than 0 on mount and the feature is disabled', async () => { - const { channel, chatClient } = await initClient(); jest.spyOn(channel, 'countUnread').mockImplementationOnce(() => 1); const markReadSpy = jest.spyOn(channel, 'markRead'); @@ -530,7 +544,6 @@ describe('Channel', () => { }); it('should use the doMarkReadRequest prop to mark channel as read, if that is defined', async () => { - const { channel, chatClient } = await initClient(); jest.spyOn(channel, 'countUnread').mockImplementationOnce(() => 1); const doMarkReadRequest = jest.fn(); @@ -545,7 +558,6 @@ describe('Channel', () => { }); it('should not query the channel from the backend when initializeOnMount is disabled', async () => { - const { channel, chatClient } = await initClient(); const watchSpy = jest.spyOn(channel, 'watch').mockImplementationOnce(); await renderComponent({ channel, @@ -556,7 +568,6 @@ describe('Channel', () => { }); it('should query the channel from the backend when initializeOnMount is enabled (the default)', async () => { - const { channel, chatClient } = await initClient(); const watchSpy = jest.spyOn(channel, 'watch').mockImplementationOnce(); await renderComponent({ channel, chatClient }); await waitFor(() => expect(watchSpy).toHaveBeenCalledTimes(1)); @@ -564,25 +575,40 @@ describe('Channel', () => { describe('Children that consume the contexts set in Channel', () => { it('should be able to open threads', async () => { - const { channel, chatClient } = await initClient(); const threadMessage = messages[0]; const hasThread = jest.fn(); + const hasThreadInstance = jest.fn(); + const mockThreadInstance = { + registerSubscriptions: jest.fn(), + threadInstanceMock: true, + }; + const getThreadSpy = jest + .spyOn(chatClient, 'getThread') + .mockResolvedValueOnce(mockThreadInstance); // this renders Channel, calls openThread from a child context consumer with a message, // and then calls hasThread with the thread id if it was set. - await renderComponent({ channel, chatClient }, ({ openThread, thread }) => { - if (!thread) { - openThread(threadMessage, { preventDefault: () => null }); - } else { - hasThread(thread.id); - } - }); + await renderComponent( + { channel, chatClient }, + ({ openThread, thread, threadInstance }) => { + if (!thread) { + openThread(threadMessage, { preventDefault: () => null }); + } else { + hasThread(thread.id); + hasThreadInstance(threadInstance); + } + }, + ); - await waitFor(() => expect(hasThread).toHaveBeenCalledWith(threadMessage.id)); + await waitFor(() => { + expect(hasThread).toHaveBeenCalledWith(threadMessage.id); + expect(getThreadSpy).not.toHaveBeenCalled(); + expect(hasThreadInstance).toHaveBeenCalledWith(undefined); + }); + getThreadSpy.mockRestore(); }); it('should be able to load more messages in a thread until reaching the end', async () => { - const { channel, chatClient } = await initClient(); const getRepliesSpy = jest.spyOn(channel, 'getReplies'); const threadMessage = messages[0]; const timestamp = new Date('2024-01-01T00:00:00.000Z').getTime(); @@ -650,7 +676,6 @@ describe('Channel', () => { }); it('should allow closing a thread after it has been opened', async () => { - const { channel, chatClient } = await initClient(); let threadHasClosed = false; const threadMessage = messages[0]; @@ -679,7 +704,6 @@ describe('Channel', () => { }); it('should call the onMentionsHover/onMentionsClick prop if a child component calls onMentionsHover with the right event', async () => { - const { channel, chatClient } = await initClient(); const onMentionsHoverMock = jest.fn(); const onMentionsClickMock = jest.fn(); const username = 'Mentioned User'; @@ -731,7 +755,6 @@ describe('Channel', () => { describe('loading more messages', () => { const limit = 10; it("should initiate the hasMore flag with the current message set's pagination hasPrev value", async () => { - const { channel, chatClient } = await initClient(); let hasMore; await renderComponent({ channel, chatClient }, ({ hasMore: hasMoreCtx }) => { hasMore = hasMoreCtx; @@ -745,7 +768,6 @@ describe('Channel', () => { expect(hasMore).toBe(false); }); it('should be able to load more messages', async () => { - const { channel, chatClient } = await initClient(); const channelQuerySpy = jest.spyOn(channel, 'query'); let newMessageAdded = false; @@ -783,7 +805,6 @@ describe('Channel', () => { }); it('should set hasMore to false if querying channel returns less messages than the limit', async () => { - const { channel, chatClient } = await initClient(); let channelHasMore = false; const newMessages = [generateMessage({ created_at: new Date(1000) })]; await renderComponent( @@ -806,7 +827,6 @@ describe('Channel', () => { }); it('should set hasMore to true if querying channel returns an amount of messages that equals the limit', async () => { - const { channel, chatClient } = await initClient(); let channelHasMore = false; const newMessages = Array(limit) .fill(null) @@ -831,7 +851,6 @@ describe('Channel', () => { }); it('should set loadingMore to true while loading more', async () => { - const { channel, chatClient } = await initClient(); const queryPromise = new Promise(() => {}); let isLoadingMore = false; @@ -845,7 +864,6 @@ describe('Channel', () => { }); it('should not load the second page, if the previous query has returned less then default limit messages', async () => { - const { channel, chatClient } = await initClient(); const firstPageOfMessages = [generateMessage()]; useMockedApis(chatClient, [ queryChannelWithNewMessages(firstPageOfMessages, channel), @@ -877,7 +895,6 @@ describe('Channel', () => { }); it('should load the second page, if the previous query has returned message count equal default messages limit', async () => { - const { channel, chatClient } = await initClient(); const firstPageMessages = Array.from({ length: 25 }, (_, i) => generateMessage({ created_at: new Date((i + 16) * 100000) }), ); @@ -924,7 +941,6 @@ describe('Channel', () => { }); }); it('should not load the second page, if the previous query has returned less then custom limit messages', async () => { - const { channel, chatClient } = await initClient(); const channelQueryOptions = { messages: { limit: 10 }, }; @@ -959,7 +975,6 @@ describe('Channel', () => { }); }); it('should load the second page, if the previous query has returned message count equal custom messages limit', async () => { - const { channel, chatClient } = await initClient(); const equalCount = 10; const channelQueryOptions = { messages: { limit: equalCount }, @@ -1421,24 +1436,28 @@ describe('Channel', () => { describe('Sending/removing/updating messages', () => { it('should remove error messages from channel state when sending a new message', async () => { - const { channel, chatClient } = await initClient(); const filterErrorMessagesSpy = jest.spyOn(channel.state, 'filterErrorMessages'); // flag to prevent infinite loop let hasSent = false; await renderComponent({ channel, chatClient }, ({ sendMessage }) => { - if (!hasSent) sendMessage({ text: 'message' }); - hasSent = true; + if (!hasSent) { + const m = generateMessage(); + sendMessage({ localMessage: { ...m, status: 'sending' }, message: m }); + hasSent = true; + } }); await waitFor(() => expect(filterErrorMessagesSpy).toHaveBeenCalledWith()); }); it('should add a preview for messages that are sent to the channel state, so that they are rendered even without API response', async () => { - const { channel, chatClient } = await initClient(); // flag to prevent infinite loop let hasSent = false; - const messageText = 'bla bla'; + const messageText = nanoid(); + jest + .spyOn(channel, 'sendMessage') + .mockImplementationOnce(() => new Promise(() => {})); const { findByText } = await renderComponent( { @@ -1447,11 +1466,11 @@ describe('Channel', () => { children: <MockMessageList />, }, ({ sendMessage }) => { - jest - .spyOn(channel, 'sendMessage') - .mockImplementationOnce(() => new Promise(() => {})); - if (!hasSent) sendMessage({ text: messageText }); - hasSent = true; + if (!hasSent) { + const m = generateMessage({ text: messageText }); + sendMessage({ localMessage: { ...m, status: 'sending' }, message: m }); + hasSent = true; + } }, ); @@ -1459,13 +1478,22 @@ describe('Channel', () => { }); it('should mark message as received when the backend reports duplicated message id', async () => { - const { channel, chatClient } = await initClient(); // flag to prevent infinite loop let hasSent = false; - const messageText = 'hello world'; - const messageId = '123456'; + const messageText = nanoid(); + const messageId = nanoid(); let originalMessageStatus = null; + jest.spyOn(channel, 'sendMessage').mockImplementation((message) => { + originalMessageStatus = message.status; + throw chatClient.errorFromResponse({ + data: { + code: 4, + message: `SendMessage failed with error: "a message with ID ${message.id} already exists"`, + }, + status: 400, + }); + }); const { findByText } = await renderComponent( { @@ -1474,22 +1502,18 @@ describe('Channel', () => { children: <MockMessageList />, }, ({ sendMessage }) => { - jest.spyOn(channel, 'sendMessage').mockImplementation((message) => { - originalMessageStatus = message.status; - throw chatClient.errorFromResponse({ - data: { - code: 4, - message: `SendMessage failed with error: "a message with ID ${message.id} already exists"`, - }, - status: 400, - }); - }); if (!hasSent) { - sendMessage({ text: messageText }, { id: messageId, status: 'sending' }); + const m = generateMessage({ + id: messageId, + status: 'sending', // FIXME: had to have been explicitly added + text: messageText, + }); + sendMessage({ localMessage: { ...m, status: 'sending' }, message: m }); + hasSent = true; } - hasSent = true; }, ); + expect(await findByText(messageText)).toBeInTheDocument(); expect(originalMessageStatus).toBe('sending'); @@ -1499,58 +1523,67 @@ describe('Channel', () => { }); it('should use the doSendMessageRequest prop to send messages if that is defined', async () => { - const { channel, chatClient } = await initClient(); - // flag to prevent infinite loop - let hasSent = false; - const doSendMessageRequest = jest.fn(() => new Promise(() => {})); - const message = { text: 'message' }; + const doSendMessageRequest = jest.fn(); + const message = generateMessage(); + + let sendMessage; await renderComponent( { channel, chatClient, doSendMessageRequest, }, - ({ sendMessage }) => { - if (!hasSent) sendMessage(message); - hasSent = true; + ({ sendMessage: sm }) => { + sendMessage = sm; }, ); - await waitFor(() => - expect(doSendMessageRequest).toHaveBeenCalledWith( - channel, - expect.objectContaining(message), - undefined, - ), + await act(() => + sendMessage({ localMessage: { ...message, status: 'sending' }, message }), + ); + + expect(doSendMessageRequest).toHaveBeenCalledWith( + channel, + expect.objectContaining(message), + undefined, ); }); it('should eventually pass the result of the sendMessage API as part of ChannelActionContext', async () => { - const { channel, chatClient } = await initClient(); - const sentMessage = { text: 'message' }; - const messageResponse = { text: 'different message' }; - let hasSent = false; + const responseText = nanoid(); + jest + .spyOn(channel, 'sendMessage') + .mockImplementationOnce((sm) => ({ message: { ...sm, text: responseText } })); + + let sendMessage; const { findByText } = await renderComponent( { channel, chatClient, children: <MockMessageList />, }, - ({ sendMessage }) => { - useMockedApis(chatClient, [sendMessageApi(generateMessage(messageResponse))]); - if (!hasSent) sendMessage(sentMessage); - hasSent = true; + ({ sendMessage: sm }) => { + sendMessage = sm; }, ); - expect(await findByText(messageResponse.text)).toBeInTheDocument(); + const m = generateMessage(); + await act(() => + sendMessage({ + localMessage: { ...m, status: 'sending' }, + message: m, + }), + ); + + expect(await findByText(responseText)).toBeInTheDocument(); }); + describe('delete message', () => { it('should throw error instead of calling default client.deleteMessage() function', async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { id, ...message } = generateMessage(); - const { channel, chatClient } = await initClient(); + const clientDeleteMessageSpy = jest.spyOn(chatClient, 'deleteMessage'); let deleteMessageHandler; await renderComponent({ channel, chatClient }, ({ deleteMessage }) => { @@ -1565,7 +1598,7 @@ describe('Channel', () => { it('should call the default client.deleteMessage() function', async () => { const message = generateMessage(); - const { channel, chatClient } = await initClient(); + const clientDeleteMessageSpy = jest .spyOn(chatClient, 'deleteMessage') .mockImplementationOnce(() => Promise.resolve({ message })); @@ -1580,7 +1613,7 @@ describe('Channel', () => { it('should throw error instead of calling custom doDeleteMessageRequest function', async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { id, ...message } = generateMessage(); - const { channel, chatClient } = await initClient(); + const clientDeleteMessageSpy = jest .spyOn(chatClient, 'deleteMessage') .mockImplementationOnce(() => Promise.resolve({ message })); @@ -1602,7 +1635,7 @@ describe('Channel', () => { it('should call the custom doDeleteMessageRequest instead of client.deleteMessage()', async () => { const message = generateMessage(); - const { channel, chatClient } = await initClient(); + const doDeleteMessageRequest = jest.fn(); const clientDeleteMessageSpy = jest .spyOn(chatClient, 'deleteMessage') @@ -1623,7 +1656,6 @@ describe('Channel', () => { }); it('should enable editing messages', async () => { - const { channel, chatClient } = await initClient(); const newText = 'something entirely different'; const updatedMessage = { ...messages[0], text: newText }; const clientUpdateMessageSpy = jest.spyOn(chatClient, 'updateMessage'); @@ -1640,7 +1672,6 @@ describe('Channel', () => { }); it('should use doUpdateMessageRequest for the editMessage callback if provided', async () => { - const { channel, chatClient } = await initClient(); const doUpdateMessageRequest = jest.fn((channelId, message) => message); await renderComponent( @@ -1660,7 +1691,6 @@ describe('Channel', () => { }); it('should update messages passed into the updateMessage callback', async () => { - const { channel, chatClient } = await initClient(); const newText = 'something entirely different'; const updatedMessage = { ...messages[0], text: newText, updated_at: Date.now() }; let hasUpdated = false; @@ -1679,38 +1709,51 @@ describe('Channel', () => { }); it('should enable retrying message sending', async () => { - const { channel, chatClient } = await initClient(); - // flag to prevent infinite loop - let hasSent = false; - let hasRetried = false; - const messageObject = { text: 'bla bla' }; + const messageObject = generateMessage({ + text: nanoid(), + }); + let retrySendMessage; + let sendMessage; + let contextMessages; await renderComponent( { channel, chatClient, children: <MockMessageList /> }, - ({ messages: contextMessages, retrySendMessage, sendMessage }) => { - if (!hasSent) { - jest - .spyOn(channel, 'sendMessage') - .mockImplementationOnce(() => Promise.reject()); - sendMessage(messageObject); - hasSent = true; - } else if ( - !hasRetried && - contextMessages.some(({ status }) => status === 'failed') - ) { - // retry - useMockedApis(chatClient, [sendMessageApi(messageObject)]); - retrySendMessage(messageObject); - hasRetried = true; - } + ({ messages: cm, retrySendMessage: rsm, sendMessage: sm }) => { + retrySendMessage = rsm; + sendMessage = sm; + contextMessages = cm; }, ); + jest + .spyOn(channel, 'sendMessage') + .mockImplementationOnce(() => Promise.reject()) + .mockImplementationOnce(() => { + const creationDate = new Date(); + const created_at = creationDate.toISOString(); + const updated_at = new Date(creationDate.getTime() + 1).toISOString(); + return { + ...messageObject, + created_at, + updated_at, + }; + }); + + await act(() => + sendMessage({ + localMessage: { ...messageObject, status: 'sending' }, + message: messageObject, + }), + ); + + expect(contextMessages.some(({ status }) => status === 'failed')).toBe(true); + + await act(() => retrySendMessage(messageObject)); + expect(screen.queryByText(messageObject.text)).toBeInTheDocument(); }); it('should remove scraped attachment on retry-sending message', async () => { - const { channel, chatClient } = await initClient(); // flag to prevent infinite loop let hasSent = false; let hasRetried = false; @@ -1726,7 +1769,13 @@ describe('Channel', () => { { channel, chatClient, children: <MockMessageList /> }, ({ messages: contextMessages, retrySendMessage, sendMessage }) => { if (!hasSent) { - sendMessage(messageObject); + sendMessage({ + localMessage: { + ...messageObject, + status: 'sending', + }, + message: messageObject, + }); hasSent = true; } else if ( !hasRetried && @@ -1751,7 +1800,6 @@ describe('Channel', () => { }); it('should allow removing messages', async () => { - const { channel, chatClient } = await initClient(); let allMessagesRemoved = false; const removeSpy = jest.spyOn(channel.state, 'removeMessage'); @@ -1803,7 +1851,6 @@ describe('Channel', () => { ); it('should eventually pass down a message when a message.new event is triggered on the channel', async () => { - const { channel, chatClient } = await initClient(); const message = generateMessage({ user }); const dispatchMessageEvent = createChannelEventDispatcher( { message }, @@ -1827,18 +1874,16 @@ describe('Channel', () => { }); it('should not overwrite the message with send response, if already updated by WS events', async () => { - const { channel, chatClient } = await initClient(); let oldText; const newText = 'new text'; - const creationDate = new Date(); - const created_at = creationDate.toISOString(); - const updated_at = new Date(creationDate.getTime() + 1).toISOString(); - let hasSent = false; jest.spyOn(channel, 'sendMessage').mockImplementationOnce((message) => { + const creationDate = new Date(); + const created_at = creationDate.toISOString(); + const updated_at = new Date(creationDate.getTime() + 1).toISOString(); + oldText = message.text; const finalMessage = { ...message, created_at, updated_at: created_at }; - useMockedApis(chatClient, [sendMessageApi(finalMessage)]); // both effects have to be emitted, otherwise the original message in status "sending" will not be filtered out (done when message.new is emitted) => and the message.updated event would add the updated message as a new message. createChannelEventDispatcher( { @@ -1866,67 +1911,67 @@ describe('Channel', () => { chatClient, channel, )(); - return channel.sendMessage(message); + return { message }; }); - const { queryByText } = await renderComponent( + let sendMessage; + const { findByText, queryByText } = await renderComponent( { channel, chatClient, children: <MockMessageList /> }, - ({ sendMessage }) => { - if (!hasSent) { - sendMessage(generateMessage()); - hasSent = true; - } + ({ sendMessage: sm }) => { + sendMessage = sm; }, ); + await act(async () => { + const m = generateMessage(); + await sendMessage({ localMessage: { ...m, status: 'sending' }, message: m }); + }); + await waitFor(async () => { expect( await queryByText(oldText, undefined, { timeout: 100 }), ).not.toBeInTheDocument(); - expect( - await queryByText(newText, undefined, { timeout: 100 }), - ).toBeInTheDocument(); }); + + expect(await findByText(newText)).toBeInTheDocument(); }); it('should overwrite the message of status "sending" regardless of updated_at timestamp', async () => { - const { channel, chatClient } = await initClient(); let oldText; const newText = 'new text'; - const creationDate = new Date(); - const created_at = creationDate.toISOString(); - const updated_at = new Date(creationDate.getTime() - 1).toISOString(); - let hasSent = false; jest.spyOn(channel, 'sendMessage').mockImplementationOnce((message) => { + const creationDate = new Date(); + const created_at = creationDate.toISOString(); + const updated_at = new Date(creationDate.getTime() - 1).toISOString(); oldText = message.text; - const finalMessage = { ...message, created_at, text: newText, updated_at }; - useMockedApis(chatClient, [sendMessageApi(finalMessage)]); - return channel.sendMessage(message); + return { message: { ...message, created_at, text: newText, updated_at } }; }); - const { queryByText } = await renderComponent( + let sendMessage; + const { findByText, queryByText } = await renderComponent( { channel, chatClient, children: <MockMessageList /> }, - ({ sendMessage }) => { - if (!hasSent) { - sendMessage(generateMessage()); - hasSent = true; - } + ({ sendMessage: sm }) => { + sendMessage = sm; }, ); + await act(async () => { + const m = generateMessage(); + + await sendMessage({ localMessage: { ...m, status: 'sending' }, message: m }); + }); + await waitFor(async () => { expect( await queryByText(oldText, undefined, { timeout: 100 }), ).not.toBeInTheDocument(); - expect( - await queryByText(newText, undefined, { timeout: 100 }), - ).toBeInTheDocument(); }); + + expect(await findByText(newText)).toBeInTheDocument(); }); it('should not mark the channel as read if a new message from another user comes in and the user is looking at the page', async () => { - const { channel, chatClient } = await initClient(); const markReadSpy = jest.spyOn(channel, 'markRead'); const message = generateMessage({ user: generateUser() }); @@ -1944,7 +1989,6 @@ describe('Channel', () => { }); it('should not mark the channel as read if the new message author is the current user and the user is looking at the page', async () => { - const { channel, chatClient } = await initClient(); const markReadSpy = jest.spyOn(channel, 'markRead'); const message = generateMessage({ user: generateUser() }); @@ -1962,7 +2006,6 @@ describe('Channel', () => { }); it('title of the page should include the unread count if the user is not looking at the page when a new message event happens', async () => { - const { channel, chatClient } = await initClient(); const unreadAmount = 1; Object.defineProperty(document, 'hidden', { configurable: true, @@ -1984,7 +2027,6 @@ describe('Channel', () => { }); it('should update the `thread` parent message if an event comes in that modifies it', async () => { - const { channel, chatClient } = await initClient(); const threadMessage = messages[0]; const newText = 'new text'; const updatedThreadMessage = { ...threadMessage, text: newText }; @@ -2011,7 +2053,6 @@ describe('Channel', () => { }); it('should update the threadMessages if a new message comes in that is part of the thread', async () => { - const { channel, chatClient } = await initClient(); const threadMessage = messages[0]; const newThreadMessage = generateMessage({ parent_id: threadMessage.id, @@ -2067,10 +2108,9 @@ describe('Channel', () => { name: 'Thread', }, ].forEach(({ callback, component: Component, getFirstMessageAvatar, name }) => { - const [threadMessage] = messages; - it(`should update user data in ${name} based on updated_at`, async () => { - const { channel, chatClient } = await initClient(); + const [threadMessage] = messages; + const updatedAttribute = { name: 'newName' }; const dispatchUserUpdatedEvent = createChannelEventDispatcher( { @@ -2108,7 +2148,8 @@ describe('Channel', () => { }); it(`should not update user data in ${name} if updated_at has not changed`, async () => { - const { channel, chatClient } = await initClient(); + const [threadMessage] = messages; + const updatedAttribute = { name: 'newName' }; const dispatchUserUpdatedEvent = createChannelEventDispatcher( { @@ -2220,7 +2261,6 @@ describe('Channel', () => { describe('Custom Components', () => { it('should render CustomMessageActionsList if provided', async () => { - const { channel, chatClient } = await initClient(); const CustomMessageActionsList = jest .fn() .mockImplementation(() => 'CustomMessageActionsList'); diff --git a/src/components/Channel/channelState.ts b/src/components/Channel/channelState.ts index 1ced66fab5..dee5b95a1b 100644 --- a/src/components/Channel/channelState.ts +++ b/src/components/Channel/channelState.ts @@ -1,10 +1,11 @@ import type { Channel, + LocalMessage, MessageResponse, ChannelState as StreamChannelState, } from 'stream-chat'; -import type { ChannelState, StreamMessage } from '../../context/ChannelStateContext'; +import type { ChannelState } from '../../context/ChannelStateContext'; export type ChannelStateReducerAction = | { @@ -34,12 +35,12 @@ export type ChannelStateReducerAction = } | { hasMore: boolean; - messages: StreamMessage[]; + messages: LocalMessage[]; type: 'loadMoreFinished'; } | { hasMoreNewer: boolean; - messages: StreamMessage[]; + messages: LocalMessage[]; type: 'loadMoreNewerFinished'; } | { @@ -49,7 +50,7 @@ export type ChannelStateReducerAction = } | { channel: Channel; - message: StreamMessage; + message: LocalMessage; type: 'openThread'; } | { @@ -65,7 +66,7 @@ export type ChannelStateReducerAction = type: 'setLoadingMoreNewer'; } | { - message: StreamMessage; + message: LocalMessage; type: 'setThread'; } | { @@ -91,6 +92,7 @@ export const makeChannelReducer = return { ...state, thread: null, + threadInstance: undefined, threadLoadingMore: false, threadMessages: [], }; diff --git a/src/components/Channel/hooks/useCreateChannelStateContext.ts b/src/components/Channel/hooks/useCreateChannelStateContext.ts index 153447759d..d9211be9d5 100644 --- a/src/components/Channel/hooks/useCreateChannelStateContext.ts +++ b/src/components/Channel/hooks/useCreateChannelStateContext.ts @@ -16,11 +16,8 @@ export const useCreateChannelStateContext = ( channelCapabilitiesArray = [], channelConfig, channelUnreadUiState, - debounceURLEnrichmentMs, dragAndDropWindow, - enrichURLForPreview, error, - findURLFn, giphyVersion, hasMore, hasMoreNewer, @@ -34,9 +31,7 @@ export const useCreateChannelStateContext = ( multipleUploads, mutes, notifications, - onLinkPreviewDismissed, pinnedMessages, - quotedMessage, read = {}, shouldGenerateVideoThumbnail, skipMessageDataMemoization, @@ -111,11 +106,8 @@ export const useCreateChannelStateContext = ( channelCapabilities, channelConfig, channelUnreadUiState, - debounceURLEnrichmentMs, dragAndDropWindow, - enrichURLForPreview, error, - findURLFn, giphyVersion, hasMore, hasMoreNewer, @@ -129,9 +121,7 @@ export const useCreateChannelStateContext = ( multipleUploads, mutes, notifications, - onLinkPreviewDismissed, pinnedMessages, - quotedMessage, read, shouldGenerateVideoThumbnail, suppressAutoscroll, @@ -149,10 +139,7 @@ export const useCreateChannelStateContext = ( channel.data?.name, // otherwise ChannelHeader will not be updated channelId, channelUnreadUiState, - debounceURLEnrichmentMs, - enrichURLForPreview, error, - findURLFn, hasMore, hasMoreNewer, highlightedMessageId, @@ -163,8 +150,6 @@ export const useCreateChannelStateContext = ( memoizedMessageData, memoizedThreadMessageData, notificationsLength, - onLinkPreviewDismissed, - quotedMessage, readUsersLength, readUsersLastReads, shouldGenerateVideoThumbnail, diff --git a/src/components/Channel/hooks/useEditMessageHandler.ts b/src/components/Channel/hooks/useEditMessageHandler.ts index d7ae375e81..4e6004bb08 100644 --- a/src/components/Channel/hooks/useEditMessageHandler.ts +++ b/src/components/Channel/hooks/useEditMessageHandler.ts @@ -1,18 +1,25 @@ -import type { StreamChat, UpdatedMessage } from 'stream-chat'; +import type { + LocalMessage, + MessageResponse, + StreamChat, + UpdateMessageOptions, +} from 'stream-chat'; import { useChatContext } from '../../../context/ChatContext'; -import type { UpdateMessageOptions } from '../../../types/types'; type UpdateHandler = ( cid: string, - updatedMessage: UpdatedMessage, + updatedMessage: LocalMessage | MessageResponse, options?: UpdateMessageOptions, ) => ReturnType<StreamChat['updateMessage']>; export const useEditMessageHandler = (doUpdateMessageRequest?: UpdateHandler) => { const { channel, client } = useChatContext('useEditMessageHandler'); - return (updatedMessage: UpdatedMessage, options?: UpdateMessageOptions) => { + return ( + updatedMessage: LocalMessage | MessageResponse, + options?: UpdateMessageOptions, + ) => { if (doUpdateMessageRequest && channel) { return Promise.resolve( doUpdateMessageRequest(channel.cid, updatedMessage, options), diff --git a/src/components/Channel/utils.ts b/src/components/Channel/utils.ts index f3a5055e32..e50e404bcf 100644 --- a/src/components/Channel/utils.ts +++ b/src/components/Channel/utils.ts @@ -1,7 +1,6 @@ import { nanoid } from 'nanoid'; import type { Dispatch, SetStateAction } from 'react'; -import type { ChannelState, MessageResponse } from 'stream-chat'; - +import type { ChannelState, MessageResponse, StreamChat } from 'stream-chat'; import type { ChannelNotifications } from '../../context/ChannelStateContext'; export const makeAddNotifications = @@ -99,3 +98,6 @@ export const findInMsgSetByDate = ( } return { index: -1 }; }; + +export const generateMessageId = ({ client }: { client: StreamChat }) => + `${client.userID}-${nanoid()}`; diff --git a/src/components/ChannelPreview/ChannelPreview.tsx b/src/components/ChannelPreview/ChannelPreview.tsx index aa140940ac..937e76b325 100644 --- a/src/components/ChannelPreview/ChannelPreview.tsx +++ b/src/components/ChannelPreview/ChannelPreview.tsx @@ -1,7 +1,7 @@ import throttle from 'lodash.throttle'; import React, { useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; -import type { Channel, Event } from 'stream-chat'; +import type { Channel, Event, LocalMessage } from 'stream-chat'; import { ChannelPreviewMessenger } from './ChannelPreviewMessenger'; import { useIsChannelMuted } from './hooks/useIsChannelMuted'; @@ -14,7 +14,6 @@ import type { MessageDeliveryStatus } from './hooks/useMessageDeliveryStatus'; import type { ChatContextValue } from '../../context/ChatContext'; import type { ChannelAvatarProps } from '../Avatar/ChannelAvatar'; import type { GroupChannelDisplayInfo } from './utils'; -import type { StreamMessage } from '../../context/ChannelStateContext'; import type { TranslationContextValue } from '../../context/TranslationContext'; export type ChannelPreviewUIComponentProps = ChannelPreviewProps & { @@ -25,7 +24,7 @@ export type ChannelPreviewUIComponentProps = ChannelPreviewProps & { /** Title of Channel to display */ groupChannelDisplayInfo?: GroupChannelDisplayInfo; /** The last message received in a channel */ - lastMessage?: StreamMessage; + lastMessage?: LocalMessage; /** @deprecated Use latestMessagePreview prop instead. */ latestMessage?: ReactNode; /** Latest message preview to display, will be a string or JSX element supporting markdown. */ @@ -86,7 +85,7 @@ export const ChannelPreview = (props: ChannelPreviewProps) => { channel, }); - const [lastMessage, setLastMessage] = useState<StreamMessage>( + const [lastMessage, setLastMessage] = useState<LocalMessage>( channel.state.messages[channel.state.messages.length - 1], ); const [unread, setUnread] = useState(0); diff --git a/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts b/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts index 46cdb19468..9210c7a32d 100644 --- a/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts +++ b/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts @@ -1,10 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; -import type { Channel, Event } from 'stream-chat'; +import type { Channel, Event, LocalMessage, UserResponse } from 'stream-chat'; import { useChatContext } from '../../../context'; -import type { StreamMessage } from '../../../context'; - export enum MessageDeliveryStatus { DELIVERED = 'delivered', READ = 'read', @@ -13,7 +11,7 @@ export enum MessageDeliveryStatus { type UseMessageStatusParamsChannelPreviewProps = { channel: Channel; /** The last message received in a channel */ - lastMessage?: StreamMessage; + lastMessage?: LocalMessage; }; export const useMessageDeliveryStatus = ({ @@ -26,7 +24,8 @@ export const useMessageDeliveryStatus = ({ >(); const isOwnMessage = useCallback( - (message?: StreamMessage) => client.user && message?.user?.id === client.user.id, + (message?: { user?: UserResponse | null }) => + client.user && message?.user?.id === client.user.id, [client], ); diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index 21523ecdfc..b1669567cd 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -55,7 +55,7 @@ export const useChat = ({ useEffect(() => { if (!client) return; - const version = process.env.STREAM_CHAT_REACT_VERSION; + const version = ''; //process.env.STREAM_CHAT_REACT_VERSION; const userAgent = client.getUserAgent(); if (!userAgent.includes('stream-chat-react')) { diff --git a/src/components/ChatAutoComplete/ChatAutoComplete.tsx b/src/components/ChatAutoComplete/ChatAutoComplete.tsx deleted file mode 100644 index d7ac33c761..0000000000 --- a/src/components/ChatAutoComplete/ChatAutoComplete.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import React, { useCallback } from 'react'; - -import { AutoCompleteTextarea } from '../AutoCompleteTextarea'; -import { LoadingIndicator } from '../Loading/LoadingIndicator'; - -import { useMessageInputContext } from '../../context/MessageInputContext'; -import { useTranslationContext } from '../../context/TranslationContext'; -import { useComponentContext } from '../../context/ComponentContext'; - -import type { CommandResponse, UserResponse } from 'stream-chat'; - -import type { TriggerSettings } from '../MessageInput/DefaultTriggerProvider'; -import type { CustomTrigger, UnknownType } from '../../types/types'; -import type { EmojiSearchIndex, EmojiSearchIndexResult } from '../MessageInput'; - -type ObjectUnion<T> = T[keyof T]; - -export type SuggestionCommand = CommandResponse; - -export type SuggestionUser = UserResponse; - -export type SuggestionEmoji<T extends UnknownType = UnknownType> = - EmojiSearchIndexResult & T; - -export type SuggestionItem<T extends UnknownType = UnknownType> = - | SuggestionUser - | SuggestionCommand - | SuggestionEmoji<T>; - -// FIXME: entity type is wrong, fix -export type SuggestionItemProps<T extends UnknownType = UnknownType> = { - className: string; - component: React.ComponentType<{ - entity: SuggestionItem<T>; - selected: boolean; - }>; - item: SuggestionItem<T>; - key: React.Key; - onClickHandler: (event: React.BaseSyntheticEvent, item: SuggestionItem<T>) => void; - onSelectHandler: (item: SuggestionItem<T>) => void; - selected: boolean; - style: React.CSSProperties; - value: string; -}; - -export interface SuggestionHeaderProps { - currentTrigger: string; - value: string; -} - -export type SuggestionListProps<V extends CustomTrigger = CustomTrigger> = ObjectUnion<{ - [key in keyof TriggerSettings<V>]: { - component: TriggerSettings<V>[key]['component']; - currentTrigger: string; - dropdownScroll: (element: HTMLDivElement) => void; - getSelectedItem: - | ((item: Parameters<TriggerSettings<V>[key]['output']>[0]) => void) - | null; - getTextToReplace: (item: Parameters<TriggerSettings<V>[key]['output']>[0]) => { - caretPosition: 'start' | 'end' | 'next' | number; - text: string; - key?: string; - }; - Header: React.ComponentType<SuggestionHeaderProps>; - onSelect: (newToken: { - caretPosition: 'start' | 'end' | 'next' | number; - text: string; - }) => void; - selectionEnd: number; - SuggestionItem: React.ComponentType<SuggestionItemProps>; - values: Parameters<Parameters<TriggerSettings<V>[key]['dataProvider']>[2]>[0]; - className?: string; - itemClassName?: string; - itemStyle?: React.CSSProperties; - style?: React.CSSProperties; - value?: string; - }; -}>; - -export type ChatAutoCompleteProps<T extends UnknownType = UnknownType> = { - /** Override the default disabled state of the underlying `textarea` component. */ - disabled?: boolean; - /** Function to override the default submit handler on the underlying `textarea` component */ - handleSubmit?: (event: React.BaseSyntheticEvent) => void; - /** Function to run on blur of the underlying `textarea` component */ - onBlur?: React.FocusEventHandler<HTMLTextAreaElement>; - /** Function to override the default onChange behavior on the underlying `textarea` component */ - onChange?: React.ChangeEventHandler<HTMLTextAreaElement>; - /** Function to run on focus of the underlying `textarea` component */ - onFocus?: React.FocusEventHandler<HTMLTextAreaElement>; - /** Function to override the default onPaste behavior on the underlying `textarea` component */ - onPaste?: (event: React.ClipboardEvent<HTMLTextAreaElement>) => void; - /** Placeholder for the underlying `textarea` component */ - placeholder?: string; - /** The initial number of rows for the underlying `textarea` component */ - rows?: number; - /** The text value of the underlying `textarea` component */ - value?: string; - /** Function to override the default emojiReplace behavior on the `wordReplace` prop of the `textarea` component */ - wordReplace?: (word: string, emojiIndex?: EmojiSearchIndex<T>) => string; -}; - -const UnMemoizedChatAutoComplete = <V extends CustomTrigger = CustomTrigger>( - props: ChatAutoCompleteProps, -) => { - const { - AutocompleteSuggestionItem: SuggestionItem, - AutocompleteSuggestionList: SuggestionList, - } = useComponentContext<V>('ChatAutoComplete'); - const { t } = useTranslationContext('ChatAutoComplete'); - - const messageInput = useMessageInputContext<V>('ChatAutoComplete'); - const { - cooldownRemaining, - disabled, - emojiSearchIndex, - textareaRef: innerRef, - } = messageInput; - - const placeholder = props.placeholder || t('Type your message'); - - const emojiReplace = props.wordReplace - ? (word: string) => props.wordReplace?.(word, emojiSearchIndex) - : async (word: string) => { - const found = (await emojiSearchIndex?.search(word)) || []; - - const emoji = found - .filter(Boolean) - .slice(0, 10) - .find(({ emoticons }) => !!emoticons?.includes(word)); - - if (!emoji) return null; - - const [firstSkin] = emoji.skins ?? []; - - return emoji.native ?? firstSkin.native; - }; - - const updateInnerRef = useCallback( - (ref: HTMLTextAreaElement | null) => { - if (innerRef) { - innerRef.current = ref; - } - }, - [innerRef], - ); - - return ( - <AutoCompleteTextarea - additionalTextareaProps={messageInput.additionalTextareaProps} - aria-label={cooldownRemaining ? t('Slow Mode ON') : placeholder} - className='str-chat__textarea__textarea str-chat__message-textarea' - closeCommandsList={messageInput.closeCommandsList} - closeMentionsList={messageInput.closeMentionsList} - containerClassName='str-chat__textarea str-chat__message-textarea-react-host' - disabled={(props.disabled ?? disabled) || !!cooldownRemaining} - disableMentions={messageInput.disableMentions} - grow={messageInput.grow} - handleSubmit={props.handleSubmit || messageInput.handleSubmit} - innerRef={updateInnerRef} - loadingComponent={LoadingIndicator} - maxRows={messageInput.maxRows} - minChar={0} - minRows={messageInput.minRows} - onBlur={props.onBlur} - onChange={props.onChange || messageInput.handleChange} - onFocus={props.onFocus} - onPaste={props.onPaste || messageInput.onPaste} - placeholder={cooldownRemaining ? t('Slow Mode ON') : placeholder} - replaceWord={emojiReplace} - rows={props.rows || 1} - shouldSubmit={messageInput.shouldSubmit} - showCommandsList={messageInput.showCommandsList} - showMentionsList={messageInput.showMentionsList} - SuggestionItem={SuggestionItem} - SuggestionList={SuggestionList} - trigger={messageInput.autocompleteTriggers || {}} - value={props.value || messageInput.text} - /> - ); -}; - -export const ChatAutoComplete = React.memo( - UnMemoizedChatAutoComplete, -) as typeof UnMemoizedChatAutoComplete; diff --git a/src/components/ChatAutoComplete/__tests__/ChatAutocomplete.test.js b/src/components/ChatAutoComplete/__tests__/ChatAutocomplete.test.js deleted file mode 100644 index 6d94894d36..0000000000 --- a/src/components/ChatAutoComplete/__tests__/ChatAutocomplete.test.js +++ /dev/null @@ -1,300 +0,0 @@ -import React, { useContext, useEffect } from 'react'; -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom'; - -import { ChatAutoComplete } from '../ChatAutoComplete'; -import { - generateChannel, - generateMember, - generateMessage, - generateUser, - getOrCreateChannelApi, - getTestClientWithUser, - queryMembersApi, - useMockedApis, -} from '../../../mock-builders'; -import { Chat } from '../../Chat/Chat'; -import { Channel } from '../../Channel/Channel'; -import { ChatContext } from '../../../context/ChatContext'; -import { MessageInput } from '../../MessageInput'; -import { - MessageInputContextProvider, - useMessageInputContext, -} from '../../../context/MessageInputContext'; - -let chatClient; -let channel; -const user = generateUser({ id: 'id', name: 'name' }); -const mentionUser = generateUser({ id: 'mention-id', name: 'mention-name' }); - -const ActiveChannelSetter = ({ activeChannel }) => { - const { setActiveChannel } = useContext(ChatContext); - useEffect(() => { - setActiveChannel(activeChannel); - }); - return null; -}; - -const searchIndexMock = { - search: () => [ - { - emoticons: [':D'], - id: 'smile', - name: 'Smile', - native: '😄', - skins: [], - }, - ], -}; - -const renderComponent = async ( - props = {}, - messageInputContextOverrides = {}, - activeChannel = channel, -) => { - const placeholderText = - props.placeholder === null ? null : props.placeholder || 'placeholder'; - - const OverrideMessageInputContext = ({ children }) => { - const currentContext = useMessageInputContext(); - const withOverrides = { - ...currentContext, - ...messageInputContextOverrides, - }; - return ( - <MessageInputContextProvider value={withOverrides}> - {children} - </MessageInputContextProvider> - ); - }; - - const AutoComplete = () => ( - <OverrideMessageInputContext> - <ChatAutoComplete {...props} placeholder={placeholderText} /> - </OverrideMessageInputContext> - ); - let renderResult; - await act(() => { - renderResult = render( - <Chat client={chatClient}> - <ActiveChannelSetter activeChannel={activeChannel} /> - <Channel> - <MessageInput emojiSearchIndex={searchIndexMock} Input={AutoComplete} /> - </Channel> - </Chat>, - ); - }); - - let textarea; - let typeText; - if (placeholderText !== null) { - textarea = await waitFor(() => renderResult.getByPlaceholderText(placeholderText)); - - typeText = (text) => { - fireEvent.change(textarea, { - target: { - selectionEnd: text.length, - value: text, - }, - }); - }; - } - return { ...renderResult, textarea, typeText }; -}; - -describe('ChatAutoComplete', () => { - beforeEach(async () => { - const messages = [generateMessage({ user })]; - const members = [generateMember({ user }), generateMember({ user: mentionUser })]; - const mockedChannelData = generateChannel({ - members, - messages, - }); - chatClient = await getTestClientWithUser(user); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannelData)]); - channel = chatClient.channel('messaging', mockedChannelData.channel.id); - }); - - afterEach(cleanup); - - it('should call onChange with the change event when you type in the input', async () => { - const onChange = jest.fn(); - const { typeText } = await renderComponent({}, { handleChange: onChange }); - typeText('something'); - expect(onChange).toHaveBeenCalledWith( - expect.objectContaining({ - target: expect.objectContaining({ - value: 'something', - }), - }), - ); - }); - - it('should pass the placeholder prop into the textarea', async () => { - const placeholder = 'something'; - const { getByPlaceholderText } = await renderComponent({ placeholder }); - - expect(getByPlaceholderText(placeholder)).toBeInTheDocument(); - }); - - it('should pass the disabled prop to the textarea', async () => { - const { textarea } = await renderComponent({}, { disabled: true }); - - expect(textarea).toBeDisabled(); - }); - - it('should give preference to prop disabled over the MessageInputContext value', async () => { - const { textarea } = await renderComponent({ disabled: false }, { disabled: true }); - - expect(textarea).toBeEnabled(); - }); - - it('should give preference to cooldown value over the prop disabled', async () => { - await renderComponent( - { disabled: false, placeholder: null }, - { cooldownRemaining: 10 }, - ); - expect(screen.queryByPlaceholderText('Placeholder')).not.toBeInTheDocument(); - const textarea = screen.getByTestId('message-input'); - expect(textarea).toBeDisabled(); - expect(textarea).toHaveProperty('placeholder', 'Slow Mode ON'); - }); - - it('should let you select emojis when you type :<emoji>', async () => { - const emojiAutocompleteText = ':smile'; - const { findByText, textarea, typeText } = await renderComponent(); - - typeText(emojiAutocompleteText); - - const emoji = await findByText('😄'); - - expect(emoji).toBeInTheDocument(); - - fireEvent.click(emoji); - - await waitFor(() => { - expect(textarea.value).toContain('😄'); - }); - }); - - it('should let you select users when you type @<username>', async () => { - const onSelectItem = jest.fn(); - const userAutocompleteText = `@${mentionUser.name}`; - const { textarea, typeText } = await renderComponent({ - onSelectItem, - }); - typeText(userAutocompleteText); - const userText = await screen.getByText(mentionUser.name); - - expect(userText).toBeInTheDocument(); - - fireEvent.click(userText); - - expect(textarea.value).toContain(mentionUser.name); - }); - - it('should let you select users when you type @<userid>', async () => { - const onSelectItem = jest.fn(); - const userAutocompleteText = `@${mentionUser.id}`; - const { findByText, textarea, typeText } = await renderComponent({ onSelectItem }); - typeText(userAutocompleteText); - const userText = await findByText(mentionUser.name); - - expect(userText).toBeInTheDocument(); - - fireEvent.click(userText); - - expect(textarea.value).toContain(mentionUser.name); - }); - - it('should let you select commands when you type /<command>', async () => { - const commandAutocompleteText = '/giph'; - const { findByText, textarea, typeText } = await renderComponent({ - commands: [ - { - args: '[text]', - description: 'Post a random gif to the channel', - name: 'giphy', - set: 'fun_set', - }, - ], - }); - typeText(commandAutocompleteText); - const command = await findByText('giphy'); - - expect(command).toBeInTheDocument(); - - fireEvent.click(command); - - expect(textarea.value).toContain('/giphy'); - }); - - it('should disable mention popup list', async () => { - const onSelectItem = jest.fn(); - const userAutocompleteText = `@${user.name}`; - const { queryAllByText, typeText } = await renderComponent( - {}, - { - disableMentions: true, - onSelectItem, - }, - ); - typeText(userAutocompleteText); - const userText = await queryAllByText(user.name); - - expect(userText).toHaveLength(0); - }); - - it('should disable popup list when the input is in "isComposing" state', async () => { - const { typeText } = await renderComponent(); - - const messageInput = await screen.findByTestId('message-input'); - - act(() => { - const cStartEvent = new Event('compositionstart', { bubbles: true }); - messageInput.dispatchEvent(cStartEvent); - }); - - const userAutocompleteText = `@${user.name}`; - typeText(userAutocompleteText); - - expect(screen.queryByText(user.name)).not.toBeInTheDocument(); - - act(() => { - const cEndEvent = new Event('compositionend', { bubbles: true }); - messageInput.dispatchEvent(cEndEvent); - }); - - expect(await screen.findByText(user.name)).toBeInTheDocument(); - }); - - it('should use the queryMembers API for mentions if a channel has many members', async () => { - const users = Array(100).fill().map(generateUser); - const members = users.map((u) => generateMember({ user: u })); - const messages = [generateMessage({ user: users[1] })]; - const mockedChannel = generateChannel({ - members, - messages, - }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const searchMember = members[0]; - useMockedApis(chatClient, [queryMembersApi([searchMember])]); - - const { findByText, textarea, typeText } = await renderComponent(); - const mentionedUser = searchMember.user; - - await act(() => { - typeText(`@${mentionedUser.id}`); - }); - - const userText = await findByText(mentionedUser.name); - expect(userText).toBeInTheDocument(); - - await act(() => { - fireEvent.click(userText); - }); - await waitFor(() => { - expect(textarea.value).toContain(mentionedUser.name); - }); - }); -}); diff --git a/src/components/ChatAutoComplete/index.ts b/src/components/ChatAutoComplete/index.ts deleted file mode 100644 index 6f51dfa9a4..0000000000 --- a/src/components/ChatAutoComplete/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ChatAutoComplete'; diff --git a/src/components/CommandItem/index.ts b/src/components/CommandItem/index.ts deleted file mode 100644 index 37a2924436..0000000000 --- a/src/components/CommandItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './CommandItem'; diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx index c969898755..de33b6ff71 100644 --- a/src/components/Dialog/DialogAnchor.tsx +++ b/src/components/Dialog/DialogAnchor.tsx @@ -67,6 +67,7 @@ export const DialogAnchor = ({ id, placement = 'auto', referenceElement = null, + tabIndex, trapFocus, ...restDivProps }: DialogAnchorProps) => { @@ -107,7 +108,7 @@ export const DialogAnchor = ({ data-testid='str-chat__dialog-contents' ref={setPopperElement} style={styles.popper} - tabIndex={0} + tabIndex={typeof tabIndex !== 'undefined' ? tabIndex : 0} > {children} </div> diff --git a/src/components/EmoticonItem/EmoticonItem.tsx b/src/components/EmoticonItem/EmoticonItem.tsx deleted file mode 100644 index 82b7c4c212..0000000000 --- a/src/components/EmoticonItem/EmoticonItem.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; - -export type EmoticonItemProps = { - entity: { - /** The parts of the Name property of the entity (or id if no name) that can be matched to the user input value. - * Default is bold for matches, but can be overwritten in css. - * */ - itemNameParts: { match: string; parts: string[] }; - /** Name for emoticon */ - name: string; - /** Native value or actual emoticon */ - native: string; - }; -}; - -const UnMemoizedEmoticonItem = (props: EmoticonItemProps) => { - const { entity } = props; - - const hasEntity = Object.keys(entity).length; - const itemParts = entity?.itemNameParts; - - const renderName = () => { - if (!hasEntity) return null; - return ( - hasEntity && - itemParts.parts.map((part, i) => - part.toLowerCase() === itemParts.match.toLowerCase() ? ( - <span className='str-chat__emoji-item--highlight' key={`part-${i}`}> - {part} - </span> - ) : ( - <span className='str-chat__emoji-item--part' key={`part-${i}`}> - {part} - </span> - ), - ) - ); - }; - - return ( - <div className='str-chat__emoji-item'> - <span className='str-chat__emoji-item--entity'>{entity.native}</span> - <span className='str-chat__emoji-item--name'>{renderName()}</span> - </div> - ); -}; - -export const EmoticonItem = React.memo( - UnMemoizedEmoticonItem, -) as typeof UnMemoizedEmoticonItem; diff --git a/src/components/EmoticonItem/index.ts b/src/components/EmoticonItem/index.ts deleted file mode 100644 index 563729487f..0000000000 --- a/src/components/EmoticonItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './EmoticonItem'; diff --git a/src/components/EventComponent/EventComponent.tsx b/src/components/EventComponent/EventComponent.tsx index ab6530fb6e..bc7e9d9183 100644 --- a/src/components/EventComponent/EventComponent.tsx +++ b/src/components/EventComponent/EventComponent.tsx @@ -1,16 +1,16 @@ import React from 'react'; -import type { Event } from 'stream-chat'; import { Avatar as DefaultAvatar } from '../Avatar'; import { useTranslationContext } from '../../context/TranslationContext'; import { getDateString } from '../../i18n/utils'; + +import type { Event, LocalMessage } from 'stream-chat'; import type { AvatarProps } from '../Avatar'; -import type { StreamMessage } from '../../context/ChannelStateContext'; import type { TimestampFormatterOptions } from '../../i18n/types'; export type EventComponentProps = TimestampFormatterOptions & { /** Message object */ - message: StreamMessage & { + message: LocalMessage & { event?: Event; }; /** Custom UI component to display user avatar, defaults to and accepts same props as: [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) */ diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingInProgress.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingInProgress.tsx index 6626961592..1d353bb61e 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingInProgress.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingInProgress.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useTimeElapsed } from '../../MessageInput/hooks/useTimeElapsed'; +import { useTimeElapsed } from './hooks/useTimeElapsed'; import { useMessageInputContext } from '../../../context'; import { RecordingTimer } from './RecordingTimer'; diff --git a/src/components/MessageInput/hooks/useTimeElapsed.ts b/src/components/MediaRecorder/AudioRecorder/hooks/useTimeElapsed.ts similarity index 100% rename from src/components/MessageInput/hooks/useTimeElapsed.ts rename to src/components/MediaRecorder/AudioRecorder/hooks/useTimeElapsed.ts diff --git a/src/components/MediaRecorder/classes/MediaRecorderController.ts b/src/components/MediaRecorder/classes/MediaRecorderController.ts index 2318e07145..55a47c5c16 100644 --- a/src/components/MediaRecorder/classes/MediaRecorderController.ts +++ b/src/components/MediaRecorder/classes/MediaRecorderController.ts @@ -1,6 +1,5 @@ import fixWebmDuration from 'fix-webm-duration'; import { nanoid } from 'nanoid'; -import type { AmplitudeRecorderConfig } from './AmplitudeRecorder'; import { AmplitudeRecorder, DEFAULT_AMPLITUDE_RECORDER_CONFIG, @@ -16,11 +15,11 @@ import { getExtensionFromMimeType, getRecordedMediaTypeFromMimeType, } from '../../ReactFileUtilities'; -import type { TranslationContextValue } from '../../../context'; import { defaultTranslatorFunction } from '../../../i18n'; import { mergeDeepUndefined } from '../../../utils/mergeDeep'; - -import type { LocalVoiceRecordingAttachment } from '../../MessageInput'; +import type { LocalVoiceRecordingAttachment } from 'stream-chat'; +import type { AmplitudeRecorderConfig } from './AmplitudeRecorder'; +import type { TranslationContextValue } from '../../../context'; export const RECORDED_MIME_TYPE_BY_BROWSER = { audio: { @@ -188,7 +187,7 @@ export class MediaRecorderController { this.amplitudeRecorder?.amplitudes.value ?? [], this.amplitudeRecorderConfig.sampleCount, ), - }; + } as LocalVoiceRecordingAttachment; }; handleErrorEvent = (e: Event) => { diff --git a/src/components/MediaRecorder/hooks/useMediaRecorder.ts b/src/components/MediaRecorder/hooks/useMediaRecorder.ts index e427b78c36..6438dc2b35 100644 --- a/src/components/MediaRecorder/hooks/useMediaRecorder.ts +++ b/src/components/MediaRecorder/hooks/useMediaRecorder.ts @@ -1,10 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { MessageInputContextValue } from '../../../context'; -import { useTranslationContext } from '../../../context'; -import type { CustomAudioRecordingConfig, MediaRecordingState } from '../classes'; import { MediaRecorderController } from '../classes'; +import { useTranslationContext } from '../../../context'; +import { useMessageComposer } from '../../MessageInput'; -import type { LocalVoiceRecordingAttachment } from '../../MessageInput'; +import type { LocalVoiceRecordingAttachment } from 'stream-chat'; +import type { CustomAudioRecordingConfig, MediaRecordingState } from '../classes'; +import type { MessageInputContextValue } from '../../../context'; export type RecordingController = { completeRecording: () => void; @@ -16,7 +17,7 @@ export type RecordingController = { type UseMediaRecorderParams = Pick< MessageInputContextValue, - 'asyncMessagesMultiSendEnabled' | 'handleSubmit' | 'uploadAttachment' + 'asyncMessagesMultiSendEnabled' | 'handleSubmit' > & { enabled: boolean; generateRecordingTitle?: (mimeType: string) => string; @@ -29,10 +30,9 @@ export const useMediaRecorder = ({ generateRecordingTitle, handleSubmit, recordingConfig, - uploadAttachment, }: UseMediaRecorderParams): RecordingController => { const { t } = useTranslationContext('useMediaRecorder'); - + const messageComposer = useMessageComposer(); const [recording, setRecording] = useState<LocalVoiceRecordingAttachment>(); const [recordingState, setRecordingState] = useState<MediaRecordingState>(); const [permissionState, setPermissionState] = useState<PermissionState>(); @@ -54,13 +54,13 @@ export const useMediaRecorder = ({ if (!recorder) return; const recording = await recorder.stop(); if (!recording) return; - await uploadAttachment(recording); + await messageComposer.attachmentManager.uploadAttachment(recording); if (!asyncMessagesMultiSendEnabled) { // FIXME: cannot call handleSubmit() directly as the function has stale reference to attachments scheduleForSubmit(true); } recorder.cleanUp(); - }, [asyncMessagesMultiSendEnabled, recorder, uploadAttachment]); + }, [asyncMessagesMultiSendEnabled, messageComposer, recorder]); useEffect(() => { if (!isScheduledForSubmit) return; diff --git a/src/components/Message/FixedHeightMessage.tsx b/src/components/Message/FixedHeightMessage.tsx index 3ea72d7a3c..d2659cc9f7 100644 --- a/src/components/Message/FixedHeightMessage.tsx +++ b/src/components/Message/FixedHeightMessage.tsx @@ -16,9 +16,7 @@ import { useMessageContext } from '../../context/MessageContext'; import { useTranslationContext } from '../../context/TranslationContext'; import { renderText } from './renderText'; -import type { TranslationLanguages } from 'stream-chat'; - -import type { StreamMessage } from '../../context/ChannelStateContext'; +import type { LocalMessage, TranslationLanguages } from 'stream-chat'; const selectColor = (number: number, dark: boolean) => { const hue = number * 137.508; // use golden angle approximation @@ -38,7 +36,7 @@ const getUserColor = (theme: string, userId: string) => export type FixedHeightMessageProps = { groupedByUser?: boolean; - message?: StreamMessage; + message?: LocalMessage; }; const UnMemoizedFixedHeightMessage = (props: FixedHeightMessageProps) => { diff --git a/src/components/Message/MessageDeleted.tsx b/src/components/Message/MessageDeleted.tsx index e3102cb594..3b0db0b8b2 100644 --- a/src/components/Message/MessageDeleted.tsx +++ b/src/components/Message/MessageDeleted.tsx @@ -1,13 +1,12 @@ import React from 'react'; import { useUserRole } from './hooks/useUserRole'; - import { useTranslationContext } from '../../context/TranslationContext'; -import type { StreamMessage } from '../../context/ChannelStateContext'; +import type { LocalMessage } from 'stream-chat'; export type MessageDeletedProps = { - message: StreamMessage; + message: LocalMessage; }; export const MessageDeleted = (props: MessageDeletedProps) => { diff --git a/src/components/Message/MessageErrorText.tsx b/src/components/Message/MessageErrorText.tsx index 68f9bb7741..35f84c353b 100644 --- a/src/components/Message/MessageErrorText.tsx +++ b/src/components/Message/MessageErrorText.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import type { StreamMessage } from '../../context'; +import { isMessageBounced } from './utils'; import { useTranslationContext } from '../../context'; -import { isMessageBounced } from './utils'; +import type { LocalMessage } from 'stream-chat'; export interface MessageErrorTextProps { - message: StreamMessage; + message: LocalMessage; theme: string; } @@ -28,7 +28,7 @@ export function MessageErrorText({ message, theme }: MessageErrorTextProps) { <div className={`str-chat__${theme}-message--error-message str-chat__message--error-message`} > - {message.errorStatusCode !== 403 + {message.error?.status !== 403 ? t<string>('Message Failed · Click to try again') : t<string>('Message Failed · Unauthorized')} </div> diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index de65e8ff16..9b4c23b875 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -21,10 +21,8 @@ import { import { Avatar as DefaultAvatar } from '../Avatar'; import { Attachment as DefaultAttachment } from '../Attachment'; -import { CUSTOM_MESSAGE_TYPE } from '../../constants/messageTypes'; -import { EditMessageForm as DefaultEditMessageForm, MessageInput } from '../MessageInput'; +import { EditMessageModal } from '../MessageInput'; import { MML } from '../MML'; -import { Modal } from '../Modal'; import { Poll } from '../Poll'; import { ReactionsList as DefaultReactionList } from '../Reactions'; import { MessageBounceModal } from '../MessageBounce/MessageBounceModal'; @@ -38,13 +36,13 @@ import { MessageEditedTimestamp } from './MessageEditedTimestamp'; import type { MessageUIComponentProps } from './types'; import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText'; +import { isDateSeparatorMessage } from '../MessageList'; type MessageSimpleWithContextProps = MessageContextValue; const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { const { additionalMessageInputProps, - clearEditingState, editing, endOfGroup, firstOfGroup, @@ -69,7 +67,6 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { const { Attachment = DefaultAttachment, Avatar = DefaultAvatar, - EditMessageInput = DefaultEditMessageForm, MessageOptions = DefaultMessageOptions, // TODO: remove this "passthrough" in the next // major release and use the new default instead @@ -84,14 +81,14 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { StreamedMessageText = DefaultStreamedMessageText, PinIndicator, } = useComponentContext('MessageSimple'); - const hasAttachment = messageHasAttachments(message); const hasReactions = messageHasReactions(message); const isAIGenerated = useMemo( () => isMessageAIGenerated?.(message), [isMessageAIGenerated, message], ); - if (message.customType === CUSTOM_MESSAGE_TYPE.date) { + + if (isDateSeparatorMessage(message)) { return null; } @@ -105,7 +102,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { const showMetadata = !groupedByUser || endOfGroup; const showReplyCountButton = !threadList && !!message.reply_count; - const allowRetry = message.status === 'failed' && message.errorStatusCode !== 403; + const allowRetry = message.status === 'failed' && message.error?.status !== 403; const isBounced = isMessageBounced(message); const isEdited = isMessageEdited(message) && !isAIGenerated; @@ -133,7 +130,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { 'str-chat__message--pinned pinned-message': message.pinned, 'str-chat__message--with-reactions': hasReactions, 'str-chat__message-send-can-be-retried': - message?.status === 'failed' && message?.errorStatusCode !== 403, + message?.status === 'failed' && message?.error?.status !== 403, 'str-chat__message-with-thread-link': showReplyCountButton, 'str-chat__virtual-message__wrapper--end': endOfGroup, 'str-chat__virtual-message__wrapper--first': firstOfGroup, @@ -146,20 +143,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { return ( <> {editing && ( - <Modal - className='str-chat__edit-message-modal' - onClose={clearEditingState} - open={editing} - > - <MessageInput - clearEditingState={clearEditingState} - grow - hideSendButton - Input={EditMessageInput} - message={message} - {...additionalMessageInputProps} - /> - </Modal> + <EditMessageModal additionalMessageInputProps={additionalMessageInputProps} /> )} {isBounceDialogOpen && ( <MessageBounceModal diff --git a/src/components/Message/MessageText.tsx b/src/components/Message/MessageText.tsx index ee3451343f..7c60146bbe 100644 --- a/src/components/Message/MessageText.tsx +++ b/src/components/Message/MessageText.tsx @@ -12,8 +12,8 @@ import { import { renderText as defaultRenderText } from './renderText'; import { MessageErrorText } from './MessageErrorText'; -import type { TranslationLanguages } from 'stream-chat'; -import type { MessageContextValue, StreamMessage } from '../../context'; +import type { LocalMessage, TranslationLanguages } from 'stream-chat'; +import type { MessageContextValue } from '../../context'; export type MessageTextProps = { /* Replaces the CSS class name placed on the component's inner `div` container */ @@ -21,7 +21,7 @@ export type MessageTextProps = { /* Adds a CSS class name to the component's outer `div` container */ customWrapperClass?: string; /* The `StreamChat` message object, which provides necessary data to the underlying UI components (overrides the value stored in `MessageContext`) */ - message?: StreamMessage; + message?: LocalMessage; /* Theme string to be added to CSS class names */ theme?: string; } & Pick<MessageContextValue, 'renderText'>; diff --git a/src/components/Message/MessageTimestamp.tsx b/src/components/Message/MessageTimestamp.tsx index 1197dd5321..77d10357f2 100644 --- a/src/components/Message/MessageTimestamp.tsx +++ b/src/components/Message/MessageTimestamp.tsx @@ -3,14 +3,14 @@ import { useMessageContext } from '../../context/MessageContext'; import { Timestamp as DefaultTimestamp } from './Timestamp'; import { useComponentContext } from '../../context'; -import type { StreamMessage } from '../../context/ChannelStateContext'; +import type { LocalMessage } from 'stream-chat'; import type { TimestampFormatterOptions } from '../../i18n/types'; export type MessageTimestampProps = TimestampFormatterOptions & { /* Adds a CSS class name to the component's outer `time` container. */ customClass?: string; /* The `StreamChat` message object, which provides necessary data to the underlying UI components (overrides the value from `MessageContext`) */ - message?: StreamMessage; + message?: LocalMessage; }; const UnMemoizedMessageTimestamp = (props: MessageTimestampProps) => { diff --git a/src/components/Message/__tests__/MessageSimple.test.js b/src/components/Message/__tests__/MessageSimple.test.js index c16e9df131..c158ad202e 100644 --- a/src/components/Message/__tests__/MessageSimple.test.js +++ b/src/components/Message/__tests__/MessageSimple.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import Dayjs from 'dayjs'; import calendar from 'dayjs/plugin/calendar'; @@ -14,16 +14,14 @@ import { MESSAGE_ACTIONS } from '../utils'; import { Chat } from '../../Chat'; import { Attachment as AttachmentMock } from '../../Attachment'; import { Avatar as AvatarMock } from '../../Avatar'; -import { EditMessageForm, MessageInput as MessageInputMock } from '../../MessageInput'; import { getReadStates } from '../../MessageList'; import { MML as MMLMock } from '../../MML'; -import { Modal as ModalMock } from '../../Modal'; import { defaultReactionOptions } from '../../Reactions'; import { ChannelActionProvider, ChannelStateProvider, - ComponentProvider, + WithComponents, } from '../../../context'; import { countReactions, @@ -32,8 +30,10 @@ import { generateMessage, generateReaction, generateUser, + getOrCreateChannelApi, getTestClientWithUser, groupReactions, + useMockedApis, } from '../../../mock-builders'; import { MessageBouncePrompt } from '../../MessageBounce'; @@ -41,16 +41,18 @@ expect.extend(toHaveNoViolations); Dayjs.extend(calendar); -jest.mock('../MessageOptions', () => ({ MessageOptions: jest.fn(() => <div />) })); -jest.mock('../MessageText', () => ({ MessageText: jest.fn(() => <div />) })); -jest.mock('../../MML', () => ({ MML: jest.fn(() => <div />) })); -jest.mock('../../Avatar', () => ({ Avatar: jest.fn(() => <div />) })); -jest.mock('../../MessageInput', () => ({ - EditMessageForm: jest.fn(() => <div data-testid='edit-message-form' />), - MessageInput: jest.fn(() => <div data-testid='message-input' />), +jest.mock('../MessageOptions', () => ({ + MessageOptions: jest.fn(() => <div data-testid='mocked-message-options' />), +})); +jest.mock('../MessageText', () => ({ + MessageText: jest.fn(() => <div data-testid='mocked-message-text' />), +})); +jest.mock('../../MML', () => ({ MML: jest.fn(() => <div data-testid='mocked-mml' />) })); +jest.mock('../../Avatar', () => ({ + Avatar: jest.fn(() => <div data-testid='mocked-avatar' />), })); jest.mock('../../Modal', () => ({ - Modal: jest.fn((props) => <div>{props.children}</div>), + Modal: jest.fn((props) => <div data-testid='mocked-modal'>{props.children}</div>), })); const alice = generateUser(); @@ -60,59 +62,6 @@ const openThreadMock = jest.fn(); const retrySendMessageMock = jest.fn(); const removeMessageMock = jest.fn(); -async function renderMessageSimple({ - channelCapabilities = { 'send-reaction': true }, - channelConfigOverrides = { replies: true }, - components = {}, - message, - props = {}, - renderer = render, -}) { - const channel = generateChannel({ - getConfig: () => channelConfigOverrides, - state: { membership: {} }, - }); - - const channelConfig = channel.getConfig(); - const client = await getTestClientWithUser(alice); - let result; - - await act(() => { - result = renderer( - <Chat client={client}> - <ChannelStateProvider value={{ channel, channelCapabilities, channelConfig }}> - <ChannelActionProvider - value={{ - openThread: openThreadMock, - removeMessage: removeMessageMock, - retrySendMessage: retrySendMessageMock, - }} - > - <ComponentProvider - value={{ - Attachment: AttachmentMock, - - Message: () => <MessageSimple {...props} />, - reactionOptions: defaultReactionOptions, - ...components, - }} - > - <Message - getMessageActions={() => Object.keys(MESSAGE_ACTIONS)} - isMyMessage={() => true} - message={message} - threadList={false} - {...props} - /> - </ComponentProvider> - </ChannelActionProvider> - </ChannelStateProvider> - </Chat>, - ); - }); - return result; -} - function generateAliceMessage(messageOptions) { return generateMessage({ user: alice, @@ -128,11 +77,79 @@ function generateBobMessage(messageOptions) { } describe('<MessageSimple />', () => { - afterEach(cleanup); - beforeEach(jest.clearAllMocks); + let channel; + let client; + + async function renderMessageSimple({ + channelCapabilities = { 'send-reaction': true }, + channelConfigOverrides = { replies: true }, + components = {}, + message, + props = {}, + renderer = render, + }) { + let result; + await act(() => { + result = renderer( + <Chat client={client}> + <ChannelStateProvider + value={{ + channel, + channelCapabilities, + channelConfig: channelConfigOverrides, + }} + > + <ChannelActionProvider + value={{ + openThread: openThreadMock, + removeMessage: removeMessageMock, + retrySendMessage: retrySendMessageMock, + }} + > + <WithComponents + overrides={{ + Attachment: AttachmentMock, + Message: () => <MessageSimple {...props} />, + reactionOptions: defaultReactionOptions, + ...components, + }} + > + <Message + getMessageActions={() => Object.keys(MESSAGE_ACTIONS)} + isMyMessage={() => true} + message={message} + threadList={false} + {...props} + /> + </WithComponents> + </ChannelActionProvider> + </ChannelStateProvider> + </Chat>, + ); + }); + return result; + } + + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + beforeEach(async () => { + const mockedChannel = generateChannel({ + state: { membership: {} }, + }); + + client = await getTestClientWithUser(alice); + useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); + channel = client.channel('messaging', mockedChannel.channel.id); + }); it('should not render anything if message is of custom type message.date', async () => { - const message = generateAliceMessage({ customType: 'message.date' }); + const message = generateAliceMessage({ + customType: 'message.date', + date: new Date(), + }); const { container } = await renderMessageSimple({ message }); expect(container).toBeEmptyDOMElement(); }); @@ -217,7 +234,7 @@ describe('<MessageSimple />', () => { const message = generateAliceMessage(); const clearEditingState = jest.fn(); - const CustomEditMessageInput = () => <div>Edit Input</div>; + const CustomEditMessageInput = () => <div data-testid='custom-edit-message-input' />; const { container } = await renderMessageSimple({ components: { @@ -227,14 +244,8 @@ describe('<MessageSimple />', () => { props: { clearEditingState, editing: true }, }); - expect(MessageInputMock).toHaveBeenCalledWith( - expect.objectContaining({ - clearEditingState, - Input: CustomEditMessageInput, - message, - }), - undefined, - ); + expect(await screen.findByTestId('custom-edit-message-input')).toBeInTheDocument(); + const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -297,21 +308,9 @@ describe('<MessageSimple />', () => { editing: true, }, }); - expect(ModalMock).toHaveBeenCalledWith( - expect.objectContaining({ - onClose: clearEditingState, - open: true, - }), - undefined, - ); - expect(MessageInputMock).toHaveBeenCalledWith( - expect.objectContaining({ - clearEditingState, - Input: EditMessageForm, - message, - }), - undefined, - ); + + expect(await screen.findByTestId('mocked-modal')).toBeInTheDocument(); + const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -678,20 +677,29 @@ describe('<MessageSimple />', () => { ], ])('bounced message %s', (_, bouncedMessageOptions) => { it('should render error badge for bounced messages', async () => { - const message = generateAliceMessage(bouncedMessageOptions); + const message = generateAliceMessage({ + ...bouncedMessageOptions, + cid: channel.cid, + }); const { queryByTestId } = await renderMessageSimple({ message }); expect(queryByTestId('error')).toBeInTheDocument(); }); it('should render open bounce modal on click', async () => { - const message = generateAliceMessage(bouncedMessageOptions); + const message = generateAliceMessage({ + ...bouncedMessageOptions, + cid: channel.cid, + }); const { getByTestId, queryByTestId } = await renderMessageSimple({ message }); fireEvent.click(getByTestId('message-inner')); expect(queryByTestId('message-bounce-prompt')).toBeInTheDocument(); }); it('should switch to message editing', async () => { - const message = generateAliceMessage(bouncedMessageOptions); + const message = generateAliceMessage({ + ...bouncedMessageOptions, + cid: channel.cid, + }); const { getByTestId, queryByTestId } = await renderMessageSimple({ message, }); @@ -701,7 +709,10 @@ describe('<MessageSimple />', () => { }); it('should retry sending message', async () => { - const message = generateAliceMessage(bouncedMessageOptions); + const message = generateAliceMessage({ + ...bouncedMessageOptions, + cid: channel.cid, + }); const { getByTestId } = await renderMessageSimple({ message, }); @@ -715,7 +726,10 @@ describe('<MessageSimple />', () => { }); it('should remove message', async () => { - const message = generateAliceMessage(bouncedMessageOptions); + const message = generateAliceMessage({ + ...bouncedMessageOptions, + cid: channel.cid, + }); const { getByTestId } = await renderMessageSimple({ message, }); @@ -729,7 +743,10 @@ describe('<MessageSimple />', () => { }); it('should use overriden modal content component', async () => { - const message = generateAliceMessage(bouncedMessageOptions); + const message = generateAliceMessage({ + ...bouncedMessageOptions, + cid: channel.cid, + }); const CustomMessageBouncePrompt = () => ( <div data-testid='custom-message-bounce-prompt'>Overriden</div> ); @@ -744,7 +761,10 @@ describe('<MessageSimple />', () => { }); it('should use overriden modal content text', async () => { - const message = generateAliceMessage(bouncedMessageOptions); + const message = generateAliceMessage({ + ...bouncedMessageOptions, + cid: channel.cid, + }); const CustomMessageBouncePrompt = () => ( <MessageBouncePrompt>Overriden</MessageBouncePrompt> ); diff --git a/src/components/Message/hooks/useActionHandler.ts b/src/components/Message/hooks/useActionHandler.ts index d0bdb93bb4..a66c6881e8 100644 --- a/src/components/Message/hooks/useActionHandler.ts +++ b/src/components/Message/hooks/useActionHandler.ts @@ -1,8 +1,8 @@ import { useChannelActionContext } from '../../../context/ChannelActionContext'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; import type React from 'react'; +import type { LocalMessage } from 'stream-chat'; export type FormData = Record<string, string>; @@ -15,7 +15,7 @@ export type ActionHandlerReturnType = ( export const handleActionWarning = `Action handler was called, but it is missing one of its required arguments. Make sure the ChannelAction and ChannelState contexts are properly set and the hook is initialized with a valid message.`; -export function useActionHandler(message?: StreamMessage): ActionHandlerReturnType { +export function useActionHandler(message?: LocalMessage): ActionHandlerReturnType { const { removeMessage, updateMessage } = useChannelActionContext('useActionHandler'); const { channel } = useChannelStateContext('useActionHandler'); diff --git a/src/components/Message/hooks/useDeleteHandler.ts b/src/components/Message/hooks/useDeleteHandler.ts index 1879b4b9e5..f51dc8bb89 100644 --- a/src/components/Message/hooks/useDeleteHandler.ts +++ b/src/components/Message/hooks/useDeleteHandler.ts @@ -4,17 +4,16 @@ import { useChannelActionContext } from '../../../context/ChannelActionContext'; import { useChatContext } from '../../../context/ChatContext'; import { useTranslationContext } from '../../../context/TranslationContext'; +import type { LocalMessage } from 'stream-chat'; import type { ReactEventHandler } from '../types'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; - export type DeleteMessageNotifications = { - getErrorNotification?: (message: StreamMessage) => string; + getErrorNotification?: (message: LocalMessage) => string; notify?: (notificationText: string, type: 'success' | 'error') => void; }; export const useDeleteHandler = ( - message?: StreamMessage, + message?: LocalMessage, notifications: DeleteMessageNotifications = {}, ): ReactEventHandler => { const { getErrorNotification, notify } = notifications; diff --git a/src/components/Message/hooks/useFlagHandler.ts b/src/components/Message/hooks/useFlagHandler.ts index 978624fe43..f8b7fda838 100644 --- a/src/components/Message/hooks/useFlagHandler.ts +++ b/src/components/Message/hooks/useFlagHandler.ts @@ -3,21 +3,20 @@ import { validateAndGetMessage } from '../utils'; import { useChatContext } from '../../../context/ChatContext'; import { useTranslationContext } from '../../../context/TranslationContext'; +import type { LocalMessage } from 'stream-chat'; import type { ReactEventHandler } from '../types'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; - export const missingUseFlagHandlerParameterWarning = 'useFlagHandler was called but it is missing one or more necessary parameters.'; export type FlagMessageNotifications = { - getErrorNotification?: (message: StreamMessage) => string; - getSuccessNotification?: (message: StreamMessage) => string; + getErrorNotification?: (message: LocalMessage) => string; + getSuccessNotification?: (message: LocalMessage) => string; notify?: (notificationText: string, type: 'success' | 'error') => void; }; export const useFlagHandler = ( - message?: StreamMessage, + message?: LocalMessage, notifications: FlagMessageNotifications = {}, ): ReactEventHandler => { const { client } = useChatContext('useFlagHandler'); diff --git a/src/components/Message/hooks/useMarkUnreadHandler.ts b/src/components/Message/hooks/useMarkUnreadHandler.ts index d255ad861a..b62d3e5829 100644 --- a/src/components/Message/hooks/useMarkUnreadHandler.ts +++ b/src/components/Message/hooks/useMarkUnreadHandler.ts @@ -1,17 +1,17 @@ import { validateAndGetMessage } from '../utils'; import { useChannelStateContext, useTranslationContext } from '../../../context'; -import type { StreamMessage } from '../../../context'; +import type { LocalMessage } from 'stream-chat'; import type { ReactEventHandler } from '../types'; export type MarkUnreadHandlerNotifications = { - getErrorNotification?: (message: StreamMessage) => string; - getSuccessNotification?: (message: StreamMessage) => string; + getErrorNotification?: (message: LocalMessage) => string; + getSuccessNotification?: (message: LocalMessage) => string; notify?: (notificationText: string, type: 'success' | 'error') => void; }; export const useMarkUnreadHandler = ( - message?: StreamMessage, + message?: LocalMessage, notifications: MarkUnreadHandlerNotifications = {}, ): ReactEventHandler => { const { getErrorNotification, getSuccessNotification, notify } = notifications; diff --git a/src/components/Message/hooks/useMentionsHandler.ts b/src/components/Message/hooks/useMentionsHandler.ts index 78e0600f64..a22d8f931b 100644 --- a/src/components/Message/hooks/useMentionsHandler.ts +++ b/src/components/Message/hooks/useMentionsHandler.ts @@ -1,12 +1,10 @@ import { useChannelActionContext } from '../../../context/ChannelActionContext'; import type React from 'react'; -import type { UserResponse } from 'stream-chat'; +import type { LocalMessage, UserResponse } from 'stream-chat'; import type { ReactEventHandler } from '../types'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; - export type CustomMentionHandler = ( event: React.BaseSyntheticEvent, mentioned_users: UserResponse[], @@ -19,7 +17,7 @@ export type MentionedUserEventHandler = ( function createEventHandler( fn?: CustomMentionHandler, - message?: StreamMessage, + message?: LocalMessage, ): ReactEventHandler { return (event) => { if (typeof fn !== 'function' || !message?.mentioned_users?.length) { @@ -30,7 +28,7 @@ function createEventHandler( } export const useMentionsHandler = ( - message?: StreamMessage, + message?: LocalMessage, customMentionHandler?: { onMentionsClick?: CustomMentionHandler; onMentionsHover?: CustomMentionHandler; diff --git a/src/components/Message/hooks/useMuteHandler.ts b/src/components/Message/hooks/useMuteHandler.ts index a8bf488ac2..06d4cefb0d 100644 --- a/src/components/Message/hooks/useMuteHandler.ts +++ b/src/components/Message/hooks/useMuteHandler.ts @@ -1,11 +1,10 @@ import { isUserMuted, validateAndGetMessage } from '../utils'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; import { useChatContext } from '../../../context/ChatContext'; import { useTranslationContext } from '../../../context/TranslationContext'; -import type { UserResponse } from 'stream-chat'; +import type { LocalMessage, UserResponse } from 'stream-chat'; import type { ReactEventHandler } from '../types'; @@ -19,7 +18,7 @@ export type MuteUserNotifications = { }; export const useMuteHandler = ( - message?: StreamMessage, + message?: LocalMessage, notifications: MuteUserNotifications = {}, ): ReactEventHandler => { const { mutes } = useChannelStateContext('useMuteHandler'); diff --git a/src/components/Message/hooks/useOpenThreadHandler.ts b/src/components/Message/hooks/useOpenThreadHandler.ts index 4c944c4771..41331532e4 100644 --- a/src/components/Message/hooks/useOpenThreadHandler.ts +++ b/src/components/Message/hooks/useOpenThreadHandler.ts @@ -1,12 +1,11 @@ import { useChannelActionContext } from '../../../context/ChannelActionContext'; +import type { LocalMessage } from 'stream-chat'; import type { ReactEventHandler } from '../types'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; - export const useOpenThreadHandler = ( - message?: StreamMessage, - customOpenThread?: (message: StreamMessage, event: React.BaseSyntheticEvent) => void, + message?: LocalMessage, + customOpenThread?: (message: LocalMessage, event: React.BaseSyntheticEvent) => void, ): ReactEventHandler => { const { openThread: channelOpenThread } = useChannelActionContext('useOpenThreadHandler'); diff --git a/src/components/Message/hooks/usePinHandler.ts b/src/components/Message/hooks/usePinHandler.ts index ce4ad60067..3f35bfbb93 100644 --- a/src/components/Message/hooks/usePinHandler.ts +++ b/src/components/Message/hooks/usePinHandler.ts @@ -1,11 +1,11 @@ import { defaultPinPermissions, validateAndGetMessage } from '../utils'; import { useChannelActionContext } from '../../../context/ChannelActionContext'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; import { useChatContext } from '../../../context/ChatContext'; import { useTranslationContext } from '../../../context/TranslationContext'; +import type { LocalMessage } from 'stream-chat'; import type { ReactEventHandler } from '../types'; // @deprecated in favor of `channelCapabilities` - TODO: remove in next major release @@ -36,12 +36,12 @@ export type PinPermissions< }; export type PinMessageNotifications = { - getErrorNotification?: (message: StreamMessage) => string; + getErrorNotification?: (message: LocalMessage) => string; notify?: (notificationText: string, type: 'success' | 'error') => void; }; export const usePinHandler = ( - message: StreamMessage, + message: LocalMessage, // @deprecated in favor of `channelCapabilities` - TODO: remove in next major release _permissions: PinPermissions = defaultPinPermissions, // eslint-disable-line notifications: PinMessageNotifications = {}, @@ -62,8 +62,7 @@ export const usePinHandler = ( if (!message.pinned) { try { - // @ts-expect-error type mismatch - const optimisticMessage: StreamMessage = { + const optimisticMessage: LocalMessage = { ...message, pinned: true, pinned_at: new Date(), diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts index 12e418bf8a..4953254496 100644 --- a/src/components/Message/hooks/useReactionHandler.ts +++ b/src/components/Message/hooks/useReactionHandler.ts @@ -2,26 +2,24 @@ import type React from 'react'; import { useCallback } from 'react'; import throttle from 'lodash.throttle'; +import { useThreadContext } from '../../Threads'; import { useChannelActionContext } from '../../../context/ChannelActionContext'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; import { useChatContext } from '../../../context/ChatContext'; -import type { Reaction, ReactionResponse } from 'stream-chat'; - -import { useThreadContext } from '../../Threads'; +import type { LocalMessage, Reaction, ReactionResponse } from 'stream-chat'; export const reactionHandlerWarning = `Reaction handler was called, but it is missing one of its required arguments. Make sure the ChannelAction and ChannelState contexts are properly set and the hook is initialized with a valid message.`; -export const useReactionHandler = (message?: StreamMessage) => { +export const useReactionHandler = (message?: LocalMessage) => { const thread = useThreadContext(); const { updateMessage } = useChannelActionContext('useReactionHandler'); const { channel, channelCapabilities } = useChannelStateContext('useReactionHandler'); const { client } = useChatContext('useReactionHandler'); const createMessagePreview = useCallback( - (add: boolean, reaction: ReactionResponse, message: StreamMessage): StreamMessage => { + (add: boolean, reaction: ReactionResponse, message: LocalMessage): LocalMessage => { const newReactionGroups = message?.reaction_groups || {}; const reactionType = reaction.type; const hasReaction = !!newReactionGroups[reactionType]; @@ -50,7 +48,7 @@ export const useReactionHandler = (message?: StreamMessage) => { } } - const newReactions: Reaction[] | undefined = add + const newReactions: ReactionResponse[] | undefined = add ? [reaction, ...(message?.latest_reactions || [])] : message.latest_reactions?.filter( (item) => !(item.type === reaction.type && item.user_id === reaction.user_id), @@ -65,7 +63,7 @@ export const useReactionHandler = (message?: StreamMessage) => { latest_reactions: newReactions || message.latest_reactions, own_reactions: newOwnReactions, reaction_groups: newReactionGroups, - } as StreamMessage; + }; }, // eslint-disable-next-line react-hooks/exhaustive-deps [client.user, client.userID], @@ -87,7 +85,6 @@ export const useReactionHandler = (message?: StreamMessage) => { try { updateMessage(tempMessage); - // @ts-expect-error message type mismatch thread?.upsertReplyLocally({ message: tempMessage }); const messageResponse = add @@ -99,7 +96,6 @@ export const useReactionHandler = (message?: StreamMessage) => { } catch (error) { // revert to the original message if the API call fails updateMessage(message); - // @ts-expect-error message type mismatch thread?.upsertReplyLocally({ message }); } }, 1000); diff --git a/src/components/Message/hooks/useReactionsFetcher.ts b/src/components/Message/hooks/useReactionsFetcher.ts index ceda43a247..dcb9f69ac7 100644 --- a/src/components/Message/hooks/useReactionsFetcher.ts +++ b/src/components/Message/hooks/useReactionsFetcher.ts @@ -1,17 +1,21 @@ -import type { StreamMessage } from '../../../context'; import { useChatContext, useTranslationContext } from '../../../context'; -import type { ReactionResponse, ReactionSort, StreamChat } from 'stream-chat'; +import type { + LocalMessage, + ReactionResponse, + ReactionSort, + StreamChat, +} from 'stream-chat'; import type { ReactionType } from '../../Reactions/types'; export const MAX_MESSAGE_REACTIONS_TO_FETCH = 1000; type FetchMessageReactionsNotifications = { - getErrorNotification?: (message: StreamMessage) => string; + getErrorNotification?: (message: LocalMessage) => string; notify?: (notificationText: string, type: 'success' | 'error') => void; }; export function useReactionsFetcher( - message: StreamMessage, + message: LocalMessage, notifications: FetchMessageReactionsNotifications = {}, ) { const { client } = useChatContext('useRectionsFetcher'); diff --git a/src/components/Message/hooks/useUserHandler.ts b/src/components/Message/hooks/useUserHandler.ts index 7edb1a3e84..cd81b0c440 100644 --- a/src/components/Message/hooks/useUserHandler.ts +++ b/src/components/Message/hooks/useUserHandler.ts @@ -1,13 +1,12 @@ import type { User } from 'stream-chat'; import type { ReactEventHandler } from '../types'; - -import type { StreamMessage } from '../../../context/ChannelStateContext'; +import type { LocalMessage } from 'stream-chat'; export type UserEventHandler = (event: React.BaseSyntheticEvent, user: User) => void; export const useUserHandler = ( - message?: StreamMessage, + message?: LocalMessage, eventHandlers?: { onUserClickHandler?: UserEventHandler; onUserHoverHandler?: UserEventHandler; diff --git a/src/components/Message/hooks/useUserRole.ts b/src/components/Message/hooks/useUserRole.ts index 8367659942..bcbcf7a50b 100644 --- a/src/components/Message/hooks/useUserRole.ts +++ b/src/components/Message/hooks/useUserRole.ts @@ -1,9 +1,9 @@ -import type { StreamMessage } from '../../../context/ChannelStateContext'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; import { useChatContext } from '../../../context/ChatContext'; +import type { LocalMessage } from 'stream-chat'; export const useUserRole = ( - message: StreamMessage, + message: LocalMessage, onlySenderCanEdit?: boolean, disableQuotedMessages?: boolean, ) => { diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index 8ace62cddb..e598b94a80 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -5,25 +5,22 @@ import type { ReactionSort, UserResponse } from 'stream-chat'; import type { PinPermissions, UserEventHandler } from './hooks'; import type { MessageActionsArray } from './utils'; +import type { LocalMessage } from 'stream-chat'; import type { GroupStyle } from '../MessageList/utils'; import type { MessageInputProps } from '../MessageInput/MessageInput'; import type { ReactionDetailsComparator, ReactionsComparator } from '../Reactions/types'; - import type { ChannelActionContextValue } from '../../context/ChannelActionContext'; -import type { StreamMessage } from '../../context/ChannelStateContext'; import type { ComponentContextValue } from '../../context/ComponentContext'; import type { MessageContextValue } from '../../context/MessageContext'; - import type { RenderTextOptions } from './renderText'; -import type { CustomTrigger } from '../../types/types'; export type ReactEventHandler = (event: React.BaseSyntheticEvent) => Promise<void> | void; -export type MessageProps<V extends CustomTrigger = CustomTrigger> = { +export type MessageProps = { /** The message object */ - message: StreamMessage; + message: LocalMessage; /** Additional props for underlying MessageInput component, [available props](https://getstream.io/chat/docs/sdk/react/message-input-components/message_input/#props) */ - additionalMessageInputProps?: MessageInputProps<V>; + additionalMessageInputProps?: MessageInputProps; /** Call this function to keep message list scrolled to the bottom when the scroll height increases, e.g. an element appears below the last message (only used in the `VirtualizedMessageList`) */ autoscrollToBottom?: () => void; /** If true, picking a reaction from the `ReactionSelector` component will close the selector */ @@ -39,23 +36,23 @@ export type MessageProps<V extends CustomTrigger = CustomTrigger> = { /** Override the default formatting of the date. This is a function that has access to the original date object, returns a string */ formatDate?: (date: Date) => string; /** Function that returns the notification text to be displayed when a delete message request fails */ - getDeleteMessageErrorNotification?: (message: StreamMessage) => string; + getDeleteMessageErrorNotification?: (message: LocalMessage) => string; /** Function that returns the notification text to be displayed when loading message reactions fails */ - getFetchReactionsErrorNotification?: (message: StreamMessage) => string; + getFetchReactionsErrorNotification?: (message: LocalMessage) => string; /** Function that returns the notification text to be displayed when a flag message request fails */ - getFlagMessageErrorNotification?: (message: StreamMessage) => string; + getFlagMessageErrorNotification?: (message: LocalMessage) => string; /** Function that returns the notification text to be displayed when a flag message request succeeds */ - getFlagMessageSuccessNotification?: (message: StreamMessage) => string; + getFlagMessageSuccessNotification?: (message: LocalMessage) => string; /** Function that returns the notification text to be displayed when mark channel messages unread request fails */ - getMarkMessageUnreadErrorNotification?: (message: StreamMessage) => string; + getMarkMessageUnreadErrorNotification?: (message: LocalMessage) => string; /** Function that returns the notification text to be displayed when mark channel messages unread request succeeds */ - getMarkMessageUnreadSuccessNotification?: (message: StreamMessage) => string; + getMarkMessageUnreadSuccessNotification?: (message: LocalMessage) => string; /** Function that returns the notification text to be displayed when a mute user request fails */ getMuteUserErrorNotification?: (user: UserResponse) => string; /** Function that returns the notification text to be displayed when a mute user request succeeds */ getMuteUserSuccessNotification?: (user: UserResponse) => string; /** Function that returns the notification text to be displayed when a pin message request fails */ - getPinMessageErrorNotification?: (message: StreamMessage) => string; + getPinMessageErrorNotification?: (message: LocalMessage) => string; /** If true, group messages sent by each user (only used in the `VirtualizedMessageList`) */ groupedByUser?: boolean; /** A list of styles to apply to this message, i.e. top, bottom, single */ @@ -113,6 +110,6 @@ export type MessageProps<V extends CustomTrigger = CustomTrigger> = { export type MessageUIComponentProps = Partial<MessageContextValue>; export type PinIndicatorProps = { - message?: StreamMessage; + message?: LocalMessage; t?: TFunction; }; diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 970a9b74b6..e8c2b64864 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -2,14 +2,20 @@ import deepequal from 'react-fast-compare'; import emojiRegex from 'emoji-regex'; import type { TFunction } from 'i18next'; -import type { MessageResponse, Mute, StreamChat, UserResponse } from 'stream-chat'; +import type { + LocalMessage, + LocalMessageBase, + MessageResponse, + Mute, + StreamChat, + UserResponse, +} from 'stream-chat'; import type { PinPermissions } from './hooks'; import type { MessageProps } from './types'; import type { ComponentContextValue, CustomMessageActions, MessageContextValue, - StreamMessage, } from '../../context'; /** @@ -38,7 +44,7 @@ export const validateAndGetMessage = <T extends unknown[]>( /** * Tell if the owner of the current message is muted */ -export const isUserMuted = (message: StreamMessage, mutes?: Mute[]) => { +export const isUserMuted = (message: LocalMessage, mutes?: Mute[]) => { if (!mutes || !message) return false; const userMuted = mutes.filter((el) => el.target.id === message.user?.id); @@ -257,11 +263,11 @@ export const shouldRenderMessageActions = ({ return true; }; -function areMessagesEqual( - prevMessage: StreamMessage, - nextMessage: StreamMessage, -): boolean { - return ( +function areMessagesEqual(prevMessage: LocalMessage, nextMessage: LocalMessage): boolean { + const areBaseMessagesEqual = ( + prevMessage: LocalMessageBase, + nextMessage: LocalMessageBase, + ) => prevMessage.deleted_at === nextMessage.deleted_at && prevMessage.latest_reactions?.length === nextMessage.latest_reactions?.length && prevMessage.own_reactions?.length === nextMessage.own_reactions?.length && @@ -271,12 +277,15 @@ function areMessagesEqual( prevMessage.text === nextMessage.text && prevMessage.type === nextMessage.type && prevMessage.updated_at === nextMessage.updated_at && - prevMessage.user?.updated_at === nextMessage.user?.updated_at && + prevMessage.user?.updated_at === nextMessage.user?.updated_at; + + return ( + areBaseMessagesEqual(prevMessage, nextMessage) && Boolean(prevMessage.quoted_message) === Boolean(nextMessage.quoted_message) && - (!prevMessage.quoted_message || - areMessagesEqual( - prevMessage.quoted_message as StreamMessage, - nextMessage.quoted_message as StreamMessage, + ((!prevMessage.quoted_message && !nextMessage.quoted_message) || + areBaseMessagesEqual( + prevMessage.quoted_message as LocalMessageBase, + nextMessage.quoted_message as LocalMessageBase, )) ); } @@ -355,10 +364,10 @@ export const areMessageUIPropsEqual = ( return areMessagesEqual(prevMessage, nextMessage); }; -export const messageHasReactions = (message?: StreamMessage) => +export const messageHasReactions = (message?: LocalMessage) => Object.values(message?.reaction_groups ?? {}).some(({ count }) => count > 0); -export const messageHasAttachments = (message?: StreamMessage) => +export const messageHasAttachments = (message?: LocalMessage) => !!message?.attachments && !!message.attachments.length; export const getImages = (message?: MessageResponse) => { @@ -453,19 +462,18 @@ export const isOnlyEmojis = (text?: string) => { }; export const isMessageBounced = ( - message: Pick<StreamMessage, 'type' | 'moderation' | 'moderation_details'>, + message: Pick<LocalMessage, 'type' | 'moderation' | 'moderation_details'>, ) => message.type === 'error' && (message.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_BOUNCE' || message.moderation?.action === 'bounce'); export const isMessageBlocked = ( - message: Pick<StreamMessage, 'type' | 'moderation' | 'moderation_details'>, + message: Pick<LocalMessage, 'type' | 'moderation' | 'moderation_details'>, ) => message.type === 'error' && (message.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_REMOVE' || message.moderation?.action === 'remove'); -export const isMessageEdited = ( - message: Pick<StreamMessage, 'message_text_updated_at'>, -) => !!message.message_text_updated_at; +export const isMessageEdited = (message: Pick<LocalMessage, 'message_text_updated_at'>) => + !!message.message_text_updated_at; diff --git a/src/components/MessageActions/CustomMessageActionsList.tsx b/src/components/MessageActions/CustomMessageActionsList.tsx index 5a4e2d5096..969439d093 100644 --- a/src/components/MessageActions/CustomMessageActionsList.tsx +++ b/src/components/MessageActions/CustomMessageActionsList.tsx @@ -1,11 +1,10 @@ import React from 'react'; +import type { LocalMessage } from 'stream-chat'; import type { CustomMessageActions } from '../../context/MessageContext'; -import type { StreamMessage } from '../../context/ChannelStateContext'; - export type CustomMessageActionsListProps = { - message: StreamMessage; + message: LocalMessage; customMessageActions?: CustomMessageActions; }; diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index ff29360fe2..a9a40b988c 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -110,6 +110,7 @@ export const MessageActions = (props: MessageActionsProps) => { id={dialogId} placement={isMine ? 'top-end' : 'top-start'} referenceElement={actionsBoxButtonRef.current} + tabIndex={-1} trapFocus > <MessageActionsBox diff --git a/src/components/MessageActions/MessageActionsBox.tsx b/src/components/MessageActions/MessageActionsBox.tsx index 3d778836c9..a1cca670a6 100644 --- a/src/components/MessageActions/MessageActionsBox.tsx +++ b/src/components/MessageActions/MessageActionsBox.tsx @@ -6,13 +6,13 @@ import { MESSAGE_ACTIONS } from '../Message/utils'; import type { MessageContextValue } from '../../context'; import { - useChannelActionContext, useComponentContext, useMessageContext, useTranslationContext, } from '../../context'; import { CustomMessageActionsList as DefaultCustomMessageActionsList } from './CustomMessageActionsList'; +import { useMessageComposer } from '../MessageInput'; type PropsDrilledToMessageActionsBox = | 'getMessageActions' @@ -51,16 +51,15 @@ const UnMemoizedMessageActionsBox = (props: MessageActionsBoxProps) => { const { CustomMessageActionsList = DefaultCustomMessageActionsList } = useComponentContext('MessageActionsBox'); - const { setQuotedMessage } = useChannelActionContext('MessageActionsBox'); const { customMessageActions, message, threadList } = useMessageContext('MessageActionsBox'); - const { t } = useTranslationContext('MessageActionsBox'); + const messageComposer = useMessageComposer(); const messageActions = getMessageActions(); const handleQuote = () => { - setQuotedMessage(message); + messageComposer.setQuotedMessage(message); const elements = message.parent_id ? document.querySelectorAll('.str-chat__thread .str-chat__textarea__textarea') diff --git a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx index 12fed928f9..6a83ae3125 100644 --- a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx @@ -1,23 +1,24 @@ import type { ComponentType } from 'react'; import React from 'react'; -import type { UnsupportedAttachmentPreviewProps } from './UnsupportedAttachmentPreview'; -import { UnsupportedAttachmentPreview as DefaultUnknownAttachmentPreview } from './UnsupportedAttachmentPreview'; -import type { VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; -import { VoiceRecordingPreview as DefaultVoiceRecordingPreview } from './VoiceRecordingPreview'; -import type { FileAttachmentPreviewProps } from './FileAttachmentPreview'; -import { FileAttachmentPreview as DefaultFilePreview } from './FileAttachmentPreview'; -import type { ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; -import { ImageAttachmentPreview as DefaultImagePreview } from './ImageAttachmentPreview'; import { isLocalAttachment, isLocalAudioAttachment, isLocalFileAttachment, isLocalImageAttachment, - isLocalMediaAttachment, + isLocalVideoAttachment, isLocalVoiceRecordingAttachment, isScrapedContent, -} from '../../Attachment'; -import { useMessageInputContext } from '../../../context'; +} from 'stream-chat'; +import type { UnsupportedAttachmentPreviewProps } from './UnsupportedAttachmentPreview'; +import { UnsupportedAttachmentPreview as DefaultUnknownAttachmentPreview } from './UnsupportedAttachmentPreview'; +import { VoiceRecordingPreview as DefaultVoiceRecordingPreview } from './VoiceRecordingPreview'; +import { FileAttachmentPreview as DefaultFilePreview } from './FileAttachmentPreview'; +import { ImageAttachmentPreview as DefaultImagePreview } from './ImageAttachmentPreview'; +import { useMessageComposer } from '../hooks'; +import { useAttachmentManagerState } from '../hooks/messageComposer/useAttachmentManagerState'; +import type { VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; +import type { FileAttachmentPreviewProps } from './FileAttachmentPreview'; +import type { ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; export type AttachmentPreviewListProps = { AudioAttachmentPreview?: ComponentType<FileAttachmentPreviewProps>; @@ -36,9 +37,11 @@ export const AttachmentPreviewList = ({ VideoAttachmentPreview = DefaultFilePreview, VoiceRecordingPreview = DefaultVoiceRecordingPreview, }: AttachmentPreviewListProps) => { - const { attachments, removeAttachments, uploadAttachment } = useMessageInputContext( - 'AttachmentPreviewList', - ); + const messageComposer = useMessageComposer(); + + const { attachments } = useAttachmentManagerState(); + + if (!attachments.length) return null; return ( <div className='str-chat__attachment-preview-list'> @@ -52,54 +55,54 @@ export const AttachmentPreviewList = ({ return ( <VoiceRecordingPreview attachment={attachment} - handleRetry={uploadAttachment} + handleRetry={messageComposer.attachmentManager.uploadAttachment} key={attachment.localMetadata.id || attachment.asset_url} - removeAttachments={removeAttachments} + removeAttachments={messageComposer.attachmentManager.removeAttachments} /> ); } else if (isLocalAudioAttachment(attachment)) { return ( <AudioAttachmentPreview attachment={attachment} - handleRetry={uploadAttachment} + handleRetry={messageComposer.attachmentManager.uploadAttachment} key={attachment.localMetadata.id || attachment.asset_url} - removeAttachments={removeAttachments} + removeAttachments={messageComposer.attachmentManager.removeAttachments} /> ); - } else if (isLocalMediaAttachment(attachment)) { + } else if (isLocalVideoAttachment(attachment)) { return ( <VideoAttachmentPreview attachment={attachment} - handleRetry={uploadAttachment} + handleRetry={messageComposer.attachmentManager.uploadAttachment} key={attachment.localMetadata.id || attachment.asset_url} - removeAttachments={removeAttachments} + removeAttachments={messageComposer.attachmentManager.removeAttachments} /> ); } else if (isLocalImageAttachment(attachment)) { return ( <ImageAttachmentPreview attachment={attachment} - handleRetry={uploadAttachment} + handleRetry={messageComposer.attachmentManager.uploadAttachment} key={attachment.localMetadata.id || attachment.image_url} - removeAttachments={removeAttachments} + removeAttachments={messageComposer.attachmentManager.removeAttachments} /> ); } else if (isLocalFileAttachment(attachment)) { return ( <FileAttachmentPreview attachment={attachment} - handleRetry={uploadAttachment} + handleRetry={messageComposer.attachmentManager.uploadAttachment} key={attachment.localMetadata.id || attachment.asset_url} - removeAttachments={removeAttachments} + removeAttachments={messageComposer.attachmentManager.removeAttachments} /> ); } else if (isLocalAttachment(attachment)) { return ( <UnsupportedAttachmentPreview attachment={attachment} - handleRetry={uploadAttachment} + handleRetry={messageComposer.attachmentManager.uploadAttachment} key={attachment.localMetadata.id} - removeAttachments={removeAttachments} + removeAttachments={messageComposer.attachmentManager.removeAttachments} /> ); } diff --git a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx index f32a3695cf..735b76760e 100644 --- a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx @@ -1,23 +1,21 @@ import React from 'react'; +import { useTranslationContext } from '../../../context'; import { FileIcon } from '../../ReactFileUtilities'; import { CloseIcon, DownloadIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; -import { useTranslationContext } from '../../../context'; -import type { AttachmentPreviewProps } from './types'; -import type { LocalAttachmentCast, LocalAttachmentUploadMetadata } from '../types'; -import type { Attachment } from 'stream-chat'; +import type { + LocalAudioAttachment, + LocalFileAttachment, + LocalVideoAttachment, +} from 'stream-chat'; +import type { UploadAttachmentPreviewProps } from './types'; -type FileLikeAttachment = Partial< - Pick<Attachment, 'title' | 'file_size' | 'asset_url' | 'mime_type'> ->; - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export type FileAttachmentPreviewProps<CustomLocalMetadata = {}> = AttachmentPreviewProps< - LocalAttachmentCast< - FileLikeAttachment, - LocalAttachmentUploadMetadata & CustomLocalMetadata - > ->; +export type FileAttachmentPreviewProps<CustomLocalMetadata = unknown> = + UploadAttachmentPreviewProps< + | LocalFileAttachment<CustomLocalMetadata> + | LocalAudioAttachment<CustomLocalMetadata> + | LocalVideoAttachment<CustomLocalMetadata> + >; export const FileAttachmentPreview = ({ attachment, @@ -25,6 +23,8 @@ export const FileAttachmentPreview = ({ removeAttachments, }: FileAttachmentPreviewProps) => { const { t } = useTranslationContext('FilePreview'); + const uploadState = attachment.localMetadata?.uploadState; + return ( <div className='str-chat__attachment-preview-file' @@ -38,7 +38,7 @@ export const FileAttachmentPreview = ({ aria-label={t('aria/Remove attachment')} className='str-chat__attachment-preview-delete' data-testid='file-preview-item-delete-button' - disabled={attachment.localMetadata?.uploadState === 'uploading'} + disabled={uploadState === 'uploading'} onClick={() => attachment.localMetadata?.id && removeAttachments([attachment.localMetadata?.id]) @@ -47,7 +47,7 @@ export const FileAttachmentPreview = ({ <CloseIcon /> </button> - {attachment.localMetadata?.uploadState === 'failed' && !!handleRetry && ( + {['blocked', 'failed'].includes(uploadState) && !!handleRetry && ( <button className='str-chat__attachment-preview-error str-chat__attachment-preview-error-file' data-testid='file-preview-item-retry-button' @@ -61,7 +61,8 @@ export const FileAttachmentPreview = ({ <div className='str-chat__attachment-preview-file-name' title={attachment.title}> {attachment.title} </div> - {attachment.localMetadata?.uploadState === 'finished' && + {/* undefined if loaded from a draft */} + {(typeof uploadState === 'undefined' || uploadState === 'finished') && !!attachment.asset_url && ( <a aria-label={t('aria/Download attachment')} @@ -75,9 +76,7 @@ export const FileAttachmentPreview = ({ <DownloadIcon /> </a> )} - {attachment.localMetadata?.uploadState === 'uploading' && ( - <LoadingIndicatorIcon size={17} /> - )} + {uploadState === 'uploading' && <LoadingIndicatorIcon size={17} />} </div> </div> ); diff --git a/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx index e952ca900a..879b2e8bf8 100644 --- a/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx @@ -1,13 +1,13 @@ import clsx from 'clsx'; -import { CloseIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; import React, { useCallback, useState } from 'react'; +import { CloseIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; import { BaseImage as DefaultBaseImage } from '../../Gallery'; import { useComponentContext, useTranslationContext } from '../../../context'; -import type { AttachmentPreviewProps } from './types'; -import type { LocalImageAttachment } from '../types'; +import type { LocalImageAttachment } from 'stream-chat'; +import type { UploadAttachmentPreviewProps } from './types'; export type ImageAttachmentPreviewProps<CustomLocalMetadata = Record<string, unknown>> = - AttachmentPreviewProps<LocalImageAttachment<CustomLocalMetadata>>; + UploadAttachmentPreviewProps<LocalImageAttachment<CustomLocalMetadata>>; export const ImageAttachmentPreview = ({ attachment, @@ -40,7 +40,7 @@ export const ImageAttachmentPreview = ({ <CloseIcon /> </button> - {uploadState === 'failed' && ( + {['blocked', 'failed'].includes(uploadState) && ( <button className='str-chat__attachment-preview-error str-chat__attachment-preview-error-image' data-testid='image-preview-item-retry-button' diff --git a/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx index 7249536f52..a23b83d8e8 100644 --- a/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx @@ -1,13 +1,19 @@ import React from 'react'; +import { isLocalUploadAttachment } from 'stream-chat'; import { CloseIcon, DownloadIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; import { FileIcon } from '../../ReactFileUtilities'; import { useTranslationContext } from '../../../context'; -import type { AttachmentPreviewProps } from './types'; -import type { AnyLocalAttachment } from '../types'; +import type { AnyLocalAttachment, LocalUploadAttachment } from 'stream-chat'; export type UnsupportedAttachmentPreviewProps< CustomLocalMetadata = Record<string, unknown>, -> = AttachmentPreviewProps<AnyLocalAttachment<CustomLocalMetadata>>; +> = { + attachment: AnyLocalAttachment<CustomLocalMetadata>; + handleRetry: ( + attachment: LocalUploadAttachment, + ) => void | Promise<LocalUploadAttachment | undefined>; + removeAttachments: (ids: string[]) => void; +}; export const UnsupportedAttachmentPreview = ({ attachment, @@ -37,15 +43,17 @@ export const UnsupportedAttachmentPreview = ({ <CloseIcon /> </button> - {attachment.localMetadata?.uploadState === 'failed' && !!handleRetry && ( - <button - className='str-chat__attachment-preview-error str-chat__attachment-preview-error-file' - data-testid='file-preview-item-retry-button' - onClick={() => handleRetry(attachment)} - > - <RetryIcon /> - </button> - )} + {isLocalUploadAttachment(attachment) && + ['blocked', 'failed'].includes(attachment.localMetadata?.uploadState) && + !!handleRetry && ( + <button + className='str-chat__attachment-preview-error str-chat__attachment-preview-error-file' + data-testid='file-preview-item-retry-button' + onClick={() => handleRetry(attachment)} + > + <RetryIcon /> + </button> + )} <div className='str-chat__attachment-preview-metadata'> <div className='str-chat__attachment-preview-title' title={title}> diff --git a/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx index 0687fa73f4..8b12b124bd 100644 --- a/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx @@ -4,11 +4,11 @@ import { RecordingTimer } from '../../MediaRecorder'; import { CloseIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; import { FileIcon } from '../../ReactFileUtilities'; import { useAudioController } from '../../Attachment/hooks/useAudioController'; -import type { AttachmentPreviewProps } from './types'; -import type { LocalVoiceRecordingAttachment } from '../types'; +import type { LocalVoiceRecordingAttachment } from 'stream-chat'; +import type { UploadAttachmentPreviewProps } from './types'; export type VoiceRecordingPreviewProps<CustomLocalMetadata = Record<string, unknown>> = - AttachmentPreviewProps<LocalVoiceRecordingAttachment<CustomLocalMetadata>>; + UploadAttachmentPreviewProps<LocalVoiceRecordingAttachment<CustomLocalMetadata>>; export const VoiceRecordingPreview = ({ attachment, @@ -44,15 +44,16 @@ export const VoiceRecordingPreview = ({ <CloseIcon /> </button> - {attachment.localMetadata?.uploadState === 'failed' && !!handleRetry && ( - <button - className='str-chat__attachment-preview-error str-chat__attachment-preview-error-file' - data-testid='file-preview-item-retry-button' - onClick={() => handleRetry(attachment)} - > - <RetryIcon /> - </button> - )} + {['blocked', 'failed'].includes(attachment.localMetadata?.uploadState) && + !!handleRetry && ( + <button + className='str-chat__attachment-preview-error str-chat__attachment-preview-error-file' + data-testid='file-preview-item-retry-button' + onClick={() => handleRetry(attachment)} + > + <RetryIcon /> + </button> + )} <div className='str-chat__attachment-preview-metadata'> <div className='str-chat__attachment-preview-file-name' title={attachment.title}> diff --git a/src/components/MessageInput/AttachmentPreviewList/index.ts b/src/components/MessageInput/AttachmentPreviewList/index.ts index 8156d1e361..bc8120e4a9 100644 --- a/src/components/MessageInput/AttachmentPreviewList/index.ts +++ b/src/components/MessageInput/AttachmentPreviewList/index.ts @@ -1,6 +1,6 @@ export * from './AttachmentPreviewList'; export type { FileAttachmentPreviewProps } from './FileAttachmentPreview'; export type { ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; -export type { AttachmentPreviewProps } from './types'; +export type { UploadAttachmentPreviewProps as AttachmentPreviewProps } from './types'; export type { UnsupportedAttachmentPreviewProps } from './UnsupportedAttachmentPreview'; export type { VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; diff --git a/src/components/MessageInput/AttachmentPreviewList/types.ts b/src/components/MessageInput/AttachmentPreviewList/types.ts index 5cfb587f1c..c34daa723a 100644 --- a/src/components/MessageInput/AttachmentPreviewList/types.ts +++ b/src/components/MessageInput/AttachmentPreviewList/types.ts @@ -1,9 +1,9 @@ -import type { LocalAttachment } from '../types'; +import type { LocalUploadAttachment } from 'stream-chat'; -export type AttachmentPreviewProps<A extends LocalAttachment> = { +export type UploadAttachmentPreviewProps<A extends LocalUploadAttachment> = { attachment: A; handleRetry: ( - attachment: LocalAttachment, - ) => void | Promise<LocalAttachment | undefined>; + attachment: LocalUploadAttachment, + ) => void | Promise<LocalUploadAttachment | undefined>; removeAttachments: (ids: string[]) => void; }; diff --git a/src/components/MessageInput/AttachmentSelector.tsx b/src/components/MessageInput/AttachmentSelector.tsx index 369bff9623..55dd21e720 100644 --- a/src/components/MessageInput/AttachmentSelector.tsx +++ b/src/components/MessageInput/AttachmentSelector.tsx @@ -20,6 +20,8 @@ import { useAttachmentSelectorContext, } from '../../context/AttachmentSelectorContext'; +import { useAttachmentManagerState } from './hooks/messageComposer/useAttachmentManagerState'; + export const SimpleAttachmentSelector = () => { const { AttachmentSelectorInitiationButtonContents, @@ -92,10 +94,12 @@ export const DefaultAttachmentSelectorComponents = { File({ closeMenu }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); const { fileInput } = useAttachmentSelectorContext(); + const { isUploadEnabled } = useAttachmentManagerState(); return ( <DialogMenuButton className='str-chat__attachment-selector-actions-menu__button str-chat__attachment-selector-actions-menu__upload-file-button' + disabled={!isUploadEnabled} // todo: add styles for disabled state onClick={() => { if (fileInput) fileInput.click(); closeMenu(); @@ -219,6 +223,7 @@ export const AttachmentSelector = ({ id={menuDialogId} placement='top-start' referenceElement={menuButtonRef.current} + tabIndex={-1} trapFocus > <div diff --git a/src/components/MessageInput/DefaultTriggerProvider.tsx b/src/components/MessageInput/DefaultTriggerProvider.tsx deleted file mode 100644 index 0b217a230a..0000000000 --- a/src/components/MessageInput/DefaultTriggerProvider.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import React from 'react'; - -import { useCommandTrigger } from './hooks/useCommandTrigger'; -import { useEmojiTrigger } from './hooks/useEmojiTrigger'; -import { useUserTrigger } from './hooks/useUserTrigger'; - -import { - MessageInputContextProvider, - useMessageInputContext, -} from '../../context/MessageInputContext'; - -import type { - SuggestionCommand, - SuggestionUser, -} from '../ChatAutoComplete/ChatAutoComplete'; -import type { CommandItemProps } from '../CommandItem/CommandItem'; -import type { EmoticonItemProps } from '../EmoticonItem/EmoticonItem'; -import type { UserItemProps } from '../UserItem/UserItem'; - -import type { CustomTrigger, UnknownType } from '../../types/types'; - -export type AutocompleteMinimalData = { - id?: string; - name?: string; -} & ({ id: string } | { name: string }); - -export type CommandTriggerSetting = TriggerSetting<CommandItemProps, SuggestionCommand>; - -export type EmojiTriggerSetting = TriggerSetting<EmoticonItemProps>; - -export type UserTriggerSetting = TriggerSetting<UserItemProps, SuggestionUser>; - -export type TriggerSetting<T extends UnknownType = UnknownType, U = UnknownType> = { - component: string | React.ComponentType<T>; - dataProvider: ( - query: string, - text: string, - onReady: (data: (U & AutocompleteMinimalData)[], token: string) => void, - ) => U[] | PromiseLike<void> | void; - output: (entity: U) => - | { - caretPosition: 'start' | 'end' | 'next' | number; - text: string; - key?: string; - } - | string - | null; - callback?: (item: U) => void; -}; - -export type TriggerSettings<V extends CustomTrigger = CustomTrigger> = - | { - [key in keyof V]: TriggerSetting<V[key]['componentProps'], V[key]['data']>; - } - | { - '/': CommandTriggerSetting; - ':': EmojiTriggerSetting; - '@': UserTriggerSetting; - }; - -export const DefaultTriggerProvider = <V extends CustomTrigger = CustomTrigger>({ - children, -}: PropsWithChildren<Record<string, unknown>>) => { - const currentValue = useMessageInputContext<V>('DefaultTriggerProvider'); - - const defaultAutocompleteTriggers: TriggerSettings = { - '/': useCommandTrigger(), - ':': useEmojiTrigger(currentValue.emojiSearchIndex), - '@': useUserTrigger({ - disableMentions: currentValue.disableMentions, - mentionAllAppUsers: currentValue.mentionAllAppUsers, - mentionQueryParams: currentValue.mentionQueryParams, - onSelectUser: currentValue.onSelectUser, - useMentionsTransliteration: currentValue.useMentionsTransliteration, - }), - }; - - const newValue = { - ...currentValue, - autocompleteTriggers: defaultAutocompleteTriggers, - }; - - return ( - <MessageInputContextProvider value={newValue}>{children}</MessageInputContextProvider> - ); -}; diff --git a/src/components/MessageInput/DropzoneProvider.tsx b/src/components/MessageInput/DropzoneProvider.tsx index 1342c94b15..69d67ee9ff 100644 --- a/src/components/MessageInput/DropzoneProvider.tsx +++ b/src/components/MessageInput/DropzoneProvider.tsx @@ -14,36 +14,45 @@ import { import type { MessageInputProps } from './MessageInput'; -import type { CustomTrigger, UnknownType } from '../../types/types'; +import type { UnknownType } from '../../types/types'; +import { useMessageComposer } from './hooks'; +import { useAttachmentManagerState } from './hooks'; +import { useStateStore } from '../../store'; +import type { MessageComposerConfig } from 'stream-chat'; -const DropzoneInner = <V extends CustomTrigger = CustomTrigger>({ - children, -}: PropsWithChildren<UnknownType>) => { - const { acceptedFiles, multipleUploads } = useChannelStateContext('DropzoneProvider'); +const attachmentManagerConfigStateSelector = (state: MessageComposerConfig) => ({ + maxNumberOfFilesPerMessage: state.attachments.maxNumberOfFilesPerMessage, +}); - const { cooldownRemaining, isUploadEnabled, maxFilesLeft, uploadNewFiles } = - useMessageInputContext<V>('DropzoneProvider'); +const DropzoneInner = ({ children }: PropsWithChildren<UnknownType>) => { + const { acceptedFiles } = useChannelStateContext('DropzoneProvider'); + + const { cooldownRemaining } = useMessageInputContext('DropzoneProvider'); + const messageComposer = useMessageComposer(); + const { maxNumberOfFilesPerMessage } = useStateStore( + messageComposer.configState, + attachmentManagerConfigStateSelector, + ); + const { availableUploadSlots, isUploadEnabled } = useAttachmentManagerState(); return ( <ImageDropzone accept={acceptedFiles} - disabled={!isUploadEnabled || maxFilesLeft === 0 || !!cooldownRemaining} - handleFiles={uploadNewFiles} - maxNumberOfFiles={maxFilesLeft} - multiple={multipleUploads} + disabled={!isUploadEnabled || !!cooldownRemaining} + handleFiles={messageComposer.attachmentManager.uploadFiles} + maxNumberOfFiles={availableUploadSlots} + multiple={maxNumberOfFilesPerMessage > 1} > {children} </ImageDropzone> ); }; -export const DropzoneProvider = <V extends CustomTrigger = CustomTrigger>( - props: PropsWithChildren<MessageInputProps<V>>, -) => { +export const DropzoneProvider = (props: PropsWithChildren<MessageInputProps>) => { const cooldownTimerState = useCooldownTimer(); - const messageInputState = useMessageInputState<V>(props); + const messageInputState = useMessageInputState(props); - const messageInputContextValue = useCreateMessageInputContext<V>({ + const messageInputContextValue = useCreateMessageInputContext({ ...cooldownTimerState, ...messageInputState, ...props, diff --git a/src/components/MessageInput/EditMessageForm.tsx b/src/components/MessageInput/EditMessageForm.tsx index b492bbf6d0..072bf224a9 100644 --- a/src/components/MessageInput/EditMessageForm.tsx +++ b/src/components/MessageInput/EditMessageForm.tsx @@ -1,24 +1,50 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; +import { MessageInput } from './MessageInput'; import { MessageInputFlat } from './MessageInputFlat'; +import { Modal } from '../Modal'; +import { + useComponentContext, + useMessageContext, + useMessageInputContext, + useTranslationContext, +} from '../../context'; +import { useMessageComposer, useMessageComposerHasSendableData } from './hooks'; -import { useMessageInputContext, useTranslationContext } from '../../context'; +import type { MessageUIComponentProps } from '../Message'; -import type { CustomTrigger } from '../../types/types'; +const EditMessageFormSendButton = () => { + const { t } = useTranslationContext(); + const hasSendableData = useMessageComposerHasSendableData(); + return ( + <button + className='str-chat__edit-message-send' + data-testid='send-button-edit-form' + disabled={!hasSendableData} + type='submit' + > + {t<string>('Send')} + </button> + ); +}; -export const EditMessageForm = <V extends CustomTrigger = CustomTrigger>() => { +export const EditMessageForm = () => { const { t } = useTranslationContext('EditMessageForm'); + const messageComposer = useMessageComposer(); + const { clearEditingState, handleSubmit } = useMessageInputContext('EditMessageForm'); - const { clearEditingState, handleSubmit } = - useMessageInputContext<V>('EditMessageForm'); + const cancel = useCallback(() => { + clearEditingState?.(); + messageComposer.restore(); + }, [clearEditingState, messageComposer]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') clearEditingState?.(); + if (event.key === 'Escape') cancel(); }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); - }, [clearEditingState]); + }, [cancel]); return ( <form @@ -31,18 +57,40 @@ export const EditMessageForm = <V extends CustomTrigger = CustomTrigger>() => { <button className='str-chat__edit-message-cancel' data-testid='cancel-button' - onClick={clearEditingState} + onClick={cancel} > {t<string>('Cancel')} </button> - <button - className='str-chat__edit-message-send' - data-testid='send-button-edit-form' - type='submit' - > - {t<string>('Send')} - </button> + <EditMessageFormSendButton /> </div> </form> ); }; + +export const EditMessageModal = ({ + additionalMessageInputProps, +}: Pick<MessageUIComponentProps, 'additionalMessageInputProps'>) => { + const { EditMessageInput = EditMessageForm } = useComponentContext(); + const { clearEditingState } = useMessageContext(); + const messageComposer = useMessageComposer(); + const onEditModalClose = useCallback(() => { + clearEditingState(); + messageComposer.restore(); + }, [clearEditingState, messageComposer]); + + return ( + <Modal + className='str-chat__edit-message-modal' + onClose={onEditModalClose} + open={true} + > + <MessageInput + clearEditingState={clearEditingState} + grow + hideSendButton + Input={EditMessageInput} + {...additionalMessageInputProps} + /> + </Modal> + ); +}; diff --git a/src/components/MessageInput/LinkPreviewList.tsx b/src/components/MessageInput/LinkPreviewList.tsx index e3276fcf22..b4297d7e19 100644 --- a/src/components/MessageInput/LinkPreviewList.tsx +++ b/src/components/MessageInput/LinkPreviewList.tsx @@ -1,29 +1,50 @@ import clsx from 'clsx'; import React, { useState } from 'react'; -import { useChannelStateContext, useMessageInputContext } from '../../context'; -import type { LinkPreview } from './types'; -import { LinkPreviewState } from './types'; -import { CloseIcon, LinkIcon } from './icons'; +import type { + LinkPreview, + LinkPreviewsManagerState, + MessageComposerState, +} from 'stream-chat'; +import { LinkPreviewsManager } from 'stream-chat'; +import { useStateStore } from '../../store'; import { PopperTooltip } from '../Tooltip'; import { useEnterLeaveHandlers } from '../Tooltip/hooks'; +import { useMessageComposer } from './hooks'; +import { CloseIcon, LinkIcon } from './icons'; -export type LinkPreviewListProps = { - linkPreviews: LinkPreview[]; -}; +const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ + linkPreviews: Array.from(state.previews.values()).filter( + (preview) => + LinkPreviewsManager.previewIsLoaded(preview) || + LinkPreviewsManager.previewIsLoading(preview), + ), +}); + +const messageComposerStateSelector = (state: MessageComposerState) => ({ + quotedMessage: state.quotedMessage, +}); + +export const LinkPreviewList = () => { + const messageComposer = useMessageComposer(); + const { linkPreviewsManager } = messageComposer; + const { quotedMessage } = useStateStore( + messageComposer.state, + messageComposerStateSelector, + ); + const { linkPreviews } = useStateStore( + linkPreviewsManager.state, + linkPreviewsManagerStateSelector, + ); -export const LinkPreviewList = ({ linkPreviews }: LinkPreviewListProps) => { - const { quotedMessage } = useChannelStateContext(); const showLinkPreviews = linkPreviews.length > 0 && !quotedMessage; if (!showLinkPreviews) return null; return ( <div className='str-chat__link-preview-list'> - {Array.from(linkPreviews.values()).map((linkPreview) => - linkPreview.state === LinkPreviewState.LOADED ? ( - <LinkPreviewCard key={linkPreview.og_scrape_url} linkPreview={linkPreview} /> - ) : null, - )} + {linkPreviews.map((linkPreview) => ( + <LinkPreviewCard key={linkPreview.og_scrape_url} linkPreview={linkPreview} /> + ))} </div> ); }; @@ -33,15 +54,22 @@ type LinkPreviewProps = { }; const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { - const { dismissLinkPreview } = useMessageInputContext(); + const { linkPreviewsManager } = useMessageComposer(); const { handleEnter, handleLeave, tooltipVisible } = useEnterLeaveHandlers<HTMLDivElement>(); const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null); + + if ( + !LinkPreviewsManager.previewIsLoaded(linkPreview) && + !LinkPreviewsManager.previewIsLoading(linkPreview) + ) + return null; + return ( <div className={clsx('str-chat__link-preview-card', { 'str-chat__link-preview-card--loading': - linkPreview.state === LinkPreviewState.LOADING, + LinkPreviewsManager.previewIsLoading(linkPreview), })} data-testid='link-preview-card' > @@ -71,7 +99,8 @@ const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { <button className='str-chat__link-preview-card__dismiss-button' data-testid='link-preview-card-dismiss-btn' - onClick={() => dismissLinkPreview(linkPreview)} + onClick={() => linkPreviewsManager.dismissPreview(linkPreview.og_scrape_url)} + type='button' > <CloseIcon /> </button> diff --git a/src/components/MessageInput/MessageInput.tsx b/src/components/MessageInput/MessageInput.tsx index 323837eb38..3b42b374f7 100644 --- a/src/components/MessageInput/MessageInput.tsx +++ b/src/components/MessageInput/MessageInput.tsx @@ -1,25 +1,28 @@ import type { PropsWithChildren } from 'react'; -import React from 'react'; +import React, { useEffect } from 'react'; -import { DefaultTriggerProvider } from './DefaultTriggerProvider'; import { MessageInputFlat } from './MessageInputFlat'; +import { useMessageComposer } from './hooks'; import { useCooldownTimer } from './hooks/useCooldownTimer'; import { useCreateMessageInputContext } from './hooks/useCreateMessageInputContext'; import { useMessageInputState } from './hooks/useMessageInputState'; -import type { StreamMessage } from '../../context/ChannelStateContext'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import type { ComponentContextValue } from '../../context/ComponentContext'; import { useComponentContext } from '../../context/ComponentContext'; import { MessageInputContextProvider } from '../../context/MessageInputContext'; import { DialogManagerProvider } from '../../context'; -import type { Channel, Message, SendFileAPIResponse } from 'stream-chat'; +import type { + Channel, + LinkPreviewsManagerConfig, + LocalAttachmentUploadMetadata, + LocalMessage, + Message, + SendFileAPIResponse, + SendMessageOptions, +} from 'stream-chat'; -import type { BaseLocalAttachmentMetadata, LocalAttachmentUploadMetadata } from './types'; import type { SearchQueryParams } from '../ChannelSearch/hooks/useChannelSearch'; -import type { MessageToSend } from '../../context/ChannelActionContext'; -import type { CustomTrigger, SendMessageOptions, UnknownType } from '../../types/types'; -import type { URLEnrichmentConfig } from './hooks/useLinkPreviews'; import type { CustomAudioRecordingConfig } from '../MediaRecorder'; export type EmojiSearchIndexResult = { @@ -30,18 +33,22 @@ export type EmojiSearchIndexResult = { native?: string; }; -export interface EmojiSearchIndex<T extends UnknownType = UnknownType> { +export interface EmojiSearchIndex { search: ( query: string, - ) => - | PromiseLike<Array<EmojiSearchIndexResult & T>> - | Array<EmojiSearchIndexResult & T> - | null; + ) => PromiseLike<Array<EmojiSearchIndexResult>> | Array<EmojiSearchIndexResult> | null; } -export type MessageInputProps<V extends CustomTrigger = CustomTrigger> = { - /** Additional props to be passed to the underlying `AutoCompleteTextarea` component, [available props](https://www.npmjs.com/package/react-textarea-autosize) */ - additionalTextareaProps?: React.TextareaHTMLAttributes<HTMLTextAreaElement>; +export type MessageInputProps = { + /** + * Additional props to be passed to the underlying `AutoCompleteTextarea` component. + * Default value is handled via MessageComposer. + * [Available props](https://www.npmjs.com/package/react-textarea-autosize) + */ + additionalTextareaProps?: Omit< + React.TextareaHTMLAttributes<HTMLTextAreaElement>, + 'defaultValue' + >; /** * When enabled, recorded messages won’t be sent immediately. * Instead, they will “stack up” with other attachments in the message composer allowing the user to send multiple attachments as part of the same message. @@ -69,22 +76,14 @@ export type MessageInputProps<V extends CustomTrigger = CustomTrigger> = { ) => Promise<SendFileAPIResponse>; /** Mechanism to be used with autocomplete and text replace features of the `MessageInput` component, see [emoji-mart `SearchIndex`](https://github.com/missive/emoji-mart#%EF%B8%8F%EF%B8%8F-headless-search) */ emojiSearchIndex?: ComponentContextValue['emojiSearchIndex']; - /** Custom error handler function to be called with a file/image upload fails */ - errorHandler?: ( - error: Error, - type: string, - file: LocalAttachmentUploadMetadata['file'] & BaseLocalAttachmentMetadata, - ) => void; /** If true, focuses the text input on component mount */ focus?: boolean; - /** Generates the default value for the underlying textarea element. The function's return value takes precedence before additionalTextareaProps.defaultValue. */ - getDefaultValue?: () => string | string[]; /** If true, expands the text input vertically for new lines */ grow?: boolean; /** Allows to hide MessageInput's send button. */ hideSendButton?: boolean; /** Custom UI component handling how the message input is rendered, defaults to and accepts the same props as [MessageInputFlat](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/MessageInputFlat.tsx) */ - Input?: React.ComponentType<MessageInputProps<V>>; + Input?: React.ComponentType<MessageInputProps>; /** Signals that the MessageInput is rendered in a message thread (Thread component) */ isThreadInput?: boolean; /** Max number of rows the underlying `textarea` component is allowed to grow */ @@ -93,23 +92,21 @@ export type MessageInputProps<V extends CustomTrigger = CustomTrigger> = { mentionAllAppUsers?: boolean; /** Object containing filters/sort/options overrides for an @mention user query */ mentionQueryParams?: SearchQueryParams['userFilters']; - /** If provided, the existing message will be edited on submit */ - message?: StreamMessage; /** Min number of rows the underlying `textarea` will start with. The `grow` on MessageInput prop has to be enabled for `minRows` to take effect. */ minRows?: number; - /** If true, disables file uploads for all attachments except for those with type 'image'. Default: false */ - noFiles?: boolean; - /** Function to override the default submit handler */ - overrideSubmitHandler?: ( - message: MessageToSend, - channelCid: string, - customMessageData?: Partial<Message>, - options?: SendMessageOptions, - ) => Promise<void> | void; + /** Function to override the default message sending process. Not message updating process. */ + overrideSubmitHandler?: (params: { + cid: string; + localMessage: LocalMessage; + message: Message; + sendOptions: SendMessageOptions; + }) => Promise<void> | void; /** When replying in a thread, the parent message object */ - parent?: StreamMessage; + parent?: LocalMessage; + // todo: X document change in configuration /** If true, triggers typing events on text input keystroke */ publishTypingEvent?: boolean; + // todo: X document the change of transliterate prop /** If true, will use an optional dependency to support transliteration in the input for mentions, default is false. See: https://github.com/getstream/transliterate */ /** * Currently, `Enter` is the default submission key and `Shift`+`Enter` is the default combination for the new line. @@ -120,41 +117,53 @@ export type MessageInputProps<V extends CustomTrigger = CustomTrigger> = { * const defaultShouldSubmit = (event) => event.key === "Enter" && !event.shiftKey; * ``` */ - shouldSubmit?: (event: KeyboardEvent) => boolean; + shouldSubmit?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => boolean; /** Configuration parameters for link previews. */ - urlEnrichmentConfig?: URLEnrichmentConfig; + urlEnrichmentConfig?: LinkPreviewsManagerConfig; useMentionsTransliteration?: boolean; }; -const MessageInputProvider = <V extends CustomTrigger = CustomTrigger>( - props: PropsWithChildren<MessageInputProps<V>>, -) => { +const MessageInputProvider = (props: PropsWithChildren<MessageInputProps>) => { const cooldownTimerState = useCooldownTimer(); - const messageInputState = useMessageInputState<V>(props); + const messageInputState = useMessageInputState(props); const { emojiSearchIndex } = useComponentContext('MessageInput'); - const messageInputContextValue = useCreateMessageInputContext<V>({ + // todo: X document how to disable publishTypingEvents + // if (typeof props.publishTypingEvent !== 'undefined') { + // messageComposer.config.publishTypingEvents = props.publishTypingEvent; + // } + + const messageInputContextValue = useCreateMessageInputContext({ ...cooldownTimerState, ...messageInputState, ...props, emojiSearchIndex: props.emojiSearchIndex ?? emojiSearchIndex, }); + const messageComposer = useMessageComposer(); + + useEffect( + () => + // create draft when leaving the channel + + () => { + messageComposer.createDraft(); + }, + [messageComposer], + ); + return ( - <MessageInputContextProvider<V> value={messageInputContextValue}> + <MessageInputContextProvider value={messageInputContextValue}> {props.children} </MessageInputContextProvider> ); }; -const UnMemoizedMessageInput = <V extends CustomTrigger = CustomTrigger>( - props: MessageInputProps<V>, -) => { +const UnMemoizedMessageInput = (props: MessageInputProps) => { const { Input: PropInput } = props; const { dragAndDropWindow } = useChannelStateContext(); - const { Input: ContextInput, TriggerProvider = DefaultTriggerProvider } = - useComponentContext<V>('MessageInput'); + const { Input: ContextInput } = useComponentContext('MessageInput'); const Input = PropInput || ContextInput || MessageInputFlat; const dialogManagerId = props.isThreadInput @@ -164,18 +173,14 @@ const UnMemoizedMessageInput = <V extends CustomTrigger = CustomTrigger>( if (dragAndDropWindow) return ( <DialogManagerProvider id={dialogManagerId}> - <TriggerProvider> - <Input /> - </TriggerProvider> + <Input /> </DialogManagerProvider> ); return ( <DialogManagerProvider id={dialogManagerId}> <MessageInputProvider {...props}> - <TriggerProvider> - <Input /> - </TriggerProvider> + <Input /> </MessageInputProvider> </DialogManagerProvider> ); diff --git a/src/components/MessageInput/MessageInputFlat.tsx b/src/components/MessageInput/MessageInputFlat.tsx index a5a6345441..c51e5e8856 100644 --- a/src/components/MessageInput/MessageInputFlat.tsx +++ b/src/components/MessageInput/MessageInputFlat.tsx @@ -1,5 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import type { Event } from 'stream-chat'; +import React, { useCallback, useMemo, useState } from 'react'; import clsx from 'clsx'; import { useDropzone } from 'react-dropzone'; import { @@ -21,38 +20,35 @@ import { QuotedMessagePreviewHeader, } from './QuotedMessagePreview'; import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList'; - -import { ChatAutoComplete } from '../ChatAutoComplete/ChatAutoComplete'; +import { useMessageComposer } from './hooks'; +import { TextAreaComposer } from '../TextAreaComposer'; +import { AIStates, useAIState } from '../AIStateIndicator'; import { RecordingAttachmentType } from '../MediaRecorder/classes'; import { useChatContext } from '../../context/ChatContext'; -import { useChannelActionContext } from '../../context/ChannelActionContext'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useTranslationContext } from '../../context/TranslationContext'; import { useMessageInputContext } from '../../context/MessageInputContext'; import { useComponentContext } from '../../context/ComponentContext'; +import { useStateStore } from '../../store'; +import { useAttachmentManagerState } from './hooks/messageComposer/useAttachmentManagerState'; +import { useMessageContext } from '../../context'; +import type { MessageComposerConfig } from 'stream-chat'; -import { AIStates, useAIState } from '../AIStateIndicator'; +const attachmentManagerConfigStateSelector = (state: MessageComposerConfig) => ({ + maxNumberOfFilesPerMessage: state.attachments.maxNumberOfFilesPerMessage, +}); export const MessageInputFlat = () => { const { t } = useTranslationContext('MessageInputFlat'); + const { message } = useMessageContext(); const { asyncMessagesMultiSendEnabled, - attachments, cooldownRemaining, - findAndEnqueueURLsToEnrich, handleSubmit, hideSendButton, - isUploadEnabled, - linkPreviews, - maxFilesLeft, - message, - numberOfUploads, - parent, recordingController, setCooldownRemaining, - text, - uploadNewFiles, } = useMessageInputContext('MessageInputFlat'); const { @@ -68,14 +64,10 @@ export const MessageInputFlat = () => { StartRecordingAudioButton = DefaultStartRecordingAudioButton, StopAIGenerationButton: StopAIGenerationButtonOverride, } = useComponentContext('MessageInputFlat'); - const { - acceptedFiles = [], - multipleUploads, - quotedMessage, - } = useChannelStateContext('MessageInputFlat'); - const { setQuotedMessage } = useChannelActionContext('MessageInputFlat'); + const { acceptedFiles = [] } = useChannelStateContext('MessageInputFlat'); const { channel } = useChatContext('MessageInputFlat'); - + const messageComposer = useMessageComposer(); + const { attachmentManager } = messageComposer; const { aiState } = useAIState(channel); const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); @@ -88,11 +80,6 @@ export const MessageInputFlat = () => { setShowRecordingPermissionDeniedNotification(false); }, []); - const failedUploadsCount = useMemo( - () => attachments.filter((a) => a.localMetadata?.uploadState === 'failed').length, - [attachments], - ); - const accept = useMemo( () => acceptedFiles.reduce<Record<string, Array<string>>>((mediaTypeMap, mediaType) => { @@ -102,39 +89,22 @@ export const MessageInputFlat = () => { [acceptedFiles], ); + const { attachments, isUploadEnabled } = useAttachmentManagerState(); + const { maxNumberOfFilesPerMessage } = useStateStore( + messageComposer.configState, + attachmentManagerConfigStateSelector, + ); + const { getRootProps, isDragActive, isDragReject } = useDropzone({ accept, - disabled: !isUploadEnabled || maxFilesLeft === 0, - multiple: multipleUploads, + disabled: !isUploadEnabled || !!cooldownRemaining, + multiple: maxNumberOfFilesPerMessage > 1, noClick: true, - onDrop: uploadNewFiles, + onDrop: attachmentManager.uploadFiles, }); - useEffect(() => { - const handleQuotedMessageUpdate = (e: Event) => { - if (e.message?.id !== quotedMessage?.id) return; - if (e.type === 'message.deleted') { - setQuotedMessage(undefined); - return; - } - setQuotedMessage(e.message); - }; - channel?.on('message.deleted', handleQuotedMessageUpdate); - channel?.on('message.updated', handleQuotedMessageUpdate); - - return () => { - channel?.off('message.deleted', handleQuotedMessageUpdate); - channel?.off('message.updated', handleQuotedMessageUpdate); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channel, quotedMessage]); - if (recordingController.recordingState) return <AudioRecorder />; - // TODO: "!message" condition is a temporary fix for shared - // state when editing a message (fix shared state issue) - const displayQuotedMessage = - !message && quotedMessage && quotedMessage.parent_id === parent?.id; const recordingEnabled = !!(recordingController.recorder && navigator.mediaDevices); // account for requirement on iOS as per this bug report: https://bugs.webkit.org/show_bug.cgi?id=252303 const isRecording = !!recordingController.recordingState; @@ -163,9 +133,7 @@ export const MessageInputFlat = () => { permissionName={RecordingPermission.MIC} /> )} - {findAndEnqueueURLsToEnrich && ( - <LinkPreviewList linkPreviews={Array.from(linkPreviews.values())} /> - )} + <LinkPreviewList /> {isDragActive && ( <div className={clsx('str-chat__dropzone-container', { @@ -176,22 +144,15 @@ export const MessageInputFlat = () => { {isDragReject && <p>{t<string>('Some of the files will not be accepted')}</p>} </div> )} - {displayQuotedMessage && <QuotedMessagePreviewHeader />} + <QuotedMessagePreviewHeader /> <div className='str-chat__message-input-inner'> <AttachmentSelector /> <div className='str-chat__message-textarea-container'> - {displayQuotedMessage && ( - <QuotedMessagePreview quotedMessage={quotedMessage} /> - )} - {isUploadEnabled && - !!(numberOfUploads + failedUploadsCount || attachments.length > 0) && ( - <AttachmentPreviewList /> - )} - + <QuotedMessagePreview /> + <AttachmentPreviewList /> <div className='str-chat__message-textarea-with-emoji-picker'> - <ChatAutoComplete /> - + <TextAreaComposer /> {EmojiPicker && <EmojiPicker />} </div> </div> @@ -207,14 +168,7 @@ export const MessageInputFlat = () => { /> ) : ( <> - <SendButton - disabled={ - !numberOfUploads && - !text.length && - attachments.length - failedUploadsCount === 0 - } - sendMessage={handleSubmit} - /> + <SendButton sendMessage={handleSubmit} /> {recordingEnabled && ( <StartRecordingAudioButton disabled={ diff --git a/src/components/MessageInput/QuotedMessagePreview.tsx b/src/components/MessageInput/QuotedMessagePreview.tsx index 5854da6906..129c157fc8 100644 --- a/src/components/MessageInput/QuotedMessagePreview.tsx +++ b/src/components/MessageInput/QuotedMessagePreview.tsx @@ -6,18 +6,28 @@ import { Avatar as DefaultAvatar } from '../Avatar'; import { Poll } from '../Poll'; import { useChatContext } from '../../context/ChatContext'; -import { useChannelActionContext } from '../../context/ChannelActionContext'; import { useComponentContext } from '../../context/ComponentContext'; import { useTranslationContext } from '../../context/TranslationContext'; -import type { TranslationLanguages } from 'stream-chat'; -import type { StreamMessage } from '../../context/ChannelStateContext'; +import { useStateStore } from '../../store'; +import { useMessageComposer } from './hooks'; +import { renderText as defaultRenderText } from '../Message/renderText'; +import type { MessageComposerState, TranslationLanguages } from 'stream-chat'; import type { MessageContextValue } from '../../context'; -import { renderText as defaultRenderText } from '../Message'; + +const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ + quotedMessage: state.quotedMessage, +}); export const QuotedMessagePreviewHeader = () => { - const { setQuotedMessage } = useChannelActionContext('QuotedMessagePreview'); const { t } = useTranslationContext('QuotedMessagePreview'); + const messageComposer = useMessageComposer(); + const { quotedMessage } = useStateStore( + messageComposer.state, + messageComposerStateStoreSelector, + ); + + if (!quotedMessage) return null; return ( <div className='str-chat__quoted-message-preview-header'> @@ -27,7 +37,7 @@ export const QuotedMessagePreviewHeader = () => { <button aria-label={t('aria/Cancel Reply')} className='str-chat__quoted-message-remove' - onClick={() => setQuotedMessage(undefined)} + onClick={() => messageComposer.setQuotedMessage(null)} > <CloseIcon /> </button> @@ -36,43 +46,50 @@ export const QuotedMessagePreviewHeader = () => { }; export type QuotedMessagePreviewProps = { - quotedMessage: StreamMessage; renderText?: MessageContextValue['renderText']; }; export const QuotedMessagePreview = ({ - quotedMessage, renderText = defaultRenderText, }: QuotedMessagePreviewProps) => { const { client } = useChatContext(); const { Attachment = DefaultAttachment, Avatar = DefaultAvatar } = useComponentContext('QuotedMessagePreview'); const { userLanguage } = useTranslationContext('QuotedMessagePreview'); + const messageComposer = useMessageComposer(); + const { quotedMessage } = useStateStore( + messageComposer.state, + messageComposerStateStoreSelector, + ); - const quotedMessageText = - quotedMessage.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] || - quotedMessage.text; + const quotedMessageText = useMemo( + () => + quotedMessage?.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] || + quotedMessage?.text, + [quotedMessage?.i18n, quotedMessage?.text, userLanguage], + ); const renderedText = useMemo( - () => renderText(quotedMessageText, quotedMessage.mentioned_users), - [quotedMessage.mentioned_users, quotedMessageText, renderText], + () => renderText(quotedMessageText, quotedMessage?.mentioned_users), + [quotedMessage, quotedMessageText, renderText], ); - const quotedMessageAttachment = useMemo(() => { - const [attachment] = quotedMessage.attachments ?? []; - return attachment ? [attachment] : []; - }, [quotedMessage.attachments]); + const quotedMessageAttachments = useMemo( + () => + quotedMessage?.attachments?.length ? quotedMessage.attachments.slice(0, 1) : [], + [quotedMessage], + ); - if (!quotedMessageText && !quotedMessageAttachment) return null; + const poll = quotedMessage?.poll_id && client.polls.fromState(quotedMessage.poll_id); - const poll = quotedMessage.poll_id && client.polls.fromState(quotedMessage.poll_id); + if (!quotedMessageText && !quotedMessageAttachments.length && !poll) return null; return ( <div className='str-chat__quoted-message-preview' data-testid='quoted-message-preview' > - {quotedMessage.user && ( + {quotedMessage?.user && ( <Avatar className='str-chat__avatar--quoted-message-sender' image={quotedMessage.user.image} @@ -85,8 +102,8 @@ export const QuotedMessagePreview = ({ <Poll isQuoted poll={poll} /> ) : ( <> - {!!quotedMessageAttachment.length && ( - <Attachment attachments={quotedMessageAttachment} isQuoted /> + {!!quotedMessageAttachments.length && ( + <Attachment attachments={quotedMessageAttachments} isQuoted /> )} <div className='str-chat__quoted-message-text' diff --git a/src/components/MessageInput/SendButton.tsx b/src/components/MessageInput/SendButton.tsx index f13471ec0a..66e1b3a6ce 100644 --- a/src/components/MessageInput/SendButton.tsx +++ b/src/components/MessageInput/SendButton.tsx @@ -1,22 +1,27 @@ import React from 'react'; -import type { Message } from 'stream-chat'; import { SendIcon } from './icons'; +import { useMessageComposerHasSendableData } from './hooks'; +import type { UpdatedMessage } from 'stream-chat'; export type SendButtonProps = { sendMessage: ( event: React.BaseSyntheticEvent, - customMessageData?: Partial<Message>, + customMessageData?: Omit<UpdatedMessage, 'mentioned_users'>, ) => void; } & React.ComponentProps<'button'>; -export const SendButton = ({ sendMessage, ...rest }: SendButtonProps) => ( - <button - aria-label='Send' - className='str-chat__send-button' - data-testid='send-button' - onClick={sendMessage} - type='button' - {...rest} - > - <SendIcon /> - </button> -); +export const SendButton = ({ sendMessage, ...rest }: SendButtonProps) => { + const hasSendableData = useMessageComposerHasSendableData(); + return ( + <button + aria-label='Send' + className='str-chat__send-button' + data-testid='send-button' + disabled={!hasSendableData} + onClick={sendMessage} + type='button' + {...rest} + > + <SendIcon /> + </button> + ); +}; diff --git a/src/components/MessageInput/__tests__/EditMessageForm.test.js b/src/components/MessageInput/__tests__/EditMessageForm.test.js new file mode 100644 index 0000000000..27b2f422f4 --- /dev/null +++ b/src/components/MessageInput/__tests__/EditMessageForm.test.js @@ -0,0 +1,1479 @@ +import React, { useEffect } from 'react'; +import { MessageComposer, SearchController } from 'stream-chat'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { toHaveNoViolations } from 'jest-axe'; +import { axe } from '../../../../axe-helper'; +import { nanoid } from 'nanoid'; + +import { MessageInput } from '../MessageInput'; +import { EditMessageForm } from '../EditMessageForm'; +import { Channel } from '../../Channel/Channel'; + +import { MessageProvider } from '../../../context/MessageContext'; +import { ChatProvider } from '../../../context/ChatContext'; +import { + dispatchMessageDeletedEvent, + dispatchMessageUpdatedEvent, + generateChannel, + generateLocalAttachmentData, + generateLocalFileUploadAttachmentData, + generateMember, + generateMessage, + generateScrapedDataAttachment, + generateUser, + initClientWithChannels, +} from '../../../mock-builders'; +import { generatePoll } from '../../../mock-builders/generator/poll'; +import { QuotedMessagePreview } from '../QuotedMessagePreview'; +import { useMessageComposer as useMessageComposerMock } from '../hooks'; + +jest.mock('../../Channel/utils', () => ({ + ...jest.requireActual('../../Channel/utils'), + makeAddNotifications: () => mockAddNotification, +})); + +jest.mock('../hooks/messageComposer/useMessageComposer', () => ({ + useMessageComposer: jest.fn().mockImplementation(), +})); + +expect.extend(toHaveNoViolations); + +const IMAGE_PREVIEW_TEST_ID = 'attachment-preview-image'; +const FILE_PREVIEW_TEST_ID = 'attachment-preview-file'; +const FILE_INPUT_TEST_ID = 'file-input'; +const FILE_UPLOAD_RETRY_BTN_TEST_ID = 'file-preview-item-retry-button'; +const SEND_BTN_TEST_ID = 'send-button'; +const SEND_BTN_EDIT_FORM_TEST_ID = 'send-button-edit-form'; +const ATTACHMENT_PREVIEW_LIST_TEST_ID = 'attachment-list-scroll-container'; +const UNKNOWN_ATTACHMENT_PREVIEW_TEST_ID = 'attachment-preview-unknown'; + +const cid = 'messaging:general'; +const inputPlaceholder = 'Type your message'; +const userId = 'userId'; +const username = 'username'; +const mentionId = 'mention-id'; +const mentionName = 'mention-name'; +const user = generateUser({ id: userId, image: 'user-image', name: username }); +const mentionUser = generateUser({ + id: mentionId, + name: mentionName, +}); +const mainListMessage = generateMessage({ cid, user }); +const threadMessage = generateMessage({ + parent_id: mainListMessage.id, + type: 'reply', + user, +}); +const mockedChannelData = generateChannel({ + channel: { + id: 'general', + own_capabilities: ['send-poll', 'upload-file'], + type: 'messaging', + }, + members: [generateMember({ user }), generateMember({ user: mentionUser })], + messages: [mainListMessage], + thread: [threadMessage], +}); + +const defaultChatContext = { + channelsQueryState: { queryInProgress: 'uninitialized' }, + getAppSettings: jest.fn(), + latestMessageDatesByChannels: {}, + mutes: [], + searchController: new SearchController(), +}; + +const cooldown = 30; +const filename = 'some.txt'; +const fileUploadUrl = 'http://www.getstream.io'; // real url, because ImageAttachmentPreview will try to load the image + +const getImage = () => new File(['content'], filename, { type: 'image/png' }); +const getFile = (name = filename) => new File(['content'], name, { type: 'text/plain' }); + +const sendMessageMock = jest.fn(); +const editMock = jest.fn(); +const mockAddNotification = jest.fn(); + +const defaultMessageContextValue = { + getMessageActions: () => ['delete', 'edit', 'quote'], + handleDelete: () => {}, + handleFlag: () => {}, + handleMute: () => {}, + handlePin: () => {}, + isMyMessage: () => true, + message: mainListMessage, + setEditingState: () => {}, +}; + +function dropFile(file, formElement) { + fireEvent.drop(formElement, { + dataTransfer: { + files: [file], + types: ['Files'], + }, + }); +} + +const renderComponent = async ({ + channelData = [], + channelProps = {}, + chatContextOverrides = {}, + customChannel, + customClient, + CustomStateSetter = null, + customUser, + messageContextOverrides = {}, + messageInputProps = {}, +} = {}) => { + let channel = customChannel; + let client = customClient; + if (!(channel || client)) { + const result = await initClientWithChannels({ + channelsData: [{ ...mockedChannelData, ...channelData }], + customUser: customUser || user, + }); + channel = result.channels[0]; + client = result.client; + } + let renderResult; + + await act(() => { + renderResult = render( + <ChatProvider + value={{ ...defaultChatContext, channel, client, ...chatContextOverrides }} + > + <Channel + doSendMessageRequest={sendMessageMock} + doUpdateMessageRequest={editMock} + {...channelProps} + > + <MessageProvider + value={{ + ...defaultMessageContextValue, + editing: true, + ...messageContextOverrides, + }} + > + {CustomStateSetter && <CustomStateSetter />} + <MessageInput Input={EditMessageForm} {...messageInputProps} /> + </MessageProvider> + </Channel> + </ChatProvider>, + ); + }); + + const submit = async () => { + const submitButton = + renderResult.queryByTestId(SEND_BTN_EDIT_FORM_TEST_ID) || + renderResult.findByText('Send') || + renderResult.findByTitle('Send'); + fireEvent.click(await submitButton); + }; + + return { channel, client, submit, ...renderResult }; +}; + +const tearDown = () => { + cleanup(); + jest.clearAllMocks(); + useMessageComposerMock.mockReset(); +}; + +function axeNoViolations(container) { + return async () => { + const results = await axe(container); + expect(results).toHaveNoViolations(); + }; +} + +const setup = async ({ channelData, composerConfig, composition } = {}) => { + const { + channels: [customChannel], + client: customClient, + } = await initClientWithChannels({ + channelsData: [channelData ?? mockedChannelData], + customUser: user, + }); + const sendImageSpy = jest.spyOn(customChannel, 'sendImage').mockResolvedValue({ + file: fileUploadUrl, + }); + const sendFileSpy = jest.spyOn(customChannel, 'sendFile').mockResolvedValue({ + file: fileUploadUrl, + }); + customChannel.initialized = true; + customClient.activeChannels[customChannel.cid] = customChannel; + const messageComposer = new MessageComposer({ + client: customClient, + composition: composition ?? mainListMessage, + compositionContext: composition ?? mainListMessage, + config: composerConfig, + }); + messageComposer.registerSubscriptions(); + // Set up the mock to return our messageComposer instance + useMessageComposerMock.mockReturnValue(messageComposer); + + return { customChannel, customClient, messageComposer, sendFileSpy, sendImageSpy }; +}; + +const setupUploadRejected = async ({ composerConfig, composition, error } = {}) => { + const { + channels: [customChannel], + client: customClient, + } = await initClientWithChannels({ + channelsData: [mockedChannelData], + customUser: user, + }); + const sendImageSpy = jest + .spyOn(customChannel, 'sendImage') + .mockRejectedValueOnce(error); + const sendFileSpy = jest.spyOn(customChannel, 'sendFile').mockRejectedValueOnce(error); + customClient.activeChannels[customChannel.cid] = customChannel; + const messageComposer = new MessageComposer({ + client: customClient, + composition: composition ?? mainListMessage, + compositionContext: composition ?? mainListMessage, + config: composerConfig, + }); + + // Set up the mock to return our messageComposer instance + useMessageComposerMock.mockReturnValue(messageComposer); + + return { customChannel, customClient, messageComposer, sendFileSpy, sendImageSpy }; +}; + +describe(`EditMessageForm`, () => { + afterEach(tearDown); + + it('should render custom EmojiPicker', async () => { + const CustomEmojiPicker = () => <div data-testid='custom-emoji-picker' />; + const { customChannel, customClient } = await setup(); + await renderComponent({ + channelProps: { EmojiPicker: CustomEmojiPicker }, + customChannel, + customClient, + }); + + await waitFor(() => { + const c = screen.getByTestId('custom-emoji-picker'); + expect(c).toBeInTheDocument(); + }); + }); + + it('should not contain placeholder text if message has text', async () => { + const message = generateMessage({ + attachments: [generateLocalFileUploadAttachmentData()], + cid, + text: 'XXX', + }); + const { customChannel, customClient } = await setup({ composition: message }); + + await renderComponent({ + customChannel, + customClient, + }); + await waitFor(() => { + const textarea = screen.getByPlaceholderText(inputPlaceholder); + expect(textarea).toBeInTheDocument(); + expect(textarea.value).toBe(message.text); + }); + }); + + it('should contain placeholder text if no default message text provided', async () => { + const message = generateMessage({ + attachments: [generateLocalFileUploadAttachmentData()], + cid, + text: '', + }); + const { customChannel, customClient } = await setup({ composition: message }); + await renderComponent({ + customChannel, + customClient, + }); + await waitFor(() => { + const textarea = screen.getByPlaceholderText(inputPlaceholder); + expect(textarea).toBeInTheDocument(); + expect(textarea.value).toBe(message.text); + }); + }); + + it('should ignore default message text if provided', async () => { + const defaultValue = nanoid(); + const { customChannel, customClient } = await setup(); + customChannel.messageComposer.textComposer.defaultValue = defaultValue; + await renderComponent({ + customChannel, + customClient, + }); + await waitFor(() => { + expect(screen.queryByDisplayValue(defaultValue)).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue(mainListMessage.text)).toBeInTheDocument(); + }); + }); + + it('should shift focus to the textarea if the `focus` prop is true', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, + messageInputProps: { + focus: true, + }, + }); + await waitFor(() => { + expect(screen.getByPlaceholderText(inputPlaceholder)).toHaveFocus(); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should not shift focus to the textarea if the `focus` prop is false', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, + messageInputProps: { + focus: false, + }, + }); + await waitFor(() => { + expect(screen.getByPlaceholderText(inputPlaceholder)).not.toHaveFocus(); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should render default file upload icon', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + const fileUploadIcon = await screen.findByTitle('Attach files'); + + await waitFor(() => { + expect(fileUploadIcon).toBeInTheDocument(); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should render custom file upload svg provided as prop', async () => { + const FileUploadIcon = () => ( + <svg> + <title>NotFileUploadIcon</title> + </svg> + ); + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + channelProps: { FileUploadIcon }, + customChannel, + customClient, + }); + + const fileUploadIcon = await screen.findByTitle('NotFileUploadIcon'); + + await waitFor(() => { + expect(fileUploadIcon).toBeInTheDocument(); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should prefer custom AttachmentSelectorInitiationButtonContents before custom FileUploadIcon', async () => { + const FileUploadIcon = () => ( + <svg> + <title>NotFileUploadIcon</title> + </svg> + ); + + const AttachmentSelectorInitiationButtonContents = () => ( + <svg> + <title>AttachmentSelectorInitiationButtonContents</title> + </svg> + ); + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + channelProps: { AttachmentSelectorInitiationButtonContents, FileUploadIcon }, + customChannel, + customClient, + }); + + const fileUploadIcon = await screen.queryByTitle('NotFileUploadIcon'); + const attachmentSelectorButtonIcon = await screen.getByTitle( + 'AttachmentSelectorInitiationButtonContents', + ); + await waitFor(() => { + expect(fileUploadIcon).not.toBeInTheDocument(); + expect(attachmentSelectorButtonIcon).toBeInTheDocument(); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + describe('Attachments', () => { + it('Pasting images and files should result in uploading the files and showing previews', async () => { + const { customChannel, customClient, sendFileSpy, sendImageSpy } = await setup(); + const { container } = await renderComponent({ customChannel, customClient }); + const file = getFile(); + const image = getImage(); + + const clipboardEvent = new Event('paste', { + bubbles: true, + }); + // set `clipboardData`. Mock DataTransfer object + clipboardEvent.clipboardData = { + items: [ + { + getAsFile: () => file, + kind: 'file', + }, + { + getAsFile: () => image, + kind: 'file', + }, + ], + }; + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + await act(async () => { + await formElement.dispatchEvent(clipboardEvent); + }); + const filenameTexts = await screen.findAllByTitle(filename); + await waitFor(() => { + expect(sendFileSpy).toHaveBeenCalledWith(file); + expect(sendImageSpy).toHaveBeenCalledWith(image); + expect(screen.getByTestId(IMAGE_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(FILE_PREVIEW_TEST_ID)).toBeInTheDocument(); + filenameTexts.forEach((filenameText) => expect(filenameText).toBeInTheDocument()); + expect(screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID)).toBeInTheDocument(); + }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('gives preference to pasting text over files', async () => { + const { customChannel, customClient, sendFileSpy, sendImageSpy } = await setup(); + const { container } = await renderComponent({ customChannel, customClient }); + const pastedString = 'pasted string'; + + const file = getFile(); + const image = getImage(); + + const clipboardEvent = new Event('paste', { + bubbles: true, + }); + // set `clipboardData`. Mock DataTransfer object + clipboardEvent.clipboardData = { + items: [ + { + getAsFile: () => file, + kind: 'file', + }, + { + getAsFile: () => image, + kind: 'file', + }, + { + getAsString: (cb) => cb(pastedString), + kind: 'string', + type: 'text/plain', + }, + ], + }; + const formElement = screen.getByPlaceholderText(inputPlaceholder); + await act(async () => { + await formElement.dispatchEvent(clipboardEvent); + }); + + await waitFor(() => { + expect(sendFileSpy).not.toHaveBeenCalled(); + expect(sendImageSpy).not.toHaveBeenCalled(); + expect(screen.queryByTestId(IMAGE_PREVIEW_TEST_ID)).not.toBeInTheDocument(); + expect(screen.queryByTestId(FILE_PREVIEW_TEST_ID)).not.toBeInTheDocument(); + expect(screen.queryByText(filename)).not.toBeInTheDocument(); + expect( + screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID), + ).not.toBeInTheDocument(); + expect(formElement).toHaveValue(mainListMessage.text + pastedString); + }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('Should upload an image when it is dropped on the dropzone', async () => { + const { customChannel, customClient, sendImageSpy } = await setup(); + const { container } = await renderComponent({ customChannel, customClient }); + // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + const file = getImage(); + await act(() => { + dropFile(file, formElement); + }); + await waitFor(() => { + expect(sendImageSpy).toHaveBeenCalledWith(file); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('Should upload, display and link to a file when it is dropped on the dropzone', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ customChannel, customClient }); + // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + + await act(() => { + dropFile(getFile(), formElement); + }); + + const filenameText = await screen.findByText(filename); + + expect(filenameText).toBeInTheDocument(); + const filePreview = screen.getByTestId(FILE_PREVIEW_TEST_ID); + await waitFor(() => { + expect(filePreview.querySelector('a')).toHaveAttribute('href', fileUploadUrl); + }); + + await axeNoViolations(container); + }); + + it('should allow uploading files with the file upload button', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ customChannel, customClient }); + const file = getFile(); + const input = screen.getByTestId(FILE_INPUT_TEST_ID); + + await act(() => { + fireEvent.change(input, { + target: { + files: [file], + }, + }); + }); + + expect(screen.getByText(filename)).toBeInTheDocument(); + const filePreview = screen.getByTestId(FILE_PREVIEW_TEST_ID); + await waitFor(() => { + expect(filePreview.querySelector('a')).toHaveAttribute('href', fileUploadUrl); + }); + await axeNoViolations(container); + }); + + // todo: JSDOM implementation used in Jest uses custom File mock that is different from that used in browser / node -> this + it.skip('should call error handler if a file failed to upload and allow retrying', async () => { + const error = new Error('failed to upload'); + const { customChannel, customClient, sendFileSpy } = await setupUploadRejected({ + error, + }); + + const { container } = await renderComponent({ + customChannel, + customClient, + }); + jest.spyOn(console, 'warn').mockImplementationOnce(() => null); + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + const file = getFile(); + + act(() => dropFile(file, formElement)); + + await waitFor(() => { + expect(sendFileSpy).toHaveBeenCalledWith(file); + }); + + sendFileSpy.mockImplementationOnce(() => Promise.resolve({ file })); + + await act(() => { + fireEvent.click(screen.getByTestId(FILE_UPLOAD_RETRY_BTN_TEST_ID)); + }); + + await waitFor(() => { + expect(sendFileSpy).toHaveBeenCalledTimes(2); + expect(sendFileSpy).toHaveBeenCalledWith(file); + }); + await axeNoViolations(container); + }); + + it('should not set multiple attribute on the file input if multipleUploads is false', async () => { + const { customChannel, customClient } = await setup({ + composerConfig: { attachments: { maxNumberOfFilesPerMessage: 1 } }, + }); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + const input = screen.getByTestId(FILE_INPUT_TEST_ID); + expect(input).not.toHaveAttribute('multiple'); + await axeNoViolations(container); + }); + + it('should set multiple attribute on the file input if multipleUploads is true', async () => { + const { customChannel, customClient } = await setup({ + composerConfig: { attachments: { maxNumberOfFilesPerMessage: 5 } }, + }); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + const input = screen.getByTestId(FILE_INPUT_TEST_ID); + expect(input).toHaveAttribute('multiple'); + await axeNoViolations(container); + }); + + const filename1 = '1.txt'; + const filename2 = '2.txt'; + it('should only allow dropping max number of files into the dropzone', async () => { + const { customChannel, customClient } = await setup({ + composerConfig: { attachments: { maxNumberOfFilesPerMessage: 1 } }, + }); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + + const file = getFile(filename1); + + act(() => dropFile(file, formElement)); + + await waitFor(() => expect(screen.queryByText(filename1)).toBeInTheDocument()); + + const file2 = getFile(filename2); + act(() => dropFile(file2, formElement)); + + await waitFor(() => expect(screen.queryByText(filename2)).not.toBeInTheDocument()); + + await axeNoViolations(container); + }); + + it('should show attachment previews if at least one non-scraped attachments available', async () => { + const { customChannel, customClient } = await setup({ + composition: { + ...mainListMessage, + attachments: [{ ...generateLocalAttachmentData(), type: 'xxx' }], + }, + }); + await renderComponent({ + customChannel, + customClient, + }); + const previewListContainer = screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID); + expect(previewListContainer).toBeInTheDocument(); + expect(previewListContainer.children).toHaveLength(1); + expect(previewListContainer.children[0]).toHaveAttribute( + 'data-testid', + UNKNOWN_ATTACHMENT_PREVIEW_TEST_ID, + ); + }); + + it('should not show scraped content in attachment previews', async () => { + const scrapedAttachment = { + ...generateLocalAttachmentData(), + ...generateScrapedDataAttachment(), + }; + const unknownAttachment = { ...generateLocalAttachmentData(), type: 'xxx' }; + const { customChannel, customClient } = await setup({ + composition: { + ...mainListMessage, + attachments: [scrapedAttachment, unknownAttachment], + }, + }); + + await renderComponent({ + customChannel, + customClient, + }); + const previewListContainer = screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID); + expect(previewListContainer).toBeInTheDocument(); + expect(previewListContainer.children).toHaveLength(1); + expect(previewListContainer.children[0]).toHaveAttribute( + 'data-testid', + UNKNOWN_ATTACHMENT_PREVIEW_TEST_ID, + ); + }); + + it('should not show attachment previews if no files uploaded and no attachments available', async () => { + const { customChannel, customClient } = await setup({ + composition: { + ...mainListMessage, + attachments: [], + }, + }); + await renderComponent({ + customChannel, + customClient, + }); + expect( + screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID), + ).not.toBeInTheDocument(); + }); + + it('should show attachment preview list if not only failed uploads are available', async () => { + const error = new Error('failed to upload'); + const { customChannel, customClient, sendFileSpy } = await setupUploadRejected({ + error, + }); + sendFileSpy.mockResolvedValueOnce({ file: fileUploadUrl }); + await renderComponent({ + customChannel, + customClient, + }); + + jest.spyOn(console, 'warn').mockImplementationOnce(() => null); + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + const file = getFile(); + + await act(async () => { + await dropFile(file, formElement); + }); + + await waitFor(() => { + expect(screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID)).toBeInTheDocument(); + expect(screen.queryByTestId(FILE_UPLOAD_RETRY_BTN_TEST_ID)).toBeInTheDocument(); + }); + + await act(async () => { + await dropFile(file, formElement); + }); + + await waitFor(() => { + const previewList = screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID); + expect(previewList).toBeInTheDocument(); + expect(previewList.children).toHaveLength(2); + }); + }); + }); + + describe('Uploads disabled', () => { + const channelData = { channel: { own_capabilities: [] } }; + + it('should render file upload button disabled', async () => { + const { customChannel, customClient } = await setup({ + channelData, + }); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + await waitFor(() => expect(screen.getByTestId(FILE_INPUT_TEST_ID)).toBeDisabled()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('pasting images and files should do nothing', async () => { + const { customChannel, customClient, sendFileSpy, sendImageSpy } = await setup({ + channelData, + }); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + + const file = getFile(); + const image = getImage(); + + const clipboardEvent = new Event('paste', { bubbles: true }); + // set `clipboardData`. Mock DataTransfer object + clipboardEvent.clipboardData = { + items: [ + { getAsFile: () => file, kind: 'file' }, + { getAsFile: () => image, kind: 'file' }, + ], + }; + const formElement = screen.getByPlaceholderText(inputPlaceholder); + + await act(() => { + formElement.dispatchEvent(clipboardEvent); + }); + + await waitFor(() => { + expect(screen.queryByText(filename)).not.toBeInTheDocument(); + expect(sendFileSpy).not.toHaveBeenCalled(); + expect(sendImageSpy).not.toHaveBeenCalled(); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('Should not upload an image when it is dropped on the dropzone', async () => { + const { customChannel, customClient, sendImageSpy } = await setup({ + channelData, + }); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + + // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + const file = getImage(); + + await act(async () => { + await dropFile(file, formElement); + }); + + await waitFor(() => { + expect(sendImageSpy).not.toHaveBeenCalled(); + }); + await waitFor(axeNoViolations(container)); + }); + }); + + describe('Submitting', () => { + it('should submit the message if content not change', async () => { + const { customChannel, customClient } = await setup(); + const { container, submit } = await renderComponent({ + customChannel, + customClient, + }); + + await act(() => submit()); + + expect(editMock).toHaveBeenCalledWith( + customChannel.cid, + expect.objectContaining({ + text: mainListMessage.text, + }), + {}, + ); + await axeNoViolations(container); + }); + it('should submit the input value with text changed', async () => { + const { customChannel, customClient } = await setup(); + const { container, submit } = await renderComponent({ + customChannel, + customClient, + }); + + const messageText = 'Some text'; + await act(async () => { + fireEvent.change(await screen.findByPlaceholderText(inputPlaceholder), { + target: { + value: messageText, + }, + }); + }); + + await act(() => submit()); + + expect(editMock).toHaveBeenCalledWith( + customChannel.cid, + expect.objectContaining({ + text: messageText, + }), + {}, + ); + await axeNoViolations(container); + }); + + // todo: button is not enabled at the moment when we want to click submit + it.skip('should add image as attachment if a message is submitted with an image', async () => { + const { customChannel, customClient, sendImageSpy } = await setup(); + const { container, submit } = await renderComponent({ + customChannel, + customClient, + }); + // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. + // const formElement = await screen.findByPlaceholderText(inputPlaceholder); + const file = getImage(); + const input = screen.getByTestId(FILE_INPUT_TEST_ID); + // await act(async () => { + // await dropFile(file, formElement); + // }); + await act(async () => { + await fireEvent.change(input, { + target: { + files: [file], + }, + }); + }); + + // wait for image uploading to complete before trying to send the message + + await waitFor(() => { + expect(sendImageSpy).toHaveBeenCalled(); + // expect(screen.getByTestId(SEND_BTN_EDIT_FORM_TEST_ID)).toBeEnabled(); + }); + + await waitFor( + () => { + expect(screen.getByTestId(SEND_BTN_EDIT_FORM_TEST_ID)).toBeEnabled(); + }, + { interval: 1, timeout: 2000 }, // Poll every 1ms up to 2 seconds + ); + + await act(async () => await submit()); + + const msgFragment = { + attachments: expect.arrayContaining([ + expect.objectContaining({ + image_url: fileUploadUrl, + type: 'image', + }), + ]), + }; + + await waitFor(() => { + expect(editMock).toHaveBeenCalledWith( + customChannel.cid, + expect.objectContaining(msgFragment), + {}, + ); + }); + + await axeNoViolations(container); + }); + + it('should allow to send custom message data', async () => { + const { customChannel, customClient } = await setup(); + const customMessageData = { customX: 'customX' }; + const CustomStateSetter = () => { + const composer = useMessageComposerMock(); + useEffect(() => { + composer.customDataManager.setData(customMessageData); + }, [composer]); + }; + const { container, submit } = await renderComponent({ + customChannel, + customClient, + CustomStateSetter, + }); + + await act(() => submit()); + + await waitFor(() => { + expect(editMock).toHaveBeenCalledWith( + customChannel.cid, + expect.objectContaining(customMessageData), + {}, + ); + }); + await axeNoViolations(container); + }); + + it('should not call overrideSubmitHandler', async () => { + const overrideMock = jest.fn().mockImplementation(() => Promise.resolve()); + const { customChannel, customClient } = await setup(); + const customMessageData = { customX: 'customX' }; + const CustomStateSetter = () => { + const composer = useMessageComposerMock(); + useEffect(() => { + composer.customDataManager.setData(customMessageData); + }, [composer]); + }; + const { container, submit } = await renderComponent({ + customChannel, + customClient, + CustomStateSetter, + messageInputProps: { + overrideSubmitHandler: overrideMock, + }, + }); + const messageText = 'Some text'; + + fireEvent.change(await screen.findByPlaceholderText(inputPlaceholder), { + target: { + value: messageText, + }, + }); + + await act(() => submit()); + expect(overrideMock).not.toHaveBeenCalled(); + + await axeNoViolations(container); + }); + + it('should not do anything if the message is empty and has no files', async () => { + const composition = { + cid, + created_at: new Date(), + updated_at: new Date(), + }; + const { customChannel, customClient } = await setup({ composition }); + const { container, submit } = await renderComponent({ + customChannel, + customClient, + messageContextOverrides: { + message: composition, + }, + }); + + await act(() => submit()); + + expect(editMock).not.toHaveBeenCalled(); + await axeNoViolations(container); + }); + + it('should submit if shouldSubmit function is not provided but keydown events do match', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + + const messageText = 'Some text'; + const input = await screen.findByPlaceholderText(inputPlaceholder); + await act(async () => { + await fireEvent.change(input, { + target: { + value: messageText, + }, + }); + }); + + await act(() => fireEvent.keyDown(input, { key: 'Enter' })); + + expect(editMock).toHaveBeenCalledWith( + customChannel.cid, + expect.objectContaining({ + text: messageText, + }), + {}, + ); + await axeNoViolations(container); + }); + + it('should not submit if shouldSubmit function is provided but keydown events do not match', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, + messageInputProps: { + shouldSubmit: (e) => e.key === '9', + }, + }); + const input = await screen.findByPlaceholderText(inputPlaceholder); + + const messageText = 'Some text'; + await act(async () => { + await fireEvent.change(input, { + target: { + value: messageText, + }, + }); + }); + + await act(() => fireEvent.keyDown(input, { key: 'Enter' })); + + expect(editMock).not.toHaveBeenCalled(); + await axeNoViolations(container); + }); + + it('should submit if shouldSubmit function is provided and keydown events do match', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, + messageInputProps: { + shouldSubmit: (e) => e.key === '9', + }, + }); + const messageText = 'Submission text.'; + const input = await screen.findByPlaceholderText(inputPlaceholder); + + await act(async () => { + await fireEvent.change(input, { + target: { + value: messageText, + }, + }); + }); + await act(async () => { + await fireEvent.keyDown(input, { + key: '9', + }); + }); + expect(editMock).toHaveBeenCalledWith( + customChannel.cid, + expect.objectContaining({ + text: messageText, + }), + {}, + ); + + await axeNoViolations(container); + }); + + it('should not submit if Shift key is pressed', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + const messageText = 'Submission text.'; + const input = await screen.findByPlaceholderText(inputPlaceholder); + + await act(async () => { + await fireEvent.change(input, { + target: { + value: messageText, + }, + }); + }); + await act(async () => { + await fireEvent.keyDown(input, { + key: 'Enter', + shiftKey: true, + }); + }); + expect(editMock).not.toHaveBeenCalled(); + + await axeNoViolations(container); + }); + }); + + it('should list all the available users to mention if only @ is typed', async () => { + const scrollIntoView = Element.prototype.scrollIntoView; + // eslint-disable-next-line jest/prefer-spy-on + Element.prototype.scrollIntoView = jest.fn(); + const { customChannel, customClient } = await setup(); + await renderComponent({ + customChannel, + customClient, + customUser: generateUser(), + }); + + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + await act(async () => { + await fireEvent.change(formElement, { + target: { + selectionEnd: 1, + selectionStart: 1, + value: '@', + }, + }); + }); + + const usernameList = await screen.findAllByTestId('user-item-name'); + expect(usernameList).toHaveLength( + Object.keys(customChannel.state.members).length - 1, // remove own user + ); + Element.prototype.scrollIntoView = scrollIntoView; + }); + + it('should add a mentioned user if @ is typed and a user is selected', async () => { + const scrollIntoView = Element.prototype.scrollIntoView; + // eslint-disable-next-line jest/prefer-spy-on + Element.prototype.scrollIntoView = jest.fn(); + const { customChannel, customClient } = await setup(); + const { container, submit } = await renderComponent({ + customChannel, + customClient, + customUser: generateUser(), + }); + + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + await act(async () => { + await fireEvent.change(formElement, { + target: { + selectionEnd: 1, + selectionStart: 1, + value: '@', + }, + }); + }); + + const usernameList = await screen.findAllByTestId('user-item-name'); + const firstItem = usernameList[0]; + await act(async () => { + await fireEvent.click(firstItem); + }); + + await act(() => submit()); + + expect(editMock.mock.calls[1]).toEqual([ + customChannel.cid, + expect.objectContaining({ + ...mainListMessage, + deleted_at: null, + error: null, + mentioned_users: [ + expect.objectContaining({ + banned: false, + created_at: '2020-04-27T13:39:49.331742Z', + id: 'mention-id', + image: expect.any(String), + name: 'mention-name', + online: false, + role: 'user', + updated_at: '2020-04-27T13:39:49.332087Z', + }), + ], + parent_id: undefined, + quoted_message: null, + reaction_groups: null, + text: '@mention-name ', + }), + {}, + ]); + + Element.prototype.scrollIntoView = scrollIntoView; + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should override the default List component when SuggestionList is provided as a prop', async () => { + const AutocompleteSuggestionList = () => ( + <div data-testid='suggestion-list'>Suggestion List</div> + ); + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + channelProps: { AutocompleteSuggestionList }, + customChannel, + customClient, + }); + + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + + // the component does not check whether there are items to be displayed + expect(await screen.findByText('Suggestion List')).toBeInTheDocument(); + + await act(() => { + fireEvent.change(formElement, { + target: { value: '/' }, + }); + }); + + await waitFor(() => + expect(screen.queryByText('Suggestion List')).toBeInTheDocument(), + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + const quotedMessagePreviewIsDisplayedCorrectly = async (message) => { + await waitFor(() => + expect(screen.queryByTestId('quoted-message-preview')).toBeInTheDocument(), + ); + await waitFor(() => expect(screen.getByText(message.text)).toBeInTheDocument()); + }; + + const quotedMessagePreviewIsNotDisplayed = (message) => { + expect(screen.queryByText(/reply to message/i)).not.toBeInTheDocument(); + expect(screen.queryByText(message.text)).not.toBeInTheDocument(); + }; + + const renderWithActiveCooldown = async ({ messageInputProps = {} } = {}) => { + const { customChannel, customClient } = await setup({ + channelData: { channel: { cooldown } }, + }); + + const lastSentSecondsAhead = 5; + await renderComponent({ + chatContextOverrides: { + latestMessageDatesByChannels: { + [customChannel.cid]: new Date( + new Date().getTime() + lastSentSecondsAhead * 1000, + ), + }, + }, + customChannel, + customClient, + messageInputProps, + }); + }; + + describe('QuotedMessagePreview', () => { + const quotedMessage = generateMessage(); + const messageWithQuotedMessage = { + ...mainListMessage, + quoted_message: quotedMessage, + }; + it('is displayed on quote action click', async () => { + const { customChannel, customClient } = await setup({ + composition: messageWithQuotedMessage, + }); + await renderComponent({ + customChannel, + customClient, + }); + await quotedMessagePreviewIsDisplayedCorrectly(mainListMessage); + }); + + it('renders proper markdown (through default renderText fn)', async () => { + const messageWithQuotedMessage = { + ...mainListMessage, + quoted_message: generateMessage({ + mentioned_users: [{ id: 'john', name: 'John Cena' }], + text: 'hey @John Cena', + user, + }), + }; + const { customChannel, customClient } = await setup({ + composition: messageWithQuotedMessage, + }); + await renderComponent({ + customChannel, + customClient, + }); + + expect(await screen.findByText('@John Cena')).toHaveAttribute('data-user-id'); + }); + + it('uses custom renderText fn if provided', async () => { + const messageWithQuotedMessage = { + ...mainListMessage, + quoted_message: generateMessage({ + text: nanoid(), + user, + }), + }; + const quotedMsgText = messageWithQuotedMessage.quoted_message.text; + const fn = jest + .fn() + .mockReturnValue(<div data-testid={quotedMsgText}>{quotedMsgText}</div>); + + const { customChannel, customClient } = await setup({ + composition: messageWithQuotedMessage, + }); + await renderComponent({ + channelProps: { + QuotedMessagePreview: (props) => ( + <QuotedMessagePreview {...props} renderText={fn} /> + ), + }, + customChannel, + customClient, + }); + + expect(fn).toHaveBeenCalled(); + expect(await screen.findByTestId(quotedMsgText)).toBeInTheDocument(); + }); + + it('is updated on original message update', async () => { + const { customChannel, customClient } = await setup({ + composition: messageWithQuotedMessage, + }); + await renderComponent({ + customChannel, + customClient, + }); + messageWithQuotedMessage.quoted_message.text = new Date().toISOString(); + await act(() => { + dispatchMessageUpdatedEvent( + customClient, + messageWithQuotedMessage.quoted_message, + customChannel, + ); + }); + await quotedMessagePreviewIsDisplayedCorrectly(mainListMessage); + }); + + it('is closed on original message delete', async () => { + const { customChannel, customClient } = await setup({ + composition: messageWithQuotedMessage, + }); + await renderComponent({ + customChannel, + customClient, + }); + await act(() => { + dispatchMessageDeletedEvent( + customClient, + messageWithQuotedMessage.quoted_message, + customChannel, + ); + }); + quotedMessagePreviewIsNotDisplayed(messageWithQuotedMessage.quoted_message); + }); + + it('renders quoted Poll component if message contains poll', async () => { + const poll = generatePoll(); + const messageWithPoll = generateMessage({ poll, poll_id: poll.id, text: 'X' }); + const messageWithQuotedMessage = { + ...mainListMessage, + quoted_message: messageWithPoll, + }; + const { customChannel, customClient } = await setup({ + channelData: { messages: [messageWithPoll, messageWithQuotedMessage] }, + composition: messageWithQuotedMessage, + }); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + + expect( + container.querySelector('.str-chat__quoted-poll-preview'), + ).toBeInTheDocument(); + }); + + it('renders custom quoted Poll component if message contains poll', async () => { + const poll = generatePoll(); + const messageWithPoll = generateMessage({ poll, poll_id: poll.id, text: 'X' }); + const messageWithQuotedMessage = { + ...mainListMessage, + quoted_message: messageWithPoll, + }; + const { customChannel, customClient } = await setup({ + channelData: { messages: [messageWithPoll, messageWithQuotedMessage] }, + composition: messageWithQuotedMessage, + }); + const pollText = 'Custom Poll component'; + const QuotedPoll = () => <div>{pollText}</div>; + + await renderComponent({ + channelProps: { QuotedPoll }, + customChannel, + customClient, + }); + + expect(screen.queryByText(pollText)).toBeInTheDocument(); + }); + }); + + describe('send button', () => { + it('should be rendered when editing a message', async () => { + const { customChannel, customClient } = await setup(); + await renderComponent({ + customChannel, + customClient, + }); + expect(screen.getByTestId(SEND_BTN_TEST_ID)).toBeInTheDocument(); + }); + + it('should not be renderer during active cooldown period', async () => { + await renderWithActiveCooldown(); + expect(screen.queryByTestId(SEND_BTN_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should not be renderer if explicitly hidden', async () => { + const { customChannel, customClient } = await setup(); + await renderComponent({ + customChannel, + customClient, + messageInputProps: { hideSendButton: true }, + }); + expect(screen.queryByTestId(SEND_BTN_TEST_ID)).not.toBeInTheDocument(); + }); + }); + + describe('cooldown timer', () => { + const COOLDOWN_TIMER_TEST_ID = 'cooldown-timer'; + + it('should be renderer during active cool-down period', async () => { + await renderWithActiveCooldown(); + expect(screen.getByTestId(COOLDOWN_TIMER_TEST_ID)).toBeInTheDocument(); + }); + + it('should not be renderer if send button explicitly hidden', async () => { + await renderWithActiveCooldown({ messageInputProps: { hideSendButton: true } }); + expect(screen.queryByTestId(COOLDOWN_TIMER_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should be removed after cool-down period elapsed', async () => { + jest.useFakeTimers(); + await renderWithActiveCooldown(); + expect(screen.getByTestId(COOLDOWN_TIMER_TEST_ID)).toHaveTextContent( + cooldown.toString(), + ); + act(() => { + jest.advanceTimersByTime(cooldown * 1000); + }); + expect(screen.queryByTestId(COOLDOWN_TIMER_TEST_ID)).not.toBeInTheDocument(); + jest.useRealTimers(); + }); + }); +}); diff --git a/src/components/MessageInput/__tests__/MessageInput.test.js b/src/components/MessageInput/__tests__/MessageInput.test.js index 74c4d181d7..e9819f0352 100644 --- a/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/src/components/MessageInput/__tests__/MessageInput.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { SearchController } from 'stream-chat'; +import { LinkPreview, SearchController } from 'stream-chat'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { toHaveNoViolations } from 'jest-axe'; @@ -7,18 +7,16 @@ import { axe } from '../../../../axe-helper'; import { nanoid } from 'nanoid'; import { MessageInput } from '../MessageInput'; -import { MessageInputFlat } from '../MessageInputFlat'; -import { EditMessageForm } from '../EditMessageForm'; import { Channel } from '../../Channel/Channel'; import { MessageActionsBox } from '../../MessageActions'; import { MessageProvider } from '../../../context/MessageContext'; -import { useMessageInputContext } from '../../../context/MessageInputContext'; import { ChatProvider } from '../../../context/ChatContext'; import { dispatchMessageDeletedEvent, dispatchMessageUpdatedEvent, generateChannel, + generateLocalAttachmentData, generateMember, generateMessage, generateScrapedDataAttachment, @@ -35,10 +33,10 @@ const FILE_PREVIEW_TEST_ID = 'attachment-preview-file'; const FILE_INPUT_TEST_ID = 'file-input'; const FILE_UPLOAD_RETRY_BTN_TEST_ID = 'file-preview-item-retry-button'; const SEND_BTN_TEST_ID = 'send-button'; -const SEND_BTN_EDIT_FORM_TEST_ID = 'send-button-edit-form'; const ATTACHMENT_PREVIEW_LIST_TEST_ID = 'attachment-list-scroll-container'; const UNKNOWN_ATTACHMENT_PREVIEW_TEST_ID = 'attachment-preview-unknown'; +const cid = 'messaging:general'; const inputPlaceholder = 'Type your message'; const userId = 'userId'; const username = 'username'; @@ -49,14 +47,18 @@ const mentionUser = generateUser({ id: mentionId, name: mentionName, }); -const mainListMessage = generateMessage({ user }); +const mainListMessage = generateMessage({ cid, user }); const threadMessage = generateMessage({ parent_id: mainListMessage.id, type: 'reply', user, }); const mockedChannelData = generateChannel({ - channel: { own_capabilities: ['send-poll', 'upload-file'] }, + channel: { + id: 'general', + own_capabilities: ['send-poll', 'upload-file'], + type: 'messaging', + }, members: [generateMember({ user }), generateMember({ user: mentionUser })], messages: [mainListMessage], thread: [threadMessage], @@ -77,21 +79,11 @@ const fileUploadUrl = 'http://www.getstream.io'; // real url, because ImageAttac const getImage = () => new File(['content'], filename, { type: 'image/png' }); const getFile = (name = filename) => new File(['content'], name, { type: 'text/plain' }); -const mockUploadApi = () => - jest.fn().mockImplementation(() => - Promise.resolve({ - file: fileUploadUrl, - }), - ); - -const mockFaultyUploadApi = (cause) => - jest.fn().mockImplementation(() => Promise.reject(cause)); - -const submitMock = jest.fn(); -const editMock = jest.fn(); +const sendMessageMock = jest.fn(); const mockAddNotification = jest.fn(); jest.mock('../../Channel/utils', () => ({ + ...jest.requireActual('../../Channel/utils'), makeAddNotifications: () => mockAddNotification, })); @@ -115,71 +107,81 @@ function dropFile(file, formElement) { }); } -const makeRenderFn = - (InputComponent) => - async ({ - channelData = [], - channelProps = {}, - chatContextOverrides = {}, - customChannel, - customClient, - customUser, - messageActionsBoxProps = {}, - messageContextOverrides = {}, - messageInputProps = {}, - } = {}) => { - let channel = customChannel; - let client = customClient; - if (!(channel || client)) { - const result = await initClientWithChannels({ - channelsData: [{ ...mockedChannelData, ...channelData }], - customUser: customUser || user, - }); - channel = result.channels[0]; - client = result.client; - } - let renderResult; - - const defaultMessageInputProps = - InputComponent.name === 'EditMessageForm' ? { message: mainListMessage } : {}; - await act(() => { - renderResult = render( - <ChatProvider - value={{ ...defaultChatContext, channel, client, ...chatContextOverrides }} - > - <Channel - doSendMessageRequest={submitMock} - doUpdateMessageRequest={editMock} - {...channelProps} +const initQuotedMessagePreview = async (message) => { + await waitFor(() => expect(screen.queryByText(message.text)).not.toBeInTheDocument()); + + const quoteButton = await screen.findByText(/^reply$/i); + await waitFor(() => expect(quoteButton).toBeInTheDocument()); + + act(() => { + fireEvent.click(quoteButton); + }); +}; + +const quotedMessagePreviewIsDisplayedCorrectly = async (message) => { + await waitFor(() => + expect(screen.queryByTestId('quoted-message-preview')).toBeInTheDocument(), + ); + await waitFor(() => expect(screen.getByText(message.text)).toBeInTheDocument()); +}; + +const quotedMessagePreviewIsNotDisplayed = (message) => { + expect(screen.queryByText(/reply to message/i)).not.toBeInTheDocument(); + expect(screen.queryByText(message.text)).not.toBeInTheDocument(); +}; + +const renderComponent = async ({ + channelData = [], + channelProps = {}, + chatContextOverrides = {}, + customChannel, + customClient, + customUser, + messageActionsBoxProps = {}, + messageContextOverrides = {}, + messageInputProps = {}, +} = {}) => { + let channel = customChannel; + let client = customClient; + if (!(channel || client)) { + const result = await initClientWithChannels({ + channelsData: [{ ...mockedChannelData, ...channelData }], + customUser: customUser || user, + }); + channel = result.channels[0]; + client = result.client; + } + let renderResult; + + await act(() => { + renderResult = render( + <ChatProvider + value={{ ...defaultChatContext, channel, client, ...chatContextOverrides }} + > + <Channel doSendMessageRequest={sendMessageMock} {...channelProps}> + <MessageProvider + value={{ ...defaultMessageContextValue, ...messageContextOverrides }} > - <MessageProvider - value={{ ...defaultMessageContextValue, ...messageContextOverrides }} - > - <MessageActionsBox - {...messageActionsBoxProps} - getMessageActions={defaultMessageContextValue.getMessageActions} - /> - </MessageProvider> - <MessageInput - Input={InputComponent} - {...{ ...defaultMessageInputProps, ...messageInputProps }} + <MessageActionsBox + {...messageActionsBoxProps} + getMessageActions={defaultMessageContextValue.getMessageActions} /> - </Channel> - </ChatProvider>, - ); - }); - - const submit = async () => { - const submitButton = - renderResult.queryByTestId(SEND_BTN_EDIT_FORM_TEST_ID) || - renderResult.findByText('Send') || - renderResult.findByTitle('Send'); - fireEvent.click(await submitButton); - }; + </MessageProvider> + <MessageInput {...messageInputProps} /> + </Channel> + </ChatProvider>, + ); + }); - return { channel, client, submit, ...renderResult }; + const submit = async () => { + const submitButton = + renderResult.findByText('Send') || renderResult.findByTitle('Send'); + fireEvent.click(await submitButton); }; + return { channel, client, submit, ...renderResult }; +}; + const tearDown = () => { cleanup(); jest.clearAllMocks(); @@ -192,1359 +194,927 @@ function axeNoViolations(container) { }; } -[ - { InputComponent: MessageInputFlat, name: 'MessageInputFlat' }, - { InputComponent: EditMessageForm, name: 'EditMessageForm' }, -].forEach(({ InputComponent, name: componentName }) => { - const renderComponent = makeRenderFn(InputComponent); - - describe(`${componentName}`, () => { - afterEach(tearDown); +const setup = async ({ channelData } = {}) => { + const { + channels: [customChannel], + client: customClient, + } = await initClientWithChannels({ + channelsData: [channelData ?? mockedChannelData], + customUser: user, + }); + const sendImageSpy = jest.spyOn(customChannel, 'sendImage').mockResolvedValueOnce({ + file: fileUploadUrl, + }); + const sendFileSpy = jest.spyOn(customChannel, 'sendFile').mockResolvedValueOnce({ + file: fileUploadUrl, + }); + customChannel.initialized = true; + customClient.activeChannels[customChannel.cid] = customChannel; + return { customChannel, customClient, sendFileSpy, sendImageSpy }; +}; - it('should render custom EmojiPicker', async () => { - const CustomEmojiPicker = () => <div data-testid='custom-emoji-picker' />; +const setupUploadRejected = async (error) => { + const { + channels: [customChannel], + client: customClient, + } = await initClientWithChannels({ + channelsData: [mockedChannelData], + customUser: user, + }); + const sendImageSpy = jest + .spyOn(customChannel, 'sendImage') + .mockRejectedValueOnce(error); + const sendFileSpy = jest.spyOn(customChannel, 'sendFile').mockRejectedValueOnce(error); + customClient.activeChannels[customChannel.cid] = customChannel; + return { customChannel, customClient, sendFileSpy, sendImageSpy }; +}; - await renderComponent({ channelProps: { EmojiPicker: CustomEmojiPicker } }); +const renderWithActiveCooldown = async ({ messageInputProps = {} } = {}) => { + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [{ channel: { cooldown } }], + customUser: user, + }); - await waitFor(() => { - const c = screen.getByTestId('custom-emoji-picker'); - expect(c).toBeInTheDocument(); - }); - }); + const lastSentSecondsAhead = 5; + await renderComponent({ + chatContextOverrides: { + latestMessageDatesByChannels: { + [channel.cid]: new Date(new Date().getTime() + lastSentSecondsAhead * 1000), + }, + }, + customChannel: channel, + customClient: client, + messageInputProps, + }); +}; - it('should contain placeholder text if no default message text provided', async () => { - await renderComponent({ messageInputProps: { message: { text: '' } } }); - await waitFor(() => { - const textarea = screen.getByPlaceholderText(inputPlaceholder); - expect(textarea).toBeInTheDocument(); - expect(textarea.value).toBe(''); - }); - }); +describe(`MessageInputFlat`, () => { + afterEach(tearDown); - it('should shift focus to the textarea if the `focus` prop is true', async () => { - const { container } = await renderComponent({ - messageInputProps: { - focus: true, - }, - }); - await waitFor(() => { - expect(screen.getByPlaceholderText(inputPlaceholder)).toHaveFocus(); - }); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); + it('should render custom EmojiPicker', async () => { + const CustomEmojiPicker = () => <div data-testid='custom-emoji-picker' />; - it('should render default file upload icon', async () => { - const { container } = await renderComponent(); - const fileUploadIcon = - componentName === 'EditMessageForm' - ? await screen.findByTitle('Attach files') - : await screen.getByTestId('invoke-attachment-selector-button'); + await renderComponent({ channelProps: { EmojiPicker: CustomEmojiPicker } }); - await waitFor(() => { - expect(fileUploadIcon).toBeInTheDocument(); - }); - const results = await axe(container); - expect(results).toHaveNoViolations(); + await waitFor(() => { + const c = screen.getByTestId('custom-emoji-picker'); + expect(c).toBeInTheDocument(); }); + }); - it('should render custom file upload svg provided as prop', async () => { - const FileUploadIcon = () => ( - <svg> - <title>NotFileUploadIcon</title> - </svg> - ); - - const { container } = await renderComponent({ channelProps: { FileUploadIcon } }); - - const fileUploadIcon = await screen.findByTitle('NotFileUploadIcon'); - - await waitFor(() => { - expect(fileUploadIcon).toBeInTheDocument(); - }); - const results = await axe(container); - expect(results).toHaveNoViolations(); + it('should contain placeholder text if no default message text provided', async () => { + await renderComponent(); + await waitFor(() => { + const textarea = screen.getByPlaceholderText(inputPlaceholder); + expect(textarea).toBeInTheDocument(); + expect(textarea.value).toBe(''); }); + }); - it('should prefer custom AttachmentSelectorInitiationButtonContents before custom FileUploadIcon', async () => { - const FileUploadIcon = () => ( - <svg> - <title>NotFileUploadIcon</title> - </svg> - ); + it('should display default value', async () => { + const defaultValue = nanoid(); + const { customChannel, customClient } = await setup(); + customChannel.messageComposer.textComposer.defaultValue = defaultValue; + await renderComponent({ + customChannel, + customClient, + }); + await waitFor(() => { + const textarea = screen.queryByDisplayValue(defaultValue); + expect(textarea).toBeInTheDocument(); + }); + }); - const AttachmentSelectorInitiationButtonContents = () => ( - <svg> - <title>AttachmentSelectorInitiationButtonContents</title> - </svg> - ); + it('should shift focus to the textarea if the `focus` prop is true', async () => { + const { container } = await renderComponent({ + messageInputProps: { + focus: true, + }, + }); + await waitFor(() => { + expect(screen.getByPlaceholderText(inputPlaceholder)).toHaveFocus(); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - const { container } = await renderComponent({ - channelProps: { AttachmentSelectorInitiationButtonContents, FileUploadIcon }, - }); + it('should render default file upload icon', async () => { + const { container } = await renderComponent(); + const fileUploadIcon = await screen.findByTestId('invoke-attachment-selector-button'); - const fileUploadIcon = await screen.queryByTitle('NotFileUploadIcon'); - const attachmentSelectorButtonIcon = await screen.getByTitle( - 'AttachmentSelectorInitiationButtonContents', - ); - await waitFor(() => { - expect(fileUploadIcon).not.toBeInTheDocument(); - expect(attachmentSelectorButtonIcon).toBeInTheDocument(); - }); - const results = await axe(container); - expect(results).toHaveNoViolations(); + await waitFor(() => { + expect(fileUploadIcon).toBeInTheDocument(); }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - describe('Attachments', () => { - it('Pasting images and files should result in uploading the files and showing previews', async () => { - const doImageUploadRequest = mockUploadApi(); - const doFileUploadRequest = mockUploadApi(); - const { container } = await renderComponent({ - messageInputProps: { - doFileUploadRequest, - doImageUploadRequest, - }, - }); + it('should render custom file upload svg provided as prop', async () => { + const FileUploadIcon = () => ( + <svg> + <title>NotFileUploadIcon</title> + </svg> + ); - const file = getFile(); - const image = getImage(); + const { container } = await renderComponent({ channelProps: { FileUploadIcon } }); - const clipboardEvent = new Event('paste', { - bubbles: true, - }); - // set `clipboardData`. Mock DataTransfer object - clipboardEvent.clipboardData = { - items: [ - { - getAsFile: () => file, - kind: 'file', - }, - { - getAsFile: () => image, - kind: 'file', - }, - ], - }; - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - act(() => { - formElement.dispatchEvent(clipboardEvent); - }); - const filenameText = await screen.findByText(filename); - await waitFor(() => { - expect(doFileUploadRequest).toHaveBeenCalledWith(file, expect.any(Object)); - expect(doImageUploadRequest).toHaveBeenCalledWith(image, expect.any(Object)); - expect(screen.getByTestId(IMAGE_PREVIEW_TEST_ID)).toBeInTheDocument(); - expect(screen.getByTestId(FILE_PREVIEW_TEST_ID)).toBeInTheDocument(); - expect(filenameText).toBeInTheDocument(); - expect(screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID)).toBeInTheDocument(); - }); + const fileUploadIcon = await screen.findByTitle('NotFileUploadIcon'); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); + await waitFor(() => { + expect(fileUploadIcon).toBeInTheDocument(); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - it('gives preference to pasting text over files', async () => { - const doImageUploadRequest = mockUploadApi(); - const doFileUploadRequest = mockUploadApi(); - const pastedString = 'pasted string'; - const { container } = await renderComponent({ - messageInputProps: { - doFileUploadRequest, - doImageUploadRequest, - }, - }); + it('should prefer custom AttachmentSelectorInitiationButtonContents before custom FileUploadIcon', async () => { + const FileUploadIcon = () => ( + <svg> + <title>NotFileUploadIcon</title> + </svg> + ); - const file = getFile(); - const image = getImage(); + const AttachmentSelectorInitiationButtonContents = () => ( + <svg> + <title>AttachmentSelectorInitiationButtonContents</title> + </svg> + ); - const clipboardEvent = new Event('paste', { - bubbles: true, - }); - // set `clipboardData`. Mock DataTransfer object - clipboardEvent.clipboardData = { - items: [ - { - getAsFile: () => file, - kind: 'file', - }, - { - getAsFile: () => image, - kind: 'file', - }, - { - getAsString: (cb) => cb(pastedString), - kind: 'string', - type: 'text/plain', - }, - ], - }; - const formElement = screen.getByPlaceholderText(inputPlaceholder); - await act(async () => { - await formElement.dispatchEvent(clipboardEvent); - }); + const { container } = await renderComponent({ + channelProps: { AttachmentSelectorInitiationButtonContents, FileUploadIcon }, + }); - await waitFor(() => { - expect(doFileUploadRequest).not.toHaveBeenCalled(); - expect(doImageUploadRequest).not.toHaveBeenCalled(); - expect(screen.queryByTestId(IMAGE_PREVIEW_TEST_ID)).not.toBeInTheDocument(); - expect(screen.queryByTestId(FILE_PREVIEW_TEST_ID)).not.toBeInTheDocument(); - expect(screen.queryByText(filename)).not.toBeInTheDocument(); - expect( - screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID), - ).not.toBeInTheDocument(); - if (componentName === 'EditMessageForm') { - expect(formElement.value.startsWith(pastedString)).toBeTruthy(); - } else { - expect(formElement).toHaveValue(pastedString); - } - }); + const fileUploadIcon = await screen.queryByTitle('NotFileUploadIcon'); + const attachmentSelectorButtonIcon = await screen.getByTitle( + 'AttachmentSelectorInitiationButtonContents', + ); + await waitFor(() => { + expect(fileUploadIcon).not.toBeInTheDocument(); + expect(attachmentSelectorButtonIcon).toBeInTheDocument(); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); + describe('Attachments', () => { + it('Pasting images and files should result in uploading the files and showing previews', async () => { + const { customChannel, customClient, sendFileSpy, sendImageSpy } = await setup(); + const { container } = await renderComponent({ customChannel, customClient }); + const file = getFile(); + const image = getImage(); - it('Should upload an image when it is dropped on the dropzone', async () => { - const doImageUploadRequest = mockUploadApi(); - const { container } = await renderComponent({ - messageInputProps: { - doImageUploadRequest, - }, - }); - // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getImage(); - await act(() => { - dropFile(file, formElement); - }); - await waitFor(() => { - expect(doImageUploadRequest).toHaveBeenCalledWith(file, expect.any(Object)); - }); - const results = await axe(container); - expect(results).toHaveNoViolations(); + const clipboardEvent = new Event('paste', { + bubbles: true, }); - - it('Should upload, display and link to a file when it is dropped on the dropzone', async () => { - const { container } = await renderComponent({ - messageInputProps: { - doFileUploadRequest: mockUploadApi(), + // set `clipboardData`. Mock DataTransfer object + clipboardEvent.clipboardData = { + items: [ + { + getAsFile: () => file, + kind: 'file', }, - }); - // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - - await act(() => { - dropFile(getFile(), formElement); - }); - - const filenameText = await screen.findByText(filename); - - expect(filenameText).toBeInTheDocument(); - const filePreview = screen.getByTestId(FILE_PREVIEW_TEST_ID); - await waitFor(() => { - expect(filePreview.querySelector('a')).toHaveAttribute('href', fileUploadUrl); - }); - - await axeNoViolations(container); - }); - - it('should allow uploading files with the file upload button', async () => { - const { container } = await renderComponent({ - messageInputProps: { - doFileUploadRequest: mockUploadApi(), + { + getAsFile: () => image, + kind: 'file', }, - }); - const file = getFile(); - const input = screen.getByTestId(FILE_INPUT_TEST_ID); - - await act(() => { - fireEvent.change(input, { - target: { - files: [file], - }, - }); - }); - - expect(screen.getByText(filename)).toBeInTheDocument(); - const filePreview = screen.getByTestId(FILE_PREVIEW_TEST_ID); - await waitFor(() => { - expect(filePreview.querySelector('a')).toHaveAttribute('href', fileUploadUrl); - }); - await axeNoViolations(container); + ], + }; + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + await act(async () => { + await formElement.dispatchEvent(clipboardEvent); }); - - it('should call error handler if an image failed to upload', async () => { - const cause = new Error('failed to upload'); - const doImageUploadRequest = mockFaultyUploadApi(cause); - const errorHandler = jest.fn(); - const { container } = await renderComponent({ - messageInputProps: { - doImageUploadRequest, - errorHandler, - }, - }); - jest.spyOn(console, 'warn').mockImplementationOnce(() => null); - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getImage(); - - act(() => { - dropFile(file, formElement); - }); - - await waitFor(() => { - expect(errorHandler).toHaveBeenCalledWith( - cause, - 'upload-attachment', - expect.any(Object), - ); - expect(doImageUploadRequest).toHaveBeenCalledWith(file, expect.any(Object)); - }); - await axeNoViolations(container); + const filenameTexts = await screen.findAllByTitle(filename); + await waitFor(() => { + expect(sendFileSpy).toHaveBeenCalledWith(file); + expect(sendImageSpy).toHaveBeenCalledWith(image); + expect(screen.getByTestId(IMAGE_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(FILE_PREVIEW_TEST_ID)).toBeInTheDocument(); + filenameTexts.forEach((filenameText) => expect(filenameText).toBeInTheDocument()); + expect(screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID)).toBeInTheDocument(); }); - it('should call error handler if a file failed to upload and allow retrying', async () => { - const cause = new Error('failed to upload'); - const doFileUploadRequest = mockFaultyUploadApi(cause); - const errorHandler = jest.fn(); - - const { container } = await renderComponent({ - messageInputProps: { - doFileUploadRequest, - errorHandler, - }, - }); - jest.spyOn(console, 'warn').mockImplementationOnce(() => null); - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getFile(); - - act(() => dropFile(file, formElement)); - - await waitFor(() => { - expect(errorHandler).toHaveBeenCalledWith( - cause, - 'upload-attachment', - expect.any(Object), - ); - expect(doFileUploadRequest).toHaveBeenCalledWith(file, expect.any(Object)); - }); - - doFileUploadRequest.mockImplementationOnce(() => Promise.resolve({ file })); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - await act(() => { - fireEvent.click(screen.getByTestId(FILE_UPLOAD_RETRY_BTN_TEST_ID)); - }); + it('gives preference to pasting text over files', async () => { + const { customChannel, customClient, sendFileSpy, sendImageSpy } = await setup(); + const { container } = await renderComponent({ customChannel, customClient }); + const pastedString = 'pasted string'; - await waitFor(() => { - expect(doFileUploadRequest).toHaveBeenCalledTimes(2); - expect(doFileUploadRequest).toHaveBeenCalledWith(file, expect.any(Object)); - }); - await axeNoViolations(container); - }); + const file = getFile(); + const image = getImage(); - it('should not set multiple attribute on the file input if multipleUploads is false', async () => { - const { container } = await renderComponent({ - channelProps: { - multipleUploads: false, - }, - }); - const input = screen.getByTestId(FILE_INPUT_TEST_ID); - expect(input).not.toHaveAttribute('multiple'); - await axeNoViolations(container); + const clipboardEvent = new Event('paste', { + bubbles: true, }); - - it('should set multiple attribute on the file input if multipleUploads is true', async () => { - const { container } = await renderComponent({ - channelProps: { - multipleUploads: true, + // set `clipboardData`. Mock DataTransfer object + clipboardEvent.clipboardData = { + items: [ + { + getAsFile: () => file, + kind: 'file', }, - }); - const input = screen.getByTestId(FILE_INPUT_TEST_ID); - expect(input).toHaveAttribute('multiple'); - await axeNoViolations(container); - }); - - const filename1 = '1.txt'; - const filename2 = '2.txt'; - it('should only allow dropping maxNumberOfFiles files into the dropzone', async () => { - const { container } = await renderComponent({ - channelProps: { - maxNumberOfFiles: 1, + { + getAsFile: () => image, + kind: 'file', }, - messageInputProps: { - doFileUploadRequest: mockUploadApi(), + { + getAsString: (cb) => cb(pastedString), + kind: 'string', + type: 'text/plain', }, - }); - - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - - const file = getFile(filename1); - - act(() => dropFile(file, formElement)); - - await waitFor(() => expect(screen.queryByText(filename1)).toBeInTheDocument()); + ], + }; + const formElement = screen.getByPlaceholderText(inputPlaceholder); + await act(async () => { + await formElement.dispatchEvent(clipboardEvent); + }); - const file2 = getFile(filename2); - act(() => dropFile(file2, formElement)); + await waitFor(() => { + expect(sendFileSpy).not.toHaveBeenCalled(); + expect(sendImageSpy).not.toHaveBeenCalled(); + expect(screen.queryByTestId(IMAGE_PREVIEW_TEST_ID)).not.toBeInTheDocument(); + expect(screen.queryByTestId(FILE_PREVIEW_TEST_ID)).not.toBeInTheDocument(); + expect(screen.queryByText(filename)).not.toBeInTheDocument(); + expect( + screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID), + ).not.toBeInTheDocument(); + expect(formElement).toHaveValue(pastedString); + }); - await waitFor(() => - expect(screen.queryByText(filename2)).not.toBeInTheDocument(), - ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - await axeNoViolations(container); + it('Should upload an image when it is dropped on the dropzone', async () => { + const { customChannel, customClient, sendImageSpy } = await setup(); + const { container } = await renderComponent({ customChannel, customClient }); + // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + const file = getImage(); + await act(() => { + dropFile(file, formElement); }); + await waitFor(() => { + expect(sendImageSpy).toHaveBeenCalledWith(file); + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - it('should only allow uploading 1 file if multipleUploads is false', async () => { - const { container } = await renderComponent({ - channelProps: { - multipleUploads: false, - }, - messageInputProps: { - doFileUploadRequest: mockUploadApi(), - }, - }); - - const formElement = await screen.findByPlaceholderText(inputPlaceholder); + it('Should upload, display and link to a file when it is dropped on the dropzone', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ customChannel, customClient }); + // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. + const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getFile(filename1); - act(() => dropFile(file, formElement)); + await act(() => { + dropFile(getFile(), formElement); + }); - await waitFor(() => expect(screen.queryByText(filename1)).toBeInTheDocument()); + const filenameText = await screen.findByText(filename); - const file2 = getFile(filename2); - act(() => dropFile(file2, formElement)); - await waitFor(() => - expect(screen.queryByText(filename2)).not.toBeInTheDocument(), - ); - await axeNoViolations(container); + expect(filenameText).toBeInTheDocument(); + const filePreview = screen.getByTestId(FILE_PREVIEW_TEST_ID); + await waitFor(() => { + expect(filePreview.querySelector('a')).toHaveAttribute('href', fileUploadUrl); }); - it('should show notification if size limit is exceeded', async () => { - defaultChatContext.getAppSettings.mockResolvedValueOnce({ - app: { - file_upload_config: { size_limit: 1 }, - image_upload_config: { size_limit: 1 }, - }, - }); - await renderComponent({ - messageInputProps: { - doFileUploadRequest: mockUploadApi(), - }, - }); - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getFile(filename1); - await act(() => dropFile(file, formElement)); - await waitFor(() => - expect(screen.queryByText(filename1)).not.toBeInTheDocument(), - ); + await axeNoViolations(container); + }); - expect(mockAddNotification).toHaveBeenCalledTimes(1); - expect(mockAddNotification.mock.calls[0][0]).toContain('File is too large'); - }); + it('should allow uploading files with the file upload button', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ customChannel, customClient }); + const file = getFile(); + const input = screen.getByTestId(FILE_INPUT_TEST_ID); - it('should apply separate limits to files and images', async () => { - defaultChatContext.getAppSettings.mockResolvedValueOnce({ - app: { - file_upload_config: { size_limit: 100 }, - image_upload_config: { size_limit: 1 }, - }, - }); - const doImageUploadRequest = mockUploadApi(); - await renderComponent({ - messageInputProps: { - doImageUploadRequest, + await act(() => { + fireEvent.change(input, { + target: { + files: [file], }, }); - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getImage(); - await act(() => { - dropFile(file, formElement); - }); - await waitFor(() => { - expect(mockAddNotification).toHaveBeenCalledTimes(1); - expect(mockAddNotification.mock.calls[0][0]).toContain('File is too large'); - }); }); - it('should show attachment previews if at least one non-scraped attachments available', async () => { - await renderComponent({ - messageInputProps: { - message: { attachments: [{ type: 'xxx' }] }, - }, - }); - const previewListContainer = screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID); - expect(previewListContainer).toBeInTheDocument(); - expect(previewListContainer.children).toHaveLength(1); - expect(previewListContainer.children[0]).toHaveAttribute( - 'data-testid', - UNKNOWN_ATTACHMENT_PREVIEW_TEST_ID, - ); + expect(screen.getByText(filename)).toBeInTheDocument(); + const filePreview = screen.getByTestId(FILE_PREVIEW_TEST_ID); + await waitFor(() => { + expect(filePreview.querySelector('a')).toHaveAttribute('href', fileUploadUrl); }); + await axeNoViolations(container); + }); - it('should not show scraped content in attachment previews', async () => { - await renderComponent({ - messageInputProps: { - message: { attachments: [generateScrapedDataAttachment(), { type: 'xxx' }] }, - }, - }); - const previewListContainer = screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID); - expect(previewListContainer).toBeInTheDocument(); - expect(previewListContainer.children).toHaveLength(1); - expect(previewListContainer.children[0]).toHaveAttribute( - 'data-testid', - UNKNOWN_ATTACHMENT_PREVIEW_TEST_ID, - ); + it('should not set multiple attribute on the file input if multipleUploads is false', async () => { + const { + channels: [customChannel], + client: customClient, + } = await initClientWithChannels({ + channelsData: [mockedChannelData], + customUser: user, }); - - it('should not show attachment previews if no files uploaded and no attachments available', async () => { - await renderComponent({ - messageInputProps: { - message: {}, - }, - }); - expect( - screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID), - ).not.toBeInTheDocument(); + customChannel.messageComposer.attachmentManager.maxNumberOfFilesPerMessage = 1; + const { container } = await renderComponent({ + customChannel, + customClient, }); + const input = screen.getByTestId(FILE_INPUT_TEST_ID); + expect(input).not.toHaveAttribute('multiple'); + await axeNoViolations(container); + }); - it('should not show attachment previews if no files uploaded and attachments available are only link previews', async () => { - await renderComponent({ - messageInputProps: { - message: { attachments: [generateScrapedDataAttachment()] }, - }, - }); - expect( - screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID), - ).not.toBeInTheDocument(); + it('should set multiple attribute on the file input if multipleUploads is true', async () => { + const { + channels: [customChannel], + client: customClient, + } = await initClientWithChannels({ + channelsData: [mockedChannelData], + customUser: user, + }); + customChannel.messageComposer.attachmentManager.maxNumberOfFilesPerMessage = 5; + const { container } = await renderComponent({ + customChannel, + customClient, }); + const input = screen.getByTestId(FILE_INPUT_TEST_ID); + expect(input).toHaveAttribute('multiple'); + await axeNoViolations(container); + }); - it('should not show attachment preview list if only failed uploads are available', async () => { - const cause = new Error('failed to upload'); - const doFileUploadRequest = mockFaultyUploadApi(cause); + const filename1 = '1.txt'; + const filename2 = '2.txt'; + it('should only allow dropping max number of files into the dropzone', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + act(() => { + customChannel.messageComposer.attachmentManager.maxNumberOfFilesPerMessage = 1; + }); - await renderComponent({ - messageInputProps: { - doFileUploadRequest, - }, - }); - jest.spyOn(console, 'warn').mockImplementationOnce(() => null); - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getFile(); + const formElement = await screen.findByPlaceholderText(inputPlaceholder); - act(() => dropFile(file, formElement)); + const file = getFile(filename1); - await waitFor(() => { - expect( - screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID), - ).not.toBeInTheDocument(); - }); - }); + act(() => dropFile(file, formElement)); - it('should show attachment preview list if not only failed uploads are available', async () => { - const cause = new Error('failed to upload'); - const doFileUploadRequest = jest - .fn() - .mockImplementationOnce(() => Promise.reject(cause)) - .mockImplementationOnce(() => - Promise.resolve({ - file: fileUploadUrl, - }), - ); - await renderComponent({ - messageInputProps: { - doFileUploadRequest, - }, - }); - jest.spyOn(console, 'warn').mockImplementationOnce(() => null); - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getFile(); + await waitFor(() => expect(screen.queryByText(filename1)).toBeInTheDocument()); - await act(() => dropFile(file, formElement)); + const file2 = getFile(filename2); + act(() => dropFile(file2, formElement)); - await waitFor(() => { - expect(screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID)).toBeInTheDocument(); - expect(screen.getByTestId(FILE_UPLOAD_RETRY_BTN_TEST_ID)).toBeInTheDocument(); - }); + await waitFor(() => expect(screen.queryByText(filename2)).not.toBeInTheDocument()); - await act(() => dropFile(file, formElement)); + await axeNoViolations(container); + }); - await waitFor(() => { - const previewList = screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID); - expect(previewList).toBeInTheDocument(); - expect(previewList.children).toHaveLength(2); - }); + it('should show attachment previews if at least one non-scraped attachments available', async () => { + const { customChannel, customClient } = await setup(); + customChannel.messageComposer.attachmentManager.state.next({ + attachments: [{ ...generateLocalAttachmentData(), type: 'xxx' }], }); - - // TODO: Check if pasting plaintext is not prevented -> tricky because recreating exact event is hard - // TODO: Remove image/file -> difficult because there is no easy selector and components are in react-file-utils + await renderComponent({ + customChannel, + customClient, + }); + const previewListContainer = screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID); + expect(previewListContainer).toBeInTheDocument(); + expect(previewListContainer.children).toHaveLength(1); + expect(previewListContainer.children[0]).toHaveAttribute( + 'data-testid', + UNKNOWN_ATTACHMENT_PREVIEW_TEST_ID, + ); }); - describe('Uploads disabled', () => { - const channelData = { channel: { own_capabilities: [] } }; - - it('pasting images and files should do nothing', async () => { - const doImageUploadRequest = mockUploadApi(); - const doFileUploadRequest = mockUploadApi(); - const { container } = await renderComponent({ - channelData, - messageInputProps: { - doFileUploadRequest, - doImageUploadRequest, - }, - }); - - const file = getFile(); - const image = getImage(); - - const clipboardEvent = new Event('paste', { bubbles: true }); - // set `clipboardData`. Mock DataTransfer object - clipboardEvent.clipboardData = { - items: [ - { getAsFile: () => file, kind: 'file' }, - { getAsFile: () => image, kind: 'file' }, - ], - }; - const formElement = screen.getByPlaceholderText(inputPlaceholder); - - await act(() => { - formElement.dispatchEvent(clipboardEvent); - }); - - await waitFor(() => { - expect(screen.queryByText(filename)).not.toBeInTheDocument(); - expect(doFileUploadRequest).not.toHaveBeenCalled(); - expect(doImageUploadRequest).not.toHaveBeenCalled(); - }); - const results = await axe(container); - expect(results).toHaveNoViolations(); + it('should not show scraped content in attachment previews', async () => { + const { customChannel, customClient } = await setup(); + const scrapedAttachment = { + ...generateLocalAttachmentData(), + ...generateScrapedDataAttachment(), + }; + const unknownAttachment = { ...generateLocalAttachmentData(), type: 'xxx' }; + customChannel.messageComposer.attachmentManager.state.next({ + attachments: [scrapedAttachment, unknownAttachment], }); + await renderComponent({ + customChannel, + customClient, + }); + const previewListContainer = screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID); + expect(previewListContainer).toBeInTheDocument(); + expect(previewListContainer.children).toHaveLength(1); + expect(previewListContainer.children[0]).toHaveAttribute( + 'data-testid', + UNKNOWN_ATTACHMENT_PREVIEW_TEST_ID, + ); + }); - it('Should not upload an image when it is dropped on the dropzone', async () => { - const doImageUploadRequest = mockUploadApi(); - const { container } = await renderComponent({ - channelData, - messageInputProps: { - doImageUploadRequest, - }, - }); - - // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getImage(); - - act(() => { - dropFile(file, formElement); - }); + it('should not show attachment previews if no files uploaded and no attachments available', async () => { + const { customChannel, customClient } = await setup(); + customChannel.messageComposer.attachmentManager.state.next({ + attachments: [], + }); + await renderComponent({ + customChannel, + customClient, + }); + expect( + screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID), + ).not.toBeInTheDocument(); + }); - await waitFor(() => { - expect(doImageUploadRequest).not.toHaveBeenCalled(); - }); - await waitFor(axeNoViolations(container)); + it('should not show attachment previews if no files uploaded and attachments available are only link previews', async () => { + const { customChannel, customClient } = await setup(); + customChannel.messageComposer.attachmentManager.state.next({ + attachments: [], + }); + const linkPreviewData = generateScrapedDataAttachment(); + customChannel.messageComposer.linkPreviewsManager.state.next({ + previews: new Map([ + [linkPreviewData.og_scrape_url, new LinkPreview({ data: linkPreviewData })], + ]), }); + await renderComponent({ + customChannel, + customClient, + }); + expect( + screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID), + ).not.toBeInTheDocument(); }); - describe('Submitting', () => { - it('should submit the input value when clicking the submit button', async () => { - const { channel, container, submit } = await renderComponent(); + it('should show attachment preview list if not only failed uploads are available', async () => { + const cause = new Error('failed to upload'); + const { customChannel, customClient, sendFileSpy } = + await setupUploadRejected(cause); + sendFileSpy.mockResolvedValueOnce({ file: fileUploadUrl }); + await renderComponent({ + customChannel, + customClient, + }); - const messageText = 'Some text'; + jest.spyOn(console, 'warn').mockImplementationOnce(() => null); + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + const file = getFile(); - fireEvent.change(await screen.findByPlaceholderText(inputPlaceholder), { - target: { - value: messageText, - }, - }); + await act(async () => await dropFile(file, formElement)); - await act(() => submit()); - - if (componentName === 'EditMessageForm') { - expect(editMock).toHaveBeenCalledWith( - channel.cid, - expect.objectContaining({ - text: messageText, - }), - {}, - ); - } else { - expect(submitMock).toHaveBeenCalledWith( - channel, - expect.objectContaining({ - text: messageText, - }), - {}, - ); - } - await axeNoViolations(container); - }); - - it('should allow to send custom message data', async () => { - const customMessageData = { customX: 'customX' }; - const CustomInputForm = () => { - const { handleChange, handleSubmit, value } = useMessageInputContext(); - return ( - <form> - <input - onChange={handleChange} - placeholder={inputPlaceholder} - value={value} - /> - <button - onClick={(event) => { - handleSubmit(event, customMessageData); - }} - type='submit' - > - Send - </button> - </form> - ); - }; - - const messageInputProps = - componentName === 'EditMessageForm' - ? { - messageInputProps: { - message: { - text: `abc`, - }, - }, - } - : {}; - - const renderComponent = makeRenderFn(CustomInputForm); - const { channel, container, submit } = await renderComponent(messageInputProps); - - fireEvent.change(await screen.findByPlaceholderText(inputPlaceholder), { - target: { - value: 'Some text', - }, - }); + await waitFor(() => { + expect(screen.queryByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID)).toBeInTheDocument(); + expect(screen.queryByTestId(FILE_UPLOAD_RETRY_BTN_TEST_ID)).toBeInTheDocument(); + }); - await act(() => submit()); + await act(async () => await dropFile(file, formElement)); - await waitFor(() => { - const isEdit = componentName === 'EditMessageForm'; - const calledMock = isEdit ? editMock : submitMock; - expect(calledMock).toHaveBeenCalledWith( - isEdit ? channel.cid : channel, - expect.objectContaining(customMessageData), - {}, - ); - }); - await axeNoViolations(container); + await waitFor(() => { + const previewList = screen.getByTestId(ATTACHMENT_PREVIEW_LIST_TEST_ID); + expect(previewList).toBeInTheDocument(); + expect(previewList.children).toHaveLength(2); }); + }); + }); - it('should use overrideSubmitHandler prop if it is defined', async () => { - const overrideMock = jest.fn().mockImplementation(() => Promise.resolve()); - const customMessageData = undefined; - const { channel, container, submit } = await renderComponent({ - messageInputProps: { - overrideSubmitHandler: overrideMock, - }, - }); - const messageText = 'Some text'; + describe('Uploads disabled', () => { + const channelData = { channel: { own_capabilities: [] } }; - fireEvent.change(await screen.findByPlaceholderText(inputPlaceholder), { - target: { - value: messageText, - }, - }); + it('pasting images and files should do nothing', async () => { + const { customChannel, customClient, sendFileSpy, sendImageSpy } = await setup({ + channelData, + }); + const { container } = await renderComponent({ + customChannel, + customClient, + }); - await act(() => submit()); - if (componentName === 'EditMessageForm') { - expect(editMock).toHaveBeenCalledWith( - channel.cid, - expect.objectContaining({ - text: messageText, - }), - {}, - ); - } else { - expect(overrideMock).toHaveBeenCalledWith( - expect.objectContaining({ - text: messageText, - }), - channel.cid, - customMessageData, - {}, - ); - } - await axeNoViolations(container); - }); - - it('should not do anything if the message is empty and has no files', async () => { - const { container, submit } = await renderComponent({ - messageInputProps: { message: {} }, - }); + const file = getFile(); + const image = getImage(); + + const clipboardEvent = new Event('paste', { bubbles: true }); + // set `clipboardData`. Mock DataTransfer object + clipboardEvent.clipboardData = { + items: [ + { getAsFile: () => file, kind: 'file' }, + { getAsFile: () => image, kind: 'file' }, + ], + }; + const formElement = screen.getByPlaceholderText(inputPlaceholder); - await act(() => submit()); + await act(() => { + formElement.dispatchEvent(clipboardEvent); + }); - expect( - componentName === 'EditMessageForm' ? editMock : submitMock, - ).not.toHaveBeenCalled(); - await axeNoViolations(container); + await waitFor(() => { + expect(screen.queryByText(filename)).not.toBeInTheDocument(); + expect(sendFileSpy).not.toHaveBeenCalled(); + expect(sendImageSpy).not.toHaveBeenCalled(); }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - it('should add image as attachment if a message is submitted with an image', async () => { - const doImageUploadRequest = mockUploadApi(); - const { channel, container, submit } = await renderComponent({ - messageInputProps: { - doImageUploadRequest, - }, - }); + it('Should not upload an image when it is dropped on the dropzone', async () => { + const { customChannel, customClient, sendImageSpy } = await setup({ + channelData, + }); + const { container } = await renderComponent({ + customChannel, + customClient, + }); - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getImage(); - - act(() => dropFile(file, formElement)); - - // wait for image uploading to complete before trying to send the message - - await waitFor(() => expect(doImageUploadRequest).toHaveBeenCalled()); - - await act(() => submit()); - - const msgFragment = { - attachments: expect.arrayContaining([ - expect.objectContaining({ - image_url: fileUploadUrl, - type: 'image', - }), - ]), - }; - - if (componentName === 'EditMessageForm') { - expect(editMock).toHaveBeenCalledWith( - channel.cid, - expect.objectContaining(msgFragment), - {}, - ); - } else { - expect(submitMock).toHaveBeenCalledWith( - channel, - expect.objectContaining(msgFragment), - {}, - ); - } - await axeNoViolations(container); - }); - - it('should add file as attachment if a message is submitted with a file', async () => { - const doFileUploadRequest = mockUploadApi(); - const { channel, container, submit } = await renderComponent({ - messageInputProps: { - doFileUploadRequest, - }, - }); + // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + const file = getImage(); - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = getFile(); - - act(() => dropFile(file, formElement)); - - // wait for file uploading to complete before trying to send the message - - await waitFor(() => expect(doFileUploadRequest).toHaveBeenCalled()); - - await act(() => submit()); - - const msgFragment = { - attachments: expect.arrayContaining([ - expect.objectContaining({ - asset_url: fileUploadUrl, - type: 'file', - }), - ]), - }; - - if (componentName === 'EditMessageForm') { - expect(editMock).toHaveBeenCalledWith( - channel.cid, - expect.objectContaining(msgFragment), - {}, - ); - } else { - expect(submitMock).toHaveBeenCalledWith( - channel, - expect.objectContaining(msgFragment), - {}, - ); - } - await axeNoViolations(container); - }); - - it('should add audio as attachment if a message is submitted with an audio file', async () => { - const doFileUploadRequest = mockUploadApi(); - const { channel, container, submit } = await renderComponent({ - messageInputProps: { - doFileUploadRequest, - }, - }); + await act(async () => { + await dropFile(file, formElement); + }); - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - const file = new File(['Message in a bottle'], 'the-police.mp3', { - type: 'audio/mp3', - }); + await waitFor(() => { + expect(sendImageSpy).not.toHaveBeenCalled(); + }); + await waitFor(axeNoViolations(container)); + }); + }); - act(() => dropFile(file, formElement)); - - // wait for file uploading to complete before trying to send the message - - await waitFor(() => expect(doFileUploadRequest).toHaveBeenCalled()); - - await act(() => submit()); - - const msgFragment = { - attachments: expect.arrayContaining([ - expect.objectContaining({ - asset_url: fileUploadUrl, - type: 'audio', - }), - ]), - }; - - if (componentName === 'EditMessageForm') { - expect(editMock).toHaveBeenCalledWith( - channel.cid, - expect.objectContaining(msgFragment), - {}, - ); - } else { - expect(submitMock).toHaveBeenCalledWith( - channel, - expect.objectContaining(msgFragment), - {}, - ); - } - await axeNoViolations(container); - }); - - it('should submit if shouldSubmit function is not provided but keydown events do match', async () => { - const submitHandler = jest.fn(); - const { channel, container } = await renderComponent({ - messageInputProps: { - overrideSubmitHandler: submitHandler, - }, - }); - const input = await screen.findByPlaceholderText(inputPlaceholder); - - const messageText = 'Submission text.'; - await act(() => - fireEvent.change(input, { - target: { - value: messageText, - }, - }), - ); + describe('Submitting', () => { + it('should submit the input value when clicking the submit button', async () => { + const { customChannel, customClient } = await setup(); + const { container, submit } = await renderComponent({ + customChannel, + customClient, + }); + + const messageText = 'Some text'; + fireEvent.change(await screen.findByPlaceholderText(inputPlaceholder), { + target: { + value: messageText, + }, + }); - await act(() => fireEvent.keyDown(input, { key: 'Enter' })); + await act(() => submit()); - const msgFragment = { + expect(sendMessageMock).toHaveBeenCalledWith( + customChannel, + expect.objectContaining({ text: messageText, - }; - - if (componentName === 'EditMessageForm') { - expect(editMock).toHaveBeenCalledWith( - channel.cid, - expect.objectContaining(msgFragment), - {}, - ); - } else { - expect(submitHandler).toHaveBeenCalledWith( - expect.objectContaining(msgFragment), - channel.cid, - undefined, - {}, - ); - } - await axeNoViolations(container); - }); - - it('should not submit if shouldSubmit function is provided but keydown events do not match', async () => { - const submitHandler = jest.fn(); - const { container } = await renderComponent({ - messageInputProps: { - overrideSubmitHandler: submitHandler, - shouldSubmit: (e) => e.key === '9', - }, - }); - const input = await screen.findByPlaceholderText(inputPlaceholder); - - const messageText = 'Submission text.'; - await act(() => - fireEvent.change(input, { - target: { - value: messageText, - }, - }), - ); + }), + {}, + ); + await axeNoViolations(container); + }); - await act(() => fireEvent.keyDown(input, { key: 'Enter' })); + it('should allow to send custom message data', async () => { + const { customChannel, customClient } = await setup(); + const customMessageData = { customX: 'customX' }; + const CustomStateSetter = null; - expect(submitHandler).not.toHaveBeenCalled(); - await axeNoViolations(container); + customChannel.messageComposer.customDataManager.setData(customMessageData); + const { container, submit } = await renderComponent({ + customChannel, + customClient, + CustomStateSetter, }); - it('should submit if shouldSubmit function is provided and keydown events do match', async () => { - const submitHandler = jest.fn(); - - const { channel, container } = await renderComponent({ - messageInputProps: { - overrideSubmitHandler: submitHandler, - shouldSubmit: (e) => e.key === '9', - }, - }); - const messageText = 'Submission text.'; - const input = await screen.findByPlaceholderText(inputPlaceholder); - - await act(() => { - fireEvent.change(input, { - target: { - value: messageText, - }, - }); - - fireEvent.keyDown(input, { - key: '9', - }); - }); - - const msgFragment = { - text: messageText, - }; - - if (componentName === 'EditMessageForm') { - expect(editMock).toHaveBeenCalledWith( - channel.cid, - expect.objectContaining(msgFragment), - {}, - ); - } else { - expect(submitHandler).toHaveBeenCalledWith( - expect.objectContaining(msgFragment), - channel.cid, - undefined, - {}, - ); - } - - await axeNoViolations(container); - }); - - it('should not submit if Shift key is pressed', async () => { - const submitHandler = jest.fn(); - - const { container } = await renderComponent({ - messageInputProps: { - overrideSubmitHandler: submitHandler, - }, - }); - const messageText = 'Submission text.'; - const input = await screen.findByPlaceholderText(inputPlaceholder); - - await act(() => { - fireEvent.change(input, { - target: { - value: messageText, - }, - }); - - fireEvent.keyDown(input, { - key: 'Enter', - shiftKey: true, - }); - }); + fireEvent.change(await screen.findByPlaceholderText(inputPlaceholder), { + target: { + value: 'Some text', + }, + }); - expect(submitHandler).not.toHaveBeenCalled(); + await act(() => submit()); - await axeNoViolations(container); + await waitFor(() => { + expect(sendMessageMock).toHaveBeenCalledWith( + customChannel, + expect.objectContaining(customMessageData), + {}, + ); }); + await axeNoViolations(container); }); - it('should edit a message if it is passed through the message prop', async () => { - const file = { - asset_url: 'somewhere.txt', - file_size: 1000, - mime_type: 'text/plain', - title: 'title', - type: 'file', - }; - const image = { - fallback: 'fallback.png', - image_url: 'somewhere.png', - type: 'image', - }; - const mentioned_users = [{ id: userId, name: username }]; - - const message = generateMessage({ - attachments: [file, image], - mentioned_users, - text: `@${username} what's up!`, - }); - const { channel, container, submit } = await renderComponent({ + it('should use overrideSubmitHandler prop if it is defined', async () => { + const overrideMock = jest.fn().mockImplementation(() => Promise.resolve()); + const { customChannel, customClient } = await setup(); + const customMessageData = { customX: 'customX' }; + customChannel.messageComposer.customDataManager.setData(customMessageData); + const { container, submit } = await renderComponent({ + customChannel, + customClient, messageInputProps: { - clearEditingState: () => {}, - message, + overrideSubmitHandler: overrideMock, + }, + }); + const messageText = 'Some text'; + + fireEvent.change(await screen.findByPlaceholderText(inputPlaceholder), { + target: { + value: messageText, }, }); await act(() => submit()); - expect(editMock).toHaveBeenCalledWith( - channel.cid, + expect(overrideMock).toHaveBeenCalledWith( expect.objectContaining({ - attachments: expect.arrayContaining([ - expect.objectContaining(image), - expect.objectContaining(file), - ]), - mentioned_users: [{ id: userId, name: username }], - text: message.text, + cid: customChannel.cid, + localMessage: expect.objectContaining({ + text: messageText, + ...customMessageData, + }), + message: expect.objectContaining({ + text: messageText, + ...customMessageData, + }), + sendOptions: expect.objectContaining({}), }), - {}, ); - const results = await axe(container); - expect(results).toHaveNoViolations(); + await axeNoViolations(container); }); - it('should list all the available users to mention if only @ is typed', async () => { - const { channel } = await renderComponent({ customUser: generateUser() }); - - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - - await act(() => { - fireEvent.change(formElement, { - target: { - selectionEnd: 1, - value: '@', + it('should not do anything if the message is empty and has no files', async () => { + const { customChannel, customClient } = await setup(); + const { container, submit } = await renderComponent({ + customChannel, + customClient, + messageContextOverrides: { + message: { + cid: customChannel.cid, + created_at: new Date(), + updated_at: new Date(), }, - }); + }, }); - const usernameList = screen.getAllByTestId('user-item-name'); - expect(usernameList).toHaveLength(Object.keys(channel.state.members).length); - }); + await act(() => submit()); - it('should add a mentioned user if @ is typed and a user is selected', async () => { - const { channel, container, submit } = await renderComponent(); + expect(sendMessageMock).not.toHaveBeenCalled(); + await axeNoViolations(container); + }); + it('should add image as attachment if a message is submitted with an image', async () => { + const { customChannel, customClient, sendImageSpy } = await setup(); + const { container, submit } = await renderComponent({ + customChannel, + customClient, + }); + // drop on the form input. Technically could be dropped just outside of it as well, but the input should always work. const formElement = await screen.findByPlaceholderText(inputPlaceholder); - - await act(() => { - fireEvent.change(formElement, { - target: { - selectionEnd: 1, - value: '@', - }, - }); + const file = getImage(); + await act(async () => { + await dropFile(file, formElement); }); - const usernameListItem = await screen.getByTestId('user-item-name'); - expect(usernameListItem).toBeInTheDocument(); + // wait for image uploading to complete before trying to send the message - await act(() => { - fireEvent.click(usernameListItem); - }); + await waitFor(() => expect(sendImageSpy).toHaveBeenCalled()); - await act(() => submit()); + await act(async () => await submit()); - if (componentName === 'EditMessageForm') { - expect(editMock).toHaveBeenCalledWith( - channel.cid, - expect.objectContaining({ - ...mainListMessage, - created_at: expect.any(Date), - mentioned_users: [ - { - banned: false, - created_at: '2020-04-27T13:39:49.331742Z', - id: 'mention-id', - image: expect.any(String), - name: 'mention-name', - online: false, - role: 'user', - updated_at: '2020-04-27T13:39:49.332087Z', - }, - ], - text: '@mention-name ', - updated_at: expect.any(Date), - }), - {}, - ); - } else { - expect(submitMock).toHaveBeenCalledWith( - channel, + const msgFragment = { + attachments: expect.arrayContaining([ expect.objectContaining({ - mentioned_users: expect.arrayContaining([mentionId]), + image_url: fileUploadUrl, + type: 'image', }), + ]), + }; + + await waitFor(() => { + expect(sendMessageMock).toHaveBeenCalledWith( + customChannel, + expect.objectContaining(msgFragment), {}, ); - } - const results = await axe(container); - expect(results).toHaveNoViolations(); + }); + + await axeNoViolations(container); }); - it('should remove mentioned users if they are no longer mentioned in the message text', async () => { - const { channel, container, submit } = await renderComponent({ - messageInputProps: { - message: { - mentioned_users: [{ id: userId, name: username }], - text: `@${username}`, - }, - }, + it('should submit if shouldSubmit function is not provided but keydown events do match', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, }); - // remove all text from input - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - await act(() => { - fireEvent.change(formElement, { + const messageText = 'Some text'; + const input = await screen.findByPlaceholderText(inputPlaceholder); + + await act(async () => { + await fireEvent.change(input, { target: { - selectionEnd: 1, - value: 'no mentioned users', + value: messageText, }, }); }); - await act(() => submit()); + await act(() => fireEvent.keyDown(input, { key: 'Enter' })); - expect(editMock).toHaveBeenCalledWith( - channel.cid, + expect(sendMessageMock).toHaveBeenCalledWith( + customChannel, expect.objectContaining({ - mentioned_users: [], + text: messageText, }), {}, ); - const results = await axe(container); - expect(results).toHaveNoViolations(); + await axeNoViolations(container); }); - it('should override the default List component when SuggestionList is provided as a prop', async () => { - const AutocompleteSuggestionList = () => ( - <div data-testid='suggestion-list'>Suggestion List</div> - ); - + it('should not submit if shouldSubmit function is provided but keydown events do not match', async () => { + const { customChannel, customClient } = await setup(); const { container } = await renderComponent({ - channelProps: { AutocompleteSuggestionList }, + customChannel, + customClient, + messageInputProps: { + shouldSubmit: (e) => e.key === '9', + }, }); + const input = await screen.findByPlaceholderText(inputPlaceholder); - const formElement = await screen.findByPlaceholderText(inputPlaceholder); - - await waitFor(() => - expect(screen.queryByText('Suggestion List')).not.toBeInTheDocument(), - ); - - act(() => { - fireEvent.change(formElement, { - target: { value: '/' }, - }); + const messageText = 'Some text'; + fireEvent.change(input, { + target: { + value: messageText, + }, }); - if (componentName !== 'EditMessageForm') { - await waitFor(() => - expect(screen.getByTestId('suggestion-list')).toBeInTheDocument(), - ); - const results = await axe(container); - expect(results).toHaveNoViolations(); - } - const results = await axe(container); - expect(results).toHaveNoViolations(); + await act(() => fireEvent.keyDown(input, { key: 'Enter' })); + + expect(sendMessageMock).not.toHaveBeenCalled(); + await axeNoViolations(container); }); - }); -}); -describe('EditMessageForm only', () => { - afterEach(tearDown); + it('should submit if shouldSubmit function is provided and keydown events do match', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, + messageInputProps: { + shouldSubmit: (e) => e.key === '9', + }, + }); + const messageText = 'Submission text.'; + const input = await screen.findByPlaceholderText(inputPlaceholder); - const renderComponent = makeRenderFn(EditMessageForm); + await act(async () => { + await fireEvent.change(input, { + target: { + value: messageText, + }, + }); + }); + await act(async () => { + await fireEvent.keyDown(input, { + key: '9', + }); + }); + expect(sendMessageMock).toHaveBeenCalledWith( + customChannel, + expect.objectContaining({ + text: messageText, + }), + {}, + ); - it('should render file upload button disabled', async () => { - const channelData = { channel: { own_capabilities: [] } }; - const { container } = await renderComponent({ - channelData, + await axeNoViolations(container); }); - await waitFor(() => expect(screen.getByTestId(FILE_INPUT_TEST_ID)).toBeDisabled()); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); -}); -describe(`MessageInputFlat only`, () => { - afterEach(tearDown); + it('should not submit if Shift key is pressed', async () => { + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + customChannel, + customClient, + }); + const messageText = 'Submission text.'; + const input = await screen.findByPlaceholderText(inputPlaceholder); - const renderComponent = makeRenderFn(MessageInputFlat); + await act(async () => { + await fireEvent.change(input, { + target: { + value: messageText, + }, + }); + }); + await act(async () => { + await fireEvent.keyDown(input, { + key: 'Enter', + shiftKey: true, + }); + }); - it('should contain default message text if provided', async () => { - const defaultValue = nanoid(); - await renderComponent({ - messageInputProps: { - additionalTextareaProps: { defaultValue }, - }, - }); - await waitFor(() => { - const textarea = screen.queryByDisplayValue(defaultValue); - expect(textarea).toBeInTheDocument(); + expect(sendMessageMock).not.toHaveBeenCalled(); + + await axeNoViolations(container); }); }); - it('should prefer value from getDefaultValue before additionalTextareaProps.defaultValue', async () => { - const defaultValue = nanoid(); - const generatedDefaultValue = nanoid(); - const getDefaultValue = () => generatedDefaultValue; + it('should list all the available users to mention if only @ is typed', async () => { + const scrollIntoView = Element.prototype.scrollIntoView; + // eslint-disable-next-line jest/prefer-spy-on + Element.prototype.scrollIntoView = jest.fn(); + const { customChannel, customClient } = await setup(); await renderComponent({ - messageInputProps: { - additionalTextareaProps: { defaultValue }, - getDefaultValue, - }, + customChannel, + customClient, + customUser: generateUser(), }); - await waitFor(() => { - const textarea = screen.queryByDisplayValue(generatedDefaultValue); - expect(textarea).toBeInTheDocument(); + + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + await act(async () => { + await fireEvent.change(formElement, { + target: { + selectionEnd: 1, + selectionStart: 1, + value: '@', + }, + }); }); + + const usernameList = await screen.findAllByTestId('user-item-name'); + expect(usernameList).toHaveLength( + Object.keys(customChannel.state.members).length - 1, // remove own user + ); + Element.prototype.scrollIntoView = scrollIntoView; }); - const renderWithActiveCooldown = async ({ messageInputProps = {} } = {}) => { - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [{ channel: { cooldown } }], - customUser: user, + it('should add a mentioned user if @ is typed and a user is selected', async () => { + const scrollIntoView = Element.prototype.scrollIntoView; + // eslint-disable-next-line jest/prefer-spy-on + Element.prototype.scrollIntoView = jest.fn(); + const { customChannel, customClient } = await setup(); + const { container, submit } = await renderComponent({ + customChannel, + customClient, + customUser: generateUser(), }); - const lastSentSecondsAhead = 5; - await renderComponent({ - chatContextOverrides: { - latestMessageDatesByChannels: { - [channel.cid]: new Date(new Date().getTime() + lastSentSecondsAhead * 1000), + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + await act(async () => { + await fireEvent.change(formElement, { + target: { + selectionEnd: 1, + selectionStart: 1, + value: '@', }, - }, - customChannel: channel, - customClient: client, - messageInputProps, + }); }); - }; - const initQuotedMessagePreview = async () => { - await waitFor(() => - expect(screen.queryByTestId('quoted-message-preview')).not.toBeInTheDocument(), + const usernameList = await screen.findAllByTestId('user-item-name'); + const firstItem = usernameList[0]; + await act(async () => { + await fireEvent.click(firstItem); + }); + + await act(() => submit()); + + expect(sendMessageMock).toHaveBeenCalledWith( + customChannel, + expect.objectContaining({ + mentioned_users: expect.arrayContaining([mentionId]), + text: '@mention-name ', + }), + {}, ); - const quoteButton = await screen.findByText(/^reply$/i); - await waitFor(() => expect(quoteButton).toBeInTheDocument()); + Element.prototype.scrollIntoView = scrollIntoView; + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - act(() => { - fireEvent.click(quoteButton); + it('should override the default List component when SuggestionList is provided as a prop', async () => { + const AutocompleteSuggestionList = () => ( + <div data-testid='suggestion-list'>Suggestion List</div> + ); + const { customChannel, customClient } = await setup(); + const { container } = await renderComponent({ + channelProps: { AutocompleteSuggestionList }, + customChannel, + customClient, + }); + + const formElement = await screen.findByPlaceholderText(inputPlaceholder); + + // the component does not check whether there are items to be displayed + expect(await screen.findByText('Suggestion List')).toBeInTheDocument(); + + await act(() => { + fireEvent.change(formElement, { + target: { value: '/' }, + }); }); - }; - const quotedMessagePreviewIsDisplayedCorrectly = async (message) => { await waitFor(() => - expect(screen.queryByTestId('quoted-message-preview')).toBeInTheDocument(), + expect(screen.queryByText('Suggestion List')).toBeInTheDocument(), ); - await waitFor(() => expect(screen.getByText(message.text)).toBeInTheDocument()); - }; - - const quotedMessagePreviewIsNotDisplayed = (message) => { - expect(screen.queryByText(/reply to message/i)).not.toBeInTheDocument(); - expect(screen.queryByText(message.text)).not.toBeInTheDocument(); - }; + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); describe('QuotedMessagePreview', () => { it('is displayed on quote action click', async () => { @@ -1661,12 +1231,7 @@ describe(`MessageInputFlat only`, () => { expect(screen.getByTestId(SEND_BTN_TEST_ID)).toBeInTheDocument(); }); - it('should be rendered when editing a message', async () => { - await renderComponent({ messageInputProps: { message: generateMessage() } }); - expect(screen.getByTestId(SEND_BTN_TEST_ID)).toBeInTheDocument(); - }); - - it('should not be renderer during active cooldown period', async () => { + it('should not be rendered during active cooldown period', async () => { await renderWithActiveCooldown(); expect(screen.queryByTestId(SEND_BTN_TEST_ID)).not.toBeInTheDocument(); }); @@ -1680,25 +1245,29 @@ describe(`MessageInputFlat only`, () => { await renderComponent(); expect(screen.getByTestId(SEND_BTN_TEST_ID)).toBeDisabled(); }); + it('should be enabled if there is text to be submitted', async () => { await renderComponent(); - fireEvent.change(screen.getByPlaceholderText(inputPlaceholder), { - target: { - value: 'X', - }, + await act(() => { + fireEvent.change(screen.getByPlaceholderText(inputPlaceholder), { + target: { + value: 'X', + }, + }); }); expect(screen.getByTestId(SEND_BTN_TEST_ID)).toBeEnabled(); }); + it('should be enabled if there are uploads to be submitted', async () => { + const { customChannel, customClient } = await setup(); await renderComponent({ - messageInputProps: { - doFileUploadRequest: mockUploadApi(), - }, + customChannel, + customClient, }); const file = getFile(); - await act(() => { - fireEvent.change(screen.getByTestId(FILE_INPUT_TEST_ID), { + await act(async () => { + await fireEvent.change(screen.getByTestId(FILE_INPUT_TEST_ID), { target: { files: [file], }, @@ -1706,15 +1275,25 @@ describe(`MessageInputFlat only`, () => { }); expect(screen.getByTestId(SEND_BTN_TEST_ID)).toBeEnabled(); }); - it('should be enabled if there are attachments to be submitted', async () => { + + it('should disabled if there are failed attachments only', async () => { + const error = new Error('Upload failed'); + const { customChannel, customClient } = await setupUploadRejected(error); await renderComponent({ - messageInputProps: { - message: { attachments: [{}] }, - }, + customChannel, + customClient, }); - expect(screen.getByTestId(SEND_BTN_TEST_ID)).toBeEnabled(); + const file = getFile(); + + await act(async () => { + await fireEvent.change(screen.getByTestId(FILE_INPUT_TEST_ID), { + target: { + files: [file], + }, + }); + }); + expect(screen.getByTestId(SEND_BTN_TEST_ID)).not.toBeEnabled(); }); - it.todo('should not be enabled if there are failed attachments only'); }); describe('cooldown timer', () => { diff --git a/src/components/MessageInput/hooks/index.ts b/src/components/MessageInput/hooks/index.ts index 82ebbf2a8b..93aecd36b0 100644 --- a/src/components/MessageInput/hooks/index.ts +++ b/src/components/MessageInput/hooks/index.ts @@ -1,2 +1,3 @@ export * from './useCooldownTimer'; export * from './useMessageInputState'; +export * from './messageComposer'; diff --git a/src/components/MessageInput/hooks/messageComposer/index.ts b/src/components/MessageInput/hooks/messageComposer/index.ts new file mode 100644 index 0000000000..5ab6a93d83 --- /dev/null +++ b/src/components/MessageInput/hooks/messageComposer/index.ts @@ -0,0 +1,3 @@ +export * from './useAttachmentManagerState'; +export * from './useMessageComposerHasSendableData'; +export * from './useMessageComposer'; diff --git a/src/components/MessageInput/hooks/messageComposer/useAttachmentManagerState.ts b/src/components/MessageInput/hooks/messageComposer/useAttachmentManagerState.ts new file mode 100644 index 0000000000..933f931ceb --- /dev/null +++ b/src/components/MessageInput/hooks/messageComposer/useAttachmentManagerState.ts @@ -0,0 +1,22 @@ +import { useMessageComposer } from './useMessageComposer'; +import { useStateStore } from '../../../../store'; +import type { AttachmentManagerState } from 'stream-chat'; + +const stateSelector = (state: AttachmentManagerState) => ({ + attachments: state.attachments, +}); + +export const useAttachmentManagerState = () => { + const { attachmentManager } = useMessageComposer(); + const { attachments } = useStateStore(attachmentManager.state, stateSelector); + return { + attachments, + availableUploadSlots: attachmentManager.availableUploadSlots, + blockedUploadsCount: attachmentManager.blockedUploadsCount, + failedUploadsCount: attachmentManager.failedUploadsCount, + isUploadEnabled: attachmentManager.isUploadEnabled, + pendingUploadsCount: attachmentManager.pendingUploadsCount, + successfulUploadsCount: attachmentManager.successfulUploadsCount, + uploadsInProgressCount: attachmentManager.uploadsInProgressCount, + }; +}; diff --git a/src/components/MessageInput/hooks/messageComposer/useMessageComposer.ts b/src/components/MessageInput/hooks/messageComposer/useMessageComposer.ts new file mode 100644 index 0000000000..7fe324c733 --- /dev/null +++ b/src/components/MessageInput/hooks/messageComposer/useMessageComposer.ts @@ -0,0 +1,96 @@ +import { useEffect, useMemo } from 'react'; +import { FixedSizeQueueCache, MessageComposer } from 'stream-chat'; +import { useThreadContext } from '../../../Threads'; +import { + useChannelStateContext, + useChatContext, + useMessageContext, +} from '../../../../context'; +import { useLegacyThreadContext } from '../../../Thread'; + +const queueCache = new FixedSizeQueueCache<string, MessageComposer>(64); + +export const useMessageComposer = () => { + const { client } = useChatContext(); + const { channel } = useChannelStateContext(); + const { editing, message: editedMessage } = useMessageContext(); + // legacy thread will receive new composer + const { legacyThread: parentMessage } = useLegacyThreadContext(); + const threadInstance = useThreadContext(); + + const cachedEditedMessage = useMemo(() => { + if (!editedMessage) return undefined; + + return editedMessage; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editedMessage?.id]); + + const cachedParentMessage = useMemo(() => { + if (!parentMessage) return undefined; + + return parentMessage; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parentMessage?.id]); + + // composer hierarchy + // edited message (always new) -> thread instance (own) -> thread message (always new) -> channel (own) + // editedMessage ?? thread ?? parentMessage ?? channel; + const messageComposer = useMemo(() => { + if (editing && cachedEditedMessage) { + const tag = MessageComposer.constructTag(cachedEditedMessage); + + const cachedComposer = queueCache.get(tag); + if (cachedComposer) return cachedComposer; + + return new MessageComposer({ + client, + composition: cachedEditedMessage, + compositionContext: cachedEditedMessage, + }); + } else if (threadInstance) { + return threadInstance.messageComposer; + } else if (cachedParentMessage) { + const compositionContext = { + ...cachedParentMessage, + legacyThreadId: cachedParentMessage.id, + }; + + const tag = MessageComposer.constructTag(compositionContext); + + const cachedComposer = queueCache.get(tag); + if (cachedComposer) return cachedComposer; + + return new MessageComposer({ + client, + compositionContext, + }); + } else { + return channel.messageComposer; + } + }, [ + cachedEditedMessage, + cachedParentMessage, + channel, + client, + editing, + threadInstance, + ]); + + if ( + (['legacy_thread', 'message'] as MessageComposer['contextType'][]).includes( + messageComposer.contextType, + ) && + !queueCache.peek(messageComposer.tag) + ) { + queueCache.add(messageComposer.tag, messageComposer); + } + + useEffect(() => { + const unsubscribe = messageComposer.registerSubscriptions(); + return () => { + unsubscribe(); + }; + }, [messageComposer]); + + return messageComposer; +}; diff --git a/src/components/MessageInput/hooks/messageComposer/useMessageComposerHasSendableData.ts b/src/components/MessageInput/hooks/messageComposer/useMessageComposerHasSendableData.ts new file mode 100644 index 0000000000..3c77d44922 --- /dev/null +++ b/src/components/MessageInput/hooks/messageComposer/useMessageComposerHasSendableData.ts @@ -0,0 +1,11 @@ +import { useMessageComposer } from './useMessageComposer'; +import { useStateStore } from '../../../../store'; +import type { EditingAuditState } from 'stream-chat'; + +const editingAuditStateStateSelector = (state: EditingAuditState) => state; + +export const useMessageComposerHasSendableData = () => { + const messageComposer = useMessageComposer(); + useStateStore(messageComposer.editingAuditState, editingAuditStateStateSelector); + return messageComposer.hasSendableData; +}; diff --git a/src/components/MessageInput/hooks/useAttachments.ts b/src/components/MessageInput/hooks/useAttachments.ts deleted file mode 100644 index f451586570..0000000000 --- a/src/components/MessageInput/hooks/useAttachments.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { useCallback } from 'react'; -import { nanoid } from 'nanoid'; - -import { checkUploadPermissions } from './utils'; -import { isLocalAttachment, isLocalImageAttachment } from '../../Attachment'; -import type { FileLike } from '../../ReactFileUtilities'; -import { - createFileFromBlobs, - generateFileName, - isBlobButNotFile, -} from '../../ReactFileUtilities'; - -import { - useChannelActionContext, - useChannelStateContext, - useChatContext, - useTranslationContext, -} from '../../../context'; - -import type { Attachment, SendFileAPIResponse } from 'stream-chat'; -import type { - MessageInputReducerAction, - MessageInputState, -} from './useMessageInputState'; -import type { MessageInputProps } from '../MessageInput'; -import type { - AttachmentLoadingState, - BaseLocalAttachmentMetadata, - LocalAttachment, -} from '../types'; -import type { CustomTrigger } from '../../../types/types'; - -const apiMaxNumberOfFiles = 10; - -// const isAudioFile = (file: FileLike) => file.type.includes('audio/'); -const isImageFile = (file: FileLike) => - file.type.startsWith('image/') && !file.type.endsWith('.photoshop'); // photoshop files begin with 'image/' -// const isVideoFile = (file: FileLike) => file.type.includes('video/'); - -const getAttachmentTypeFromMime = (mimeType: string) => { - if (mimeType.startsWith('image/') && !mimeType.endsWith('.photoshop')) return 'image'; - if (mimeType.includes('video/')) return 'video'; - if (mimeType.includes('audio/')) return 'audio'; - return 'file'; -}; - -const ensureIsLocalAttachment = ( - attachment: Attachment | LocalAttachment, -): LocalAttachment => { - if (isLocalAttachment(attachment)) { - return attachment; - } - // local is considered local only if localMetadata has `id` so this is to doublecheck - const { localMetadata, ...rest } = attachment as LocalAttachment; - return { - localMetadata: { - ...(localMetadata ?? {}), - id: (localMetadata as BaseLocalAttachmentMetadata)?.id || nanoid(), - }, - ...rest, - }; -}; - -export const useAttachments = <V extends CustomTrigger = CustomTrigger>( - props: MessageInputProps<V>, - state: MessageInputState, - dispatch: React.Dispatch<MessageInputReducerAction>, - textareaRef: React.MutableRefObject<HTMLTextAreaElement | undefined>, -) => { - const { doFileUploadRequest, doImageUploadRequest, errorHandler, noFiles } = props; - const { getAppSettings } = useChatContext('useAttachments'); - const { t } = useTranslationContext('useAttachments'); - const { addNotification } = useChannelActionContext('useAttachments'); - const { channel, maxNumberOfFiles, multipleUploads } = - useChannelStateContext('useAttachments'); - - // Number of files that the user can still add. Should never be more than the amount allowed by the API. - // If multipleUploads is false, we only want to allow a single upload. - const maxFilesAllowed = !multipleUploads ? 1 : maxNumberOfFiles || apiMaxNumberOfFiles; - - const numberOfUploads = Object.values(state.attachments).filter( - ({ localMetadata }) => - localMetadata.uploadState && localMetadata.uploadState !== 'failed', - ).length; - - const maxFilesLeft = maxFilesAllowed - numberOfUploads; - - const removeAttachments = useCallback( - (ids: string[]) => { - if (!ids.length) return; - dispatch({ ids, type: 'removeAttachments' }); - }, - [dispatch], - ); - - const upsertAttachments = useCallback( - (attachments: (Attachment | LocalAttachment)[]) => { - if (!attachments.length) return; - dispatch({ - attachments: attachments.map(ensureIsLocalAttachment), - type: 'upsertAttachments', - }); - }, - [dispatch], - ); - - const uploadAttachment = useCallback( - async (att: LocalAttachment): Promise<LocalAttachment | undefined> => { - const { localMetadata, ...providedAttachmentData } = att; - - if (!localMetadata?.file) return att; - - const { file } = localMetadata; - const isImage = isImageFile(file); - - if (noFiles && !isImage) return att; - - const canUpload = await checkUploadPermissions({ - addNotification, - file, - getAppSettings, - t, - uploadType: isImage ? 'image' : 'file', - }); - - if (!canUpload) return att; - - localMetadata.id = localMetadata?.id ?? nanoid(); - const finalAttachment: Attachment = { - type: getAttachmentTypeFromMime(file.type), - }; - if (isImage) { - // @ts-expect-error previewUri is being defined - localMetadata.previewUri = URL.createObjectURL?.(file); - if (file instanceof File) { - finalAttachment.fallback = file.name; - } - } else { - finalAttachment.file_size = file.size; - finalAttachment.mime_type = file.type; - if (file instanceof File) { - finalAttachment.title = file.name; - } - } - - Object.assign(finalAttachment, providedAttachmentData); - - upsertAttachments([ - { - ...finalAttachment, - localMetadata: { - ...localMetadata, - uploadState: 'uploading', - }, - }, - ]); - - let response: SendFileAPIResponse; - try { - const doUploadRequest = isImage ? doImageUploadRequest : doFileUploadRequest; - - if (doUploadRequest) { - response = await doUploadRequest(file, channel); - } else { - response = await channel[isImage ? 'sendImage' : 'sendFile'](file); - } - } catch (error) { - let finalError: Error = { - message: t('Error uploading attachment'), - name: 'Error', - }; - if (typeof (error as Error).message === 'string') { - finalError = error as Error; - } else if (typeof error === 'object') { - finalError = Object.assign(finalError, error); - } - - console.error(finalError); - addNotification(finalError.message, 'error'); - - const failedAttachment: LocalAttachment = { - ...finalAttachment, - localMetadata: { - ...localMetadata, - uploadState: 'failed' as AttachmentLoadingState, - }, - }; - - upsertAttachments([failedAttachment]); - - if (errorHandler) { - errorHandler(finalError as Error, 'upload-attachment', { - ...file, - id: localMetadata.id, - }); - } - - return failedAttachment; - } - - if (!response) { - // Copied this from useImageUpload / useFileUpload. - - // If doUploadRequest returns any falsy value, then don't create the upload preview. - // This is for the case if someone wants to handle failure on app level. - removeAttachments([localMetadata.id]); - return; - } - - const uploadedAttachment: LocalAttachment = { - ...finalAttachment, - localMetadata: { - ...localMetadata, - uploadState: 'finished' as AttachmentLoadingState, - }, - }; - - if (isLocalImageAttachment(uploadedAttachment)) { - if (uploadedAttachment.localMetadata.previewUri) { - URL.revokeObjectURL(uploadedAttachment.localMetadata.previewUri); - delete uploadedAttachment.localMetadata.previewUri; - } - uploadedAttachment.image_url = response.file; - } else { - uploadedAttachment.asset_url = response.file; - } - if (response.thumb_url) { - uploadedAttachment.thumb_url = response.thumb_url; - } - - upsertAttachments([uploadedAttachment]); - - return uploadedAttachment; - }, - [ - addNotification, - channel, - doFileUploadRequest, - doImageUploadRequest, - errorHandler, - getAppSettings, - noFiles, - removeAttachments, - t, - upsertAttachments, - ], - ); - - const uploadNewFiles = useCallback( - (files: FileList | File[] | FileLike[]) => { - const filesToBeUploaded = noFiles - ? Array.from(files).filter(isImageFile) - : Array.from(files); - - filesToBeUploaded.slice(0, maxFilesLeft).forEach((fileLike) => { - uploadAttachment({ - localMetadata: { - file: isBlobButNotFile(fileLike) - ? createFileFromBlobs({ - blobsArray: [fileLike], - fileName: generateFileName(fileLike.type), - mimeType: fileLike.type, - }) - : fileLike, - id: nanoid(), - }, - }); - }); - - textareaRef.current?.focus(); - }, - [maxFilesLeft, noFiles, textareaRef, uploadAttachment], - ); - - return { - maxFilesLeft, - numberOfUploads, - removeAttachments, - uploadAttachment, - uploadNewFiles, - upsertAttachments, - }; -}; diff --git a/src/components/MessageInput/hooks/useCommandTrigger.ts b/src/components/MessageInput/hooks/useCommandTrigger.ts deleted file mode 100644 index c050be43b4..0000000000 --- a/src/components/MessageInput/hooks/useCommandTrigger.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { CommandItem } from '../../CommandItem/CommandItem'; - -import { useChannelStateContext } from '../../../context/ChannelStateContext'; -import { useTranslationContext } from '../../../context'; - -import type { CommandResponse } from 'stream-chat'; - -import type { CommandTriggerSetting } from '../DefaultTriggerProvider'; - -type ValidCommand = Required<Pick<CommandResponse, 'name'>> & - Omit<CommandResponse, 'name'>; - -export const useCommandTrigger = (): CommandTriggerSetting => { - const { channelConfig } = useChannelStateContext('useCommandTrigger'); - const { t } = useTranslationContext('useCommandTrigger'); - - const commands = channelConfig?.commands; - - return { - component: CommandItem, - dataProvider: (query, text, onReady) => { - if (text.indexOf('/') !== 0 || !commands) { - return []; - } - const selectedCommands = commands.filter( - (command) => command.name?.indexOf(query) !== -1, - ); - - // sort alphabetically unless you're matching the first char - selectedCommands.sort((a, b) => { - let nameA = a.name?.toLowerCase(); - let nameB = b.name?.toLowerCase(); - if (nameA?.indexOf(query) === 0) { - nameA = `0${nameA}`; - } - if (nameB?.indexOf(query) === 0) { - nameB = `0${nameB}`; - } - // Should confirm possible null / undefined when TS is fully implemented - if (nameA != null && nameB != null) { - if (nameA < nameB) { - return -1; - } - if (nameA > nameB) { - return 1; - } - } - - return 0; - }); - - const result = selectedCommands.slice(0, 5); - if (onReady) - onReady( - result - .filter( - (result): result is CommandResponse & { name: string } => - result.name !== undefined, - ) - .map((commandData) => { - const translatedCommandData: ValidCommand = { - name: commandData.name, - }; - - if (commandData.args) - translatedCommandData.args = t(`${commandData.name}-command-args`, { - defaultValue: commandData.args, - }); - if (commandData.description) - translatedCommandData.description = t( - `${commandData.name}-command-description`, - { - defaultValue: commandData.description, - }, - ); - - return translatedCommandData; - }), - query, - ); - - return result; - }, - output: (entity) => ({ - caretPosition: 'next', - key: entity.name, - text: `/${entity.name}`, - }), - }; -}; diff --git a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts index 5562fc79ad..c2b14612a4 100644 --- a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts +++ b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts @@ -1,164 +1,88 @@ import { useMemo } from 'react'; import type { MessageInputContextValue } from '../../../context/MessageInputContext'; -import type { CustomTrigger } from '../../../types/types'; -export const useCreateMessageInputContext = <V extends CustomTrigger = CustomTrigger>( - value: MessageInputContextValue<V>, -) => { +export const useCreateMessageInputContext = (value: MessageInputContextValue) => { const { additionalTextareaProps, asyncMessagesMultiSendEnabled, - attachments, audioRecordingEnabled, - autocompleteTriggers, - cancelURLEnrichment, clearEditingState, - closeCommandsList, - closeMentionsList, cooldownInterval, cooldownRemaining, disabled, disableMentions, - dismissLinkPreview, doFileUploadRequest, doImageUploadRequest, emojiSearchIndex, - errorHandler, - findAndEnqueueURLsToEnrich, focus, grow, - handleChange, handleSubmit, hideSendButton, insertText, isThreadInput, - isUploadEnabled, - linkPreviews, - maxFilesLeft, maxRows, mentionAllAppUsers, - mentioned_users, mentionQueryParams, - message, minRows, - noFiles, - numberOfUploads, onPaste, - onSelectUser, - openCommandsList, - openMentionsList, - overrideSubmitHandler, parent, publishTypingEvent, recordingController, - removeAttachments, setCooldownRemaining, - setText, shouldSubmit, - showCommandsList, - showMentionsList, - text, textareaRef, - uploadAttachment, - uploadNewFiles, - upsertAttachments, useMentionsTransliteration, } = value; - const editing = message?.editing; - const linkPreviewsValue = Array.from(linkPreviews.values()).join(); - const mentionedUsersLength = mentioned_users.length; const parentId = parent?.id; - const messageInputContext: MessageInputContextValue<V> = useMemo( + const messageInputContext: MessageInputContextValue = useMemo( () => ({ additionalTextareaProps, asyncMessagesMultiSendEnabled, - attachments, audioRecordingEnabled, - autocompleteTriggers, - cancelURLEnrichment, clearEditingState, - closeCommandsList, - closeMentionsList, cooldownInterval, cooldownRemaining, disabled, disableMentions, - dismissLinkPreview, doFileUploadRequest, doImageUploadRequest, emojiSearchIndex, - errorHandler, - findAndEnqueueURLsToEnrich, focus, grow, - handleChange, handleSubmit, hideSendButton, insertText, isThreadInput, - isUploadEnabled, - linkPreviews, - maxFilesLeft, maxRows, mentionAllAppUsers, - mentioned_users, mentionQueryParams, - message, minRows, - noFiles, - numberOfUploads, onPaste, - onSelectUser, - openCommandsList, - openMentionsList, - overrideSubmitHandler, parent, publishTypingEvent, recordingController, - removeAttachments, setCooldownRemaining, - setText, shouldSubmit, - showCommandsList, - showMentionsList, - text, textareaRef, - uploadAttachment, - uploadNewFiles, - upsertAttachments, useMentionsTransliteration, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ asyncMessagesMultiSendEnabled, - attachments, audioRecordingEnabled, - cancelURLEnrichment, cooldownInterval, cooldownRemaining, - dismissLinkPreview, - editing, emojiSearchIndex, - findAndEnqueueURLsToEnrich, handleSubmit, hideSendButton, - isUploadEnabled, isThreadInput, - linkPreviewsValue, - mentionedUsersLength, minRows, parentId, publishTypingEvent, recordingController, - removeAttachments, - showCommandsList, - showMentionsList, - text, - uploadAttachment, - upsertAttachments, ], ); diff --git a/src/components/MessageInput/hooks/useEmojiTrigger.ts b/src/components/MessageInput/hooks/useEmojiTrigger.ts deleted file mode 100644 index 0b342a5041..0000000000 --- a/src/components/MessageInput/hooks/useEmojiTrigger.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { EmoticonItem } from '../../EmoticonItem/EmoticonItem'; -import type { EmojiTriggerSetting } from '../DefaultTriggerProvider'; -import type { EmojiSearchIndex } from '../MessageInput'; - -export const useEmojiTrigger = <T extends EmojiSearchIndex>( - emojiSearchIndex?: T, -): EmojiTriggerSetting => ({ - component: EmoticonItem, - dataProvider: async (query, _, onReady) => { - if (query.length === 0 || query.charAt(0).match(/[^a-zA-Z0-9+-]/)) { - return onReady([], query); - } - const emojis = (await emojiSearchIndex?.search(query)) ?? []; - - // emojiIndex.search sometimes returns undefined values, so filter those out first - const result = emojis - .filter(Boolean) - .slice(0, 7) - .map(({ id, name, native, skins = [] }) => { - const [firstSkin] = skins; - - return { - id, - name, - native: native ?? firstSkin.native, - }; - }); - - if (onReady) onReady(result, query); - }, - output: (entity) => ({ - caretPosition: 'next', - key: entity.id as string, - text: `${'native' in entity ? entity.native : ''}`, - }), -}); diff --git a/src/components/MessageInput/hooks/useLinkPreviews.ts b/src/components/MessageInput/hooks/useLinkPreviews.ts deleted file mode 100644 index 3ccf8c6427..0000000000 --- a/src/components/MessageInput/hooks/useLinkPreviews.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { find } from 'linkifyjs'; -import type { Dispatch } from 'react'; -import { useCallback, useEffect, useRef } from 'react'; -import debounce from 'lodash.debounce'; -import { useChannelStateContext, useChatContext } from '../../../context'; -import type { - MessageInputReducerAction, - MessageInputState, -} from './useMessageInputState'; - -import type { LinkPreview, LinkPreviewMap } from '../types'; -import { LinkPreviewState, SetLinkPreviewMode } from '../types'; -import type { DebouncedFunc } from 'lodash'; - -export type URLEnrichmentConfig = { - /** Number of milliseconds to debounce firing the URL enrichment queries when typing. The default value is 1500(ms). */ - debounceURLEnrichmentMs?: number; - /** Allows for toggling the URL enrichment and link previews in `MessageInput`. By default, the feature is disabled. */ - enrichURLForPreview?: boolean; - /** Custom function to identify URLs in a string and request OG data */ - findURLFn?: (text: string) => string[]; - /** Custom function to react to link preview dismissal */ - onLinkPreviewDismissed?: (linkPreview: LinkPreview) => void; -}; - -type UseEnrichURLsParams = URLEnrichmentConfig & { - dispatch: Dispatch<MessageInputReducerAction>; - linkPreviews: MessageInputState['linkPreviews']; -}; - -export type EnrichURLsController = { - /** Function cancels all the scheduled or in-progress URL enrichment queries and resets the state. */ - cancelURLEnrichment: () => void; - /** Function called when a single link preview is dismissed. */ - dismissLinkPreview: (linkPreview: LinkPreview) => void; - /** Function that triggers the search for URLs and their enrichment. */ - findAndEnqueueURLsToEnrich?: DebouncedFunc< - (text: string, mode?: SetLinkPreviewMode) => void - >; -}; - -export const useLinkPreviews = ({ - debounceURLEnrichmentMs: debounceURLEnrichmentMsInputContext, - dispatch, - enrichURLForPreview = false, - findURLFn: findURLFnInputContext, - linkPreviews, - onLinkPreviewDismissed: onLinkPreviewDismissedInputContext, -}: UseEnrichURLsParams): EnrichURLsController => { - const { client } = useChatContext(); - // FIXME: the value of channelConfig is stale due to omitting it from the memoization deps in useCreateChannelStateContext - const { - channelConfig, - debounceURLEnrichmentMs: debounceURLEnrichmentMsChannelContext, - findURLFn: findURLFnChannelContext, - onLinkPreviewDismissed: onLinkPreviewDismissedChannelContext, - } = useChannelStateContext(); - - const shouldDiscardEnrichQueries = useRef(false); - - const findURLFn = findURLFnInputContext ?? findURLFnChannelContext; - const onLinkPreviewDismissed = - onLinkPreviewDismissedInputContext ?? onLinkPreviewDismissedChannelContext; - const debounceURLEnrichmentMs = - debounceURLEnrichmentMsInputContext ?? debounceURLEnrichmentMsChannelContext ?? 1500; - - const dismissLinkPreview = useCallback( - (linkPreview: LinkPreview) => { - onLinkPreviewDismissed?.(linkPreview); - const previewToRemoveMap = new Map(); - linkPreview.state = LinkPreviewState.DISMISSED; - previewToRemoveMap.set(linkPreview.og_scrape_url, linkPreview); - dispatch({ - linkPreviews: previewToRemoveMap, - mode: SetLinkPreviewMode.UPSERT, - type: 'setLinkPreviews', - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [onLinkPreviewDismissed], - ); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const findAndEnqueueURLsToEnrich = useCallback( - debounce( - (text: string, mode = SetLinkPreviewMode.SET) => { - const urls = findURLFn - ? findURLFn(text) - : find(text, 'url').reduce<string[]>((acc, link) => { - if (link.isLink) acc.push(link.href); - return acc; - }, []); - - shouldDiscardEnrichQueries.current = urls.length === 0; - - dispatch({ - linkPreviews: urls.reduce<LinkPreviewMap>((acc, url) => { - acc.set(url, { og_scrape_url: url, state: LinkPreviewState.QUEUED }); - return acc; - }, new Map()), - mode, - type: 'setLinkPreviews', - }); - }, - debounceURLEnrichmentMs, - { leading: false, trailing: true }, - ), - [debounceURLEnrichmentMs, shouldDiscardEnrichQueries, findURLFn], - ); - - const cancelURLEnrichment = useCallback(() => { - findAndEnqueueURLsToEnrich.cancel(); - findAndEnqueueURLsToEnrich(''); - findAndEnqueueURLsToEnrich.flush(); - }, [findAndEnqueueURLsToEnrich]); - - useEffect(() => { - const enqueuedLinks = Array.from(linkPreviews.values()).reduce<LinkPreviewMap>( - (acc, linkPreview) => { - if (linkPreview.state === 'queued') { - const loadingLinkPreview: LinkPreview = { - ...linkPreview, - state: LinkPreviewState.LOADING, - }; - acc.set(linkPreview.og_scrape_url, loadingLinkPreview); - } - return acc; - }, - new Map(), - ); - - if (!enqueuedLinks.size) return; - - dispatch({ - linkPreviews: enqueuedLinks, - mode: SetLinkPreviewMode.UPSERT, - type: 'setLinkPreviews', - }); - - enqueuedLinks.forEach((linkPreview) => { - client - .enrichURL(linkPreview.og_scrape_url) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .then(({ duration, ...ogAttachment }) => { - if (shouldDiscardEnrichQueries.current) return; - - const linkPreviewsMap = new Map(); - linkPreviewsMap.set(linkPreview.og_scrape_url, { - ...ogAttachment, - state: LinkPreviewState.LOADED, - }); - - dispatch({ - linkPreviews: linkPreviewsMap, - mode: SetLinkPreviewMode.UPSERT, - type: 'setLinkPreviews', - }); - }) - .catch(() => { - const linkPreviewsMap = new Map(); - linkPreviewsMap.set(linkPreview.og_scrape_url, { - ...linkPreview, - state: LinkPreviewState.FAILED, - }); - dispatch({ - linkPreviews: linkPreviewsMap, - mode: SetLinkPreviewMode.UPSERT, - type: 'setLinkPreviews', - }); - }); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldDiscardEnrichQueries, linkPreviews]); - - return { - cancelURLEnrichment, - dismissLinkPreview, - findAndEnqueueURLsToEnrich: - channelConfig?.url_enrichment && enrichURLForPreview - ? findAndEnqueueURLsToEnrich - : undefined, - }; -}; diff --git a/src/components/MessageInput/hooks/useMessageInputState.ts b/src/components/MessageInput/hooks/useMessageInputState.ts index 3f54fcc59e..6b8c3069c9 100644 --- a/src/components/MessageInput/hooks/useMessageInputState.ts +++ b/src/components/MessageInput/hooks/useMessageInputState.ts @@ -1,388 +1,61 @@ -import type { Reducer } from 'react'; -import type React from 'react'; -import { useCallback, useReducer, useState } from 'react'; -import { nanoid } from 'nanoid'; - -import type { StreamMessage } from '../../../context/ChannelStateContext'; -import { useChannelStateContext } from '../../../context/ChannelStateContext'; - -import { useAttachments } from './useAttachments'; -import type { EnrichURLsController } from './useLinkPreviews'; -import { useLinkPreviews } from './useLinkPreviews'; +import { useEffect } from 'react'; +import { useMessageComposer } from './messageComposer'; import { useMessageInputText } from './useMessageInputText'; import { useSubmitHandler } from './useSubmitHandler'; import { usePasteHandler } from './usePasteHandler'; -import type { RecordingController } from '../../MediaRecorder/hooks/useMediaRecorder'; import { useMediaRecorder } from '../../MediaRecorder/hooks/useMediaRecorder'; -import type { LinkPreviewMap, LocalAttachment } from '../types'; -import { LinkPreviewState, SetLinkPreviewMode } from '../types'; -import type { Attachment, Message, OGAttachment, UserResponse } from 'stream-chat'; - +import type React from 'react'; +import type { UpdatedMessage } from 'stream-chat'; +import type { RecordingController } from '../../MediaRecorder/hooks/useMediaRecorder'; import type { MessageInputProps } from '../MessageInput'; -import type { CustomTrigger, SendMessageOptions } from '../../../types/types'; -import { mergeDeep } from '../../../utils/mergeDeep'; - -export type MessageInputState = { - attachments: LocalAttachment[]; - linkPreviews: LinkPreviewMap; - mentioned_users: UserResponse[]; - setText: (text: string) => void; - text: string; -}; - -type UpsertAttachmentsAction = { - attachments: LocalAttachment[]; - type: 'upsertAttachments'; -}; - -type RemoveAttachmentsAction = { - ids: string[]; - type: 'removeAttachments'; -}; - -type SetTextAction = { - getNewText: (currentStateText: string) => string; - type: 'setText'; -}; - -type ClearAction = { - type: 'clear'; -}; - -type SetLinkPreviewsAction = { - linkPreviews: LinkPreviewMap; - mode: SetLinkPreviewMode; - type: 'setLinkPreviews'; -}; - -type AddMentionedUserAction = { - type: 'addMentionedUser'; - user: UserResponse; -}; - -export type MessageInputReducerAction = - | SetTextAction - | ClearAction - | SetLinkPreviewsAction - | AddMentionedUserAction - | UpsertAttachmentsAction - | RemoveAttachmentsAction; - -export type MessageInputHookProps = EnrichURLsController & { - handleChange: React.ChangeEventHandler<HTMLTextAreaElement>; +export type MessageInputHookProps = { handleSubmit: ( event?: React.BaseSyntheticEvent, - customMessageData?: Partial<Message>, - options?: SendMessageOptions, + customMessageData?: Omit<UpdatedMessage, 'mentioned_users'>, ) => void; insertText: (textToInsert: string) => void; - isUploadEnabled: boolean; - maxFilesLeft: number; - numberOfUploads: number; onPaste: (event: React.ClipboardEvent<HTMLTextAreaElement>) => void; - onSelectUser: (item: UserResponse) => void; recordingController: RecordingController; - removeAttachments: (ids: string[]) => void; textareaRef: React.MutableRefObject<HTMLTextAreaElement | null | undefined>; - uploadAttachment: (attachment: LocalAttachment) => Promise<LocalAttachment | undefined>; - uploadNewFiles: (files: FileList | File[]) => void; - upsertAttachments: (attachments: (Attachment | LocalAttachment)[]) => void; -}; - -const makeEmptyMessageInputState = (): MessageInputState => ({ - attachments: [], - linkPreviews: new Map(), - mentioned_users: [], - setText: () => null, - text: '', -}); - -/** - * Initializes the state. Empty if the message prop is falsy. - */ -const initState = ( - message?: Pick<StreamMessage, 'attachments' | 'mentioned_users' | 'text'>, -): MessageInputState => { - if (!message) { - return makeEmptyMessageInputState(); - } - - const linkPreviews = - message.attachments?.reduce<LinkPreviewMap>((acc, attachment) => { - if (!attachment.og_scrape_url) return acc; - acc.set(attachment.og_scrape_url, { - ...(attachment as OGAttachment), - state: LinkPreviewState.LOADED, - }); - return acc; - }, new Map()) ?? new Map(); - - const attachments = - message.attachments - ?.filter(({ og_scrape_url }) => !og_scrape_url) - .map( - (att) => - ({ - ...att, - localMetadata: { id: nanoid() }, - }) as LocalAttachment, - ) || []; - - const mentioned_users: StreamMessage['mentioned_users'] = message.mentioned_users || []; - - return { - attachments, - linkPreviews, - mentioned_users, - setText: () => null, - text: message.text || '', - }; }; -/** - * MessageInput state reducer - */ -const messageInputReducer = ( - state: MessageInputState, - action: MessageInputReducerAction, -) => { - switch (action.type) { - case 'setText': - return { ...state, text: action.getNewText(state.text) }; - - case 'clear': - return makeEmptyMessageInputState(); +export const useMessageInputState = (props: MessageInputProps): MessageInputHookProps => { + const { asyncMessagesMultiSendEnabled, audioRecordingConfig, audioRecordingEnabled } = + props; - case 'upsertAttachments': { - const attachments = [...state.attachments]; - action.attachments.forEach((actionAttachment) => { - const attachmentIndex = state.attachments.findIndex( - (att) => - att.localMetadata?.id && - att.localMetadata?.id === actionAttachment.localMetadata?.id, - ); + const messageComposer = useMessageComposer(); - if (attachmentIndex === -1) { - attachments.push(actionAttachment); - } else { - const upsertedAttachment = mergeDeep( - state.attachments[attachmentIndex] ?? {}, - actionAttachment, - ); - attachments.splice(attachmentIndex, 1, upsertedAttachment); - } - }); - - return { - ...state, - attachments, - }; - } - - case 'removeAttachments': { - return { - ...state, - attachments: state.attachments.filter( - (att) => !action.ids.includes(att.localMetadata?.id), - ), - }; - } - - case 'setLinkPreviews': { - const linkPreviews = new Map(state.linkPreviews); - - if (action.mode === SetLinkPreviewMode.REMOVE) { - Array.from(action.linkPreviews.keys()).forEach((key) => { - linkPreviews.delete(key); - }); - } else { - Array.from(action.linkPreviews.values()).reduce<LinkPreviewMap>( - (acc, linkPreview) => { - const existingPreview = acc.get(linkPreview.og_scrape_url); - const alreadyEnqueued = - linkPreview.state === LinkPreviewState.QUEUED && - existingPreview?.state !== LinkPreviewState.FAILED; - - if (existingPreview && alreadyEnqueued) return acc; - acc.set(linkPreview.og_scrape_url, linkPreview); - return acc; - }, - linkPreviews, - ); - - if (action.mode === SetLinkPreviewMode.SET) { - Array.from(state.linkPreviews.keys()).forEach((key) => { - if (!action.linkPreviews.get(key)) linkPreviews.delete(key); - }); - } + useEffect(() => { + const threadId = messageComposer.threadId; + if (!threadId || !messageComposer.channel || !messageComposer.compositionIsEmpty) + return; + // get draft data for legacy thead composer + messageComposer.channel.getDraft({ parent_id: threadId }).then(({ draft }) => { + if (draft) { + messageComposer.initState({ composition: draft }); } - - return { - ...state, - linkPreviews, - }; - } - - case 'addMentionedUser': - return { - ...state, - mentioned_users: state.mentioned_users.concat(action.user), - }; - - default: - return state; - } -}; - -export type CommandsListState = { - closeCommandsList: () => void; - openCommandsList: () => void; - showCommandsList: boolean; -}; - -export type MentionsListState = { - closeMentionsList: () => void; - openMentionsList: () => void; - showMentionsList: boolean; -}; - -/** - * hook for MessageInput state - */ -export const useMessageInputState = <V extends CustomTrigger = CustomTrigger>( - props: MessageInputProps<V>, -): MessageInputState & MessageInputHookProps & CommandsListState & MentionsListState => { - const { - additionalTextareaProps, - asyncMessagesMultiSendEnabled, - audioRecordingConfig, - audioRecordingEnabled, - getDefaultValue, - message, - urlEnrichmentConfig, - } = props; - - const { - channelCapabilities = {}, - enrichURLForPreview: enrichURLForPreviewChannelContext, - } = useChannelStateContext('useMessageInputState'); - - const defaultValue = getDefaultValue?.() || additionalTextareaProps?.defaultValue; - const initialStateValue = - message || - ((Array.isArray(defaultValue) - ? { text: defaultValue.join('') } - : { text: defaultValue?.toString() }) as Partial<StreamMessage>); - - const [state, dispatch] = useReducer( - messageInputReducer as Reducer<MessageInputState, MessageInputReducerAction>, - initialStateValue, - initState, - ); - - const enrichURLsController = useLinkPreviews({ - dispatch, - linkPreviews: state.linkPreviews, - ...urlEnrichmentConfig, - enrichURLForPreview: - urlEnrichmentConfig?.enrichURLForPreview ?? enrichURLForPreviewChannelContext, - }); - - const { handleChange, insertText, textareaRef } = useMessageInputText<V>( - props, - state, - dispatch, - enrichURLsController.findAndEnqueueURLsToEnrich, - ); - - const [showCommandsList, setShowCommandsList] = useState(false); - const [showMentionsList, setShowMentionsList] = useState(false); - - const openCommandsList = () => { - dispatch({ - getNewText: () => '/', - type: 'setText', - }); - setShowCommandsList(true); - }; - - const closeCommandsList = () => setShowCommandsList(false); - - const openMentionsList = () => { - dispatch({ - getNewText: (currentText) => currentText + '@', - type: 'setText', }); - setShowMentionsList(true); - }; + }, [messageComposer]); - const closeMentionsList = () => setShowMentionsList(false); + const { insertText, textareaRef } = useMessageInputText(props); - const { - maxFilesLeft, - numberOfUploads, - removeAttachments, - uploadAttachment, - uploadNewFiles, - upsertAttachments, - } = useAttachments<V>(props, state, dispatch, textareaRef); + const { handleSubmit } = useSubmitHandler(props); - const { handleSubmit } = useSubmitHandler<V>( - props, - state, - dispatch, - numberOfUploads, - enrichURLsController, - ); const recordingController = useMediaRecorder({ asyncMessagesMultiSendEnabled, enabled: !!audioRecordingEnabled, handleSubmit, recordingConfig: audioRecordingConfig, - uploadAttachment, }); - const isUploadEnabled = !!channelCapabilities['upload-file']; - - const { onPaste } = usePasteHandler( - uploadNewFiles, - insertText, - isUploadEnabled, - enrichURLsController.findAndEnqueueURLsToEnrich, - ); - - const onSelectUser = useCallback((item: UserResponse) => { - dispatch({ type: 'addMentionedUser', user: item }); - }, []); - - const setText = useCallback((text: string) => { - dispatch({ getNewText: () => text, type: 'setText' }); - }, []); + const { onPaste } = usePasteHandler(insertText); return { - ...state, - ...enrichURLsController, - closeCommandsList, - closeMentionsList, - handleChange, handleSubmit, insertText, - isUploadEnabled, - maxFilesLeft, - numberOfUploads, onPaste, - onSelectUser, - openCommandsList, - openMentionsList, recordingController, - removeAttachments, - setText, - showCommandsList, - showMentionsList, textareaRef, - uploadAttachment, - uploadNewFiles, - upsertAttachments, }; }; diff --git a/src/components/MessageInput/hooks/useMessageInputText.ts b/src/components/MessageInput/hooks/useMessageInputText.ts index 07799ab172..090ee5ff7e 100644 --- a/src/components/MessageInput/hooks/useMessageInputText.ts +++ b/src/components/MessageInput/hooks/useMessageInputText.ts @@ -1,27 +1,22 @@ import { useCallback, useEffect, useRef } from 'react'; -import { logChatPromiseExecution } from 'stream-chat'; -import type { - MessageInputReducerAction, - MessageInputState, -} from './useMessageInputState'; +import { type TextComposerState } from 'stream-chat'; import type { MessageInputProps } from '../MessageInput'; -import { useChannelStateContext } from '../../../context/ChannelStateContext'; -import type { CustomTrigger } from '../../../types/types'; -import type { EnrichURLsController } from './useLinkPreviews'; +import { useMessageComposer } from './messageComposer'; +import { useStateStore } from '../../../store'; -export const useMessageInputText = <V extends CustomTrigger = CustomTrigger>( - props: MessageInputProps<V>, - state: MessageInputState, - dispatch: React.Dispatch<MessageInputReducerAction>, - findAndEnqueueURLsToEnrich?: EnrichURLsController['findAndEnqueueURLsToEnrich'], -) => { - const { channel } = useChannelStateContext('useMessageInputText'); - const { additionalTextareaProps, focus, parent, publishTypingEvent = true } = props; - const { text } = state; +const messageComposerStateSelector = (state: TextComposerState) => ({ + text: state.text, +}); +export const useMessageInputText = (props: MessageInputProps) => { + const { focus } = props; + const messageComposer = useMessageComposer(); const textareaRef = useRef<HTMLTextAreaElement>(undefined); - + const { text } = useStateStore( + messageComposer.textComposer.state, + messageComposerStateSelector, + ); // Focus useEffect(() => { if (focus && textareaRef.current) { @@ -34,42 +29,17 @@ export const useMessageInputText = <V extends CustomTrigger = CustomTrigger>( const insertText = useCallback( (textToInsert: string) => { - const { maxLength } = additionalTextareaProps || {}; - - if (!textareaRef.current) { - return dispatch({ - getNewText: (text) => { - const updatedText = text + textToInsert; - if (maxLength && updatedText.length > maxLength) { - return updatedText.slice(0, maxLength); - } - return updatedText; - }, - type: 'setText', - }); - } - - const { selectionEnd, selectionStart } = textareaRef.current; - newCursorPosition.current = selectionStart + textToInsert.length; - - dispatch({ - getNewText: (prevText) => { - const updatedText = - prevText.slice(0, selectionStart) + - textToInsert + - prevText.slice(selectionEnd); - - if (maxLength && updatedText.length > maxLength) { - return updatedText.slice(0, maxLength); - } - - return updatedText; - }, - type: 'setText', + const selection = textareaRef?.current && { + end: textareaRef.current.selectionEnd, + start: textareaRef.current.selectionStart, + }; + messageComposer.textComposer.insertText({ + selection, + text: textToInsert, }); + if (selection) newCursorPosition.current = selection.start + textToInsert.length; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [additionalTextareaProps, newCursorPosition, textareaRef], + [messageComposer, newCursorPosition, textareaRef], ); useEffect(() => { @@ -81,31 +51,7 @@ export const useMessageInputText = <V extends CustomTrigger = CustomTrigger>( } }, [text, newCursorPosition]); - const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = useCallback( - (event) => { - event.preventDefault(); - if (!event || !event.target) { - return; - } - - const newText = event.target.value; - dispatch({ - getNewText: () => newText, - type: 'setText', - }); - - findAndEnqueueURLsToEnrich?.(newText); - - if (publishTypingEvent && newText && channel) { - logChatPromiseExecution(channel.keystroke(parent?.id), 'start typing event'); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [channel, findAndEnqueueURLsToEnrich, parent, publishTypingEvent], - ); - return { - handleChange, insertText, textareaRef, }; diff --git a/src/components/MessageInput/hooks/usePasteHandler.ts b/src/components/MessageInput/hooks/usePasteHandler.ts index 06e00d8e9f..ee47bae1e2 100644 --- a/src/components/MessageInput/hooks/usePasteHandler.ts +++ b/src/components/MessageInput/hooks/usePasteHandler.ts @@ -1,15 +1,9 @@ import { useCallback } from 'react'; -import type { FileLike } from '../../ReactFileUtilities'; import { dataTransferItemsToFiles } from '../../ReactFileUtilities'; -import type { EnrichURLsController } from './useLinkPreviews'; -import { SetLinkPreviewMode } from '../types'; +import { useMessageComposer } from './messageComposer'; -export const usePasteHandler = ( - uploadNewFiles: (files: FileList | FileLike[] | File[]) => void, - insertText: (textToInsert: string) => void, - isUploadEnabled: boolean, - findAndEnqueueURLsToEnrich?: EnrichURLsController['findAndEnqueueURLsToEnrich'], -) => { +export const usePasteHandler = (insertText: (textToInsert: string) => void) => { + const { attachmentManager } = useMessageComposer(); const onPaste = useCallback( (clipboardEvent: React.ClipboardEvent<HTMLTextAreaElement>) => { (async (event) => { @@ -36,15 +30,12 @@ export const usePasteHandler = ( if (plainTextPromise) { const pastedText = await plainTextPromise; insertText(pastedText); - findAndEnqueueURLsToEnrich?.(pastedText, SetLinkPreviewMode.UPSERT); - findAndEnqueueURLsToEnrich?.flush(); - } else if (fileLikes.length && isUploadEnabled) { - uploadNewFiles(fileLikes); - return; + } else { + attachmentManager.uploadFiles(fileLikes); } })(clipboardEvent); }, - [findAndEnqueueURLsToEnrich, insertText, isUploadEnabled, uploadNewFiles], + [attachmentManager, insertText], ); return { onPaste }; diff --git a/src/components/MessageInput/hooks/useSubmitHandler.ts b/src/components/MessageInput/hooks/useSubmitHandler.ts index 2d5471fb51..2665de5fad 100644 --- a/src/components/MessageInput/hooks/useSubmitHandler.ts +++ b/src/components/MessageInput/hooks/useSubmitHandler.ts @@ -1,212 +1,64 @@ -import { useEffect, useRef } from 'react'; +import { useCallback } from 'react'; import { useChannelActionContext } from '../../../context/ChannelActionContext'; -import { useChannelStateContext } from '../../../context/ChannelStateContext'; import { useTranslationContext } from '../../../context/TranslationContext'; -import { LinkPreviewState } from '../types'; -import type { Attachment, Message, UpdatedMessage } from 'stream-chat'; - -import type { - MessageInputReducerAction, - MessageInputState, -} from './useMessageInputState'; import type { MessageInputProps } from '../MessageInput'; +import { useMessageComposer } from './messageComposer'; -import type { CustomTrigger, SendMessageOptions } from '../../../types/types'; -import type { EnrichURLsController } from './useLinkPreviews'; - -export const useSubmitHandler = <V extends CustomTrigger = CustomTrigger>( - props: MessageInputProps<V>, - state: MessageInputState, - dispatch: React.Dispatch<MessageInputReducerAction>, - numberOfUploads: number, - enrichURLsController: EnrichURLsController, -) => { - const { - clearEditingState, - message, - overrideSubmitHandler, - parent, - publishTypingEvent, - } = props; +export const useSubmitHandler = (props: MessageInputProps) => { + const { clearEditingState, overrideSubmitHandler } = props; - const { attachments, linkPreviews, mentioned_users, text } = state; - - const { cancelURLEnrichment, findAndEnqueueURLsToEnrich } = enrichURLsController; - const { channel } = useChannelStateContext('useSubmitHandler'); const { addNotification, editMessage, sendMessage } = useChannelActionContext('useSubmitHandler'); const { t } = useTranslationContext('useSubmitHandler'); - - const textReference = useRef({ hasChanged: false, initialText: text }); - - useEffect(() => { - if (!textReference.current.initialText.length) { - textReference.current.initialText = text; - return; - } - - textReference.current.hasChanged = text !== textReference.current.initialText; - }, [text]); - - const handleSubmit = async ( - event?: React.BaseSyntheticEvent, - customMessageData?: Partial<Message>, - options?: SendMessageOptions, - ) => { - event?.preventDefault(); - const trimmedMessage = text.trim(); - const isEmptyMessage = - trimmedMessage === '' || - trimmedMessage === '>' || - trimmedMessage === '``````' || - trimmedMessage === '``' || - trimmedMessage === '**' || - trimmedMessage === '____' || - trimmedMessage === '__' || - trimmedMessage === '****'; - - if ( - isEmptyMessage && - numberOfUploads === 0 && - attachments.length === 0 && - !customMessageData?.poll_id - ) - return; - const someAttachmentsUploading = attachments.some( - (att) => att.localMetadata?.uploadState === 'uploading', - ); - - if (someAttachmentsUploading) { - return addNotification(t('Wait until all attachments have uploaded'), 'error'); - } - - const attachmentsFromUploads = attachments - .filter( - (att) => - att.localMetadata?.uploadState !== 'failed' || - (findAndEnqueueURLsToEnrich && !att.og_scrape_url), // filter out all the attachments scraped before the message was edited - ) - .map((localAttachment) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { localMetadata: _, ...attachment } = localAttachment; - return attachment as Attachment; - }); - - const sendOptions = { ...options }; - let attachmentsFromLinkPreviews: Attachment[] = []; - if (findAndEnqueueURLsToEnrich) { - // prevent showing link preview in MessageInput after the message has been sent - cancelURLEnrichment(); - const someLinkPreviewsLoading = Array.from(linkPreviews.values()).some( - (linkPreview) => - [LinkPreviewState.QUEUED, LinkPreviewState.LOADING].includes(linkPreview.state), - ); - const someLinkPreviewsDismissed = Array.from(linkPreviews.values()).some( - (linkPreview) => linkPreview.state === LinkPreviewState.DISMISSED, - ); - - attachmentsFromLinkPreviews = someLinkPreviewsLoading - ? [] - : Array.from(linkPreviews.values()) - .filter( - (linkPreview) => - linkPreview.state === LinkPreviewState.LOADED && - !attachmentsFromUploads.find( - (attFromUpload) => - attFromUpload.og_scrape_url === linkPreview.og_scrape_url, - ), - ) - - .map( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ({ state: linkPreviewState, ...ogAttachment }) => - ogAttachment as Attachment, - ); - - // scraped attachments are added only if all enrich queries has completed. Otherwise, the scraping has to be done server-side. - sendOptions.skip_enrich_url = - (!someLinkPreviewsLoading && attachmentsFromLinkPreviews.length > 0) || - someLinkPreviewsDismissed; - } - - const newAttachments = [...attachmentsFromUploads, ...attachmentsFromLinkPreviews]; - - // Instead of checking if a user is still mentioned every time the text changes, - // just filter out non-mentioned users before submit, which is cheaper - // and allows users to easily undo any accidental deletion - const actualMentionedUsers = Array.from( - new Set( - mentioned_users.filter( - ({ id, name }) => text.includes(`@${id}`) || text.includes(`@${name}`), - ), - ), - ); - - const updatedMessage = { - attachments: newAttachments, - mentioned_users: actualMentionedUsers, - text, - }; - - if (message && message.type !== 'error') { - delete message.i18n; - - try { - await editMessage( - { - ...message, - ...updatedMessage, - ...customMessageData, - } as unknown as UpdatedMessage, - sendOptions, - ); - - clearEditingState?.(); - dispatch({ type: 'clear' }); - } catch (err) { - addNotification(t('Edit message request failed'), 'error'); - } - } else { - try { - dispatch({ type: 'clear' }); - - if (overrideSubmitHandler) { - await overrideSubmitHandler( - { - ...updatedMessage, - parent, - }, - channel.cid, - customMessageData, - sendOptions, - ); - } else { - await sendMessage( - { - ...updatedMessage, - parent, - }, - customMessageData, - sendOptions, - ); + const messageComposer = useMessageComposer(); + + const handleSubmit = useCallback( + async (event?: React.BaseSyntheticEvent) => { + event?.preventDefault(); + const composition = await messageComposer.compose(); + if (!composition || !composition.message) return; + + const { localMessage, message, sendOptions } = composition; + + if (messageComposer.editedMessage && localMessage.type !== 'error') { + try { + await editMessage(localMessage, sendOptions); + clearEditingState?.(); + } catch (err) { + addNotification(t('Edit message request failed'), 'error'); + } + } else { + try { + // todo: get rid of overrideSubmitHandler once MessageComposer supports submission flow + if (overrideSubmitHandler) { + await overrideSubmitHandler({ + cid: messageComposer.channel.cid, + localMessage, + message, + sendOptions, + }); + } else { + await sendMessage({ localMessage, message, options: sendOptions }); + } + messageComposer.clear(); + if (messageComposer.config.text.publishTypingEvents) + await messageComposer.channel.stopTyping(); + } catch (err) { + addNotification(t('Send message request failed'), 'error'); } - - if (publishTypingEvent) await channel.stopTyping(); - } catch (err) { - dispatch({ - getNewText: () => text, - type: 'setText', - }); - - actualMentionedUsers?.forEach((user) => { - dispatch({ type: 'addMentionedUser', user }); - }); - - addNotification(t('Send message request failed'), 'error'); } - } - }; + }, + [ + addNotification, + clearEditingState, + editMessage, + messageComposer, + overrideSubmitHandler, + sendMessage, + t, + ], + ); return { handleSubmit }; }; diff --git a/src/components/MessageInput/hooks/useUserTrigger.ts b/src/components/MessageInput/hooks/useUserTrigger.ts deleted file mode 100644 index 3e0fdb0559..0000000000 --- a/src/components/MessageInput/hooks/useUserTrigger.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { useCallback, useState } from 'react'; -import throttle from 'lodash.throttle'; - -import type { SearchLocalUserParams } from './utils'; -import { searchLocalUsers } from './utils'; - -import { UserItem } from '../../UserItem/UserItem'; - -import { useChatContext } from '../../../context/ChatContext'; -import { useChannelStateContext } from '../../../context/ChannelStateContext'; - -import type { UserResponse } from 'stream-chat'; - -import type { SearchQueryParams } from '../../ChannelSearch/hooks/useChannelSearch'; -import type { UserTriggerSetting } from '../../MessageInput/DefaultTriggerProvider'; - -export type UserTriggerParams = { - onSelectUser: (item: UserResponse) => void; - disableMentions?: boolean; - mentionAllAppUsers?: boolean; - mentionQueryParams?: SearchQueryParams['userFilters']; - useMentionsTransliteration?: boolean; -}; - -export const useUserTrigger = (params: UserTriggerParams): UserTriggerSetting => { - const { - disableMentions, - mentionAllAppUsers, - mentionQueryParams = {}, - onSelectUser, - useMentionsTransliteration, - } = params; - - const [searching, setSearching] = useState(false); - - const { client, mutes } = useChatContext('useUserTrigger'); - const { channel } = useChannelStateContext('useUserTrigger'); - - const { members } = channel.state; - const { watchers } = channel.state; - - const getMembersAndWatchers = useCallback(() => { - const memberUsers = members ? Object.values(members).map(({ user }) => user) : []; - const watcherUsers = watchers ? Object.values(watchers) : []; - const users = [...memberUsers, ...watcherUsers]; - - // make sure we don't list users twice - const uniqueUsers = {} as Record<string, UserResponse>; - - users.forEach((user) => { - if (user && !uniqueUsers[user.id]) { - uniqueUsers[user.id] = user; - } - }); - - return Object.values(uniqueUsers); - }, [members, watchers]); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const queryMembersThrottled = useCallback( - throttle(async (query: string, onReady: (users: UserResponse[]) => void) => { - try { - const response = await channel.queryMembers({ - name: { $autocomplete: query }, - }); - - const users = response.members.map((member) => member.user) as UserResponse[]; - - if (onReady && users.length) { - onReady(users); - } else { - onReady([]); - } - } catch (error) { - console.log({ error }); - } - }, 200), - [channel], - ); - - const queryUsers = async (query: string, onReady: (users: UserResponse[]) => void) => { - if (!query || searching) return; - setSearching(true); - - try { - const { users } = await client.queryUsers( - { - $or: [{ id: { $autocomplete: query } }, { name: { $autocomplete: query } }], - ...(typeof mentionQueryParams.filters === 'function' - ? mentionQueryParams.filters(query) - : mentionQueryParams.filters), - }, - Array.isArray(mentionQueryParams.sort) - ? [{ id: 1 }, ...mentionQueryParams.sort] - : { id: 1, ...mentionQueryParams.sort }, - { limit: 10, ...mentionQueryParams.options }, - ); - - if (onReady && users.length) { - onReady(users); - } else { - onReady([]); - } - } catch (error) { - console.log({ error }); - } - - setSearching(false); - }; - - const queryUsersThrottled = throttle(queryUsers, 200); - - return { - callback: (item) => onSelectUser(item), - component: UserItem, - dataProvider: (query, text, onReady) => { - if (disableMentions) return; - - const filterMutes = (data: UserResponse[]) => { - if (text.includes('/unmute') && !mutes.length) { - return []; - } - if (!mutes.length) return data; - - if (text.includes('/unmute')) { - return data.filter((suggestion) => - mutes.some((mute) => mute.target.id === suggestion.id), - ); - } - return data.filter((suggestion) => - mutes.every((mute) => mute.target.id !== suggestion.id), - ); - }; - - if (mentionAllAppUsers) { - return queryUsersThrottled(query, (data: UserResponse[]) => { - if (onReady) onReady(filterMutes(data), query); - }); - } - - /** - * By default, we return maximum 100 members via queryChannels api call. - * Thus it is safe to assume, that if number of members in channel.state is < 100, - * then all the members are already available on client side and we don't need to - * make any api call to queryMembers endpoint. - */ - if (!query || Object.values(members || {}).length < 100) { - const users = getMembersAndWatchers(); - - const params: SearchLocalUserParams = { - ownUserId: client.userID, - query, - text, - useMentionsTransliteration, - users, - }; - - const matchingUsers = searchLocalUsers(params); - - const usersToShow = mentionQueryParams.options?.limit ?? 7; - const data = matchingUsers.slice(0, usersToShow); - - if (onReady) onReady(filterMutes(data), query); - return data; - } - - return queryMembersThrottled(query, (data: UserResponse[]) => { - if (onReady) onReady(filterMutes(data), query); - }); - }, - output: (entity) => ({ - caretPosition: 'next', - key: entity.id, - text: `@${entity.name || entity.id}`, - }), - }; -}; diff --git a/src/components/MessageInput/hooks/utils.ts b/src/components/MessageInput/hooks/utils.ts index bed6f0eaa6..b01b866ca3 100644 --- a/src/components/MessageInput/hooks/utils.ts +++ b/src/components/MessageInput/hooks/utils.ts @@ -1,10 +1,4 @@ -import type { AppSettingsAPIResponse, FileUploadConfig, UserResponse } from 'stream-chat'; - -import type { ChannelActionContextValue } from '../../../context/ChannelActionContext'; -import type { ChatContextValue } from '../../../context/ChatContext'; -import type { TranslationContextValue } from '../../../context/TranslationContext'; - -import { DEFAULT_UPLOAD_SIZE_LIMIT_BYTES } from '../../../constants/limits'; +import type { UserResponse } from 'stream-chat'; export const accentsMap: { [key: string]: string } = { a: 'á|à|ã|â|À|Á|Ã|Â', @@ -109,98 +103,6 @@ export const searchLocalUsers = (params: SearchLocalUserParams): UserResponse[] return matchingUsers; }; -type CheckUploadPermissionsParams = { - addNotification: ChannelActionContextValue['addNotification']; - file: File; - getAppSettings: ChatContextValue['getAppSettings']; - t: TranslationContextValue['t']; - uploadType: 'image' | 'file'; -}; - -export const checkUploadPermissions = async (params: CheckUploadPermissionsParams) => { - const { addNotification, file, getAppSettings, t, uploadType } = params; - - let appSettings: AppSettingsAPIResponse | null = null; - appSettings = await getAppSettings(); - - const { - allowed_file_extensions, - allowed_mime_types, - blocked_file_extensions, - blocked_mime_types, - size_limit, - } = - ((uploadType === 'image' - ? appSettings?.app?.image_upload_config - : appSettings?.app?.file_upload_config) as FileUploadConfig) || {}; - - const sendNotAllowedErrorNotification = () => - addNotification( - t(`Upload type: "{{ type }}" is not allowed`, { - type: file.type || 'unknown type', - }), - 'error', - ); - - if (allowed_file_extensions?.length) { - const allowed = allowed_file_extensions.some((ext) => - file.name.toLowerCase().endsWith(ext.toLowerCase()), - ); - - if (!allowed) { - sendNotAllowedErrorNotification(); - return false; - } - } - - if (blocked_file_extensions?.length) { - const blocked = blocked_file_extensions.some((ext) => - file.name.toLowerCase().endsWith(ext.toLowerCase()), - ); - - if (blocked) { - sendNotAllowedErrorNotification(); - return false; - } - } - - if (allowed_mime_types?.length) { - const allowed = allowed_mime_types.some( - (type) => type.toLowerCase() === file.type?.toLowerCase(), - ); - - if (!allowed) { - sendNotAllowedErrorNotification(); - return false; - } - } - - if (blocked_mime_types?.length) { - const blocked = blocked_mime_types.some( - (type) => type.toLowerCase() === file.type?.toLowerCase(), - ); - - if (blocked) { - sendNotAllowedErrorNotification(); - return false; - } - } - - const sizeLimit = size_limit || DEFAULT_UPLOAD_SIZE_LIMIT_BYTES; - if (file.size && file.size > sizeLimit) { - addNotification( - t('File is too large: {{ size }}, maximum upload size is {{ limit }}', { - limit: prettifyFileSize(sizeLimit), - size: prettifyFileSize(file.size), - }), - 'error', - ); - return false; - } - - return true; -}; - export function prettifyFileSize(bytes: number, precision = 3) { const units = ['B', 'kB', 'MB', 'GB']; const exponent = Math.min( diff --git a/src/components/MessageInput/index.ts b/src/components/MessageInput/index.ts index 46c2d6e13e..23b328446d 100644 --- a/src/components/MessageInput/index.ts +++ b/src/components/MessageInput/index.ts @@ -9,7 +9,6 @@ export type { VoiceRecordingPreviewProps, } from './AttachmentPreviewList'; export * from './CooldownTimer'; -export * from './DefaultTriggerProvider'; export * from './EditMessageForm'; export * from './hooks'; export * from './icons'; @@ -18,4 +17,3 @@ export * from './MessageInput'; export * from './MessageInputFlat'; export * from './QuotedMessagePreview'; export * from './SendButton'; -export * from './types'; diff --git a/src/components/MessageInput/types.ts b/src/components/MessageInput/types.ts deleted file mode 100644 index daa202f69d..0000000000 --- a/src/components/MessageInput/types.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* eslint-disable @typescript-eslint/no-empty-object-type */ -import type { Attachment, OGAttachment } from 'stream-chat'; - -export type AttachmentLoadingState = 'uploading' | 'finished' | 'failed'; - -export enum LinkPreviewState { - /** Link preview has been dismissed using MessageInputContextValue.dismissLinkPreview **/ - DISMISSED = 'dismissed', - /** Link preview could not be loaded, the enrichment request has failed. **/ - FAILED = 'failed', - /** Link preview has been successfully loaded. **/ - LOADED = 'loaded', - /** The enrichment query is in progress for a given link. **/ - LOADING = 'loading', - /** The link is scheduled for enrichment. **/ - QUEUED = 'queued', -} - -export type LinkURL = string; - -export type LinkPreview = OGAttachment & { - state: LinkPreviewState; -}; - -export enum SetLinkPreviewMode { - UPSERT, - SET, - REMOVE, -} - -export type LinkPreviewMap = Map<LinkURL, LinkPreview>; - -export type VoiceRecordingAttachment = Attachment & { - asset_url: string; - type: 'voiceRecording'; - duration?: number; - file_size?: number; - mime_type?: string; - title?: string; - waveform_data?: Array<number>; -}; - -type FileAttachment = Attachment & { - type: 'file'; - asset_url?: string; - file_size?: number; - mime_type?: string; - title?: string; -}; - -export type AudioAttachment = Attachment & { - type: 'audio'; - asset_url?: string; - file_size?: number; - mime_type?: string; - title?: string; -}; - -export type VideoAttachment = Attachment & { - type: 'video'; - asset_url?: string; - mime_type?: string; - thumb_url?: string; - title?: string; -}; - -type ImageAttachment = Attachment & { - type: 'image'; - fallback?: string; - image_url?: string; - original_height?: number; - original_width?: number; -}; - -export type BaseLocalAttachmentMetadata = { - id: string; -}; - -export type LocalAttachmentUploadMetadata = { - file?: File; - uploadState?: AttachmentLoadingState; -}; - -export type LocalImageAttachmentUploadMetadata = LocalAttachmentUploadMetadata & { - previewUri?: string; -}; - -export type LocalAttachmentCast<A, L = {}> = A & { - localMetadata: L & BaseLocalAttachmentMetadata; -}; - -export type LocalAttachmentMetadata<CustomLocalMetadata = {}> = CustomLocalMetadata & - BaseLocalAttachmentMetadata & - LocalImageAttachmentUploadMetadata; - -export type LocalVoiceRecordingAttachment<CustomLocalMetadata = {}> = LocalAttachmentCast< - VoiceRecordingAttachment, - LocalAttachmentUploadMetadata & CustomLocalMetadata ->; - -export type LocalAudioAttachment<CustomLocalMetadata = {}> = LocalAttachmentCast< - AudioAttachment, - LocalAttachmentUploadMetadata & CustomLocalMetadata ->; - -export type LocalVideoAttachment<CustomLocalMetadata = {}> = LocalAttachmentCast< - VideoAttachment, - LocalAttachmentUploadMetadata & CustomLocalMetadata ->; - -export type LocalImageAttachment<CustomLocalMetadata = {}> = LocalAttachmentCast< - ImageAttachment, - LocalImageAttachmentUploadMetadata & CustomLocalMetadata ->; - -export type LocalFileAttachment<CustomLocalMetadata = {}> = LocalAttachmentCast< - FileAttachment, - LocalAttachmentUploadMetadata & CustomLocalMetadata ->; - -export type AnyLocalAttachment<CustomLocalMetadata = {}> = LocalAttachmentCast< - Attachment, - LocalAttachmentMetadata<CustomLocalMetadata> ->; - -export type LocalAttachment = - | AnyLocalAttachment - | LocalFileAttachment - | LocalImageAttachment - | LocalAudioAttachment - | LocalVideoAttachment - | LocalVoiceRecordingAttachment; - -export type LocalAttachmentToUpload<CustomLocalMetadata = {}> = Partial<Attachment> & { - localMetadata: Partial<BaseLocalAttachmentMetadata> & - LocalAttachmentUploadMetadata & - CustomLocalMetadata; -}; diff --git a/src/components/MessageList/GiphyPreviewMessage.tsx b/src/components/MessageList/GiphyPreviewMessage.tsx index 069a2f6613..c6d4b7d3a9 100644 --- a/src/components/MessageList/GiphyPreviewMessage.tsx +++ b/src/components/MessageList/GiphyPreviewMessage.tsx @@ -1,11 +1,10 @@ import React from 'react'; import { Message } from '../Message/Message'; - -import type { StreamMessage } from '../../context/ChannelStateContext'; +import type { LocalMessage } from 'stream-chat'; export type GiphyPreviewMessageProps = { - message: StreamMessage; + message: LocalMessage; }; export const GiphyPreviewMessage = (props: GiphyPreviewMessageProps) => { diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 4828b0e9ad..d68a3be3ce 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -28,16 +28,13 @@ import { defaultPinPermissions, MESSAGE_ACTIONS } from '../Message/utils'; import { TypingIndicator as DefaultTypingIndicator } from '../TypingIndicator'; import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; -import type { MessageRenderer } from './renderMessages'; import { defaultRenderMessages } from './renderMessages'; -import type { GroupStyle, ProcessMessagesParams } from './utils'; +import type { LocalMessage } from 'stream-chat'; +import type { MessageRenderer } from './renderMessages'; +import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils'; import type { MessageProps } from '../Message/types'; - -import type { - ChannelStateContextValue, - StreamMessage, -} from '../../context/ChannelStateContext'; +import type { ChannelStateContextValue } from '../../context/ChannelStateContext'; import { DEFAULT_LOAD_PAGE_SCROLL_THRESHOLD, @@ -113,7 +110,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { hasMoreNewer, listElement, loadMoreScrollThreshold, - messages, + messages, // todo: is it correct to base the scroll logic on an array that does not contain date separators or intro? scrolledUpThreshold: props.scrolledUpThreshold, suppressAutoscroll, }); @@ -317,9 +314,9 @@ export type MessageListProps = Partial<Pick<MessageProps, PropsDrilledToMessage> disableDateSeparator?: boolean; /** Callback function to set group styles for each message */ groupStyles?: ( - message: StreamMessage, - previousMessage: StreamMessage, - nextMessage: StreamMessage, + message: RenderedMessage, + previousMessage: RenderedMessage, + nextMessage: RenderedMessage, noGroupByUser: boolean, maxTimeBetweenGroupedMessages?: number, ) => GroupStyle; @@ -350,7 +347,7 @@ export type MessageListProps = Partial<Pick<MessageProps, PropsDrilledToMessage> /** The limit to use when paginating messages */ messageLimit?: number; /** The messages to render in the list, defaults to messages stored in [ChannelStateContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_state_context/) */ - messages?: StreamMessage[]; + messages?: LocalMessage[]; /** If true, turns off message UI grouping by user */ noGroupByUser?: boolean; /** Overrides the way MessageList renders messages */ diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 70099d3f04..fd613b8cf1 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -25,7 +25,7 @@ import { useMarkRead } from './hooks/useMarkRead'; import { MessageNotification as DefaultMessageNotification } from './MessageNotification'; import { MessageListNotifications as DefaultMessageListNotifications } from './MessageListNotifications'; import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; -import type { GroupStyle, ProcessMessagesParams } from './utils'; +import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils'; import { getGroupStyles, getLastReceived, processMessages } from './utils'; import type { MessageProps, MessageUIComponentProps } from '../Message'; import { MessageSimple } from '../Message'; @@ -50,7 +50,6 @@ import { useChannelActionContext } from '../../context/ChannelActionContext'; import type { ChannelNotifications, ChannelStateContextValue, - StreamMessage, } from '../../context/ChannelStateContext'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import type { ChatContextValue } from '../../context/ChatContext'; @@ -61,6 +60,7 @@ import { VirtualizedMessageListContextProvider } from '../../context/Virtualized import type { Channel, + LocalMessage, ChannelState as StreamChannelState, UserResponse, } from 'stream-chat'; @@ -107,7 +107,7 @@ export type VirtuosoContext = Required< /** Mapping of message ID of own messages to the array of users, who read the given message */ ownMessagesReadByOthers: Record<string, UserResponse[]>; /** The original message list enriched with date separators, omitted deleted messages or giphy previews. */ - processedMessages: StreamMessage[]; + processedMessages: RenderedMessage[]; /** Instance of VirtuosoHandle object providing the API to navigate in the virtualized list by various scroll actions. */ virtuosoRef: RefObject<VirtuosoHandle | null>; /** Message id which was marked as unread. ALl the messages following this message are considered unrea. */ @@ -155,12 +155,12 @@ function fractionalItemSize(element: HTMLElement) { return element.getBoundingClientRect().height; } -function findMessageIndex(messages: Array<{ id: string }>, id: string) { +function findMessageIndex(messages: RenderedMessage[], id: string) { return messages.findIndex((message) => message.id === id); } function calculateInitialTopMostItemIndex( - messages: Array<{ id: string }>, + messages: RenderedMessage[], highlightedMessageId: string | undefined, ) { if (highlightedMessageId) { @@ -317,7 +317,7 @@ const VirtualizedMessageListWithContext = ( !shouldGroupByUser, maxTimeBetweenGroupedMessages, ); - if (style) acc[message.id] = style; + if (style && message.id) acc[message.id] = style; return acc; }, {}), // processedMessages were incorrectly rebuilt with a new object identity at some point, hence the .length usage @@ -547,7 +547,7 @@ export type VirtualizedMessageListProps = Partial< closeReactionSelectorOnClick?: boolean; /** Custom render function, if passed, certain UI props are ignored */ customMessageRenderer?: ( - messageList: StreamMessage[], + messageList: RenderedMessage[], index: number, ) => React.ReactElement; /** @deprecated Use additionalVirtuosoProps.defaultItemHeight instead. Will be removed with next major release - `v11.0.0`. @@ -558,9 +558,9 @@ export type VirtualizedMessageListProps = Partial< disableDateSeparator?: boolean; /** Callback function to set group styles for each message */ groupStyles?: ( - message: StreamMessage, - previousMessage: StreamMessage, - nextMessage: StreamMessage, + message: RenderedMessage, + previousMessage: RenderedMessage, + nextMessage: RenderedMessage, noGroupByUser: boolean, maxTimeBetweenGroupedMessages?: number, ) => GroupStyle; @@ -594,7 +594,7 @@ export type VirtualizedMessageListProps = Partial< /** The limit to use when paginating messages */ messageLimit?: number; /** Optional prop to override the messages available from [ChannelStateContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_state_context/) */ - messages?: StreamMessage[]; + messages?: LocalMessage[]; /** * @deprecated Use additionalVirtuosoProps.overscan instead. Will be removed with next major release - `v11.0.0`. * The amount of extra content the list should render in addition to what's necessary to fill in the viewport diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index 28e5d1dec3..2703ba462c 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -7,11 +7,11 @@ import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyState import { LoadingIndicator as DefaultLoadingIndicator } from '../Loading'; import { isMessageEdited, Message } from '../Message'; -import type { StreamMessage } from '../../context'; import { useComponentContext } from '../../context'; -import { getIsFirstUnreadMessage, isDateSeparatorMessage } from './utils'; +import { getIsFirstUnreadMessage, isDateSeparatorMessage, isIntroMessage } from './utils'; -import type { GroupStyle } from './utils'; +import type { LocalMessage } from 'stream-chat'; +import type { GroupStyle, RenderedMessage } from './utils'; import type { VirtuosoContext } from './VirtualizedMessageList'; import type { UnknownType } from '../../types/types'; @@ -26,8 +26,8 @@ export function calculateFirstItemIndex(numItemsPrepended: number) { } export const makeItemsRenderedHandler = ( - renderedItemsActions: Array<(msg: StreamMessage[]) => void>, - processedMessages: StreamMessage[], + renderedItemsActions: Array<(msg: RenderedMessage[]) => void>, + processedMessages: RenderedMessage[], ) => throttle((items: ListItem<UnknownType>[]) => { const renderedMessages = items @@ -36,7 +36,9 @@ export const makeItemsRenderedHandler = ( return processedMessages[calculateItemIndex(item.originalIndex, PREPEND_OFFSET)]; }) .filter((msg) => !!msg); - renderedItemsActions.forEach((action) => action(renderedMessages as StreamMessage[])); + renderedItemsActions.forEach((action) => + action(renderedMessages as RenderedMessage[]), + ); }, 200); type CommonVirtuosoComponentProps = { @@ -136,7 +138,7 @@ export const messageRenderer = ( const message = messageList[streamMessageIndex]; - if (!message) return <div style={{ height: '1px' }}></div>; // returning null or zero height breaks the virtuoso + if (!message || isIntroMessage(message)) return <div style={{ height: '1px' }}></div>; // returning null or zero height breaks the virtuoso if (isDateSeparatorMessage(message)) { return DateSeparator ? ( @@ -148,12 +150,16 @@ export const messageRenderer = ( return MessageSystem ? <MessageSystem message={message} /> : null; } + const maybePrevMessage = messageList[streamMessageIndex - 1] as + | LocalMessage + | undefined; + const maybeNextMessage = messageList[streamMessageIndex + 1] as + | LocalMessage + | undefined; const groupedByUser = shouldGroupByUser && streamMessageIndex > 0 && - message.user?.id === messageList[streamMessageIndex - 1].user?.id; - const maybePrevMessage: StreamMessage | undefined = messageList[streamMessageIndex - 1]; - const maybeNextMessage: StreamMessage | undefined = messageList[streamMessageIndex + 1]; + message.user?.id === maybePrevMessage?.user?.id; // FIXME: firstOfGroup & endOfGroup should be derived from groupStyles which apply a more complex logic const firstOfGroup = diff --git a/src/components/MessageList/__tests__/MessageList.test.js b/src/components/MessageList/__tests__/MessageList.test.js index d23e919622..bdc50bab55 100644 --- a/src/components/MessageList/__tests__/MessageList.test.js +++ b/src/components/MessageList/__tests__/MessageList.test.js @@ -35,8 +35,6 @@ jest.mock('../../EmptyStateIndicator', () => ({ const UNREAD_MESSAGES_SEPARATOR_TEST_ID = 'unread-messages-separator'; -let chatClient; -let channel; const user1 = generateUser(); const user2 = generateUser(); const message1 = generateMessage({ text: 'message1', user: user1 }); @@ -59,29 +57,34 @@ const renderComponent = ({ channelProps, chatClient, msgListProps }) => ); describe('MessageList', () => { + let chatClient; + let channel; + let markReadMock; + beforeEach(async () => { chatClient = await getTestClientWithUser({ id: 'vishal' }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannelData)]); channel = chatClient.channel('messaging', mockedChannelData.id); await channel.watch(); + + markReadMock = jest + .spyOn(channel, 'markRead') + .mockResolvedValue(markReadApi(channel)); }); afterEach(() => { cleanup(); jest.clearAllMocks(); + markReadMock.mockRestore(); }); it('should add new message at the bottom of the list', async () => { - const { getByTestId, getByText } = renderComponent({ + const { findByTestId, getByText } = renderComponent({ channelProps: { channel }, chatClient, }); - const markReadMock = jest - .spyOn(channel, 'markRead') - .mockReturnValueOnce(markReadApi(channel)); - await waitFor(() => { - expect(getByTestId('reverse-infinite-scroll')).toBeInTheDocument(); - }); + + expect(await findByTestId('reverse-infinite-scroll')).toBeInTheDocument(); const newMessage = generateMessage({ user: user2 }); act(() => dispatchMessageNewEvent(chatClient, newMessage, mockedChannelData.channel)); @@ -92,7 +95,6 @@ describe('MessageList', () => { // MessageErrorIcon has path with id "background" - that is not permitted from the a11i standpoint // const results = await axe(container); // expect(results).toHaveNoViolations(); - markReadMock.mockRestore(); }); it('should render the thread head if provided', async () => { @@ -134,12 +136,10 @@ describe('MessageList', () => { }); it('should render EmptyStateIndicator with corresponding list type in main message list', async () => { - await act(() => { - renderComponent({ - channelProps: { channel }, - chatClient, - msgListProps: { messages: [] }, - }); + renderComponent({ + channelProps: { channel }, + chatClient, + msgListProps: { messages: [] }, }); await waitFor(() => { @@ -151,12 +151,10 @@ describe('MessageList', () => { }); it('should not render EmptyStateIndicator with corresponding list type in thread', async () => { - await act(() => { - renderComponent({ - channelProps: { channel }, - chatClient, - msgListProps: { messages: [], threadList: true }, - }); + renderComponent({ + channelProps: { channel }, + chatClient, + msgListProps: { messages: [], threadList: true }, }); await waitFor(() => { @@ -165,15 +163,12 @@ describe('MessageList', () => { }); it('Message UI components should render `Avatar` when the custom prop is provided', async () => { - let renderResult; - await act(() => { - renderResult = renderComponent({ - channelProps: { - Avatar, - channel, - }, - chatClient, - }); + const renderResult = renderComponent({ + channelProps: { + Avatar, + channel, + }, + chatClient, }); await waitFor(() => { @@ -186,19 +181,14 @@ describe('MessageList', () => { it('should accept a custom group style function', async () => { const classNameSuffix = 'msg-list-test'; - const markReadMock = jest - .spyOn(channel, 'markRead') - .mockReturnValueOnce(markReadApi(channel)); - await act(() => { - renderComponent({ - channelProps: { - Avatar, - channel, - }, - chatClient, - msgListProps: { groupStyles: () => classNameSuffix }, - }); + renderComponent({ + channelProps: { + Avatar, + channel, + }, + chatClient, + msgListProps: { groupStyles: () => classNameSuffix }, }); await waitFor(() => { @@ -220,17 +210,12 @@ describe('MessageList', () => { // MessageErrorIcon has path with id "background" - that is not permitted from the a11i standpoint // const results = await axe(renderResult.container); // expect(results).toHaveNoViolations(); - markReadMock.mockRestore(); }); it('should render DateSeparator by default', async () => { - let container; - await act(() => { - const result = renderComponent({ - channelProps: { channel }, - chatClient, - }); - container = result.container; + const { container } = renderComponent({ + channelProps: { channel }, + chatClient, }); await waitFor(() => { @@ -242,14 +227,10 @@ describe('MessageList', () => { }); it('should not render DateSeparator if disableDateSeparator is true', async () => { - let container; - await act(() => { - const result = renderComponent({ - channelProps: { channel }, - chatClient, - msgListProps: { disableDateSeparator: true }, - }); - container = result.container; + const { container } = renderComponent({ + channelProps: { channel }, + chatClient, + msgListProps: { disableDateSeparator: true }, }); await waitFor(() => { @@ -265,14 +246,12 @@ describe('MessageList', () => { const headerText = 'header is rendered'; const Header = () => <div>{headerText}</div>; - await act(() => { - renderComponent({ - channelProps: { channel, HeaderComponent: Header }, - chatClient, - msgListProps: { - messages: [intro], - }, - }); + renderComponent({ + channelProps: { channel, HeaderComponent: Header }, + chatClient, + msgListProps: { + messages: [intro], + }, }); await waitFor(() => { @@ -286,14 +265,12 @@ describe('MessageList', () => { type: 'system', }); - await act(() => { - renderComponent({ - channelProps: { channel }, - chatClient, - msgListProps: { - messages: [system], - }, - }); + renderComponent({ + channelProps: { channel }, + chatClient, + msgListProps: { + messages: [system], + }, }); await waitFor(() => { @@ -305,12 +282,10 @@ describe('MessageList', () => { const customRenderMessages = ({ messages }) => messages.map((msg) => <li key={msg.id}>prefixed {msg.text}</li>); - await act(() => { - renderComponent({ - channelProps: { channel }, - chatClient, - msgListProps: { renderMessages: customRenderMessages }, - }); + renderComponent({ + channelProps: { channel }, + chatClient, + msgListProps: { renderMessages: customRenderMessages }, }); await waitFor(() => { diff --git a/src/components/MessageList/__tests__/utils.test.js b/src/components/MessageList/__tests__/utils.test.js index 323d860a34..30dff953cf 100644 --- a/src/components/MessageList/__tests__/utils.test.js +++ b/src/components/MessageList/__tests__/utils.test.js @@ -486,11 +486,20 @@ describe('getGroupStyles', () => { it('is date message', () => { if (position === 'bottom') { - nextMessage = { ...nextMessage, customType: CUSTOM_MESSAGE_TYPE.date }; + nextMessage = { + ...nextMessage, + customType: CUSTOM_MESSAGE_TYPE.date, + date: new Date(), + }; } if (position === 'top') { - previousMessage = { ...previousMessage, customType: CUSTOM_MESSAGE_TYPE.date }; + previousMessage = { + ...previousMessage, + customType: CUSTOM_MESSAGE_TYPE.date, + date: new Date(), + }; } + expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe( position, ); diff --git a/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts b/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts index 4fd5e2969d..5d10e87c5f 100644 --- a/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts +++ b/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts @@ -1,26 +1,24 @@ import { useMemo } from 'react'; -import type { GroupStyle, ProcessMessagesParams } from '../../utils'; +import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from '../../utils'; import { getGroupStyles, insertIntro, processMessages } from '../../utils'; import { useChatContext } from '../../../../context/ChatContext'; import { useComponentContext } from '../../../../context/ComponentContext'; -import type { Channel } from 'stream-chat'; - -import type { StreamMessage } from '../../../../context/ChannelStateContext'; +import type { Channel, LocalMessage } from 'stream-chat'; export const useEnrichedMessages = (args: { channel: Channel; disableDateSeparator: boolean; hideDeletedMessages: boolean; hideNewMessageSeparator: boolean; - messages: StreamMessage[]; + messages: LocalMessage[]; noGroupByUser: boolean; groupStyles?: ( - message: StreamMessage, - previousMessage: StreamMessage, - nextMessage: StreamMessage, + message: RenderedMessage, + previousMessage: RenderedMessage, + nextMessage: RenderedMessage, noGroupByUser: boolean, maxTimeBetweenGroupedMessages?: number, ) => GroupStyle; diff --git a/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx b/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx index e8e5bcb3e6..ac186855fa 100644 --- a/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx +++ b/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx @@ -2,20 +2,19 @@ import type React from 'react'; import { useMemo } from 'react'; import { useLastReadData } from '../useLastReadData'; -import type { GroupStyle } from '../../utils'; import { getLastReceived } from '../../utils'; import { useChatContext } from '../../../../context/ChatContext'; import { useComponentContext } from '../../../../context/ComponentContext'; import type { ChannelState as StreamChannelState } from 'stream-chat'; -import type { StreamMessage } from '../../../../context/ChannelStateContext'; +import type { GroupStyle, RenderedMessage } from '../../utils'; import type { ChannelUnreadUiState } from '../../../../types/types'; import type { MessageRenderer, SharedMessageProps } from '../../renderMessages'; type UseMessageListElementsProps = { - enrichedMessages: StreamMessage[]; + enrichedMessages: RenderedMessage[]; internalMessageProps: SharedMessageProps; messageGroupStyles: Record<string, GroupStyle>; renderMessages: MessageRenderer; diff --git a/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts b/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts index f7b96f12aa..f5823adacc 100644 --- a/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts +++ b/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts @@ -1,8 +1,7 @@ import { useLayoutEffect, useRef } from 'react'; import { useChatContext } from '../../../../context/ChatContext'; - -import type { StreamMessage } from '../../../../context/ChannelStateContext'; +import type { LocalMessage } from 'stream-chat'; export type ContainerMeasures = { offsetHeight: number; @@ -11,7 +10,7 @@ export type ContainerMeasures = { export type UseMessageListScrollManagerParams = { loadMoreScrollThreshold: number; - messages: StreamMessage[]; + messages: LocalMessage[]; onScrollBy: (scrollBy: number) => void; scrollContainerMeasures: () => ContainerMeasures; scrolledUpThreshold: number; @@ -36,7 +35,7 @@ export function useMessageListScrollManager(params: UseMessageListScrollManagerP offsetHeight: 0, scrollHeight: 0, }); - const messages = useRef<StreamMessage[]>(undefined); + const messages = useRef<LocalMessage[]>(undefined); const scrollTop = useRef(0); useLayoutEffect(() => { diff --git a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx index 204181e46a..17ca06f99e 100644 --- a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx +++ b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx @@ -2,15 +2,14 @@ import type React from 'react'; import { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { useMessageListScrollManager } from './useMessageListScrollManager'; - -import type { StreamMessage } from '../../../../context/ChannelStateContext'; +import type { LocalMessage } from 'stream-chat'; export type UseScrollLocationLogicParams = { hasMoreNewer: boolean; listElement: HTMLDivElement | null; loadMoreScrollThreshold: number; suppressAutoscroll: boolean; - messages?: StreamMessage[]; + messages?: LocalMessage[]; scrolledUpThreshold?: number; }; diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useGiphyPreview.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useGiphyPreview.ts index 7656b3f985..bf865e7d34 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useGiphyPreview.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useGiphyPreview.ts @@ -2,16 +2,15 @@ import { useEffect, useState } from 'react'; import { useChatContext } from '../../../../context/ChatContext'; -import type { EventHandler } from 'stream-chat'; - -import type { StreamMessage } from '../../../../context/ChannelStateContext'; +import type { EventHandler, LocalMessage } from 'stream-chat'; export const useGiphyPreview = (separateGiphyPreview: boolean) => { - const [giphyPreviewMessage, setGiphyPreviewMessage] = useState<StreamMessage>(); + const [giphyPreviewMessage, setGiphyPreviewMessage] = useState<LocalMessage>(); const { client } = useChatContext('useGiphyPreview'); useEffect(() => { + if (!separateGiphyPreview) return; const handleEvent: EventHandler = (event) => { const { message, user } = event; @@ -20,7 +19,7 @@ export const useGiphyPreview = (separateGiphyPreview: boolean) => { } }; - if (separateGiphyPreview) client.on('message.new', handleEvent); + client.on('message.new', handleEvent); return () => client.off('message.new', handleEvent); }, [client, separateGiphyPreview]); diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useMessageSetKey.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useMessageSetKey.ts index 55dcd70c40..392624afeb 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useMessageSetKey.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useMessageSetKey.ts @@ -1,8 +1,8 @@ import { useEffect, useRef, useState } from 'react'; -import type { StreamMessage } from '../../../../context'; +import type { LocalMessage } from 'stream-chat'; type UseMessageSetKeyParams = { - messages?: StreamMessage[]; + messages?: LocalMessage[]; }; export const useMessageSetKey = ({ messages }: UseMessageSetKeyParams) => { diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts index d2ad75a71a..88eb77fcb0 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts @@ -1,9 +1,9 @@ import { useEffect, useRef, useState } from 'react'; - -import type { StreamMessage } from '../../../../context/ChannelStateContext'; +import type { LocalMessage } from 'stream-chat'; +import type { RenderedMessage } from '../../utils'; export function useNewMessageNotification( - messages: StreamMessage[], + messages: RenderedMessage[], currentUserId: string | undefined, hasMoreNewer?: boolean, ) { @@ -37,7 +37,7 @@ export function useNewMessageNotification( if (atBottom.current) return; /* if the new message belongs to current user scroll to bottom */ - if (lastMessage.user?.id !== currentUserId && didMount.current) { + if ((lastMessage as LocalMessage).user?.id !== currentUserId && didMount.current) { /* otherwise just show newMessage notification */ setNewMessagesNotification(true); } diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/usePrependMessagesCount.ts b/src/components/MessageList/hooks/VirtualizedMessageList/usePrependMessagesCount.ts index db07ea1a76..5f9f3ef77c 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/usePrependMessagesCount.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/usePrependMessagesCount.ts @@ -1,6 +1,6 @@ import { useMemo, useRef } from 'react'; - -import type { StreamMessage } from '../../../../context/ChannelStateContext'; +import type { RenderedMessage } from '../../utils'; +import { isLocalMessage } from '../../utils'; const STATUSES_EXCLUDED_FROM_PREPEND = { failed: true, @@ -8,12 +8,12 @@ const STATUSES_EXCLUDED_FROM_PREPEND = { } as const as Record<string, boolean>; export function usePrependedMessagesCount( - messages: StreamMessage[], + messages: RenderedMessage[], hasDateSeparator: boolean, ) { const firstRealMessageIndex = hasDateSeparator ? 1 : 0; - const firstMessageOnFirstLoadedPage = useRef<StreamMessage>(undefined); - const previousFirstMessageOnFirstLoadedPage = useRef<StreamMessage>(undefined); + const firstMessageOnFirstLoadedPage = useRef<RenderedMessage>(undefined); + const previousFirstMessageOnFirstLoadedPage = useRef<RenderedMessage>(undefined); const previousNumItemsPrepended = useRef(0); const numItemsPrepended = useMemo(() => { @@ -38,9 +38,12 @@ export function usePrependedMessagesCount( // That in turn leads to incorrect index calculation in VirtualizedMessageList trying to access a message // at non-existent index. Therefore, we ignore messages of status "sending" / "failed" in order they are // not considered as prepended messages. + const currentFirstMessageStatus = isLocalMessage(currentFirstMessage) + ? currentFirstMessage.status + : undefined; const firstMsgMovedAfterMessagesInExcludedStatus = !!( - currentFirstMessage?.status && - STATUSES_EXCLUDED_FROM_PREPEND[currentFirstMessage.status] + currentFirstMessageStatus && + STATUSES_EXCLUDED_FROM_PREPEND[currentFirstMessageStatus] ); if (noNewMessages || firstMsgMovedAfterMessagesInExcludedStatus) { diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useScrollToBottomOnNewMessage.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useScrollToBottomOnNewMessage.ts index 08f2f6f1fd..5ae0fa6198 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useScrollToBottomOnNewMessage.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useScrollToBottomOnNewMessage.ts @@ -1,9 +1,9 @@ import { useEffect, useRef, useState } from 'react'; -import type { StreamMessage } from '../../../../context'; +import type { RenderedMessage } from '../../utils'; type UseScrollToBottomOnNewMessageParams = { scrollToBottom: () => void; - messages?: StreamMessage[]; + messages?: RenderedMessage[]; /** When `true`, the list will scroll to the latest message when the window regains focus */ scrollToLatestMessageOnFocus?: boolean; }; diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useShouldForceScrollToBottom.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useShouldForceScrollToBottom.ts index f4e01e0530..3d149a5c87 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useShouldForceScrollToBottom.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useShouldForceScrollToBottom.ts @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react'; - -import type { StreamMessage } from '../../../../context/ChannelStateContext'; +import type { RenderedMessage } from '../../utils'; +import type { LocalMessage } from 'stream-chat'; export function useShouldForceScrollToBottom( - messages: StreamMessage[], + messages: RenderedMessage[], currentUserId?: string, ) { const lastFocusedOwnMessage = useRef(''); @@ -14,7 +14,7 @@ export function useShouldForceScrollToBottom( const lastMessage = messages[messages.length - 1]; if ( - lastMessage.user?.id === currentUserId && + (lastMessage as LocalMessage).user?.id === currentUserId && lastFocusedOwnMessage.current !== lastMessage.id ) { lastFocusedOwnMessage.current = lastMessage.id; diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts index 91cb5c4275..dbaf6665dd 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; -import type { StreamMessage } from '../../../../context'; +import type { RenderedMessage } from '../../utils'; +import type { LocalMessage } from 'stream-chat'; export type UseUnreadMessagesNotificationParams = { showAlways: boolean; @@ -27,19 +28,24 @@ export const useUnreadMessagesNotificationVirtualized = ({ const [show, setShow] = useState(false); const toggleShowUnreadMessagesNotification = useCallback( - (renderedMessages: StreamMessage[]) => { + (renderedMessages: RenderedMessage[]) => { if (!unreadCount) return; const firstRenderedMessage = renderedMessages[0]; const lastRenderedMessage = renderedMessages.slice(-1)[0]; if (!(firstRenderedMessage && lastRenderedMessage)) return; + + const firstRenderedMessageTime = new Date( + (firstRenderedMessage as LocalMessage).created_at ?? 0, + ).getTime(); + const lastRenderedMessageTime = new Date( + (lastRenderedMessage as LocalMessage).created_at ?? 0, + ).getTime(); + const lastReadTime = new Date(lastRead ?? 0).getTime(); + const scrolledBelowSeparator = - !!lastRead && - new Date(firstRenderedMessage.created_at as string | Date).getTime() > - lastRead.getTime(); + !!lastReadTime && firstRenderedMessageTime > lastReadTime; const scrolledAboveSeparator = - !!lastRead && - new Date(lastRenderedMessage.created_at as string | Date).getTime() < - lastRead.getTime(); + !!lastReadTime && lastRenderedMessageTime < lastReadTime; setShow( showAlways diff --git a/src/components/MessageList/hooks/useLastReadData.ts b/src/components/MessageList/hooks/useLastReadData.ts index 8cca83e677..6e803496c2 100644 --- a/src/components/MessageList/hooks/useLastReadData.ts +++ b/src/components/MessageList/hooks/useLastReadData.ts @@ -1,13 +1,13 @@ import { useMemo } from 'react'; +import { isLocalMessage } from '../utils'; import { getReadStates } from '../utils'; -import type { UserResponse } from 'stream-chat'; - -import type { StreamMessage } from '../../../context/ChannelStateContext'; +import type { LocalMessage, UserResponse } from 'stream-chat'; +import type { RenderedMessage } from '../utils'; type UseLastReadDataParams = { - messages: StreamMessage[]; + messages: RenderedMessage[]; returnAllReadData: boolean; userID: string | undefined; read?: Record<string, { last_read: Date; user: UserResponse }>; @@ -16,13 +16,10 @@ type UseLastReadDataParams = { export const useLastReadData = (props: UseLastReadDataParams) => { const { messages, read, returnAllReadData, userID } = props; - return useMemo( - () => - getReadStates( - messages.filter(({ user }) => user?.id === userID), - read, - returnAllReadData, - ), - [messages, read, returnAllReadData, userID], - ); + return useMemo(() => { + const ownLocalMessages = messages.filter( + (msg) => isLocalMessage(msg) && msg.user?.id === userID, + ) as LocalMessage[]; + return getReadStates(ownLocalMessages, read, returnAllReadData); + }, [messages, read, returnAllReadData, userID]); }; diff --git a/src/components/MessageList/hooks/useMarkRead.ts b/src/components/MessageList/hooks/useMarkRead.ts index 26969fc186..540b34ee24 100644 --- a/src/components/MessageList/hooks/useMarkRead.ts +++ b/src/components/MessageList/hooks/useMarkRead.ts @@ -1,11 +1,10 @@ import { useEffect } from 'react'; -import type { StreamMessage } from '../../../context'; import { useChannelActionContext, useChannelStateContext, useChatContext, } from '../../../context'; -import type { Channel, Event, MessageResponse } from 'stream-chat'; +import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; const hasReadLastMessage = (channel: Channel, userId: string) => { const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.id; @@ -98,7 +97,7 @@ export const useMarkRead = ({ ]); }; -function getPreviousLastMessage(messages: StreamMessage[], newMessage?: MessageResponse) { +function getPreviousLastMessage(messages: LocalMessage[], newMessage?: MessageResponse) { if (!newMessage) return; let previousLastMessage; for (let i = messages.length - 1; i >= 0; i--) { diff --git a/src/components/MessageList/renderMessages.tsx b/src/components/MessageList/renderMessages.tsx index f60cdcc900..50b571d3b7 100644 --- a/src/components/MessageList/renderMessages.tsx +++ b/src/components/MessageList/renderMessages.tsx @@ -1,24 +1,21 @@ import React, { Fragment } from 'react'; -import type { ReactNode } from 'react'; -import type { UserResponse } from 'stream-chat'; - -import { getIsFirstUnreadMessage, isDateSeparatorMessage } from './utils'; +import { getIsFirstUnreadMessage, isDateSeparatorMessage, isIntroMessage } from './utils'; import { Message } from '../Message'; import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; import { UnreadMessagesSeparator as DefaultUnreadMessagesSeparator } from './UnreadMessagesSeparator'; -import { CUSTOM_MESSAGE_TYPE } from '../../constants/messageTypes'; +import type { ReactNode } from 'react'; +import type { UserResponse } from 'stream-chat'; +import type { GroupStyle, RenderedMessage } from './utils'; +import type { MessageProps } from '../Message'; import type { ComponentContextValue, CustomClasses } from '../../context'; -import type { GroupStyle } from './utils'; import type { ChannelUnreadUiState } from '../../types'; -import type { StreamMessage } from '../../context/ChannelStateContext'; -import type { MessageProps } from '../Message'; export interface RenderMessagesOptions { components: ComponentContextValue; lastReceivedMessageId: string | null; messageGroupStyles: Record<string, GroupStyle>; - messages: Array<StreamMessage>; + messages: Array<RenderedMessage>; /** * Object mapping message IDs of own messages to the users who read those messages. */ @@ -80,17 +77,19 @@ export function defaultRenderMessages({ /> </li>, ); - } else if (message.customType === CUSTOM_MESSAGE_TYPE.intro && HeaderComponent) { - renderedMessages.push( - <li key='intro'> - <HeaderComponent /> - </li>, - ); + } else if (isIntroMessage(message)) { + if (HeaderComponent) { + renderedMessages.push( + <li key='intro'> + <HeaderComponent /> + </li>, + ); + } } else if (message.type === 'system') { renderedMessages.push( <li data-message-id={message.id} - key={message.id || (message.created_at as string)} + key={message.id || message.created_at.toISOString()} > <MessageSystem message={message} /> </li>, @@ -114,7 +113,7 @@ export function defaultRenderMessages({ }); renderedMessages.push( - <Fragment key={message.id || (message.created_at as string)}> + <Fragment key={message.id || message.created_at.toISOString()}> {isFirstUnreadMessage && UnreadMessagesSeparator && ( <li className='str-chat__li str-chat__unread-messages-separator-wrapper'> <UnreadMessagesSeparator diff --git a/src/components/MessageList/utils.ts b/src/components/MessageList/utils.ts index 85f339a2c7..17fc5e7962 100644 --- a/src/components/MessageList/utils.ts +++ b/src/components/MessageList/utils.ts @@ -4,9 +4,22 @@ import { CUSTOM_MESSAGE_TYPE } from '../../constants/messageTypes'; import { isMessageEdited } from '../Message/utils'; import { isDate } from '../../i18n'; -import type { MessageLabel, UserResponse } from 'stream-chat'; +import type { LocalMessage, MessageLabel, UserResponse } from 'stream-chat'; -import type { StreamMessage } from '../../context/ChannelStateContext'; +type IntroMessage = { + customType: typeof CUSTOM_MESSAGE_TYPE.intro; + id: string; +}; + +type DateSeparatorMessage = { + customType: typeof CUSTOM_MESSAGE_TYPE.date; + date: Date; + id: string; + type: MessageLabel; + unread: boolean; +}; + +export type RenderedMessage = LocalMessage | DateSeparatorMessage | IntroMessage; type ProcessMessagesContext = { /** the connected user ID */ @@ -22,23 +35,21 @@ type ProcessMessagesContext = { }; export type ProcessMessagesParams = ProcessMessagesContext & { - messages: StreamMessage[]; + messages: LocalMessage[]; reviewProcessedMessage?: (params: { /** array of messages representing the changes applied around a given processed message */ - changes: StreamMessage[]; + changes: RenderedMessage[]; /** configuration params and information forwarded from `processMessages` */ context: ProcessMessagesContext; /** index of the processed message in the original messages array */ index: number; /** array of messages retrieved from the back-end */ - messages: StreamMessage[]; + messages: LocalMessage[]; /** newly built array of messages to be later rendered */ - processedMessages: StreamMessage[]; - }) => StreamMessage[]; + processedMessages: RenderedMessage[]; + }) => LocalMessage[]; /** Signals whether to separate giphy preview as well as used to set the giphy preview state */ - setGiphyPreviewMessage?: React.Dispatch< - React.SetStateAction<StreamMessage | undefined> - >; + setGiphyPreviewMessage?: React.Dispatch<React.SetStateAction<LocalMessage | undefined>>; }; /** @@ -55,7 +66,7 @@ export type ProcessMessagesParams = ProcessMessagesContext & { * * The only required params are messages and userId, the rest are config params: * - * @return {StreamMessage[]} Transformed list of messages + * @return {LocalMessage[]} Transformed list of messages */ export const processMessages = (params: ProcessMessagesParams) => { const { messages, reviewProcessedMessage, setGiphyPreviewMessage, ...context } = params; @@ -70,7 +81,7 @@ export const processMessages = (params: ProcessMessagesParams) => { let unread = false; let ephemeralMessagePresent = false; let lastDateSeparator; - const newMessages: StreamMessage[] = []; + const newMessages: RenderedMessage[] = []; for (let i = 0; i < messages.length; i += 1) { const message = messages[i]; @@ -89,7 +100,7 @@ export const processMessages = (params: ProcessMessagesParams) => { continue; } - const changes: StreamMessage[] = []; + const changes: RenderedMessage[] = []; const messageDate = (message.created_at && isDate(message.created_at) && @@ -113,15 +124,12 @@ export const processMessages = (params: ProcessMessagesParams) => { // do not show date separator for current user's messages if (enableDateSeparator && unread && message.user?.id !== userId) { - changes.push( - // @ts-expect-error type mismatch - { - customType: CUSTOM_MESSAGE_TYPE.date, - date: message.created_at, - id: makeDateMessageId(message.created_at), - unread, - } as StreamMessage, - ); + changes.push({ + customType: CUSTOM_MESSAGE_TYPE.date, + date: message.created_at, + id: makeDateMessageId(message.created_at), + unread, + } as DateSeparatorMessage); } } @@ -133,7 +141,7 @@ export const processMessages = (params: ProcessMessagesParams) => { (hideDeletedMessages && previousMessage?.type === 'deleted' && lastDateSeparator !== messageDate)) && - changes[changes.length - 1]?.customType !== CUSTOM_MESSAGE_TYPE.date // do not show two date separators in a row) + !isDateSeparatorMessage(changes[changes.length - 1]) // do not show two date separators in a row) ) { lastDateSeparator = messageDate; @@ -142,7 +150,7 @@ export const processMessages = (params: ProcessMessagesParams) => { customType: CUSTOM_MESSAGE_TYPE.date, date: message.created_at, id: makeDateMessageId(message.created_at), - } as StreamMessage, + } as DateSeparatorMessage, message, ); } else { @@ -168,6 +176,11 @@ export const processMessages = (params: ProcessMessagesParams) => { return newMessages; }; +export const makeIntroMessage = (): IntroMessage => ({ + customType: CUSTOM_MESSAGE_TYPE.intro, + id: nanoid(), +}); + export const makeDateMessageId = (date?: string | Date) => { let idSuffix; try { @@ -179,9 +192,9 @@ export const makeDateMessageId = (date?: string | Date) => { }; // fast since it usually iterates just the last few messages -export const getLastReceived = (messages: StreamMessage[]) => { +export const getLastReceived = (messages: RenderedMessage[]) => { for (let i = messages.length - 1; i > 0; i -= 1) { - if (messages[i].status === 'received') { + if ((messages[i] as LocalMessage).status === 'received') { return messages[i].id; } } @@ -190,7 +203,7 @@ export const getLastReceived = (messages: StreamMessage[]) => { }; export const getReadStates = ( - messages: StreamMessage[], + messages: LocalMessage[], read: Record<string, { last_read: Date; user: UserResponse }> = {}, returnAllReadData: boolean, ) => { @@ -231,11 +244,9 @@ export const getReadStates = ( return readData; }; -export const insertIntro = (messages: StreamMessage[], headerPosition?: number) => { +export const insertIntro = (messages: RenderedMessage[], headerPosition?: number) => { const newMessages = messages; - const intro = { - customType: CUSTOM_MESSAGE_TYPE.intro, - } as unknown as StreamMessage; + const intro = makeIntroMessage(); // if no headerPosition is set, HeaderComponent will go at the top if (!headerPosition) { @@ -251,24 +262,19 @@ export const insertIntro = (messages: StreamMessage[], headerPosition?: number) // else loop over the messages for (let i = 0; i < messages.length; i += 1) { - const message = messages[i]; - const messageTime = - message.created_at && isDate(message.created_at) - ? message.created_at.getTime() - : null; + const messageTime = isDate((messages[i] as LocalMessage).created_at) + ? (messages[i] as LocalMessage).created_at.getTime() + : null; - const nextMessage = messages[i + 1]; - const nextMessageTime = - nextMessage.created_at && isDate(nextMessage.created_at) - ? nextMessage.created_at.getTime() - : null; + const nextMessageTime = isDate((messages[i + 1] as LocalMessage).created_at) + ? (messages[i + 1] as LocalMessage).created_at.getTime() + : null; // header position is smaller than message time so comes after; if (messageTime && messageTime < headerPosition) { // if header position is also smaller than message time continue; if (nextMessageTime && nextMessageTime < headerPosition) { - if (messages[i + 1] && messages[i + 1].customType === CUSTOM_MESSAGE_TYPE.date) - continue; + if (messages[i + 1] && isDateSeparatorMessage(messages[i + 1])) continue; if (!nextMessageTime) { newMessages.push(intro); return newMessages; @@ -286,22 +292,20 @@ export const insertIntro = (messages: StreamMessage[], headerPosition?: number) export type GroupStyle = '' | 'middle' | 'top' | 'bottom' | 'single'; export const getGroupStyles = ( - message: StreamMessage, - previousMessage: StreamMessage, - nextMessage: StreamMessage, + message: RenderedMessage, + previousMessage: RenderedMessage, + nextMessage: RenderedMessage, noGroupByUser: boolean, maxTimeBetweenGroupedMessages?: number, ): GroupStyle => { - if (message.customType === CUSTOM_MESSAGE_TYPE.date) return ''; - - if (message.customType === CUSTOM_MESSAGE_TYPE.intro) return ''; + if (isDateSeparatorMessage(message) || isIntroMessage(message)) return ''; if (noGroupByUser || message.attachments?.length !== 0) return 'single'; const isTopMessage = !previousMessage || - previousMessage.customType === CUSTOM_MESSAGE_TYPE.intro || - previousMessage.customType === CUSTOM_MESSAGE_TYPE.date || + isIntroMessage(previousMessage) || + isDateSeparatorMessage(previousMessage) || previousMessage.type === 'system' || previousMessage.type === 'error' || previousMessage.attachments?.length !== 0 || @@ -318,8 +322,8 @@ export const getGroupStyles = ( const isBottomMessage = !nextMessage || - nextMessage.customType === CUSTOM_MESSAGE_TYPE.intro || - nextMessage.customType === CUSTOM_MESSAGE_TYPE.date || + isIntroMessage(nextMessage) || + isDateSeparatorMessage(nextMessage) || nextMessage.type === 'system' || nextMessage.type === 'error' || nextMessage.attachments?.length !== 0 || @@ -362,24 +366,25 @@ export const hasMoreMessagesProbably = (returnedCountMessages: number, limit: nu export const hasNotMoreMessages = (returnedCountMessages: number, limit: number) => returnedCountMessages < limit; -type DateSeparatorMessage = { - customType: typeof CUSTOM_MESSAGE_TYPE.date; - date: Date; - id: string; - type: MessageLabel; - unread: boolean; -}; +export function isIntroMessage(message: unknown): message is IntroMessage { + return (message as IntroMessage).customType === CUSTOM_MESSAGE_TYPE.intro; +} export function isDateSeparatorMessage( - message: StreamMessage, + message: unknown, ): message is DateSeparatorMessage { return ( - message.customType === CUSTOM_MESSAGE_TYPE.date && - !!message.date && - isDate(message.date) + message !== null && + typeof message === 'object' && + (message as DateSeparatorMessage).customType === CUSTOM_MESSAGE_TYPE.date && + isDate((message as DateSeparatorMessage).date) ); } +export function isLocalMessage(message: unknown): message is LocalMessage { + return !isDateSeparatorMessage(message) && !isIntroMessage(message); +} + export const getIsFirstUnreadMessage = ({ firstUnreadMessageId, isFirstMessage, @@ -390,11 +395,11 @@ export const getIsFirstUnreadMessage = ({ unreadMessageCount = 0, }: { isFirstMessage: boolean; - message: StreamMessage; + message: LocalMessage; firstUnreadMessageId?: string; lastReadDate?: Date; lastReadMessageId?: string; - previousMessage?: StreamMessage; + previousMessage?: RenderedMessage; unreadMessageCount?: number; }) => { // prevent showing unread indicator in threads diff --git a/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx new file mode 100644 index 0000000000..c7aeefaa98 --- /dev/null +++ b/src/components/Poll/PollCreationDialog/MultipleAnswersField.tsx @@ -0,0 +1,68 @@ +import clsx from 'clsx'; +import React from 'react'; +import { SimpleSwitchField } from '../../Form/SwitchField'; +import { FieldError } from '../../Form/FieldError'; +import { useTranslationContext } from '../../../context'; +import { useMessageComposer } from '../../MessageInput'; +import { useStateStore } from '../../../store'; +import type { PollComposerState } from 'stream-chat'; + +const pollComposerStateSelector = (state: PollComposerState) => ({ + enforce_unique_vote: state.data.enforce_unique_vote, + error: state.errors.max_votes_allowed, + max_votes_allowed: state.data.max_votes_allowed, +}); + +export const MultipleAnswersField = () => { + const { t } = useTranslationContext(); + const { pollComposer } = useMessageComposer(); + const { enforce_unique_vote, error, max_votes_allowed } = useStateStore( + pollComposer.state, + pollComposerStateSelector, + ); + return ( + <div + className={clsx('str-chat__form__expandable-field', { + 'str-chat__form__expandable-field--expanded': !enforce_unique_vote, + })} + > + <SimpleSwitchField + checked={!enforce_unique_vote} + id='enforce_unique_vote' + labelText={t<string>('Multiple answers')} + onChange={(e) => { + pollComposer.updateFields({ enforce_unique_vote: !e.target.checked }); + }} + /> + {!enforce_unique_vote && ( + <div + className={clsx('str-chat__form__input-field', { + 'str-chat__form__input-field--has-error': error, + })} + > + <div className={clsx('str-chat__form__input-field__value')}> + <FieldError + className='str-chat__form__input-field__error' + data-testid={'poll-max-votes-allowed-input-field-error'} + text={error && t(error)} + /> + <input + id='max_votes_allowed' + onBlur={() => { + pollComposer.handleFieldBlur('max_votes_allowed'); + }} + onChange={(e) => { + pollComposer.updateFields({ + max_votes_allowed: e.target.value, + }); + }} + placeholder={t<string>('Maximum number of votes (from 2 to 10)')} + type='number' + value={max_votes_allowed} + /> + </div> + </div> + )} + </div> + ); +}; diff --git a/src/components/Poll/PollCreationDialog/NameField.tsx b/src/components/Poll/PollCreationDialog/NameField.tsx new file mode 100644 index 0000000000..9abab17295 --- /dev/null +++ b/src/components/Poll/PollCreationDialog/NameField.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import clsx from 'clsx'; +import { FieldError } from '../../Form/FieldError'; +import { useTranslationContext } from '../../../context'; +import { useMessageComposer } from '../../MessageInput'; +import { useStateStore } from '../../../store'; +import type { PollComposerState } from 'stream-chat'; + +const pollComposerStateSelector = (state: PollComposerState) => ({ + error: state.errors.name, + name: state.data.name, +}); + +export const NameField = () => { + const { t } = useTranslationContext(); + const { pollComposer } = useMessageComposer(); + const { error, name } = useStateStore(pollComposer.state, pollComposerStateSelector); + return ( + <div + className={clsx( + 'str-chat__form__field str-chat__form__input-field str-chat__form__input-field--with-label', + { + 'str-chat__form__input-field--has-error': error, + }, + )} + > + <label className='str-chat__form__field-label' htmlFor='name'> + {t<string>('Question')} + </label> + <div className={clsx('str-chat__form__input-field__value')}> + <FieldError + className='str-chat__form__input-field__error' + data-testid={'poll-name-input-field-error'} + text={error && t(error)} + /> + <input + id='name' + onBlur={() => { + pollComposer.handleFieldBlur('name'); + }} + onChange={(e) => { + pollComposer.updateFields({ name: e.target.value }); + }} + placeholder={t<string>('Ask a question')} + type='text' + value={name} + /> + </div> + </div> + ); +}; diff --git a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx index 6b1489e69a..9d103b7b47 100644 --- a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx +++ b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx @@ -1,49 +1,31 @@ import clsx from 'clsx'; -import { MAX_POLL_OPTIONS } from '../constants'; -import { nanoid } from 'nanoid'; import React, { useCallback } from 'react'; import { FieldError } from '../../Form/FieldError'; import { DragAndDropContainer } from '../../DragAndDrop/DragAndDropContainer'; import { useTranslationContext } from '../../../context'; -import type { OptionErrors, PollFormState, PollOptionFormData } from './types'; +import { useMessageComposer } from '../../MessageInput'; +import { useStateStore } from '../../../store'; +import type { PollComposerState } from 'stream-chat'; -const VALIDATION_ERRORS = { 'Option already exists': true } as const as Record< - string, - boolean ->; +const pollComposerStateSelector = (state: PollComposerState) => ({ + errors: state.errors.options, + options: state.data.options, +}); -export type OptionFieldSetProps = { - errors: OptionErrors; - options: PollFormState['options']; - setErrors: (fn: (prev: OptionErrors) => OptionErrors) => void; - setState: (fn: (prev: PollFormState) => PollFormState) => void; -}; - -export const OptionFieldSet = ({ - errors, - options, - setErrors, - setState, -}: OptionFieldSetProps) => { +export const OptionFieldSet = () => { + const { pollComposer } = useMessageComposer(); + const { errors, options } = useStateStore( + pollComposer.state, + pollComposerStateSelector, + ); const { t } = useTranslationContext('OptionFieldSet'); - const findOptionDuplicate = (sourceOption: PollOptionFormData) => { - const isDuplicateFilter = (option: PollOptionFormData) => - !!sourceOption.text.trim() && // do not include empty options into consideration - option.id !== sourceOption.id && - option.text.trim() === sourceOption.text.trim(); - - return options.find(isDuplicateFilter); - }; - const onSetNewOrder = useCallback( (newOrder: number[]) => { - setState((prev) => ({ - ...prev, - options: newOrder.map((index) => prev.options[index]), - })); + const prevOptions = pollComposer.options; + pollComposer.updateFields({ options: newOrder.map((index) => prevOptions[index]) }); }, - [setState], + [pollComposer], ); const draggable = options.length > 1; @@ -56,83 +38,47 @@ export const OptionFieldSet = ({ draggable={draggable} onSetNewOrder={onSetNewOrder} > - {options.map((option, i) => ( - <div - className={clsx('str-chat__form__input-field', { - 'str-chat__form__input-field--draggable': draggable, - 'str-chat__form__input-field--has-error': errors[option.id], - })} - key={`new-poll-option-${i}`} - > - <div className='str-chat__form__input-field__value'> - <FieldError - className='str-chat__form__input-field__error' - data-testid={'poll-option-input-field-error'} - text={errors[option.id]} - /> - <input - id={option.id} - onBlur={(e) => { - if (findOptionDuplicate({ id: e.target.id, text: e.target.value })) { - setErrors((prev) => ({ - ...prev, - [e.target.id]: t<string>('Option already exists'), - })); - } - }} - onChange={(e) => { - setState((prev) => { - const shouldAddEmptyOption = - prev.options.length < MAX_POLL_OPTIONS && - (!prev.options || - (prev.options.slice(i + 1).length === 0 && !!e.target.value)); - const shouldRemoveOption = - prev.options && - prev.options.slice(i + 1).length > 0 && - !e.target.value; - - const optionListHead = prev.options ? prev.options.slice(0, i) : []; - const optionListTail = shouldAddEmptyOption - ? [{ id: nanoid(), text: '' }] - : (prev.options || []).slice(i + 1); - - if ( - (errors[option.id] && !e.target.value) || - (VALIDATION_ERRORS[errors[option.id]] && - !findOptionDuplicate({ id: e.target.id, text: e.target.value })) - ) { - setErrors((prev) => { - delete prev[option.id]; - return prev; - }); + {options.map((option, i) => { + const error = errors?.[option.id]; + return ( + <div + className={clsx('str-chat__form__input-field', { + 'str-chat__form__input-field--draggable': draggable, + 'str-chat__form__input-field--has-error': error, + })} + key={`new-poll-option-${i}`} + > + <div className='str-chat__form__input-field__value'> + <FieldError + className='str-chat__form__input-field__error' + data-testid={'poll-option-input-field-error'} + text={error && t(error)} + /> + <input + id={option.id} + onBlur={() => { + pollComposer.handleFieldBlur('options'); + }} + onChange={(e) => { + pollComposer.updateFields({ + options: { index: i, text: e.target.value }, + }); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + const nextInputId = options[i + 1].id; + document.getElementById(nextInputId)?.focus(); } - - return { - ...prev, - options: [ - ...optionListHead, - ...(shouldRemoveOption - ? [] - : [{ ...option, text: e.target.value }]), - ...optionListTail, - ], - }; - }); - }} - onKeyUp={(event) => { - if (event.key === 'Enter') { - const nextInputId = options[i + 1].id; - document.getElementById(nextInputId)?.focus(); - } - }} - placeholder={t<string>('Add an option')} - type='text' - value={option.text} - /> + }} + placeholder={t<string>('Add an option')} + type='text' + value={option.text} + /> + </div> + {draggable && <div className='str-chat__drag-handle' />} </div> - {draggable && <div className='str-chat__drag-handle' />} - </div> - ))} + ); + })} </DragAndDropContainer> </fieldset> ); diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx index 9d8b09b6a7..d80a508cba 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx @@ -1,193 +1,81 @@ -import clsx from 'clsx'; -import { nanoid } from 'nanoid'; -import React, { useState } from 'react'; -import { FieldError } from '../../Form/FieldError'; +import React, { useCallback } from 'react'; +import type { PollComposerState } from 'stream-chat'; +import { VotingVisibility } from 'stream-chat'; +import { MultipleAnswersField } from './MultipleAnswersField'; +import { NameField } from './NameField'; import { OptionFieldSet } from './OptionFieldSet'; import { PollCreationDialogControls } from './PollCreationDialogControls'; -import { VALID_MAX_VOTES_VALUE_REGEX } from '../constants'; import { ModalHeader } from '../../Modal/ModalHeader'; import { SimpleSwitchField } from '../../Form/SwitchField'; -import { useChatContext, useTranslationContext } from '../../../context'; - -import type { VotingVisibility } from 'stream-chat'; -import type { OptionErrors, PollFormState } from './types'; +import { useMessageComposer } from '../../MessageInput'; +import { useTranslationContext } from '../../../context'; +import { useStateStore } from '../../../store'; export type PollCreationDialogProps = { close: () => void; }; +const pollComposerStateSelector = (state: PollComposerState) => ({ + allow_answers: state.data.allow_answers, + allow_user_suggested_options: state.data.allow_user_suggested_options, + voting_visibility: state.data.voting_visibility, +}); + export const PollCreationDialog = ({ close }: PollCreationDialogProps) => { - const { client } = useChatContext(); const { t } = useTranslationContext(); + const { pollComposer } = useMessageComposer(); + const { allow_answers, allow_user_suggested_options, voting_visibility } = + useStateStore(pollComposer.state, pollComposerStateSelector); - const [nameError, setNameError] = useState<string>(); - const [optionsErrors, setOptionsErrors] = useState<OptionErrors>({}); - const [multipleAnswerCountError, setMultipleAnswerCountError] = useState<string>(); - const [state, setState] = useState<PollFormState>( - () => - ({ - allow_answers: false, - allow_user_suggested_options: false, - description: '', - enforce_unique_vote: true, - id: nanoid(), - max_votes_allowed: '', - name: '', - options: [{ id: nanoid(), text: '' }], - user_id: client.user?.id, - voting_visibility: 'public', - }) as PollFormState, - ); + const onClose = useCallback(() => { + pollComposer.initState(); + close(); + }, [pollComposer, close]); return ( <div className='str-chat__dialog str-chat__poll-creation-dialog' data-testid='poll-creation-dialog' > - <ModalHeader close={close} title={t<string>('Create poll')} /> + <ModalHeader close={onClose} title={t<string>('Create poll')} /> <div className='str-chat__dialog__body'> <form autoComplete='off'> - <div - className={clsx( - 'str-chat__form__field str-chat__form__input-field str-chat__form__input-field--with-label', - { - 'str-chat__form__input-field--has-error': nameError, - }, - )} - > - <label className='str-chat__form__field-label' htmlFor='name'> - {t<string>('Question')} - </label> - <div className={clsx('str-chat__form__input-field__value')}> - <FieldError - className='str-chat__form__input-field__error' - data-testid={'poll-name-input-field-error'} - text={nameError} - /> - <input - id='name' - onBlur={(e) => { - if (!e.target.value) { - setNameError('The field is required'); - } - }} - onChange={(e) => { - setState((prev) => ({ ...prev, name: e.target.value })); - if (nameError && e.target.value) { - setNameError(undefined); - } - }} - placeholder={t<string>('Ask a question')} - type='text' - value={state.name} - /> - </div> - </div> - <OptionFieldSet - errors={optionsErrors} - options={state.options} - setErrors={setOptionsErrors} - setState={setState} - /> - <div - className={clsx('str-chat__form__expandable-field', { - 'str-chat__form__expandable-field--expanded': !state.enforce_unique_vote, - })} - > - <SimpleSwitchField - checked={!state.enforce_unique_vote} - id='enforce_unique_vote' - labelText={t<string>('Multiple answers')} - onChange={(e) => { - setState((prev) => ({ - ...prev, - enforce_unique_vote: !e.target.checked, - max_votes_allowed: '', - })); - setMultipleAnswerCountError(undefined); - }} - /> - {!state.enforce_unique_vote && ( - <div - className={clsx('str-chat__form__input-field', { - 'str-chat__form__input-field--has-error': multipleAnswerCountError, - })} - > - <div className={clsx('str-chat__form__input-field__value')}> - <FieldError - className='str-chat__form__input-field__error' - data-testid={'poll-max-votes-allowed-input-field-error'} - text={multipleAnswerCountError} - /> - <input - id='max_votes_allowed' - onChange={(e) => { - const isValidValue = - !e.target.value || - e.target.value.match(VALID_MAX_VOTES_VALUE_REGEX); - if (!isValidValue) { - setMultipleAnswerCountError( - t<string>('Type a number from 2 to 10'), - ); - } else if (multipleAnswerCountError) { - setMultipleAnswerCountError(undefined); - } - setState((prev) => ({ - ...prev, - max_votes_allowed: e.target.value, - })); - }} - placeholder={t<string>('Maximum number of votes (from 2 to 10)')} - type='number' - value={state.max_votes_allowed} - /> - </div> - </div> - )} - </div> + <NameField /> + <OptionFieldSet /> + <MultipleAnswersField /> <SimpleSwitchField - checked={state.voting_visibility === 'anonymous'} + checked={voting_visibility === 'anonymous'} id='voting_visibility' labelText={t<string>('Anonymous poll')} onChange={(e) => - setState((prev) => ({ - ...prev, - voting_visibility: (e.target.checked - ? 'anonymous' - : 'public') as VotingVisibility, - })) + pollComposer.updateFields({ + voting_visibility: e.target.checked + ? VotingVisibility.anonymous + : VotingVisibility.public, + }) } /> <SimpleSwitchField - checked={state.allow_user_suggested_options} + checked={allow_user_suggested_options} id='allow_user_suggested_options' labelText={t<string>('Allow option suggestion')} onChange={(e) => - setState((prev) => ({ - ...prev, + pollComposer.updateFields({ allow_user_suggested_options: e.target.checked, - })) + }) } /> <SimpleSwitchField - checked={state.allow_answers} + checked={allow_answers} id='allow_answers' labelText={t<string>('Allow comments')} onChange={(e) => - setState((prev) => ({ ...prev, allow_answers: e.target.checked })) + pollComposer.updateFields({ allow_answers: e.target.checked }) } /> </form> </div> - <PollCreationDialogControls - close={close} - errors={[ - ...(nameError ?? []), - ...(multipleAnswerCountError ?? []), - ...Object.keys(optionsErrors), - ]} - state={state} - /> + <PollCreationDialogControls close={close} /> </div> ); }; diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx index cbec914dd0..0284f9abb3 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx @@ -1,81 +1,48 @@ import React from 'react'; -import { VALID_MAX_VOTES_VALUE_REGEX } from '../constants'; -import { - useChatContext, - useMessageInputContext, - useTranslationContext, -} from '../../../context'; -import type { PollFormState } from './types'; +import { useMessageComposer } from '../../MessageInput'; +import { useMessageInputContext, useTranslationContext } from '../../../context'; +import { useStateStore } from '../../../store'; +import type { PollComposerState } from 'stream-chat'; + +const pollComposerStateSelector = (state: PollComposerState) => ({ + errors: Object.values(state.errors).filter((e) => e), +}); export type PollCreationDialogControlsProps = { close: () => void; - errors: string[]; - state: PollFormState; }; export const PollCreationDialogControls = ({ close, - errors, - state, }: PollCreationDialogControlsProps) => { - const { client } = useChatContext(); const { t } = useTranslationContext('PollCreationDialogControls'); - const { handleSubmit: handleSubmitMessage } = useMessageInputContext( - 'PollCreationDialogControls', + const { handleSubmit: handleSubmitMessage } = useMessageInputContext(); + const messageComposer = useMessageComposer(); + const { errors } = useStateStore( + messageComposer.pollComposer.state, + pollComposerStateSelector, ); - const canSubmit = () => { - const hasAtLeastOneOption = state.options.filter((o) => !!o.text).length > 0; - const hasName = !!state.name; - const maxVotesAllowedNumber = parseInt( - state.max_votes_allowed?.match(VALID_MAX_VOTES_VALUE_REGEX)?.[0] || '', - ); - - const validMaxVotesAllowed = - state.max_votes_allowed === '' || - (!!maxVotesAllowedNumber && - (2 <= maxVotesAllowedNumber || maxVotesAllowedNumber <= 10)); - - const noErrors = errors.length === 0; - - return hasAtLeastOneOption && hasName && validMaxVotesAllowed && noErrors; - }; - return ( <div className='str-chat__dialog__controls'> <button className='str-chat__dialog__controls-button str-chat__dialog__controls-button--cancel' - onClick={close} + onClick={() => { + messageComposer.pollComposer.initState(); + close(); + }} > {t<string>('Cancel')} </button> <button className='str-chat__dialog__controls-button str-chat__dialog__controls-button--submit' - disabled={!canSubmit()} - onClick={async (e) => { - let pollId: string; - try { - const { poll } = await client.createPoll({ - ...state, - max_votes_allowed: state.max_votes_allowed - ? parseInt(state.max_votes_allowed) - : undefined, - options: state.options - ?.filter((o) => o.text) - .map((o) => ({ text: o.text })), - }); - pollId = poll.id; - } catch (e) { - // todo: add notification - return; - } - try { - await handleSubmitMessage(e, { poll_id: pollId }); - } catch (e) { - // todo: add notification - return; - } - close(); + disabled={!messageComposer.pollComposer.canCreatePoll || errors.length > 0} + onClick={() => { + messageComposer + .createPoll() + .then(() => handleSubmitMessage()) + .then(close) + .catch(console.error); }} type='submit' > diff --git a/src/components/Poll/PollCreationDialog/types.ts b/src/components/Poll/PollCreationDialog/types.ts index 25c1734ed0..9ea1509091 100644 --- a/src/components/Poll/PollCreationDialog/types.ts +++ b/src/components/Poll/PollCreationDialog/types.ts @@ -1,5 +1,3 @@ -import type { VotingVisibility } from 'stream-chat'; - type Id = string; export type PollOptionFormData = { @@ -7,18 +5,4 @@ export type PollOptionFormData = { text: string; }; -export type PollFormState = { - id: Id; - max_votes_allowed: string; - name: string; - options: PollOptionFormData[]; - allow_answers?: boolean; - allow_user_suggested_options?: boolean; - description?: string; - enforce_unique_vote?: boolean; - is_closed?: boolean; - user_id?: string; - voting_visibility?: VotingVisibility; -}; - export type OptionErrors = Record<Id, string>; diff --git a/src/components/Poll/constants.ts b/src/components/Poll/constants.ts index f81c63c4aa..06f607ec9d 100644 --- a/src/components/Poll/constants.ts +++ b/src/components/Poll/constants.ts @@ -1,5 +1,3 @@ export const MAX_POLL_OPTIONS = 100 as const; -export const VALID_MAX_VOTES_VALUE_REGEX = /^([2-9]|10)$/; - export const MAX_OPTIONS_DISPLAYED = 10 as const; diff --git a/src/components/ReactFileUtilities/UploadButton.tsx b/src/components/ReactFileUtilities/UploadButton.tsx index d57a87975b..95abafb339 100644 --- a/src/components/ReactFileUtilities/UploadButton.tsx +++ b/src/components/ReactFileUtilities/UploadButton.tsx @@ -9,9 +9,16 @@ import { useMessageInputContext, useTranslationContext, } from '../../context'; - +import { useMessageComposer } from '../MessageInput'; +import { useAttachmentManagerState } from '../MessageInput/hooks/messageComposer/useAttachmentManagerState'; +import { useStateStore } from '../../store'; +import type { MessageComposerConfig } from 'stream-chat'; import type { PartialSelected } from '../../types/types'; +const attachmentManagerConfigStateSelector = (state: MessageComposerConfig) => ({ + maxNumberOfFilesPerMessage: state.attachments.maxNumberOfFilesPerMessage, +}); + /** * @deprecated Use FileInputProps instead. */ @@ -46,18 +53,23 @@ export const UploadFileInput = forwardRef(function UploadFileInput( ref: React.ForwardedRef<HTMLInputElement>, ) { const { t } = useTranslationContext('UploadFileInput'); - const { acceptedFiles = [], multipleUploads } = - useChannelStateContext('UploadFileInput'); - const { isUploadEnabled, maxFilesLeft, uploadNewFiles } = - useMessageInputContext('UploadFileInput'); + const { cooldownRemaining } = useMessageInputContext(); + const { acceptedFiles = [] } = useChannelStateContext('UploadFileInput'); + const messageComposer = useMessageComposer(); + const { attachmentManager } = messageComposer; + const { isUploadEnabled } = useAttachmentManagerState(); + const { maxNumberOfFilesPerMessage } = useStateStore( + messageComposer.configState, + attachmentManagerConfigStateSelector, + ); const id = useMemo(() => nanoid(), []); const onFileChange = useCallback( (files: Array<File>) => { - uploadNewFiles(files); + attachmentManager.uploadFiles(files); onFileChangeCustom?.(files); }, - [onFileChangeCustom, uploadNewFiles], + [onFileChangeCustom, attachmentManager], ); return ( @@ -65,9 +77,9 @@ export const UploadFileInput = forwardRef(function UploadFileInput( accept={acceptedFiles?.join(',')} aria-label={t('aria/File upload')} data-testid='file-input' - disabled={!isUploadEnabled || maxFilesLeft === 0} + disabled={!isUploadEnabled || !!cooldownRemaining} id={id} - multiple={multipleUploads} + multiple={maxNumberOfFilesPerMessage > 1} {...props} className={clsx('str-chat__file-input', className)} onFileChange={onFileChange} diff --git a/src/components/CommandItem/CommandItem.tsx b/src/components/TextAreaComposer/SuggestionList/CommandItem.tsx similarity index 77% rename from src/components/CommandItem/CommandItem.tsx rename to src/components/TextAreaComposer/SuggestionList/CommandItem.tsx index bc040eda37..4a66609758 100644 --- a/src/components/CommandItem/CommandItem.tsx +++ b/src/components/TextAreaComposer/SuggestionList/CommandItem.tsx @@ -12,7 +12,7 @@ export type CommandItemProps = { }; }; -const UnMemoizedCommandItem = (props: PropsWithChildren<CommandItemProps>) => { +export const CommandItem = (props: PropsWithChildren<CommandItemProps>) => { const { entity } = props; return ( @@ -25,7 +25,3 @@ const UnMemoizedCommandItem = (props: PropsWithChildren<CommandItemProps>) => { </div> ); }; - -export const CommandItem = React.memo( - UnMemoizedCommandItem, -) as typeof UnMemoizedCommandItem; diff --git a/src/components/TextAreaComposer/SuggestionList/EmoticonItem.tsx b/src/components/TextAreaComposer/SuggestionList/EmoticonItem.tsx new file mode 100644 index 0000000000..8eacbee578 --- /dev/null +++ b/src/components/TextAreaComposer/SuggestionList/EmoticonItem.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +export type EmoticonItemProps = { + entity: { + /** Name for emoticon */ + name: string; + /** Native value or actual emoticon */ + native: string; + /** The parts of the Name property of the entity (or id if no name) that can be matched to the user input value. + * Default is bold for matches, but can be overwritten in css. + * */ + tokenizedDisplayName: { token: string; parts: string[] }; + }; +}; + +export const EmoticonItem = (props: EmoticonItemProps) => { + const { entity } = props; + const hasEntity = Object.keys(entity).length; + if (!hasEntity) return null; + + const { parts, token } = entity.tokenizedDisplayName ?? ({} as EmoticonItemProps); + + const renderName = () => + parts?.map((part, i) => + part.toLowerCase() === token ? ( + <span className='str-chat__emoji-item--highlight' key={`part-${i}`}> + {part} + </span> + ) : ( + <span className='str-chat__emoji-item--part' key={`part-${i}`}> + {part} + </span> + ), + ) ?? null; + + return ( + <div className='str-chat__emoji-item'> + <span className='str-chat__emoji-item--entity'>{entity.native}</span> + <span className='str-chat__emoji-item--name'>{renderName()}</span> + </div> + ); +}; diff --git a/src/components/TextAreaComposer/SuggestionList/SuggestionList.tsx b/src/components/TextAreaComposer/SuggestionList/SuggestionList.tsx new file mode 100644 index 0000000000..6ce5dc2e1b --- /dev/null +++ b/src/components/TextAreaComposer/SuggestionList/SuggestionList.tsx @@ -0,0 +1,104 @@ +import clsx from 'clsx'; +import React, { useEffect, useState } from 'react'; +import { CommandItem } from './CommandItem'; +import { EmoticonItem } from './EmoticonItem'; +import { SuggestionListItem as DefaultSuggestionListItem } from './SuggestionListItem'; +import { UserItem } from './UserItem'; +import { useComponentContext } from '../../../context/ComponentContext'; +import { useStateStore } from '../../../store'; +import { InfiniteScrollPaginator } from '../../InfiniteScrollPaginator/InfiniteScrollPaginator'; +import { useMessageComposer } from '../../MessageInput'; +import type { + SearchSourceState, + TextComposerState, + TextComposerSuggestion, +} from 'stream-chat'; +import type { SuggestionItemProps } from './SuggestionListItem'; + +export type SuggestionListProps = Partial<{ + SuggestionItem: React.ComponentType<SuggestionItemProps>; + className?: string; + closeOnClickOutside?: boolean; + containerClassName?: string; + focusedItemIndex: number; + setFocusedItemIndex: (index: number) => void; +}>; + +const textComposerStateSelector = (state: TextComposerState) => ({ + suggestions: state.suggestions, +}); + +const searchSourceStateSelector = ( + nextValue: SearchSourceState<TextComposerSuggestion>, +): { items: TextComposerSuggestion[] } => ({ + items: nextValue.items ?? [], +}); + +export const defaultComponents = { + '/': CommandItem, + ':': EmoticonItem, + '@': UserItem, +}; + +export const SuggestionList = ({ + className, + closeOnClickOutside = true, + containerClassName, + focusedItemIndex, + setFocusedItemIndex, +}: SuggestionListProps) => { + const { AutocompleteSuggestionItem = DefaultSuggestionListItem } = + useComponentContext(); + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { suggestions } = useStateStore(textComposer.state, textComposerStateSelector); + const { items } = + useStateStore(suggestions?.searchSource.state, searchSourceStateSelector) ?? {}; + const [container, setContainer] = useState<HTMLDivElement | null>(null); + + // @ts-expect-error component type mismatch + const component = suggestions?.trigger && defaultComponents[suggestions?.trigger]; + + useEffect(() => { + if (!closeOnClickOutside || !suggestions || !container) return; + const handleClick = (event: MouseEvent) => { + if (container.contains(event.target as Node)) return; + textComposer.closeSuggestions(); + }; + document.addEventListener('click', handleClick); + return () => { + document.removeEventListener('click', handleClick); + }; + }, [closeOnClickOutside, suggestions, container, textComposer]); + + if (!suggestions || !items?.length || !component) return null; + + return ( + <div + className={clsx('str-chat__suggestion-list-container', containerClassName)} + ref={setContainer} + > + <InfiniteScrollPaginator + loadNextOnScrollToBottom={suggestions.searchSource.search} + threshold={100} + > + <ul + className={clsx( + 'str-chat__suggestion-list str-chat__suggestion-list--react', + className, + )} + > + {items.map((item, i) => ( + <AutocompleteSuggestionItem + component={component} + focused={focusedItemIndex === i} + item={item} + key={item.id.toString()} + onMouseEnter={() => setFocusedItemIndex?.(i)} + /> + ))} + </ul> + </InfiniteScrollPaginator> + </div> + ); +}; diff --git a/src/components/TextAreaComposer/SuggestionList/SuggestionListItem.tsx b/src/components/TextAreaComposer/SuggestionList/SuggestionListItem.tsx new file mode 100644 index 0000000000..eb90e1b191 --- /dev/null +++ b/src/components/TextAreaComposer/SuggestionList/SuggestionListItem.tsx @@ -0,0 +1,65 @@ +import clsx from 'clsx'; +import type { Ref } from 'react'; +import { useLayoutEffect } from 'react'; +import React, { useCallback, useRef } from 'react'; +import { useMessageComposer } from '../../MessageInput'; +import type { CommandResponse, TextComposerSuggestion, UserResponse } from 'stream-chat'; +import type { EmojiSearchIndexResult } from '../../MessageInput'; + +export type SuggestionCommand = CommandResponse; +export type SuggestionUser = UserResponse; +export type SuggestionEmoji = EmojiSearchIndexResult; +export type SuggestionItem = SuggestionUser | SuggestionCommand | SuggestionEmoji; + +export type SuggestionItemProps = { + component: React.ComponentType<{ + entity: SuggestionItem; + focused: boolean; + }>; + item: TextComposerSuggestion; + focused: boolean; + className?: string; + onMouseEnter?: () => void; +}; + +export const SuggestionListItem = React.forwardRef< + HTMLButtonElement, + SuggestionItemProps +>(function SuggestionListItem( + { className, component: Component, focused, item, onMouseEnter }: SuggestionItemProps, + innerRef: Ref<HTMLButtonElement>, +) { + const { textComposer } = useMessageComposer(); + const containerRef = useRef<HTMLLIElement>(null); + + const handleSelect = useCallback(() => { + textComposer.handleSelect(item); + }, [item, textComposer]); + + useLayoutEffect(() => { + if (!focused) return; + containerRef.current?.scrollIntoView({ behavior: 'instant', block: 'nearest' }); + }, [focused, containerRef]); + + return ( + <li + className={clsx('str-chat__suggestion-list-item', className, { + 'str-chat__suggestion-item--selected': focused, + })} + onMouseEnter={onMouseEnter} + ref={containerRef} + > + <button + onClick={handleSelect} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleSelect(); + } + }} + ref={innerRef} + > + <Component entity={item} focused={focused} /> + </button> + </li> + ); +}); diff --git a/src/components/UserItem/UserItem.tsx b/src/components/TextAreaComposer/SuggestionList/UserItem.tsx similarity index 71% rename from src/components/UserItem/UserItem.tsx rename to src/components/TextAreaComposer/SuggestionList/UserItem.tsx index d61a23afcf..247bdd91c6 100644 --- a/src/components/UserItem/UserItem.tsx +++ b/src/components/TextAreaComposer/SuggestionList/UserItem.tsx @@ -1,8 +1,8 @@ import React from 'react'; import clsx from 'clsx'; -import type { AvatarProps } from '../Avatar'; -import { Avatar as DefaultAvatar } from '../Avatar'; +import type { AvatarProps } from '../../Avatar'; +import { Avatar as DefaultAvatar } from '../../Avatar'; export type UserItemProps = { /** The user */ @@ -10,7 +10,7 @@ export type UserItemProps = { /** The parts of the Name property of the entity (or id if no name) that can be matched to the user input value. * Default is bold for matches, but can be overwritten in css. * */ - itemNameParts: { match: string; parts: string[] }; + tokenizedDisplayName: { token: string; parts: string[] }; /** Id of the user */ id?: string; /** Image of the user */ @@ -25,16 +25,16 @@ export type UserItemProps = { /** * UI component for mentions rendered in suggestion list */ -const UnMemoizedUserItem = ({ Avatar = DefaultAvatar, entity }: UserItemProps) => { +export const UserItem = ({ Avatar = DefaultAvatar, entity }: UserItemProps) => { const hasEntity = !!Object.keys(entity).length; - const itemParts = entity?.itemNameParts; + if (!hasEntity) return null; - const renderName = () => { - if (!hasEntity) return null; - - return itemParts.parts.map((part, i) => { - const matches = part.toLowerCase() === itemParts.match.toLowerCase(); + const { parts, token } = entity.tokenizedDisplayName; + const renderName = () => + parts.map((part, i) => { + const matches = part.toLowerCase() === token; + const partWithHTMLSpacesAround = part.replace(/^\s+|\s+$/g, '\u00A0'); return ( <span className={clsx({ @@ -43,11 +43,10 @@ const UnMemoizedUserItem = ({ Avatar = DefaultAvatar, entity }: UserItemProps) = })} key={`part-${i}`} > - {part} + {partWithHTMLSpacesAround} </span> ); }); - }; return ( <div className='str-chat__user-item'> @@ -63,5 +62,3 @@ const UnMemoizedUserItem = ({ Avatar = DefaultAvatar, entity }: UserItemProps) = </div> ); }; - -export const UserItem = React.memo(UnMemoizedUserItem) as typeof UnMemoizedUserItem; diff --git a/src/components/TextAreaComposer/SuggestionList/index.ts b/src/components/TextAreaComposer/SuggestionList/index.ts new file mode 100644 index 0000000000..e064a452e4 --- /dev/null +++ b/src/components/TextAreaComposer/SuggestionList/index.ts @@ -0,0 +1,5 @@ +export * from './CommandItem'; +export * from './EmoticonItem'; +export * from './SuggestionList'; +export * from './SuggestionListItem'; +export * from './UserItem'; diff --git a/src/components/TextAreaComposer/TextAreaComposer.tsx b/src/components/TextAreaComposer/TextAreaComposer.tsx new file mode 100644 index 0000000000..b55f2d8256 --- /dev/null +++ b/src/components/TextAreaComposer/TextAreaComposer.tsx @@ -0,0 +1,272 @@ +import clsx from 'clsx'; +import type { ChangeEventHandler, TextareaHTMLAttributes, UIEventHandler } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Textarea from 'react-textarea-autosize'; +import { useMessageComposer } from '../MessageInput'; +import type { SearchSourceState, TextComposerState } from 'stream-chat'; +import { + useComponentContext, + useMessageInputContext, + useTranslationContext, +} from '../../context'; +import { useStateStore } from '../../store'; +import { SuggestionList as DefaultSuggestionList } from './SuggestionList'; + +const textComposerStateSelector = (state: TextComposerState) => ({ + selection: state.selection, + suggestions: state.suggestions, + text: state.text, +}); + +const searchSourceStateSelector = (state: SearchSourceState) => ({ + isLoadingItems: state.isLoading, + items: state.items, +}); + +/** + * isComposing prevents double submissions in Korean and other languages. + * starting point for a read: + * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/isComposing + * In the long term, the fix should happen by handling keypress, but changing this has unknown implications. + */ +const defaultShouldSubmit = (event: React.KeyboardEvent<HTMLTextAreaElement>) => + event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing; + +/** + * TODO: X + * - disableMentions - just do not provide mentions middleware + * - style props - just use CSS + * - what was loadingComponent prop for? + * - do we want to keep movePopupAsYouType prop? + * - do we want to keep onCaretPositionChange prop? + * - scrollToItem prop was what for? - removing it todo: document it + */ +export type TextComposerProps = Omit< + TextareaHTMLAttributes<HTMLTextAreaElement>, + 'style' | 'defaultValue' +> & { + closeSuggestionsOnClickOutside?: boolean; + containerClassName?: string; + dropdownClassName?: string; + grow?: boolean; + itemClassName?: string; + listClassName?: string; + maxRows?: number; + shouldSubmit?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => boolean; +}; + +export const TextAreaComposer = ({ + className, + closeSuggestionsOnClickOutside, + containerClassName, + disabled, + // dropdownClassName, // todo: X find a different way to prevent prop drilling + grow: growProp, + // itemClassName, // todo: X find a different way to prevent prop drilling + listClassName, + maxRows: maxRowsProp = 1, + onBlur, + onChange, + onKeyDown, + onScroll, + placeholder: placeholderProp, + shouldSubmit: shouldSubmitProp, + ...restProps +}: TextComposerProps) => { + const { t } = useTranslationContext(); + const { AutocompleteSuggestionList = DefaultSuggestionList } = useComponentContext(); + const { + additionalTextareaProps, + cooldownRemaining, + grow: growContext, + handleSubmit, + maxRows: maxRowsContext, + onPaste, + shouldSubmit: shouldSubmitContext, + textareaRef, + } = useMessageInputContext(); + + const grow = growProp ?? growContext; + const maxRows = maxRowsProp ?? maxRowsContext; + const placeholder = placeholderProp ?? additionalTextareaProps?.placeholder; + const shouldSubmit = shouldSubmitProp ?? shouldSubmitContext ?? defaultShouldSubmit; + + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { selection, suggestions, text } = useStateStore( + textComposer.state, + textComposerStateSelector, + ); + + const { isLoadingItems } = + useStateStore(suggestions?.searchSource.state, searchSourceStateSelector) ?? {}; + + const containerRef = useRef<HTMLDivElement>(null); + const [focusedItemIndex, setFocusedItemIndex] = useState(0); + + const [isComposing, setIsComposing] = useState(false); + + const changeHandler: ChangeEventHandler<HTMLTextAreaElement> = useCallback( + (e) => { + if (onChange) { + onChange(e); + return; + } + if (!textareaRef.current) return; + textComposer.handleChange({ + selection: { + end: textareaRef.current.selectionEnd, + start: textareaRef.current.selectionStart, + }, + text: e.target.value, + }); + }, + [onChange, textComposer, textareaRef], + ); + + const onCompositionEnd = useCallback(() => { + setIsComposing(false); + }, []); + + const onCompositionStart = useCallback(() => { + setIsComposing(true); + }, []); + + const keyDownHandler = useCallback( + (event: React.KeyboardEvent<HTMLTextAreaElement>) => { + if (onKeyDown) { + onKeyDown(event); + return; + } + + if (event.key === 'Enter') { + // allow next line only on Shift + Enter. Enter is reserved for submission. + event.preventDefault(); + } + + if ( + textComposer.suggestions && + textComposer.suggestions.searchSource.items?.length + ) { + if (event.key === 'Escape') return textComposer.closeSuggestions(); + const loadedItems = textComposer.suggestions.searchSource.items; + if (event.key === 'Enter') { + textComposer.handleSelect(loadedItems[focusedItemIndex]); + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + setFocusedItemIndex((prev) => { + let nextIndex = prev - 1; + if (suggestions?.searchSource.hasNext) { + nextIndex = prev; + } else if (nextIndex < 0) { + nextIndex = loadedItems.length - 1; + } + return nextIndex; + }); + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + setFocusedItemIndex((prev) => { + let nextIndex = prev + 1; + if (suggestions?.searchSource.hasNext) { + nextIndex = prev; + } else if (nextIndex >= loadedItems.length) { + nextIndex = 0; + } + + return nextIndex; + }); + } + } else if (shouldSubmit(event) && textareaRef.current) { + handleSubmit(); + textareaRef.current.selectionEnd = 0; + } + }, + [ + focusedItemIndex, + handleSubmit, + onKeyDown, + shouldSubmit, + suggestions, + textComposer, + textareaRef, + ], + ); + + const scrollHandler: UIEventHandler<HTMLTextAreaElement> = useCallback( + (event) => { + if (onScroll) { + onScroll(event); + } else { + textComposer.closeSuggestions(); + } + }, + [onScroll, textComposer], + ); + + useEffect(() => { + // FIXME: find the real reason for cursor being set to the end on each change + // This is a workaround to prevent the cursor from jumping + // to the end of the textarea when the user is typing + // at the position that is not at the end of the textarea value. + if (textareaRef.current && !isComposing) { + textareaRef.current.selectionStart = selection.start; + textareaRef.current.selectionEnd = selection.end; + } + }, [text, textareaRef, selection.start, selection.end, isComposing]); + + useEffect(() => { + if (textComposer.suggestions) { + setFocusedItemIndex(0); + } + }, [textComposer.suggestions]); + + return ( + <div + className={clsx( + 'rta', + 'str-chat__textarea str-chat__message-textarea-react-host', + containerClassName, + { + ['rta--loading']: isLoadingItems, + }, + )} + ref={containerRef} + > + <Textarea + {...restProps} + aria-label={cooldownRemaining ? t('Slow Mode ON') : placeholder} + className={clsx( + 'rta__textarea', + 'str-chat__textarea__textarea str-chat__message-textarea', + className, + )} + data-testid='message-input' + disabled={disabled || !!cooldownRemaining} + maxRows={grow ? maxRows : 1} + onBlur={onBlur} + onChange={changeHandler} + onCompositionEnd={onCompositionEnd} + onCompositionStart={onCompositionStart} + onKeyDown={keyDownHandler} + onPaste={onPaste} + onScroll={scrollHandler} + placeholder={placeholder || t('Type your message')} + ref={(ref) => { + textareaRef.current = ref; + }} + value={text} + /> + {/* todo: X document the layout change for the accessibility purpose (tabIndex) */} + {!isComposing && ( + <AutocompleteSuggestionList + className={listClassName} + closeOnClickOutside={closeSuggestionsOnClickOutside} + focusedItemIndex={focusedItemIndex} + setFocusedItemIndex={setFocusedItemIndex} + /> + )} + </div> + ); +}; diff --git a/src/components/CommandItem/__tests__/CommandItem.test.js b/src/components/TextAreaComposer/__tests__/CommandItem.test.js similarity index 97% rename from src/components/CommandItem/__tests__/CommandItem.test.js rename to src/components/TextAreaComposer/__tests__/CommandItem.test.js index df962b7384..3d131cac61 100644 --- a/src/components/CommandItem/__tests__/CommandItem.test.js +++ b/src/components/TextAreaComposer/__tests__/CommandItem.test.js @@ -3,7 +3,7 @@ import React from 'react'; import { cleanup, render } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { CommandItem } from '../CommandItem'; +import { CommandItem } from '../SuggestionList'; afterEach(cleanup); diff --git a/src/components/EmoticonItem/__tests__/EmoticonItem.test.js b/src/components/TextAreaComposer/__tests__/EmoticonItem.test.js similarity index 72% rename from src/components/EmoticonItem/__tests__/EmoticonItem.test.js rename to src/components/TextAreaComposer/__tests__/EmoticonItem.test.js index d6d185a778..36d7d4b64d 100644 --- a/src/components/EmoticonItem/__tests__/EmoticonItem.test.js +++ b/src/components/TextAreaComposer/__tests__/EmoticonItem.test.js @@ -3,34 +3,21 @@ import React from 'react'; import { cleanup, render, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { EmoticonItem } from '../EmoticonItem'; +import { EmoticonItem } from '../SuggestionList'; afterEach(cleanup); describe('EmoticonItem', () => { - it('should render component with empty entity', () => { + it('should not render component with empty entity', () => { const { container } = render(<EmoticonItem entity={{}} />); - expect(container).toMatchInlineSnapshot(` - <div> - <div - class="str-chat__emoji-item" - > - <span - class="str-chat__emoji-item--entity" - /> - <span - class="str-chat__emoji-item--name" - /> - </div> - </div> - `); + expect(container).toBeEmptyDOMElement(); }); it('should render component with custom entity prop', async () => { const entity = { - itemNameParts: { match: 'n', parts: ['n', 'ame'] }, name: 'name', native: 'native', + tokenizedDisplayName: { parts: ['n', 'ame'], token: 'n' }, }; const Component = <EmoticonItem entity={entity} />; diff --git a/src/components/UserItem/__tests__/UserItem.test.js b/src/components/TextAreaComposer/__tests__/UserItem.test.js similarity index 98% rename from src/components/UserItem/__tests__/UserItem.test.js rename to src/components/TextAreaComposer/__tests__/UserItem.test.js index 0c8207cccc..baf2eeaf22 100644 --- a/src/components/UserItem/__tests__/UserItem.test.js +++ b/src/components/TextAreaComposer/__tests__/UserItem.test.js @@ -6,7 +6,7 @@ import { toHaveNoViolations } from 'jest-axe'; import { axe } from '../../../../axe-helper'; expect.extend(toHaveNoViolations); -import { UserItem } from '../UserItem'; +import { UserItem } from '../SuggestionList'; afterEach(cleanup); diff --git a/src/components/TextAreaComposer/index.ts b/src/components/TextAreaComposer/index.ts new file mode 100644 index 0000000000..e48da55bbd --- /dev/null +++ b/src/components/TextAreaComposer/index.ts @@ -0,0 +1,2 @@ +export * from './SuggestionList'; +export * from './TextAreaComposer'; diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index 569d0ffa2f..8dfb5fbb5c 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import clsx from 'clsx'; import { MESSAGE_ACTIONS } from '../Message'; @@ -20,13 +20,11 @@ import { useStateStore } from '../../store'; import type { MessageProps, MessageUIComponentProps } from '../Message/types'; import type { MessageActionsArray } from '../Message/utils'; +import type { LocalMessage, ThreadState } from 'stream-chat'; -import type { CustomTrigger } from '../../types/types'; -import type { ThreadState } from 'stream-chat'; - -export type ThreadProps<V extends CustomTrigger = CustomTrigger> = { +export type ThreadProps = { /** Additional props for `MessageInput` component: [available props](https://getstream.io/chat/docs/sdk/react/message-input-components/message_input/#props) */ - additionalMessageInputProps?: MessageInputProps<V>; + additionalMessageInputProps?: MessageInputProps; /** Additional props for `MessageList` component: [available props](https://getstream.io/chat/docs/sdk/react/core-components/message_list/#props) */ additionalMessageListProps?: MessageListProps; /** Additional props for `Message` component of the parent message: [available props](https://getstream.io/chat/docs/sdk/react/message-components/message/#props) */ @@ -47,16 +45,21 @@ export type ThreadProps<V extends CustomTrigger = CustomTrigger> = { virtualized?: boolean; }; +const LegacyThreadContext = React.createContext<{ + legacyThread: LocalMessage | undefined; +}>({ legacyThread: undefined }); + +export const useLegacyThreadContext = () => useContext(LegacyThreadContext); + /** * The Thread component renders a parent Message with a list of replies */ -export const Thread = <V extends CustomTrigger = CustomTrigger>( - props: ThreadProps<V>, -) => { +export const Thread = (props: ThreadProps) => { const { channel, channelConfig, thread } = useChannelStateContext('Thread'); const threadInstance = useThreadContext(); - if ((!thread && !threadInstance) || channelConfig?.replies === false) return null; + if (!thread && !threadInstance) return null; + if (channelConfig?.replies === false) return null; // the wrapper ensures a key variable is set and the component recreates on thread switch return ( @@ -75,9 +78,7 @@ const selector = (nextValue: ThreadState) => ({ replies: nextValue.replies, }); -const ThreadInner = <V extends CustomTrigger = CustomTrigger>( - props: ThreadProps<V> & { key: string }, -) => { +const ThreadInner = (props: ThreadProps & { key: string }) => { const { additionalMessageInputProps, additionalMessageListProps, @@ -92,8 +93,6 @@ const ThreadInner = <V extends CustomTrigger = CustomTrigger>( } = props; const threadInstance = useThreadContext(); - const { isLoadingNext, isLoadingPrev, parentMessage, replies } = - useStateStore(threadInstance?.state, selector) ?? {}; const { thread, @@ -112,6 +111,9 @@ const ThreadInner = <V extends CustomTrigger = CustomTrigger>( VirtualMessage, } = useComponentContext('Thread'); + const { isLoadingNext, isLoadingPrev, parentMessage, replies } = + useStateStore(threadInstance?.state, selector) ?? {}; + const ThreadInput = PropInput ?? additionalMessageInputProps?.Input ?? ContextInput ?? MessageInputFlat; @@ -122,11 +124,13 @@ const ThreadInner = <V extends CustomTrigger = CustomTrigger>( const ThreadMessageList = virtualized ? VirtualizedMessageList : MessageList; useEffect(() => { - if (thread?.id && thread?.reply_count) { + if (threadInstance) return; + + if ((thread?.reply_count ?? 0) > 0) { // FIXME: integrators can customize channel query options but cannot customize channel.getReplies() options loadMoreThread(); } - }, [thread, loadMoreThread]); + }, [thread, loadMoreThread, threadInstance]); const threadProps: Pick< VirtualizedMessageListProps, @@ -172,28 +176,35 @@ const ThreadInner = <V extends CustomTrigger = CustomTrigger>( ); return ( - <div className={threadClass}> - <ThreadHeader closeThread={closeThread} thread={messageAsThread} /> - <ThreadMessageList - disableDateSeparator={!enableDateSeparator} - head={head} - Message={MessageUIComponent} - messageActions={messageActions} - suppressAutoscroll={threadSuppressAutoscroll} - threadList - {...threadProps} - {...(virtualized - ? additionalVirtualizedMessageListProps - : additionalMessageListProps)} - /> - <MessageInput - focus={autoFocus} - Input={ThreadInput} - isThreadInput - parent={thread ?? parentMessage} - publishTypingEvent={false} - {...additionalMessageInputProps} - /> - </div> + // Thread component needs a context which we can use for message composer + <LegacyThreadContext.Provider + value={{ + legacyThread: thread ?? undefined, + }} + > + <div className={threadClass}> + <ThreadHeader closeThread={closeThread} thread={messageAsThread} /> + <ThreadMessageList + disableDateSeparator={!enableDateSeparator} + head={head} + Message={MessageUIComponent} + messageActions={messageActions} + suppressAutoscroll={threadSuppressAutoscroll} + threadList + {...threadProps} + {...(virtualized + ? additionalVirtualizedMessageListProps + : additionalMessageListProps)} + /> + <MessageInput + focus={autoFocus} + Input={ThreadInput} + isThreadInput + parent={thread ?? parentMessage} + publishTypingEvent={false} + {...additionalMessageInputProps} + /> + </div> + </LegacyThreadContext.Provider> ); }; diff --git a/src/components/Thread/ThreadHeader.tsx b/src/components/Thread/ThreadHeader.tsx index 903bbac9a5..cc5dce7bd1 100644 --- a/src/components/Thread/ThreadHeader.tsx +++ b/src/components/Thread/ThreadHeader.tsx @@ -1,18 +1,19 @@ import React from 'react'; -import type { ChannelPreviewInfoParams } from '../ChannelPreview/hooks/useChannelPreviewInfo'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; import { CloseIcon } from './icons'; -import type { StreamMessage } from '../../context/ChannelStateContext'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useTranslationContext } from '../../context/TranslationContext'; +import type { ChannelPreviewInfoParams } from '../ChannelPreview/hooks/useChannelPreviewInfo'; +import type { LocalMessage } from 'stream-chat'; + export type ThreadHeaderProps = { /** Callback for closing the thread */ closeThread: (event?: React.BaseSyntheticEvent) => void; /** The thread parent message */ - thread: StreamMessage; + thread: LocalMessage; }; export const ThreadHeader = ( diff --git a/src/components/Thread/__tests__/Thread.test.js b/src/components/Thread/__tests__/Thread.test.js index 77811e5d7e..4d97c2319f 100644 --- a/src/components/Thread/__tests__/Thread.test.js +++ b/src/components/Thread/__tests__/Thread.test.js @@ -330,6 +330,12 @@ describe('Thread', () => { expect(channelActionContextMock.loadMoreThread).toHaveBeenCalledTimes(1); }); + it('should not call the loadMoreThread callback on mount if the thread start has a non-zero reply count but threadInstance is provided', () => { + renderComponent({ channelStateOverrides: { threadInstance: {} }, chatClient }); + + expect(channelActionContextMock.loadMoreThread).not.toHaveBeenCalled(); + }); + it('should render null if replies is disabled', async () => { const client = await getTestClientWithUser(); const ch = generateChannel({ getConfig: () => ({ replies: false }) }); diff --git a/src/components/Threads/ThreadContext.tsx b/src/components/Threads/ThreadContext.tsx index 39925eae96..e18dae2d41 100644 --- a/src/components/Threads/ThreadContext.tsx +++ b/src/components/Threads/ThreadContext.tsx @@ -9,11 +9,7 @@ export type ThreadContextValue = Thread | undefined; export const ThreadContext = createContext<ThreadContextValue>(undefined); -export const useThreadContext = () => { - const thread = useContext(ThreadContext); - - return thread ?? undefined; -}; +export const useThreadContext = () => useContext(ThreadContext); export const ThreadProvider = ({ children, diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index 8694b57103..76692e282b 100644 --- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx +++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react'; import clsx from 'clsx'; -import type { FormatMessageResponse, ThreadState } from 'stream-chat'; +import type { LocalMessage, ThreadState } from 'stream-chat'; import type { ComponentPropsWithoutRef } from 'react'; import { Timestamp } from '../../Message/Timestamp'; @@ -36,7 +36,7 @@ const getTitleFromMessage = ({ message, }: { currentUserId?: string; - message?: FormatMessageResponse; + message?: LocalMessage; }) => { const attachment = message?.attachments?.at(0); diff --git a/src/components/UserItem/index.ts b/src/components/UserItem/index.ts deleted file mode 100644 index 0d1af3af8b..0000000000 --- a/src/components/UserItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './UserItem'; diff --git a/src/components/Window/Window.tsx b/src/components/Window/Window.tsx index fdc873e22a..e8b0ffd786 100644 --- a/src/components/Window/Window.tsx +++ b/src/components/Window/Window.tsx @@ -2,12 +2,12 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; import clsx from 'clsx'; -import type { StreamMessage } from '../../context/ChannelStateContext'; +import type { LocalMessage } from 'stream-chat'; import { useChannelStateContext } from '../../context/ChannelStateContext'; export type WindowProps = { /** optional prop to force addition of class str-chat__main-panel---with-thread-opn to the Window root element */ - thread?: StreamMessage; + thread?: LocalMessage; }; const UnMemoizedWindow = (props: PropsWithChildren<WindowProps>) => { diff --git a/src/components/index.ts b/src/components/index.ts index e34e166189..ac28c96264 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,11 +7,8 @@ export * from './ChannelList'; export * from './ChannelPreview'; export * from './ChannelSearch'; export * from './Chat'; -export * from './ChatAutoComplete'; -export * from './CommandItem'; export * from './DateSeparator'; export * from './Dialog'; -export * from './EmoticonItem'; export * from './EmptyStateIndicator'; export * from './EventComponent'; export * from './Gallery'; @@ -29,10 +26,10 @@ export * from './Modal'; export * from './Poll'; export * from './Reactions'; export * from './SafeAnchor'; +export * from './TextAreaComposer'; export * from './Thread'; export * from './Tooltip'; export * from './TypingIndicator'; -export * from './UserItem'; export * from './Window'; export * from './Threads'; export * from './ChatView'; diff --git a/src/context/ChannelActionContext.tsx b/src/context/ChannelActionContext.tsx index e4c74eec92..e7ed002e99 100644 --- a/src/context/ChannelActionContext.tsx +++ b/src/context/ChannelActionContext.tsx @@ -2,27 +2,18 @@ import type { PropsWithChildren } from 'react'; import React, { useContext } from 'react'; import type { - APIErrorResponse, - Attachment, - ErrorFromResponse, + LocalMessage, Message, MessageResponse, - UpdatedMessage, + SendMessageOptions, UpdateMessageAPIResponse, - UserResponse, + UpdateMessageOptions, } from 'stream-chat'; -import type { StreamMessage } from './ChannelStateContext'; - import type { ChannelStateReducerAction } from '../components/Channel/channelState'; import type { CustomMentionHandler } from '../components/Message/hooks/useMentionsHandler'; -import type { - ChannelUnreadUiState, - SendMessageOptions, - UnknownType, - UpdateMessageOptions, -} from '../types/types'; +import type { ChannelUnreadUiState, UnknownType } from '../types/types'; export type MarkReadWrapperOptions = { /** @@ -33,29 +24,15 @@ export type MarkReadWrapperOptions = { updateChannelUiUnreadState?: boolean; }; -export type MessageAttachments = Array<Attachment>; - -export type MessageToSend = { - attachments?: MessageAttachments; - error?: ErrorFromResponse<APIErrorResponse>; - errorStatusCode?: number; - id?: string; - mentioned_users?: UserResponse[]; - parent?: StreamMessage; - parent_id?: string; - status?: string; - text?: string; -}; - -export type RetrySendMessage = (message: StreamMessage) => Promise<void>; +export type RetrySendMessage = (message: LocalMessage) => Promise<void>; export type ChannelActionContextValue = { addNotification: (text: string, type: 'success' | 'error') => void; closeThread: (event?: React.BaseSyntheticEvent) => void; - deleteMessage: (message: StreamMessage) => Promise<MessageResponse>; + deleteMessage: (message: LocalMessage) => Promise<MessageResponse>; dispatch: React.Dispatch<ChannelStateReducerAction>; editMessage: ( - message: UpdatedMessage, + message: LocalMessage | MessageResponse, options?: UpdateMessageOptions, ) => Promise<UpdateMessageAPIResponse | void>; jumpToFirstUnreadMessage: ( @@ -74,19 +51,18 @@ export type ChannelActionContextValue = { markRead: (options?: MarkReadWrapperOptions) => void; onMentionsClick: CustomMentionHandler; onMentionsHover: CustomMentionHandler; - openThread: (message: StreamMessage, event?: React.BaseSyntheticEvent) => void; - removeMessage: (message: StreamMessage) => void; + openThread: (message: LocalMessage, event?: React.BaseSyntheticEvent) => void; + removeMessage: (message: LocalMessage) => void; retrySendMessage: RetrySendMessage; - sendMessage: ( - message: MessageToSend, - customMessageData?: Partial<Message>, - options?: SendMessageOptions, - ) => Promise<void>; + sendMessage: (params: { + localMessage: LocalMessage; + message: Message; + options?: SendMessageOptions; + }) => Promise<void>; setChannelUnreadUiState: React.Dispatch< React.SetStateAction<ChannelUnreadUiState | undefined> >; - setQuotedMessage: React.Dispatch<React.SetStateAction<StreamMessage | undefined>>; - updateMessage: (message: StreamMessage) => void; + updateMessage: (message: MessageResponse | LocalMessage) => void; }; export const ChannelActionContext = React.createContext< diff --git a/src/context/ChannelStateContext.tsx b/src/context/ChannelStateContext.tsx index 5c4696a8bd..850665ea6d 100644 --- a/src/context/ChannelStateContext.tsx +++ b/src/context/ChannelStateContext.tsx @@ -1,11 +1,9 @@ -import React, { useContext } from 'react'; import type { PropsWithChildren } from 'react'; +import React, { useContext } from 'react'; import type { - APIErrorResponse, Channel, ChannelConfigWithInfo, - ErrorFromResponse, - MessageResponse, + LocalMessage, Mute, ChannelState as StreamChannelState, } from 'stream-chat'; @@ -17,7 +15,6 @@ import type { UnknownType, VideoAttachmentSizeHandler, } from '../types/types'; -import type { URLEnrichmentConfig } from '../components/MessageInput/hooks/useLinkPreviews'; export type ChannelNotifications = Array<{ id: string; @@ -25,16 +22,6 @@ export type ChannelNotifications = Array<{ type: 'success' | 'error'; }>; -export type StreamMessage = - // FIXME: we should use only one of the two (either formatted or unformatted) - (ReturnType<StreamChannelState['formatMessage']> | MessageResponse) & { - customType?: string; - errorStatusCode?: number; - error?: ErrorFromResponse<APIErrorResponse>; - editing?: boolean; - date?: Date; - }; - export type ChannelState = { suppressAutoscroll: boolean; error?: Error | null; @@ -45,14 +32,13 @@ export type ChannelState = { loadingMore?: boolean; loadingMoreNewer?: boolean; members?: StreamChannelState['members']; - messages?: StreamMessage[]; - pinnedMessages?: StreamMessage[]; - quotedMessage?: StreamMessage; + messages?: LocalMessage[]; + pinnedMessages?: LocalMessage[]; read?: StreamChannelState['read']; - thread?: StreamMessage | null; + thread?: LocalMessage | null; threadHasMore?: boolean; threadLoadingMore?: boolean; - threadMessages?: StreamMessage[]; + threadMessages?: LocalMessage[]; threadSuppressAutoscroll?: boolean; typing?: StreamChannelState['typing']; watcherCount?: number; @@ -70,14 +56,10 @@ export type ChannelStateContextValue = Omit<ChannelState, 'typing'> & { videoAttachmentSizeHandler: VideoAttachmentSizeHandler; acceptedFiles?: string[]; channelUnreadUiState?: ChannelUnreadUiState; - debounceURLEnrichmentMs?: URLEnrichmentConfig['debounceURLEnrichmentMs']; dragAndDropWindow?: boolean; - enrichURLForPreview?: URLEnrichmentConfig['enrichURLForPreview']; - findURLFn?: URLEnrichmentConfig['findURLFn']; giphyVersion?: GiphyVersions; maxNumberOfFiles?: number; mutes?: Array<Mute>; - onLinkPreviewDismissed?: URLEnrichmentConfig['onLinkPreviewDismissed']; watcher_count?: number; }; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index b8184f48d7..58906d6380 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -15,7 +15,6 @@ import type { EventComponentProps, FixedHeightMessageProps, GiphyPreviewMessageProps, - LinkPreviewListProps, LoadingIndicatorProps, MessageBouncePromptProps, MessageDeletedProps, @@ -41,8 +40,6 @@ import type { SendButtonProps, StartRecordingAudioButtonProps, StreamedMessageTextProps, - SuggestionItemProps, - SuggestionListProps, ThreadHeaderProps, ThreadListItemProps, ThreadListItemUIProps, @@ -51,16 +48,22 @@ import type { UnreadMessagesNotificationProps, UnreadMessagesSeparatorProps, } from '../components'; + +import type { + SuggestionItemProps, + SuggestionListProps, +} from '../components/TextAreaComposer'; + import type { SearchProps, SearchResultsPresearchProps, SearchSourceResultListProps, } from '../experimental'; -import type { CustomTrigger, PropsWithChildrenOnly, UnknownType } from '../types/types'; +import type { PropsWithChildrenOnly, UnknownType } from '../types/types'; import type { StopAIGenerationButtonProps } from '../components/MessageInput/StopAIGenerationButton'; -export type ComponentContextValue<V extends CustomTrigger = CustomTrigger> = { +export type ComponentContextValue = { /** Custom UI component to display a message attachment, defaults to and accepts same props as: [Attachment](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/Attachment.tsx) */ Attachment?: React.ComponentType<AttachmentProps>; /** Custom UI component to display an attachment previews in MessageInput, defaults to and accepts same props as: [Attachment](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/AttachmentPreviewList.tsx) */ @@ -72,8 +75,10 @@ export type ComponentContextValue<V extends CustomTrigger = CustomTrigger> = { /** Custom UI component to display AudioRecorder in MessageInput, defaults to and accepts same props as: [AudioRecorder](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/AudioRecorder.tsx) */ AudioRecorder?: React.ComponentType; /** Optional UI component to override the default suggestion Item component, defaults to and accepts same props as: [Item](https://github.com/GetStream/stream-chat-react/blob/master/src/components/AutoCompleteTextarea/Item.js) */ + // TODO: X rename to SuggestionListItem AutocompleteSuggestionItem?: React.ComponentType<SuggestionItemProps>; /** Optional UI component to override the default List component that displays suggestions, defaults to and accepts same props as: [List](https://github.com/GetStream/stream-chat-react/blob/master/src/components/AutoCompleteTextarea/List.js) */ + // TODO: X rename to SuggestionList AutocompleteSuggestionList?: React.ComponentType<SuggestionListProps>; /** UI component to display a user's avatar, defaults to and accepts same props as: [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) */ Avatar?: React.ComponentType<AvatarProps>; @@ -105,9 +110,9 @@ export type ComponentContextValue<V extends CustomTrigger = CustomTrigger> = { /** Custom UI component to render at the top of the `MessageList` */ HeaderComponent?: React.ComponentType; /** Custom UI component handling how the message input is rendered, defaults to and accepts the same props as [MessageInputFlat](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/MessageInputFlat.tsx) */ - Input?: React.ComponentType<MessageInputProps<V>>; + Input?: React.ComponentType<MessageInputProps>; /** Custom component to render link previews in message input **/ - LinkPreviewList?: React.ComponentType<LinkPreviewListProps>; + LinkPreviewList?: React.ComponentType; /** Custom UI component to render while the `MessageList` is loading new messages, defaults to and accepts same props as: [LoadingIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingIndicator.tsx) */ LoadingIndicator?: React.ComponentType<LoadingIndicatorProps>; /** Custom UI component to display a message in the standard `MessageList`, defaults to and accepts the same props as: [MessageSimple](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageSimple.tsx) */ @@ -200,7 +205,7 @@ export type ComponentContextValue<V extends CustomTrigger = CustomTrigger> = { ThreadHead?: React.ComponentType<MessageProps>; /** Custom UI component to display the header of a `Thread`, defaults to and accepts same props as: [DefaultThreadHeader](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Thread/Thread.tsx) */ ThreadHeader?: React.ComponentType<ThreadHeaderProps>; - ThreadInput?: React.ComponentType<MessageInputProps<V>>; + ThreadInput?: React.ComponentType<MessageInputProps>; ThreadListEmptyPlaceholder?: React.ComponentType; ThreadListItem?: React.ComponentType<ThreadListItemProps>; ThreadListItemUI?: React.ComponentType<ThreadListItemUIProps>; @@ -224,40 +229,35 @@ export type ComponentContextValue<V extends CustomTrigger = CustomTrigger> = { export const ComponentContext = React.createContext<ComponentContextValue>({}); -export const ComponentProvider = <V extends CustomTrigger = CustomTrigger>({ +export const ComponentProvider = ({ children, value, }: PropsWithChildren<{ - value: Partial<ComponentContextValue<V>>; + value: Partial<ComponentContextValue>; }>) => ( <ComponentContext.Provider value={value as unknown as ComponentContextValue}> {children} </ComponentContext.Provider> ); -export const useComponentContext = <V extends CustomTrigger = CustomTrigger>( +export const useComponentContext = ( /** * @deprecated */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _componentName?: string, -) => useContext(ComponentContext) as unknown as ComponentContextValue<V>; +) => useContext(ComponentContext) as unknown as ComponentContextValue; /** * Typescript currently does not support partial inference, so if ComponentContext * typing is desired while using the HOC withComponentContext, the Props for the * wrapped component must be provided as the first generic. */ -export const withComponentContext = < - P extends UnknownType, - V extends CustomTrigger = CustomTrigger, ->( +export const withComponentContext = <P extends UnknownType>( Component: React.ComponentType<P>, ) => { - const WithComponentContextComponent = ( - props: Omit<P, keyof ComponentContextValue<V>>, - ) => { - const componentContext = useComponentContext<V>(); + const WithComponentContextComponent = (props: Omit<P, keyof ComponentContextValue>) => { + const componentContext = useComponentContext(); return <Component {...(props as P)} {...componentContext} />; }; diff --git a/src/context/MessageBounceContext.tsx b/src/context/MessageBounceContext.tsx index b1bd53f384..9751f69f9f 100644 --- a/src/context/MessageBounceContext.tsx +++ b/src/context/MessageBounceContext.tsx @@ -1,16 +1,16 @@ import type { ReactEventHandler } from 'react'; import React, { createContext, useCallback, useContext, useMemo } from 'react'; import { useMessageContext } from './MessageContext'; -import type { PropsWithChildrenOnly } from '../types/types'; -import type { StreamMessage } from './ChannelStateContext'; import { useChannelActionContext } from './ChannelActionContext'; import { isMessageBounced } from '../components'; +import type { LocalMessage } from 'stream-chat'; +import type { PropsWithChildrenOnly } from '../types/types'; export interface MessageBounceContextValue { handleDelete: ReactEventHandler; handleEdit: ReactEventHandler; handleRetry: ReactEventHandler; - message: StreamMessage; + message: LocalMessage; } const MessageBounceContext = createContext<MessageBounceContextValue | undefined>( diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index faf9ee847a..cb7f30c463 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -1,10 +1,15 @@ import type { PropsWithChildren, ReactNode } from 'react'; import React, { useContext } from 'react'; -import type { Mute, ReactionResponse, ReactionSort, UserResponse } from 'stream-chat'; +import type { + LocalMessage, + Mute, + ReactionResponse, + ReactionSort, + UserResponse, +} from 'stream-chat'; import type { ChannelActionContextValue } from './ChannelActionContext'; -import type { StreamMessage } from './ChannelStateContext'; import type { ActionHandlerReturnType } from '../components/Message/hooks/useActionHandler'; import type { PinPermissions } from '../components/Message/hooks/usePinHandler'; @@ -23,7 +28,7 @@ import type { UnknownType } from '../types/types'; export type CustomMessageActions = { [key: string]: ( - message: StreamMessage, + message: LocalMessage, event: React.BaseSyntheticEvent, ) => Promise<void> | void; }; @@ -71,7 +76,7 @@ export type MessageContextValue = { /** Function that returns whether the Message belongs to the current user */ isMyMessage: () => boolean; /** The message object */ - message: StreamMessage; + message: LocalMessage; /** Indicates whether a message has not been read yet or has been marked unread */ messageIsUnread: boolean; /** Handler function for a click event on an @mention in Message */ @@ -109,7 +114,7 @@ export type MessageContextValue = { /** * A factory function that determines whether a message is AI generated or not. */ - isMessageAIGenerated?: (message: StreamMessage) => boolean; + isMessageAIGenerated?: (message: LocalMessage) => boolean; /** Latest message id on current channel */ lastReceivedId?: string | null; /** DOMRect object for parent MessageList component */ diff --git a/src/context/MessageInputContext.tsx b/src/context/MessageInputContext.tsx index e7bbab0b3d..de248122ca 100644 --- a/src/context/MessageInputContext.tsx +++ b/src/context/MessageInputContext.tsx @@ -1,53 +1,37 @@ import React, { createContext, useContext } from 'react'; import type { PropsWithChildren } from 'react'; -import type { TriggerSettings } from '../components/MessageInput/DefaultTriggerProvider'; import type { CooldownTimerState, MessageInputProps } from '../components/MessageInput'; -import type { - CommandsListState, - MentionsListState, - MessageInputHookProps, - MessageInputState, -} from '../components/MessageInput/hooks/useMessageInputState'; +import type { MessageInputHookProps } from '../components/MessageInput/hooks/useMessageInputState'; -import type { CustomTrigger } from '../types/types'; +export type MessageInputContextValue = MessageInputHookProps & + Omit<MessageInputProps, 'Input' | 'overrideSubmitHandler'> & + CooldownTimerState; -export type MessageInputContextValue<V extends CustomTrigger = CustomTrigger> = - MessageInputState & - MessageInputHookProps & - Omit<MessageInputProps<V>, 'Input'> & - CooldownTimerState & { - autocompleteTriggers?: TriggerSettings<V>; - } & CommandsListState & - MentionsListState; - -export const MessageInputContext = createContext< - (MessageInputState & MessageInputHookProps) | undefined ->(undefined); +export const MessageInputContext = createContext<MessageInputHookProps | undefined>( + undefined, +); -export const MessageInputContextProvider = <V extends CustomTrigger = CustomTrigger>({ +export const MessageInputContextProvider = ({ children, value, }: PropsWithChildren<{ - value: MessageInputContextValue<V>; + value: MessageInputContextValue; }>) => ( - <MessageInputContext.Provider value={value as MessageInputContextValue}> + <MessageInputContext.Provider value={value as unknown as MessageInputContextValue}> {children} </MessageInputContext.Provider> ); -export const useMessageInputContext = <V extends CustomTrigger = CustomTrigger>( +export const useMessageInputContext = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars componentName?: string, ) => { const contextValue = useContext(MessageInputContext); if (!contextValue) { - console.warn( - `The useMessageInputContext hook was called outside of the MessageInputContext provider. Make sure this hook is called within the MessageInput's UI component. The errored call is located in the ${componentName} component.`, - ); - - return {} as MessageInputContextValue<V>; + return {} as MessageInputContextValue; } - return contextValue as MessageInputContextValue<V>; + return contextValue as unknown as MessageInputContextValue; }; diff --git a/src/experimental/MessageActions/MessageActions.tsx b/src/experimental/MessageActions/MessageActions.tsx index 8ac4f21bc5..794d3004a2 100644 --- a/src/experimental/MessageActions/MessageActions.tsx +++ b/src/experimental/MessageActions/MessageActions.tsx @@ -81,6 +81,7 @@ export const MessageActions = ({ id={dropdownDialogId} placement={isMyMessage() ? 'top-end' : 'top-start'} referenceElement={actionsBoxButtonElement} + tabIndex={-1} trapFocus > <DropdownBox open={dropdownDialogIsOpen}> diff --git a/src/experimental/MessageActions/defaults.tsx b/src/experimental/MessageActions/defaults.tsx index 36fa2f9fe4..cfbe8519e7 100644 --- a/src/experimental/MessageActions/defaults.tsx +++ b/src/experimental/MessageActions/defaults.tsx @@ -7,12 +7,8 @@ import { ThreadIcon, } from '../../components/Message/icons'; import { ReactionSelectorWithButton } from '../../components/Reactions/ReactionSelectorWithButton'; -import { - useChannelActionContext, - useChatContext, - useMessageContext, - useTranslationContext, -} from '../../context'; +import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; +import { useMessageComposer } from '../../components'; import type { ComponentPropsWithoutRef } from 'react'; @@ -33,12 +29,12 @@ export const DefaultDropdownActionButton = ({ const DefaultMessageActionComponents = { dropdown: { Quote() { - const { setQuotedMessage } = useChannelActionContext(); const { message } = useMessageContext(); const { t } = useTranslationContext(); + const messageComposer = useMessageComposer(); const handleQuote = () => { - setQuotedMessage(message); + messageComposer.setQuotedMessage(message); const elements = message.parent_id ? document.querySelectorAll('.str-chat__thread .str-chat__textarea__textarea') diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts index 99a5a0be86..431ccc17ed 100644 --- a/src/i18n/utils.ts +++ b/src/i18n/utils.ts @@ -24,8 +24,10 @@ export const isDayOrMoment = ( output: TDateTimeParserOutput, ): output is Dayjs.Dayjs | Moment => !!(output as Dayjs.Dayjs | Moment)?.isSame; -export const isDate = (output: TDateTimeParserOutput): output is Date => - !!(output as Date)?.getMonth; +export const isDate = (output: unknown): output is Date => + output !== null && + typeof output === 'object' && + typeof (output as Date).getTime === 'function'; export function getDateString({ calendar, diff --git a/src/mock-builders/api/getOrCreateChannel.js b/src/mock-builders/api/getOrCreateChannel.js index 7293886f40..06e8fff087 100644 --- a/src/mock-builders/api/getOrCreateChannel.js +++ b/src/mock-builders/api/getOrCreateChannel.js @@ -24,6 +24,9 @@ export const getOrCreateChannelApi = ( pinnedMessages: channel.pinnedMessages, read: channel.read, }; + if (channel.draft) { + result.draft = channel.draft; + } return mockedApiResponse(result, 'post'); }; diff --git a/src/mock-builders/event/draftDeleted.ts b/src/mock-builders/event/draftDeleted.ts new file mode 100644 index 0000000000..9ce83ebf96 --- /dev/null +++ b/src/mock-builders/event/draftDeleted.ts @@ -0,0 +1,16 @@ +import type { DraftResponse, StreamChat } from 'stream-chat'; + +export const dispatchDraftDeleted = ({ + client, + draft, +}: { + client: StreamChat; + draft: DraftResponse; +}) => { + client.dispatchEvent({ + cid: draft.channel_cid, + created_at: new Date().toISOString(), + draft, + type: 'draft.deleted', + }); +}; diff --git a/src/mock-builders/event/draftUpdated.ts b/src/mock-builders/event/draftUpdated.ts new file mode 100644 index 0000000000..ea28e0b568 --- /dev/null +++ b/src/mock-builders/event/draftUpdated.ts @@ -0,0 +1,16 @@ +import type { DraftResponse, StreamChat } from 'stream-chat'; + +export const dispatchDraftUpdated = ({ + client, + draft, +}: { + client: StreamChat; + draft: DraftResponse; +}) => { + client.dispatchEvent({ + cid: draft.channel_cid, + created_at: new Date().toISOString(), + draft, + type: 'draft.updated', + }); +}; diff --git a/src/mock-builders/generator/messageDraft.ts b/src/mock-builders/generator/messageDraft.ts new file mode 100644 index 0000000000..11275d1969 --- /dev/null +++ b/src/mock-builders/generator/messageDraft.ts @@ -0,0 +1,13 @@ +import { generateMessage } from './message'; +import type { DraftResponse } from 'stream-chat'; + +export const generateMessageDraft = ({ + channel_cid, + ...customMsgDraft +}: Partial<DraftResponse>) => + ({ + channel_cid, + created_at: new Date().toISOString(), + message: generateMessage(), + ...customMsgDraft, + }) as DraftResponse; diff --git a/src/plugins/Emojis/index.ts b/src/plugins/Emojis/index.ts index 4929ed23a5..016578681c 100644 --- a/src/plugins/Emojis/index.ts +++ b/src/plugins/Emojis/index.ts @@ -1,2 +1,3 @@ export * from './EmojiPicker'; +export * from './middleware'; export { EmojiPickerIcon } from './icons'; diff --git a/src/plugins/Emojis/middleware/index.ts b/src/plugins/Emojis/middleware/index.ts new file mode 100644 index 0000000000..39ce2afab3 --- /dev/null +++ b/src/plugins/Emojis/middleware/index.ts @@ -0,0 +1 @@ +export * from './textComposerEmojiMiddleware'; diff --git a/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts b/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts new file mode 100644 index 0000000000..e8423192bd --- /dev/null +++ b/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts @@ -0,0 +1,195 @@ +import mergeWith from 'lodash.mergewith'; +import type { + SearchSourceOptions, + SearchSourceType, + TextComposerMiddlewareOptions, + TextComposerMiddlewareParams, + TextComposerSuggestion, +} from 'stream-chat'; +import { + BaseSearchSource, + getTokenizedSuggestionDisplayName, + getTriggerCharWithToken, + insertItemWithTrigger, + replaceWordWithEntity, +} from 'stream-chat'; +import type { + EmojiSearchIndex, + EmojiSearchIndexResult, +} from '../../../components/MessageInput'; + +class EmojiSearchSource< + T extends TextComposerSuggestion<EmojiSearchIndexResult>, +> extends BaseSearchSource<T> { + readonly type: SearchSourceType = 'emoji'; + private emojiSearchIndex: EmojiSearchIndex; + + constructor(emojiSearchIndex: EmojiSearchIndex, options?: SearchSourceOptions) { + super(options); + this.emojiSearchIndex = emojiSearchIndex; + } + + async query(searchQuery: string) { + if (searchQuery.length === 0) { + return { items: [] as T[], next: null }; + } + const emojis = (await this.emojiSearchIndex.search(searchQuery)) ?? []; + + // emojiIndex.search sometimes returns undefined values, so filter those out first + return { + items: emojis + .filter(Boolean) + .slice(0, 7) + .map(({ emoticons = [], id, name, native, skins = [] }) => { + const [firstSkin] = skins; + + return { + emoticons, + id, + name, + native: native ?? firstSkin.native, + }; + }) as T[], + next: null, // todo: generate cursor + }; + } + + protected filterQueryResults(items: T[]): T[] | Promise<T[]> { + return items.map((item) => ({ + ...item, + ...getTokenizedSuggestionDisplayName({ + displayName: item.id, + searchToken: this.searchQuery, + }), + })); + } +} + +const DEFAULT_OPTIONS: TextComposerMiddlewareOptions = { minChars: 1, trigger: ':' }; + +/** + * TextComposer middleware for mentions + * Usage: + * + * const textComposer = new TextComposer(options); + * + * textComposer.use(new createTextComposerEmojiMiddleware(emojiSearchIndex, { + * minChars: 2 + * })); + * + * @param emojiSearchIndex + * @param {{ + * minChars: number; + * trigger: string; + * }} options + * @returns + */ +export const createTextComposerEmojiMiddleware = < + T extends EmojiSearchIndexResult = EmojiSearchIndexResult, +>( + emojiSearchIndex: EmojiSearchIndex, + options?: Partial<TextComposerMiddlewareOptions>, +) => { + const finalOptions = mergeWith(DEFAULT_OPTIONS, options ?? {}); + const emojiSearchSource = new EmojiSearchSource(emojiSearchIndex); + emojiSearchSource.activate(); + + return { + id: 'stream-io/emoji-middleware', + onChange: async ({ input, nextHandler }: TextComposerMiddlewareParams<T>) => { + const { state } = input; + if (!state.selection) return nextHandler(input); + + const triggerWithToken = getTriggerCharWithToken({ + acceptTrailingSpaces: false, + text: state.text.slice(0, state.selection.end), + trigger: finalOptions.trigger, + }); + + const triggerWasRemoved = + !triggerWithToken || triggerWithToken.length < finalOptions.minChars; + + if (triggerWasRemoved) { + const hasSuggestionsForTrigger = + input.state.suggestions?.trigger === finalOptions.trigger; + const newInput = { ...input }; + if (hasSuggestionsForTrigger && newInput.state.suggestions) { + delete newInput.state.suggestions; + } + return nextHandler(newInput); + } + + const newSearchTriggerred = + triggerWithToken && triggerWithToken === finalOptions.trigger; + + if (newSearchTriggerred) { + emojiSearchSource.resetStateAndActivate(); + } + + const textWithReplacedWord = await replaceWordWithEntity({ + caretPosition: state.selection.end, + getEntityString: async (word: string) => { + const { items } = await emojiSearchSource.query(word); + + const emoji = items + .filter(Boolean) + .slice(0, 10) + .find(({ emoticons }) => !!emoticons?.includes(word)); + + if (!emoji) return null; + + const [firstSkin] = emoji.skins ?? []; + + return emoji.native ?? firstSkin.native; + }, + text: state.text, + }); + + if (textWithReplacedWord !== state.text) { + return { + state: { + ...state, + suggestions: undefined, // to prevent the TextComposerMiddlewareExecutor to run the first page query + text: textWithReplacedWord, + }, + stop: true, // Stop other middleware from processing '@' character + }; + } + + return { + state: { + ...state, + suggestions: { + query: triggerWithToken.slice(1), + searchSource: emojiSearchSource, + trigger: finalOptions.trigger, + }, + }, + stop: true, // Stop other middleware from processing '@' character + }; + }, + onSuggestionItemSelect: ({ + input, + nextHandler, + selectedSuggestion, + }: TextComposerMiddlewareParams<T>) => { + const { state } = input; + if (!selectedSuggestion || state.suggestions?.trigger !== finalOptions.trigger) + return nextHandler(input); + + emojiSearchSource.resetStateAndActivate(); + return Promise.resolve({ + state: { + ...state, + ...insertItemWithTrigger({ + insertText: `${'native' in selectedSuggestion ? selectedSuggestion.native : ''} `, + selection: state.selection, + text: state.text, + trigger: finalOptions.trigger, + }), + suggestions: undefined, // Clear suggestions after selection + }, + }); + }, + }; +}; diff --git a/src/types/types.ts b/src/types/types.ts index 0902131c94..69e1ad7d2f 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -12,13 +12,6 @@ import type { export type UnknownType = Record<string, unknown>; export type PropsWithChildrenOnly = PropsWithChildren<Record<never, never>>; -export type CustomTrigger = { - [key: string]: { - componentProps: UnknownType; - data: UnknownType; - }; -}; - export type CustomMessageType = 'channel.intro' | 'message.date'; export type DefaultAttachmentType = UnknownType & { @@ -117,22 +110,6 @@ export type VideoAttachmentSizeHandler = ( export type ChannelUnreadUiState = Omit<ValuesType<StreamChannelState['read']>, 'user'>; -// todo: fix export from stream-chat - for some reason not exported -export type SendMessageOptions = { - force_moderation?: boolean; - is_pending_message?: boolean; - keep_channel_hidden?: boolean; - pending?: boolean; - pending_message_metadata?: Record<string, string>; - skip_enrich_url?: boolean; - skip_push?: boolean; -}; - -// todo: fix export from stream-chat - for some reason not exported -export type UpdateMessageOptions = { - skip_enrich_url?: boolean; -}; - export type Readable<T> = { [key in keyof T]: T[key]; } & {}; diff --git a/yarn.lock b/yarn.lock index fb3e84e59e..797b025884 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8597,6 +8597,11 @@ linkifyjs@^4.1.0: resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.1.0.tgz#0460bfcc37d3348fa80e078d92e7bbc82588db15" integrity sha512-Ffv8VoY3+ixI1b3aZ3O+jM6x17cOsgwfB1Wq7pkytbo1WlyRp6ZO0YDMqiWT/gQPY/CmtiGuKfzDIVqxh1aCTA== +linkifyjs@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.2.0.tgz#9dd30222b9cbabec9c950e725ec00031c7fa3f08" + integrity sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw== + lint-staged@^15.2.1: version "15.2.1" resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-15.2.1.tgz#25beb6e587f54245b20163f5efede073f12c4d1b" @@ -12150,10 +12155,9 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -stream-chat@^9.0.0-rc.8: - version "9.0.0-rc.8" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.0.0-rc.8.tgz#e188e481841493584691ae491916843d0ef5f9cd" - integrity sha512-P+Ksnu1cQQfL1t2/QTJ5rr/z2Jehvd2ap41xZgtfbJssHSD7ahe14TCF/1L7q4jjaNlZcTtLcKXCWbbOdKjDcg== +"stream-chat@https://github.com/GetStream/stream-chat-js.git#feat/message-composer": + version "0.0.0-development" + resolved "https://github.com/GetStream/stream-chat-js.git#cb37c34ee27cbcc0f2405026fe16ecaf6b22bd1c" dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -12162,6 +12166,7 @@ stream-chat@^9.0.0-rc.8: form-data "^4.0.0" isomorphic-ws "^5.0.0" jsonwebtoken "^9.0.2" + linkifyjs "^4.2.0" ws "^8.18.1" stream-combiner2@~1.1.1: