diff --git a/__mocks__/react-native-device-info.js b/__mocks__/react-native-device-info.js index 427998a1712..3097a204b27 100644 --- a/__mocks__/react-native-device-info.js +++ b/__mocks__/react-native-device-info.js @@ -1,3 +1,7 @@ import mockDeviceInfo from 'react-native-device-info/jest/react-native-device-info-mock'; +mockDeviceInfo.getSystemVersion = jest.fn(() => '14.0'); +mockDeviceInfo.getVersion = jest.fn(() => '1.0.0'); +mockDeviceInfo.getBuildNumber = jest.fn(() => '1'); + export default mockDeviceInfo; diff --git a/app/containers/Avatar/Avatar.tsx b/app/containers/Avatar/Avatar.tsx index 6d375147249..ab063d3a218 100644 --- a/app/containers/Avatar/Avatar.tsx +++ b/app/containers/Avatar/Avatar.tsx @@ -1,10 +1,10 @@ import { memo } from 'react'; import { View } from 'react-native'; import { Image } from 'expo-image'; -import { settings as RocketChatSettings } from '@rocket.chat/sdk'; import Emoji from '../markdown/components/emoji/Emoji'; import { getAvatarURL } from '../../lib/methods/helpers/getAvatarUrl'; +import { getAuthHeaders } from '../../lib/methods/helpers/getAuthHeaders'; import { SubscriptionType } from '../../definitions'; import { type IAvatar } from './interfaces'; import I18n from '../../i18n'; @@ -81,7 +81,7 @@ const Avatar = memo( style={avatarStyle} source={{ uri, - headers: RocketChatSettings.customHeaders + headers: uri ? getAuthHeaders(uri) : undefined }} priority='high' /> diff --git a/app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap b/app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap index 81ef3deecc6..b1e2129b8df 100644 --- a/app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap +++ b/app/containers/Avatar/__snapshots__/Avatar.test.tsx.snap @@ -37,7 +37,9 @@ exports[`Story Snapshots: AvatarBase64 should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAADTESURBVHgB7d1behRH0vDxiOpqDSB5Rl6B2ytAXoGbxwjPncXdvCA/iBWAV4BYAbACxPMK3rlD3M2HmKG9AsQKaK/APYMOjLq64sssSZyMQIc+ZFb+f8/YgJgx45JUERkZGSkCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAgqCJq1l2bfyJvZw37/jJzpaWelJwAAHAMJwBgdBPNyKp8z01k1a2Vq31S/J9oy1Vb189JmVWX2RH+IalfMeu4T23Of3Z77M3ql6W8m1muIbjS07DX7U12SBgBIGwnACOy0/9aqgnxprYbK+VLUBXxpnTioj4D7/9PLVDZ8kmClvJRMNppWdqfWH20IAKD2SABOaXf+ytyuaTvUQH9CLgmwDffv8tJXDc4+W+0IAKBWSACOwZfwt6eKtlnZziQ7X5rM1SDYH4n7QumUUr5UzTrndvMOWwgAEDcSgM+o9uzzYm6g5YIP+CbSFhzYMCl/bVi2RoUAAOJDAvARH/S38v5SpvpTSiv803LPaa208kmjP9U521npCgAgaCQAste0J1P5Nbfn3WaVPxQbZvagUTTXSAYAIEzJJgBVeX+quEHQHzmSAQAIUHIJwE57qWXN4j5Bf/z2GwkfTPen1mgiBIDJSioB2Ll09dqg1Lvs64fAVjLTBzQQAsBkJJMA+OBfmq4IAmNdE7tN8yAAjFcSCYAv+5fN4pUgWH4yoaqtZf3mbRIBABi9JBKArfmrLvhrSxAJtgcAYNRqnwC8vrjYzlSeC2Lkhw3dm1l/tCIAgKHKpOZcWXlJEKs5ley+r+Bszl9ZEgDA0NQ/ARA9L4ictg4Sgf9cWlwQAMCp1X4LYGt+0QQ1Yys0CwLA6ZAAIFqZyvKb3fze1wwVAoBjq/0WgJgQHGqqNFmeavZf0B8AAMeXQBOgbAhq7G1/wH0/70EAAEdS+wSgNHsiSIAulc3ixfaPV28KAOCLat8D8Ht7aXYqL14J8/8TYt2s37xAkyAAHK72FQDfIFaK3BMkRKvRzzuXFm8JAOCTkrkMaHt+8TlXACdpI+vnl6kGAMCH6n8KYN9/XRBwP9AQmJ45egMA4I+SqQB4vh/gT1P9F2baEiSIAUIAcCCpBMDb+etSy8r+c5KAVFnXxC7PrD+iGgQgaclsARw4+4+VrmbNC6rWFSSomhvwggZBAKlLrgJwwFcCyqJ4wfHAdKnKmu7mv7AlACBFySYA3ub8lTm17DlJQMqYGQAgTcltAbzP7wOblr8IElbNDOCUAIDkJJ0AeC4JWDEzkoC0zVqpd+gLAJCSpLcA3vf64uJypkIASJzvC/jvbn6dK4YB1B0JwHs25xdX3AO5JkgcfQEA6i/5LYD39fu53wfmfHjyfF9A/7lvEhUAqCkSgPf4sm/WyC8zIwDv5gVcpSIEoJbYAviE6nige/kL4GQqy2efrt6WCdlp/63VaGazA3N/qbaqD1o5q6KzpuVsQ/Qv1Ydk//f8z/Xdz98ya8lRqHY/+t/13IvibU+Eyl6CPBD7t1rW2//zui5x7mWl9Bpa9gb9sne28/euAAgWCcAhNi9evamqdwSQ4ScB1l6afSNvZsupfM5MZ9UF54Ng7gO5D+BW2qxGPqPCTHqaac/9+3Xd26ZXWvmbTxp8wtBwH/PJwhRjmYGJIAH4jNc/LN7NMrkhgBw/CfAr94MA79bLcz64l6Lu19KKPbAPm08U3PPdOEgSXEmh657XBgkCMDokAJ/hbw+cahbP3U9pBsM+W5lef3j97a/c10i/udvalcacD/KZZt+4FfzckcvtOKoNlzR1rZSXksmG32o4U+QbynFN4MRIAL6AOwPwMbPy1yzLfifQB+GDxKBpZZeKAXA0JABH8J8fFhcamTwWAME72E4opXypmnVICoBPIwE4IvoBgHgdJAXux18zkQ7bBwAJwJHRDwDUjqsK2IaoPsl28w0mPyI1JADHQD8AUGfVfIOOif3a6E91SAhQdyQAx8R8ACAZbysE53bzDlsGqBsSgBPYnl98biJtAZAM97LslGZPptQ6NBWiDkgAToCtACB1e9sFmemDs89WOwJEiATghNgKAOBV447V1tgqQGxIAE6BrQAAH1OVtdLKJ9P9qTWSAYSMBOAU2AoA8Hm24isD009X1wQIDAnAKb2+uLicqdwSADjEwTYBPQMICQnAKfkBQX+a6r8w05YAwBdZ18SeNPpTd5k1gEkiARgCVwVouyrAcwGAY6iOFkr5YGb90YoAY0YCMCQ0BAI4uf1jhf3mbaoCGBcSgCGpGgIHxSsBgFOgKoBxIQEYIhoCAQyPdVWzNd1t3KMqgFEgARii6sbA3FUBOBYIYKhshe0BDBsJwJBRBQAwKn57wFTuMVcAw0ACMGRUAQCMXnWU8DZ9AjgNEoARoAoAYDxIBHByJAAjQBUAwHiRCOD4SABGhCoAgPEjEcDRkQCMSFUFaBa/CwCMHYkAvowEYIReX7y6lqn+JAAwESQCOBwJwAhxRwCAMFg3M73OTYR4HwnAiHFHAIBwMFAI72SCkSrNnggABEGXymbxavvSz3d22kstQdJIAEasXzRXBAACYmY3y2b/+eb8lSVBstgCGAO2AQCEy7puW+AC2wLpoQIwBqXoAwGAIGnLbwtszV+9z7ZAWkgAxqDfb6yJSU8AIFi+P4BtgZSwBTAmbAMAiAenBVJABWBMOA0AIB7VaYEX2z9evSmoLSoAY7Lz16VWOSheCQBExAWJjvbz61QD6ocEYIy2Li7+zg2BGIOe+9b+Q8+JinU/9V820dYnPuq/TvlaxYGeZnb73P97eFdQGyQAY8TdADiivQBu1nPfoF1V65Wmv5lYTzSrAruZuY+XvUa/rH59Rs70tLMykkZTay/NvpE3VTJQ5Hlr78+X2SzTWTVrmZazDdG/HCQSez9aS1BD9AbUCQnAGG1evHpTVe8IEqddvxr3f1WBXbVbltZrDoqNUQbycfOJw1Zzt2WWzbp/1zkVlzCInPdVMBOZEyoMkeKCobogARgjLgdKiQvyZhullr+JqQ/0XR/gz3b+3hVUDhKEssxamVqroXLeVw9IDuLgFjN3zz39318E0SIBGKPf3Qtvqln8LqiTnpr4QP9SpLEhMtiY7k9167KKnxSfHGzmxZyvHLi31Fwm+g3HaEPEFMGYkQCMGY2AUXsb7N2qtcOKfvw256/MVRWDrGxnkp2nWhAEGgQjRQIwZlvziy9k76WF0Llgb1r+6lf2jX6/Q7APk08KpPqe0raa+h4Dvr8mgC2B+JAAjJlLAFbcD9cEoXm7ujfL1maKfIMyfpz8zI1+v5jzVQK17HsSgnFiSyAmJABjtvnD4l3N5IZg4lzA7xDw68/3E7xuFO0sswUqBOPgkgDT62efrXYEQSMBGLOti4vL7qnfEkxCLxNbG0j263S/sUbAT5OvEAwGu+3Msp9Mq8ZCeghGIFNZPvt09bYgWCQAY8YsgPHyq/yBiNvHl85XrEjwCf54rqotuYD1vZm2BENDX0DYSADGbHP+5yUVuy8Ymaq0L/ZkumiusMrHcVQNhaZtFb3GVsHQbGT9/DJ9AeEhARgzEoDRIOhj2KqtgqK/QDIwDDQHhogEYMxIAIaHoI9xOUgGskxusE1wUtUI4csz6482BEEgARgzegBOy4/VtQczRX6XoI9J2Js7oDfpGTiRnos616efrq4JJo4EYMw4BXAiPZPygT+uRyMfQvKfHxYXcpVrprIgODLN7BcmB04eCcCYMQfg6CjxIxZ+i6AoiqVGZteoChwNxwQnjwRgzF5fvLqWqf4kOIwL9LZWmj5gtY8YVVUB3yvA5UVfRBIwWSQAY8ZdAIfqlSb32NtHXewNHOovq1QJP8OGDkESMDkkAGPmEgATvLU/jvfBzPqjFQFq6O3kQdVbbA98GknAZJAAjNFm+8qcNrMXgoMJfbcp8yMlm/NXlkgEPo0kYPxIAMZo69LiLTFZloQR+IG9REAtu8GAoQ+RBIwXCcCYpB78CfzAH/l7CBoqt2gYfIckYHxIAMYg5eBP4Ae+rEoEMrvP1sAekoDxIAEYsVSDP4EfOD56BN4hCRg9EoAR2rl09VppuiIJIfADp0cisCdTWzr79OEDwUiQAIzI5vzSnEqRTMe/qvkZ/bc5zgcMj9saWE59uqBJ+R0XCI0GCcAI7LSXWoO8eKGaxPAPBvgAI/TeQKFrkqZe1s+/4yrh4SMBGDIf/Mtm/7l7tC2pO5O1LM9/OfsPvjGBUfOJgJX952lWA6yb9ZsXSAKGiwRgyDbnr264TP281Jgv9w9Kvc4+PzB+CfcHbOz28wtfU2kcmkwwNNuXfr5T9+Dvyv23z+42vyP4A5Ph+2w0a14wsdSa4+ammv07gqGhAjAkdT/u57v7Nc+vU+4HwlHdPNiwOylVAzgeODwkAEOwc3GxXao8l3rqme/uf/bwrgAIzu/tpdm8USxnmdyQRGQmF85ShTw1EoBTqnPTH6t+IB6JTRPkZMAQ0ANwSnUN/m7V/8u5Z6sXCP5AHHxfzn93m98l0hswWzaLx4JToQJwCnXc998b6NO8PLO+wuANIFKpnBRQ1bvnnv7vL4ITIQE4oeo6T8nuS42YyL3pfr7MQB8gfn5uQDmoVsn1vnJY5fL009U1wbGRAJxADff9afQDasqPE85Ubkl90Q9wQvQAnIA1i/t1Cf6+5J818u8I/kA9ffVsdXlQymX/vS71NLv3TsZxkQAck9/3d6XyttSByZof6kOjH1Bvf/7n6pofHlTXJMC/k7d/vHpTcCxsARzDXum/eCV1YHJ72q0MBEAyaj4zgK2AY6ICcAx7+/7R69mgvE7wB9Lj5+h/9c/Vm36kt9QPWwHHRAJwRNWRP9GWRMyX/0zyCzP/erQiAJLl+wJMyuuuElirEz9sBRwPWwBHUIfSvw/+fg+Q/X4ABzbnr8xlqo9rNi+ArYAjogJwBINmf1miZi9p9gPwsZn1Rxs1bA5kK+CIqAB8QewDf1xJ7MHM+uqSAMAh/NAgK/vP61QJ8Fsc/upkwaFIAD7D2kuz283+i1j3/v1kPxf82Q8D8EX+hMBUs/CNznWZHNjb7efffs1k00OxBfAZ21PFjWgb/0xuE/wBHJUPlC5gXnA/rcs9ILNTecE78DOoABwi6sY/zvgDOKG6VQIyVwWgIfDTqAAcItrGP4I/gFOoWyWAhsDDUQH4hNcXF9uZSnxDfwj+AIakTpWAzOTC2WerHcEHqAB8ggv+dyQy1VW+BH8AQ1KnSoDV+zbEEyMB+Ig/9iexZbwmazT8ARg2nwRkjTz6mwT9hMAdV9kVfIAE4CMqGlmmaC/PFfl1AYAR8APE6jAsqFSjF+AjJADv2Vv9a0si4b8hs0ZzQTnnCmCEDpKAuO8O0NZ+hRf7SADeE9Pqn9n+AMbJv2tKkcsSsfgqvKNFArAvttX/oNTrBH8A4/TVs9WOmf0i0dIWvQDvkADsyyS7JrEwuf0VR1oATMDMs4d3y1LuSaQ4EfAOCYDsnfv3XaISAY77AZi0YpAvS6THAzkR8A4JgFT76UsSAb/vP93PlwUAJujgeGCsTYFUAfYkPwkwopn//hvuO/b9AYTiPz8sLjQyeSwRakr53dT6o7pcfHQiyVcAYpn5b2a3Cf4AQvLnf66uxdoP0LdsQRKXfALgSiDfS/BsxTfeCAAExvcDRDkkSOWGv+9AEpZ0AhDD0b/9YT+3BQAC5PsBSrMY5wPM/invL0nCkk4AYjj6V1L6BxC4GbeXXprEt1BR/UkSlmwTYBzNf7Yyvf6QOf8AorA1v/hCIrtMLeVmwGQrAEVeLEnAKP0DiI2rAkQ3JTDlZsBkE4BMLejyP6V/ALHxE0qjOxWgckMSlWQCsNm+Mhdy859f/bs9tRUBgMhUUwLjGhA0m+pkwDQrAI1sSQIWaUctAFSnAkwsqu3LgUqS2wBJJgCaWcCdn7Yyk/h0KgBx83NLYpoNoCLxXAY3RMklAKGX/2n8A1AH/spyiUeS2wDpVQBybUuwbIXGPwB14BsC3cq6I5EoI7kRdpiSSwCygAc/sPoHUCeDiIYDqcYwFn64kkoA/PAfCzbLY/UPoF5iqgL42JDa3QBJJQD9RhHshCpW/wDqKKYqQDPbTeo0QFIJQJZZoJ9cVv8A6qmqAkRyIqDR0KS2AZJKAEK9+jczfSAAUFOxTAdMrREwmQTA7/+HePzPZ8ZnXYYsAFBT/aK5Esd0QG2l1AeQTAIQ6v6/n/kvAFBj1XRAkygqnSn1ASSTAGSBlnb6/ak1AYCaM5U43nWNLKrrjE8jl0RkmZw3CY2t+MxYcCTmSnP95m6rr1nLTGfVrFV9XLXrtlJ6WSm9M0W+oTzTD+y0/9Yqp/I5/8zEylkVnTWxnmba5Zl92mHPTDTrNcy6PLPj882A2/OLnXCPYu9xi8XzkohkEoAQv+ho/vsyP55zoOWCSvb9tvhtnKz6ZOre3yrVz91/Svfr7WYhW/OLG+4DG6L6ZPrpanIVlmqkaSbfu5JruzSZc89l9t0z23to6h/eB8/satd92L2c7ddpV5VKLbj5gD/IGwt+UNhnn5n79cEz27y42HMLi05p5ZNGf6pztsNJni8ZmPyaadgJgPuMJ1MBUEmAn/+vzeyFBMQ3/517+vBbwR/4lf6bqeLGoJSb6l/Ep/undd3fOlm/ebvOL2j/zLby/pIPYMNJdm2l7s/M2/phcUEzuTG0Z+aSepp6D7fz16VWOSheSeCyfvHt2c7fu1JzSSQA/3Hf5I1MHktQbGV6/WFMl2WM3HAD/yf/hFoGNVdWveFWrcs8s6PbuvQ/C2LZnVGcDPKT77SfX6ci8Gnu6/V56NsANiivz/zr0YrUXBJNgCE2AA5ceVrwli9bbzf7L0YXyDxdKpv959s/Xr0pNbA5vzS3OX91w71M7472mRWvdi4t3pIa8MeBfQASazwe1bFgH9z8M9u+9POd1EbLHoXfBpDQJdIImEQCYLrXLBaSwW7eEVS2XHBx+6rPxzOnQVtW6h23531/bzZEnPyqX6V44falx9Kw5BOzrfnFFzE/M7/qH+TFi3GtPs3s5pRLamN+ZiPSkcA1xP4iCUijAqD6jQRETTp0/+/vW19cfOyWTMsydnvVgBhfzn5l6Vf9Mn5zsT4zn2T6Vf/oKiWH0ZarBrxwf35SM+Y/x58GCH0oUCoTAVOZAxBYOceSL/9Xwb/Z77gN0wm+GP3LOa6AtvXjz/f9ylImZi+gbc5fiaZEuhf8J5FkvuVPFDzeuXT1mmCPSuDvQE1i66b2CYDvOpXAuLLthiTOB/9xla8/L54kwK/8XS1+SSZvViWL4pkFEPzfKk1XqiOa8Nsjob8DZ/3RUKm52icARVG0JDCpHxPygSyM4H+gWtU+Drlhyweyya78/2A29MTJ90mEEvwPlOoqAfQEuBezdSRwjWZW+ypA7RMAVW1JQPz+vyTMv5QDC2QH5v40NQiy071aNQYWyPZoy5rFfQmQD7JlkM9sL3FK/XTATOfRRuh9ALuD+g8Eqn8CYGGdAHArgJeSqIBfyhWfmIRWoq2emVqQQdbzHfUhHqv0QXb8DX9Hpa1Qk81xcp+foLcBsqz+fQC1TwDMQnsJlMnu/w+a/eVwX8p7fLANaXVmU4MbIV5j/T4r9VZIz2xz/spS8M8swGRz3Moy7MVQGVj1eBTqnwAENgOgYVlXEvTavezcvn8EXdDamsqLIFa0fvUf6HbJx2ZDeWae+zqLYnVtKmlXAbKwm6FTmAVQ+wSgoRrUJ/FNkSdZAWjE9LJTuRHCitZXTCQWgTyzGFb/B/z2ScpVAJfcdiVgbrv2a6m5VOYAhKKX4gCgaiUb12CN2T/l/SWZIP/M4qiYvBVEFSCW1f8By+SGJCrP864ETMuwFo+jUP8tANGWBEIt7KaXURk0wikPH5nqTzJBg+ZuW2Kjkw1mr6vVtLYkIv7K5lRPBJz9R9iXJYU4Qn7YqACMkWX2b0mQZjbRYHoSvmIxyRdzJlmMU+NmJ1nSVg1iSNJxzTaz3YTHBGtXMDH1TwDMwunotvS+2PeGnmhLIjTJF3NkWyZvmdjEzk6HNVzq6BoN/V6SZcltiYak/glAQMfONPDBF6PQbxTxDtOY0JWgryNuDHN72m2ZgP1qTZRfa6lcPPMppdlvEixtSc2xBTBGmmC2m0X8cnMl5YmszHSCq+hTM5nIKjzPI040RVup9gFkqlQAJogEYIzKMr0EIOpGmkkNkTJtSbQmE8yiTpqcM/ImyQTAyvSqoiEhAcBIhTaH4Xi0JROgWdjTEr9kEsFMY7++Nc9bkiBVEoBJIgEYozLP0qsAiLYkYpNZzco3ErFJ3KIW3sjv4xmUZUuAMSMBGKMm5a7opFqaPY2BjT8BiL1qAkxCAscAwwm6fV5S0Tnb+XtXxmxgcc+LaGg59u+52PeSU6wOYvISOAYYTpdpVpTJJQAqYc/7DlHsndGD/vgTgNj3kqkOBqn2nxO2ADBSJhLwOd8vUNuQSTDpSsQmUTUxjXzIVlF0BYGp/6mtBBIAJk1NktmEgugQaDmZlXjUwWxSSVN/EO3XmTeJpCkIKi3BxNQ+AdCAyjiaZS1JjEnYd35/jqte/CoT0Gg0OhKrCV141ZeprkRKTTqSqDKgUe0f0wRGtydwG2A4JWgLaCzxuBRFHm0C4L45OjIB1S1pkY6NNrGJJE3+mm2VOANpqfJSEhX3nJD40QMwRg2x5L7YI34x984+W+3IhJjJA4lQozHVkQkZ2GQqNqfVMFmTRLkFWrgVAK1/A3MKxwC7EgiLesTrybky3xOJjk30peyqRdEFBV/KnuQd767adFci44PMJBPNibNwewBK03gbmI+o/lsAATVURT0X/xT6RXMltpJ2ZjrRFfhXLijEtgIptZzoM4uy2qRyTxJVTdkMeFs0hcvbap8AhHUBj7YkQf7FXEo8L7pqJRvAqmxQajTbAD5ZmVl/tCIT5rYBbktEVJvJlv9Dv8GxyGgCjF6zGVYT2k77by1JUFWejaQK4FaRQQQR/8xiqQK4bZ4gnllVOYmlCuCSlUlumUxa6Pc3pDCcqfYJwJs3YX0SB1nWlgTFUwWwlVD2ZP0zKwb6iwQulNX/gRiqAP6ZZXm+IglrqARdAXgT8Qmmo6p9AuBfou7brSuhaGRBf9GPkludLbsfgv2mql7KjWZQwePP/1xdC31Fq1nzggTEVwHKMuxk01dMUl79e6ryvYSrtxc76i2JY4CllcGcs9VEGwEPZI38cqhbAaG+lLWRXw92KyDQMnYxyJcl0GTTXCUspIrJpJgE/C5M4Aigl0QCENREJ9OQs96R88HCtAyvrO0CWagvZf/MXHISXOLkA9n0XlUnOH715pPN8BInezmzvnpTErfTXmoF3hRd+yOAXhqDgMJ6Ccym2gh4wAfaMqB9WhfIHoQayA64Z7YRVuJkL6f7+bIELLTEaX+LaUEg/UbYJwCkjHeE+XGkkQAU1pGApNoI+D7fDxBCEuCDv1uRLUkEfOJkUl6XibOX5/rNtkawR7qfOF2YdBLgg7/vlUh93/9AllnQidAgC7dXaZiSSABCuyik0Uh7G+CATwLMbHKr2qrsH0fwP7BfPbkwqdJ2VS1ZfzgXQ/A/4JOALM+/m9Qzq+ZK7Da/I/i/o6LnJWBnrOxKAlQSsTX/8ysJp+mkN72++rWgsvPXpZaV/edjHJXcEx1cn376f9EOYZnEM3PJ2u2ZZw+jG7d7wD+zwaC/7ILPNRmTvYY/9vzf5/f/y2bxSsKVzPs5ocuAJnNL2SFmdy4utgUVvzI69/Tht2PZEjBZyxr5dzEHf++DZzbi8rZfwfpnFnPw9/wzm1l/uOS3UUZdDfDPzFdqCP5/NGjutiVgammU/71kEgC3egnqk1qKtAUf8FsCLtB8a2JDH4F78EKefrZ6uU6l2OqZufL2KJ/ZuWertdq79tsofj/eJ0/DTgT8P88nGP6ZfZXyJT+f4apWQe//u6pNlLdKnkQyWwCb7Stz2sxeSDCs6/ZSvxV8ki/XFkWx1Mjs2inK3D33Mn5glq2l8DLeK3HvttWyG3LyKWu9TGytMH2QwjOz9tLsVnN3IZPsmp08Ke/5VeNA5DZB//P8BUBTzeJ3CVjmkt5UbmhMJgHwti4u/h7S7VMpfaGdxub8lTkxbWei37uXdMt9DlvyyXvEtauu0lO67R4T3Uj5ZXyQDLhn4p/bN+65+YTg8Gem5W8+UZop8o2YGvyG6d0zkzmXEJw/yjMry6zz1SDvpPrMjst9Ly+pZPclYLv9/OsUpgB6SSUAry9eXctUf5JQuBJk6OfPQ3YwT+GMnOnxAj4antnx+SrBG3kzyzM7ve35xecW8van2sb004ffSSKSSgA2L169qap3JBycBgCQhAi6/5M7tZHQKQAJbiCQcBoAQCJC7/73GiZRnw46rqQSgJnOo43g5qmr3BIAqDkVDf5dl1pPVloVAPFHUGTox6VOw++H+c5YAYCa+s8PiwuBX/5THXuVxKSXAGh4JZ6pvGBYCIDayjO5IYErtQxqcTgOySUARZEHtw3gamM3qAIAqCPf/Bd05/++RmOqI4lJLgHw5ztVgxv1OEsVAEAdDZr9ZQlcdWFTgpc1JZcAeKVoeKUeqgAAasav/sd5+dJJpVj+95JMAPr9xlpw2wBUAQDUTAyrfy/F8r+XZALgtwHKsG4H3EMVAEBNxLL6T7X87yWZAOzREK82pQoAoBZiWf2nWv73kk0AqotiwtsGoAoAIHqvLy62Y1j9e6mW/72EKwC+GVDuSXhm/zQ1YDoggGhlakHf+PeOraRa/veSTgBUyyDnPpvZTe4IABAjf+Vv6FP/DmQW4ImwMUo6AZhZf7ShEub4R+4IABCb/ca/KN5dqtZNbfb/x5JOALyByW0JkJ+ctf3jVRoCAURjr/FPWxKB0izId/84JZ8ABNsM6Fipt3xGLQAQOF/6j6Xxz6/++/2ppK7+/ZTkEwAv0GZAb9aaRSTNNABSFVPp3zOTjp8HI4kjAZDqgqC7wVYB2AoAELiYSv9e1mgmX/73SADkYDJgsFWAaivAldfmBAACE1Ppf0/aR//eRwKwL+QqgDPrvsEeMyAIQEhiK/17rP7fIQHYF3oVwJfXGBAEICRls/88ptI/q/8PkQC8J/AqQDUgiH4AACHYurR4K67gz+r/YyQA7wm/CkA/AIDJ255fvOEWS8sSFVb/HyMB+EjoVQDZ7wdgPgCASfDvnjK64M/q/1NIAD4SQxXAl93KZkFTIICxqoK/2/dXlcjePaz+P4UE4BMiqAJ4c1PN/h0BgDEwt+CIr+mv0mP1/2kkAJ/gqwAmMcyJ1qWdqhEHAEZrO/dTSbUlsTG5x+r/01RwqO1LV1+ZaUsCl6ksn326SoYLYCS2L/18x59Cksj4mf/nnj78VvBJVAA+Y1DqdYmAb8jZuXQ1oklcAGLhj/vFGPw9bvz7PCoAX7B9cfGxqSxIBDK1pbNPHz4QABiC6qx/hB3/npqsnXu2ellwKCoAX6B5/ksEDYGV0nSFGQEAhiHm4O9V7258FgnAF/jmkTgaAveoZM9JAgCcRuzB3/1/v03j35exBXBE2/OLz/3VvBKHnvvMXp9+uromAHAM0a/8afw7MioAR1RKGVM5adZ9Az+mMRDAcUS/8nc0a14QHAkJwBHNrD/aKF1ZSSLiewK4PAjAUdQh+FP6Px62AI5pa37xhfshqj125gQA+JxYz/m/j9L/8VEBOKaskV+O5VTAgb05AUwMBPAhP953yx91jjz4e5T+j48KwAlsXrx6U1UjnMNvK7v95i9+1LEASJq/2GfQ7K+p6HmJnSv9Tz9bXRYcCwnACUV2KuA91s36zQtnO+yTAananF+aU+k/jnK2/0fUpHPu2Sqr/xNgC+CE/tvPL/s9J4mOVtd5+uxfACSnOh1kRYy3+v2Bfwdrnkcxsj1EJAAn5MvosdwV8Ec+CShecUIASIvv9Peng1RlVmrApPyFrv+TYwvglOLtB9iTmVw4+2y1IwBqyzf77TSLx3FuWx6Cff9TowJwSjPPHt71l05IpAaRXHQE4GT8fv92s/+iTsHf7/sT/E+PBGAI/lvk1+PsB/AlIPtJANTS9vziDZXiRR32+w+w7z88bAEMyc5fl1pl4b7RItxby/rFt2c7f+8KgFqoZcl/Ty9r5N+x7z8cVACGxH9BDkyizEoHWdYWALWwc3GxXbeS/wEzY9TvEJEADNGf/7m6Ftt9AZVGxvXBQOT8qt+P9C1VanHE7w/cu9X3XAmGhgRgyL56trpclnJPIuL21L4XANF6u+qvwUjfT3HVjHs0/Q0fPQAjsu3na0fUYb/bz79mRDAQl2qvf2pwq66Bf4+9nF5/SJVyBKgAjIg/GeB+2JBITDd3WwIgGnVf9XvVDX/9ZlswEiQAI+JX0/7mwFiOB+4O4rriGEiVH+Ptb/Cr7V7/vuq4X9a8oFQmR4YEYIR8t2r1BRxDEkAjIBA8f633IK+OG9d6gNdB8Kfjf7RIAEYsliSgIfYXARAkX+7fmr/6qjRZrssc/8/oEfzHgwRgDA6SADEJtpTlyolUAIDA+HK/v3q87uX+9/RMcoL/mJAAjIn/gi5FLkuorParCiAab8/0N4tXdRzoc4gq+M+sr0TTPB27XDA2RZFvTDULCZO2BMBE+cD/Zqq4sVUWNzWtpJzgPwHMARizrfmfX7lv85YEiFkAwGQcBP5BKTcT2OP/GMF/QqgAjJmKdV1JryUBOiNv/IuHBAAYk49X/JrekozgP0EkAGPmgv9vAiBpBH6O+oWABGDcTLrBbrzkecv9vSsARoLAv4fgHwYSAAAYMX+cT6aKa6kHfo/gHw4SgDEz1a76jQAAtecH+JjKrVKKtv+2Tznw77GXZ3ebbcb7hoEEAACGyJf5t/L+Uqb6U5nOGf4vcvnPg+l+8ybBPxwkAAAwBJvzV+Yamv1UlflVZ6nzvcfk9syz1WVBUEgA8E5RdAXAkb2/2vcT+0rK/H9gZr/MPHt4VxAcEoAxU7MW45eAuPm9/YGWC1tWXGO1/2m+2W9Q6vWvnj3sCIJEAoC33sgZ9uaAQ+y0/9aSqfxaKbpUukRe/VUqJPOHsJeaNRe+ekqnf8hIAMZNw5wC6DEGGPjQQdA3c+V939BXLfVZ73/O22a/dd4noSMBGDO3YPgmzNeHdQXAIUEfR8F+f1xIAMbMvUuCvOhDTbsCJMrv6buK/vcH5X2C/vGw3x8nEoDxm5MAWWb/FiARVfd+c7DQkPL7gelC6W/go7x/ImrSOdtvXuZ8f3xIAMZos30lyODvWYIVgK0fFheaWdmdWn/ETWQ1V83gz4s5v8r3pf1tKdq+f8+t+Dm2dwq+5D9NyT9aJABjVDayVkPClFlaPQBblxZvucXect9FhM2Li70sk05Z2q9Tah0SgvgdBPyB2Jw/o79lxZy+XeXjtHzJv7Tm5ZlnXOMbMxKAMcoCHgvq1kHJfCNvX/r5jlu53Dz4tQ8MblW4oKoLffckqoRAZcPtBT9puOdypsg3KG+GzQf87amibVa2M8nOHwR893VdxXxW+cPjnue9c7vNZb4n4kcCMEZulXk+1AXIGxfkJAFbP/5830pb+tx/p0oIXLLmEgLfBS7bzUK25hfd87ENE1clENmgSjA576/uGyrnS83a21ZUjXv+bD4Bf2R6ooPrM0//b01QC3ybjMnv7qU11Sx+lzD1ptdXv5aa88FfvhD8j8N983RKKV+KaZdKwWj4I3nlVD7nkrbWQbAX36WPsaoa/YqcRr+aoQIwJrlvQAqU++au9Wq2Kg//aXBnmMG/+uf6KoFkbZ8JHFQK3m4fkBgcy8eB3kRbpcncQXe+q8ZUz9gFf8FY9dx22W0a/eqJBGBMVH3wCbPg4l6yL6Wm9o579Tta6nkZg7fbB5L9ITFwv9f1f5VW/naQHDS07DX7U926Jwj+89Bv7rb6mrV8kHdZZyvT7BsX6Of8in4vuL8X6IUy/sSZrJ0r8uskr/VFAjAm7l32vQTKJQAdqaG3wV/GE/w/p+pAF5lzC9i5gxnyPtCV7uf99xMEt+JS8R3W+puJuT3XrNc4OKFRFN0zcqYXygu52ouXN7ONZjZbBXbTWX/ZlWk565Kbv/hVvKn6gD+7rcVs1Qa7H+T9AzDO3Qfp3VCf1Y6g1sixx8Cf/9dm9kIClTXyb8/+o16XdoQU/EfBBc+eZtpzP+n5pKH6oLqPuV/7nw7E/q2WnShRyNS++eDPEm1VP+r+jy6g7yc0qBnf4T/dz+nwTwQVgHFoZEsSKJ/t1y3477SXWtvN/nMX/FtSU3tn2q0Kwm/X0FYF673f9z+eML0vD/sf7u+/U5qvH9/kNxC5zao/LSQAY6CZ/RRqscW90ztSIz74ly74u+fdEgBfQpNfwkgARuy1v2REwr0C2J9rl5og+ANHR7kfJAAjFnL3v9doTHWkBgj+wNH4cn+p+S8z64zxTR27eSNUDf/Ji1cSasOU2sb004ffSeQI/sBR2MvS9Cb7/DhABWCEms3dBZEs2G5pM42+/E/wB76oZ1K6Ff+jFQHeQwIwQip6SwLWMIl6pvfm/NLcwArf7c+RNOCPeqXJvZkiv8s+Pz6FBGBEQm/+q47/rT/sSKR2Ll29NiiLu5xHB/6AwI8jIQEYkYbKrZBnnMV8/G97fvGGe8Hd5Tw68AECP46FBGAE/OQ/Pw9eApaZPpAIbV1avOWSl2UBcIDAjxMhARiFpt6UgMVY/h/VjX5AxAj8OBUSgCGrutKluCYBi63875/pVrO/Nq4b/YCQ7V/W84DAj9MiARgyaxb3JXBNsXsSCd/pX0r/cZ3n+gNH8XZef8TNuwgLbVRD5Dv/M5XnEjC/ejj39OG3EgHf7GcizChH0rioB6NCBWCIMrX7oedUpdltCdzBfr+x3490sb+PkSMBGJLN+StLMUyjC332vy/5b7uSv5TaEiAxfrVvmdw7t5t3CPwYNRKAIahWrNIPeurfHls5+4+VrgRq73x/sez2KRjug5T4Ub0PzLI1yvwYJxKAIdieKm6IaUsCF+rZf9/l75sn/ewEhvsgFaz2MWm8bk9p7zKa4pUELtTmv61L/7NgZeM+I32RiA23t/+EvX2EgArAKcVw7M8Lrfmv2jbJ3bMzWWDVj5qjxI8g8eo9Bd/4p5IFnwCEtvqvGiYtu8OqHzVG0EfwqACc0N7Evxga/8JZ/b+/10/qiRoi6CMqJAAnNGj2l2OYTudX/1k21ZEJ8uX+N1PFjUFZ3HRxn1U/akS7JoMnBH3EiATgBPZK/xr0vP8Dfu7/uQke/fNNftvWv+NPSbDXjzrw3ful2JNGMVg72/l7V4BIkQAcU0ylfy9rNCdS/t+5uNg2FX91b1uAuFWlfZHGxnS/sUb3PuqCBOCYyry4E8PEvz3jH/yz9cPigmZyoxQCP6LV21/l/8oqH3VGAnAMW5cWb/ljaxKJca3+3+3xy01RmTUBovI24JvoBnv5SAW7skcUy8Cfd2xlev3hdRkhX+YfaLkgll3jSB/iod1Mys7A5KXb0O/MrD/aECBBVACOoBpa0+w/jyhf6o1q9e+fxVbeX8pUf/JlfpWMNBIh86v7jVLLl2WZdb4aMHYXOEACcAQ7U4NbMcz6f8vk3jD3/qug3xwsZGLXtqVwQV+FMj8CtFfK1/I337DX6Pc77N8DhyMB+AJ/Q52Z3ZRI+HP/2miuyCm9v9LfsmLOn98n6O+xQXndsqzr0qA5V0JuZZKdd89mTphxMC49l4F2M7UNX8YvTbus7IHjo3j7GfHt+/vFf3nd7WmuyDFVjXx5Mecq+t/7o3tGF/+n9EQH16ef/t/ap37TP8PN3CdL7yUG5pICrZIDHJt21WyjWtG7IO8DfXNQbLCqB4aDBOAQe8G/2vdvSSSOM/O/6muYKtpmZdsHqtJkjka+z+qZ5Bdm1ldO1DC20/5bq8jzlqq2XFBrudXrNyba2k8QWpJe9cCt1rXnA7z7uu254P6bqXZdta2bF0WXIA+MHlsAh9i75U9bEpFPzfyvVvbyZnbQbLZ94NFMXLla57ataPmNfN/E50v7TOk7XLWtkjUvnKavYj+gdQ/7/b0+i12XEGSzPilwCUJLRWerRMF0tjpeWSUL7udiLQmSdqu/i3XdF1XvbWAXc5WTrHcQ3M/ImR7lemDyeO1/wv55/2WJjHvB/uJLzw3Rv1SrS7fadB9sCU5sGMF/VHxVwf/oKwsHH6uSh0zfVhP0lJ9/vyr/4NcuiFf/XC17jX5ZBXFW60CcSAA+UjX9idwVQOzlub6rnLBaBVBDJADv2ZxfmlMpXgiS55LABzPrq0sCADWVCSq+6U+l/1gAk9sEfwB1RwVA4uz4x0j0bFD+MvOv4x+jBIDYcArAGTT7ayraEiTLN/uV1rw886+THfMDgNgknwBs/fjzfSntvCBZfnzs2X7zMs1+AFKSdAJQHfcrbUmQLrfff+7Z6rIAQGKS7QGI9aw/hqZXmlzm7ncAqUryFADBP22+5J818u8I/gBSllwFYOfS1Wul6YogSSZyb2Z9NZrbHQFgVJJKAAj+6fJd/oNSr7PqB4A9ySQATPlLmMnauSK/Tpc/ALyTxCkAP+hnYMVzxh4lx99Ad3vm2UPudgCAjySRAFhe3OGu+7T4Rj/N8+sh3uIHACGo/Zr49cXFdqbyXJAKVv0AcAS1rwCo+kE/1P6TwF4/ABxZ/RMAUcb81hwd/gBwfCn0AMwJ6spP87s302/eZdUPAMfDbYCI0kGT3zRNfgBwIgkkANp1m8MtQS1Q7geA4UigB8C6JtISxK7q7p9ep7sfAIah9pcBlWZPBDHz+/y3z/XzbznaBwDDU/vzcb+3l2an8uKVMAgoKn6Pv9Tywcz6oxUBAAxdEgfkX19cXM5UbgmC5wP/QOQ2e/wAMFpJnAIoivzuVLP4STgSGKqeSfnALFsj8APAeCQzIm/nr0stK/vPzbQlCEJV5hd7Ml00VzjHDwDjldSMXN8P0Gz276roNcGksNoHgAAkOSR/c/6K2wrQmyQC4+NX+5bJvXO7eYfVPgBMXtK35JirCGw1dxcyy34ylQXBUFHiB4BwcU3evo+Sgbb7EMcGj6/ngv5Gqfpgut9YI+gDQLhIAA7x+uJiW7VcUMu+d0+J0wOH0q7J4Inf058p8g2CPgDEgQTgCPwJgsFgt+0eV1tNz6edEGg3k7IzkOzXRr/fOdv5e1cAANEhATgBnxD0+8VclpXtTLLztjdfoI5bBj0x6ZqWv4o0Ngj4AFAfJABD4k8WlGXWepsUmEsIoqoUaFfNuqWWL32wFxlszKw/2hAAQC2RAIyQbyzczIs5nww0XDKQqX1joq3JJQcuyItVf5Wmv5n6q5IHG9P9qS579wCQFhKACdo/eeASgmzWJwVZprNuFd6qfk/L2YboXz7474u29n/SU7UPAvZA7N9qWW/vf+sDu/vRrejzouiekTM9AjwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAECA/j8Qp3yEfC7wkgAAAABJRU5ErkJggg==", }, ] @@ -92,7 +94,9 @@ exports[`Story Snapshots: AvatarExternalProviderUrl should match snapshot 1`] = source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://hips.hearstapps.com/hmg-prod/images/dog-puppy-on-garden-royalty-free-image-1586966191.jpg?crop=0.752xw:1.00xh;0.175xw,0&resize=1200:*&format=png&size=112", }, ] @@ -147,7 +151,9 @@ exports[`Story Snapshots: AvatarPath should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=112", }, ] @@ -202,7 +208,9 @@ exports[`Story Snapshots: AvatarRoomId should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/room/devWBbYr7inwupPqK?format=png&size=112", }, ] @@ -257,7 +265,9 @@ exports[`Story Snapshots: AvatarText should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/Avatar?format=png&size=112", }, ] @@ -312,7 +322,9 @@ exports[`Story Snapshots: AvatarUrl should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://user-images.githubusercontent.com/29778115/89444446-14738480-d728-11ea-9412-75fd978d95fb.jpg", }, ] @@ -367,7 +379,9 @@ exports[`Story Snapshots: Channel should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/@general?format=png&size=112", }, ] @@ -422,7 +436,9 @@ exports[`Story Snapshots: Children should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/Avatar?format=png&size=112", }, ] @@ -516,7 +532,9 @@ exports[`Story Snapshots: CustomBorderRadius should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/Avatar?format=png&size=112", }, ] @@ -573,7 +591,9 @@ exports[`Story Snapshots: CustomStyle should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/Avatar?format=png&size=112", }, ] @@ -628,7 +648,9 @@ exports[`Story Snapshots: Direct should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=112", }, ] @@ -748,7 +770,9 @@ exports[`Story Snapshots: RoomAvatarExternalProviderUrl should match snapshot 1` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://cdn.pensador.com/img/authors/ho/me/homer-simpson-l.jpg?format=png&size=112", }, ] @@ -803,7 +827,9 @@ exports[`Story Snapshots: Static should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://user-images.githubusercontent.com/29778115/89444446-14738480-d728-11ea-9412-75fd978d95fb.jpg", }, ] @@ -938,7 +964,9 @@ exports[`Story Snapshots: Touchable should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/Avatar?format=png&size=112", }, ] @@ -996,7 +1024,9 @@ exports[`Story Snapshots: WithETag should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/djorkaeff.alexandre?format=png&size=112&etag=5ag8KffJcZj9m5rCv", }, ] @@ -1051,7 +1081,9 @@ exports[`Story Snapshots: WithoutETag should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/djorkaeff.alexandre?format=png&size=112", }, ] @@ -1106,7 +1138,9 @@ exports[`Story Snapshots: WrongServer should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://google.com/avatar/Avatar?format=png&size=112", }, ] diff --git a/app/containers/Chip/__snapshots__/Chip.test.tsx.snap b/app/containers/Chip/__snapshots__/Chip.test.tsx.snap index 113a0ba0758..ef93cfb3e89 100644 --- a/app/containers/Chip/__snapshots__/Chip.test.tsx.snap +++ b/app/containers/Chip/__snapshots__/Chip.test.tsx.snap @@ -208,7 +208,9 @@ exports[`Story Snapshots: ChipText should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=56", }, ] @@ -391,7 +393,9 @@ exports[`Story Snapshots: ChipWithShortText should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=56", }, ] @@ -804,7 +808,9 @@ exports[`Story Snapshots: ChipWithoutIcon should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=56", }, ] diff --git a/app/containers/DirectoryItem/__snapshots__/DirectoryItem.test.tsx.snap b/app/containers/DirectoryItem/__snapshots__/DirectoryItem.test.tsx.snap index 376f230643c..0e4ed472dce 100644 --- a/app/containers/DirectoryItem/__snapshots__/DirectoryItem.test.tsx.snap +++ b/app/containers/DirectoryItem/__snapshots__/DirectoryItem.test.tsx.snap @@ -153,7 +153,7 @@ exports[`Story Snapshots: CustomStyle should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@S?format=png&size=60", }, @@ -433,7 +433,7 @@ exports[`Story Snapshots: Default should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@G?format=png&size=60", }, @@ -713,7 +713,7 @@ exports[`Story Snapshots: DirectMessage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/A?format=png&size=60", }, @@ -962,7 +962,7 @@ exports[`Story Snapshots: LongRoomName should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@L?format=png&size=60", }, @@ -1452,7 +1452,7 @@ exports[`Story Snapshots: TeamMain should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@E?format=png&size=60", }, @@ -1732,7 +1732,7 @@ exports[`Story Snapshots: WithRightLabel should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@G?format=png&size=60", }, @@ -2031,7 +2031,7 @@ exports[`Story Snapshots: WithoutDescription should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@N?format=png&size=60", }, diff --git a/app/containers/InAppNotification/IncomingCallNotification/index.tsx b/app/containers/InAppNotification/IncomingCallNotification/index.tsx index 6febc388c0b..c0b6aac0ec4 100644 --- a/app/containers/InAppNotification/IncomingCallNotification/index.tsx +++ b/app/containers/InAppNotification/IncomingCallNotification/index.tsx @@ -133,7 +133,7 @@ const IncomingCallNotification = ({ }: { notification: { rid: string; callId: string }; }): ReactElement | null => { - const { result } = useEndpointData('video-conference.info', { callId }); + const { result } = useEndpointData('/v1/video-conference.info', { callId }); const user = useUserData(rid); diff --git a/app/containers/InAppNotification/__snapshots__/NotifierComponent.test.tsx.snap b/app/containers/InAppNotification/__snapshots__/NotifierComponent.test.tsx.snap index 299c643eae3..868a63c1120 100644 --- a/app/containers/InAppNotification/__snapshots__/NotifierComponent.test.tsx.snap +++ b/app/containers/InAppNotification/__snapshots__/NotifierComponent.test.tsx.snap @@ -158,7 +158,7 @@ exports[`Story Snapshots: ChannelMessage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/room/rid-1?format=png&size=96", }, @@ -504,7 +504,7 @@ exports[`Story Snapshots: DirectMessage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=96", }, @@ -850,7 +850,7 @@ exports[`Story Snapshots: WithBlackTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/room/rid-1?format=png&size=96", }, @@ -1196,7 +1196,7 @@ exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/room/rid-1?format=png&size=96", }, diff --git a/app/containers/MessageComposer/MessageComposer.test.tsx b/app/containers/MessageComposer/MessageComposer.test.tsx index fdcf1a973e0..20fc32ef070 100644 --- a/app/containers/MessageComposer/MessageComposer.test.tsx +++ b/app/containers/MessageComposer/MessageComposer.test.tsx @@ -799,8 +799,7 @@ describe('MessageComposer', () => { type: 'image/png' }), undefined, - 'https://open.rocket.chat', - expect.objectContaining({ id: 'abc' }) + 'https://open.rocket.chat' ) ); expect(onSendMessage).not.toHaveBeenCalled(); diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx index 40ee12556aa..cfbffdca1a0 100644 --- a/app/containers/MessageComposer/MessageComposer.tsx +++ b/app/containers/MessageComposer/MessageComposer.tsx @@ -31,7 +31,6 @@ import EmojiPicker from '../EmojiPicker'; import { MessageComposerContent } from './components/MessageComposerContent'; import { useTheme } from '../../theme'; import { useAppSelector } from '../../lib/hooks/useAppSelector'; -import { getUserSelector } from '../../selectors/login'; import { sendAttachments } from '../../lib/methods/sendFileMessage/sendAttachments'; import { useAltTextSupported } from '../../lib/hooks/useAltTextSupported'; @@ -63,7 +62,6 @@ export const MessageComposer = ({ const recordingAudio = useRecordingAudio(); const { formatShortnameToUnicode } = useShortnameToUnicode(); const { colors } = useTheme(); - const user = useAppSelector(state => getUserSelector(state)); const server = useAppSelector(state => state.server.server); const altTextSupported = useAltTextSupported(); const attachments = useComposerAttachments(); @@ -134,7 +132,6 @@ export const MessageComposer = ({ rid, tmid, server, - user: { id: user.id, token: user.token }, altTextSupported, getMsg: ({ description }, index) => (index === 0 ? description || quotedMessage || textFromInput : description) }); diff --git a/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx b/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx index 296505efd85..ac9bd529297 100644 --- a/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx +++ b/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState, type ReactElement } from 'react'; import { Audio } from 'expo-av'; import { getInfoAsync } from 'expo-file-system/legacy'; import { useKeepAwake } from 'expo-keep-awake'; -import { shallowEqual } from 'react-redux'; import { useTheme } from '../../../../theme'; import { BaseButton } from '../Buttons'; @@ -32,7 +31,6 @@ export const RecordAudio = (): ReactElement | null => { const { setRecordingAudio } = useMessageComposerApi(); const { rid, tmid } = useRoomContext(); const server = useAppSelector(state => state.server.server); - const user = useAppSelector(state => ({ id: state.login.user.id, token: state.login.user.token }), shallowEqual); const permissionToUpload = useCanUploadFile(rid); useKeepAwake(); @@ -108,7 +106,7 @@ export const RecordAudio = (): ReactElement | null => { if (fileInfo) { if (permissionToUpload) { - await sendFileMessage(rid, fileInfo, tmid, server, user); + await sendFileMessage(rid, fileInfo, tmid, server); } } } catch (e) { diff --git a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx index 3d0b22be100..9275e849254 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -171,6 +171,10 @@ jest.mock('../../lib/services/voip/acceptNativeCall', () => ({ jest.mock('../../lib/services/voip/playCallEndedSound', () => ({ playCallEndedSound: jest.fn() })); +// mediaCallsStateSignals → restApi → API call. Mock to prevent network requests in tests. +jest.mock('../../lib/services/restApi', () => ({ + mediaCallsStateSignals: () => Promise.resolve({ signals: [], success: true }) +})); const mockHideActionSheet = jest.fn(); jest.mock('../ActionSheet', () => ({ diff --git a/app/containers/NewMediaCall/__snapshots__/PeerItem.test.tsx.snap b/app/containers/NewMediaCall/__snapshots__/PeerItem.test.tsx.snap index 43d9d449fdf..1238850476e 100644 --- a/app/containers/NewMediaCall/__snapshots__/PeerItem.test.tsx.snap +++ b/app/containers/NewMediaCall/__snapshots__/PeerItem.test.tsx.snap @@ -120,7 +120,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/alice.johnson?format=png&size=56", }, @@ -450,7 +450,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/very.long.username?format=png&size=56", }, diff --git a/app/containers/NewMediaCall/__snapshots__/PeerList.test.tsx.snap b/app/containers/NewMediaCall/__snapshots__/PeerList.test.tsx.snap index edea1b8d2ff..3e242201d58 100644 --- a/app/containers/NewMediaCall/__snapshots__/PeerList.test.tsx.snap +++ b/app/containers/NewMediaCall/__snapshots__/PeerList.test.tsx.snap @@ -315,7 +315,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/alice.johnson?format=png&size=56", }, @@ -520,7 +520,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/very.long.username?format=png&size=56", }, @@ -725,7 +725,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.smith?format=png&size=56", }, diff --git a/app/containers/NewMediaCall/__snapshots__/SelectedPeer.test.tsx.snap b/app/containers/NewMediaCall/__snapshots__/SelectedPeer.test.tsx.snap index 1ee4385af09..02088725e5a 100644 --- a/app/containers/NewMediaCall/__snapshots__/SelectedPeer.test.tsx.snap +++ b/app/containers/NewMediaCall/__snapshots__/SelectedPeer.test.tsx.snap @@ -101,7 +101,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/alice.johnson?format=png&size=56", }, @@ -509,7 +509,7 @@ exports[`Story Snapshots: All should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/this.is.a.very.long.username.for.storybook.preview?format=png&size=56", }, @@ -757,7 +757,7 @@ exports[`Story Snapshots: LongUsername should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/this.is.a.very.long.username.for.storybook.preview?format=png&size=56", }, @@ -1191,7 +1191,7 @@ exports[`Story Snapshots: User should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/alice.johnson?format=png&size=56", }, diff --git a/app/containers/RoomItem/__snapshots__/RoomItem.test.tsx.snap b/app/containers/RoomItem/__snapshots__/RoomItem.test.tsx.snap index fecaa188218..ea6370e963d 100644 --- a/app/containers/RoomItem/__snapshots__/RoomItem.test.tsx.snap +++ b/app/containers/RoomItem/__snapshots__/RoomItem.test.tsx.snap @@ -489,7 +489,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -1131,7 +1131,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -1814,7 +1814,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -2497,7 +2497,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -3180,7 +3180,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -3863,7 +3863,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -4546,7 +4546,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -5229,7 +5229,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -5912,7 +5912,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -6595,7 +6595,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -7278,7 +7278,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -7965,7 +7965,7 @@ exports[`Story Snapshots: Basic should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -8596,7 +8596,7 @@ exports[`Story Snapshots: CondensedRoomItem should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -9279,7 +9279,7 @@ exports[`Story Snapshots: CondensedRoomItem should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -9962,7 +9962,7 @@ exports[`Story Snapshots: CondensedRoomItem should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=72", }, @@ -14423,7 +14423,7 @@ exports[`Story Snapshots: InvitedRoom should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -15078,7 +15078,7 @@ exports[`Story Snapshots: InvitedRoom should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -15738,7 +15738,7 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -16396,7 +16396,7 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -17054,7 +17054,7 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -17712,7 +17712,7 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -18370,7 +18370,7 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -19084,7 +19084,7 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -19798,7 +19798,7 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -20517,7 +20517,7 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -21144,7 +21144,7 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -21771,7 +21771,7 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -22398,7 +22398,7 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -23025,7 +23025,7 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -23652,7 +23652,7 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -24279,7 +24279,7 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -24906,7 +24906,7 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -25533,7 +25533,7 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -26160,7 +26160,7 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -26792,7 +26792,7 @@ exports[`Story Snapshots: Tag should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -27455,7 +27455,7 @@ exports[`Story Snapshots: Tag should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -28150,7 +28150,7 @@ exports[`Story Snapshots: Tag should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -28813,7 +28813,7 @@ exports[`Story Snapshots: Tag should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -29512,7 +29512,7 @@ exports[`Story Snapshots: Touch should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -30143,7 +30143,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -30778,7 +30778,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -31405,7 +31405,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -32032,7 +32032,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -32659,7 +32659,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -33286,7 +33286,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, @@ -33913,7 +33913,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -34540,7 +34540,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -35167,7 +35167,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/@rocket.cat?format=png&size=96", }, @@ -35799,7 +35799,7 @@ exports[`Story Snapshots: User should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=96", }, @@ -36434,7 +36434,7 @@ exports[`Story Snapshots: User should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=96", }, diff --git a/app/containers/UIKit/VideoConferenceBlock/index.tsx b/app/containers/UIKit/VideoConferenceBlock/index.tsx index 53a88e36cf8..5e3e866cd79 100644 --- a/app/containers/UIKit/VideoConferenceBlock/index.tsx +++ b/app/containers/UIKit/VideoConferenceBlock/index.tsx @@ -8,7 +8,7 @@ import VideoConferenceSkeletonLoading from './components/VideoConferenceSkeleton import VideoConferenceIssue from './components/VideoConferenceIssue'; export default function VideoConferenceBlock({ callId, blockId }: { callId: string; blockId: string }): ReactElement { - const { result, error } = useEndpointData('video-conference.info', { callId }); + const { result, error } = useEndpointData('/v1/video-conference.info', { callId }); if (result?.success) { const { users, type, status, createdBy, rid } = result; diff --git a/app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap b/app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap index 674d94dd4b5..8a729542d72 100644 --- a/app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap +++ b/app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap @@ -245,7 +245,7 @@ exports[`Story Snapshots: Archived should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -708,7 +708,7 @@ exports[`Story Snapshots: ArchivedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -1179,7 +1179,7 @@ exports[`Story Snapshots: AttachmentWithTextAndLink should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -1950,7 +1950,7 @@ exports[`Story Snapshots: AttachmentWithTextAndLinkLargeFont should match snapsh [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -2721,7 +2721,7 @@ exports[`Story Snapshots: Avatar should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/no.avatar?format=png&size=72", }, @@ -3179,7 +3179,7 @@ exports[`Story Snapshots: Avatar should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -5032,7 +5032,7 @@ exports[`Story Snapshots: Basic should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -5490,7 +5490,7 @@ exports[`Story Snapshots: Basic should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -5961,7 +5961,7 @@ exports[`Story Snapshots: BasicLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -6419,7 +6419,7 @@ exports[`Story Snapshots: BasicLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -6890,7 +6890,7 @@ exports[`Story Snapshots: BlockQuote should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -7381,7 +7381,7 @@ Testing block quote" [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -7931,7 +7931,7 @@ exports[`Story Snapshots: BlockQuoteLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -8422,7 +8422,7 @@ Testing block quote" [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -8972,7 +8972,7 @@ exports[`Story Snapshots: Broadcast should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -9563,7 +9563,7 @@ exports[`Story Snapshots: BroadcastLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -10154,7 +10154,7 @@ exports[`Story Snapshots: CollapsedAttachments should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -10764,7 +10764,7 @@ exports[`Story Snapshots: CollapsedAttachments should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -11589,7 +11589,7 @@ exports[`Story Snapshots: CollapsedAttachmentsLargeFont should match snapshot 1` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -12199,7 +12199,7 @@ exports[`Story Snapshots: CollapsedAttachmentsLargeFont should match snapshot 1` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -13024,7 +13024,7 @@ exports[`Story Snapshots: CollapsibleAttachmentWithText should match snapshot 1` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -13636,7 +13636,7 @@ exports[`Story Snapshots: CollapsibleAttachmentWithText should match snapshot 1` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -14602,7 +14602,7 @@ exports[`Story Snapshots: CollapsibleAttachmentWithTextLargeFont should match sn [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -15214,7 +15214,7 @@ exports[`Story Snapshots: CollapsibleAttachmentWithTextLargeFont should match sn [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -16180,7 +16180,7 @@ exports[`Story Snapshots: ColoredAttachments should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -17412,7 +17412,7 @@ exports[`Story Snapshots: ColoredAttachmentsLargeFont should match snapshot 1`] [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -18644,7 +18644,7 @@ exports[`Story Snapshots: CustomFields should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -19457,7 +19457,7 @@ exports[`Story Snapshots: CustomFields should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -20541,7 +20541,7 @@ exports[`Story Snapshots: CustomFieldsLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -21354,7 +21354,7 @@ exports[`Story Snapshots: CustomFieldsLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -22438,7 +22438,7 @@ exports[`Story Snapshots: DateAndUnreadSeparators should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -22958,7 +22958,7 @@ exports[`Story Snapshots: DateAndUnreadSeparators should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -23686,7 +23686,7 @@ exports[`Story Snapshots: DateAndUnreadSeparators should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -24201,7 +24201,7 @@ exports[`Story Snapshots: DateAndUnreadSeparators should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -24672,7 +24672,7 @@ exports[`Story Snapshots: DateAndUnreadSeparatorsLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=93.60000000000001", }, @@ -25192,7 +25192,7 @@ exports[`Story Snapshots: DateAndUnreadSeparatorsLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -25920,7 +25920,7 @@ exports[`Story Snapshots: DateAndUnreadSeparatorsLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=93.60000000000001", }, @@ -26435,7 +26435,7 @@ exports[`Story Snapshots: DateAndUnreadSeparatorsLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -26906,7 +26906,7 @@ exports[`Story Snapshots: Discussion should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -27484,7 +27484,7 @@ exports[`Story Snapshots: Discussion should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -28064,7 +28064,7 @@ exports[`Story Snapshots: Discussion should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -28644,7 +28644,7 @@ exports[`Story Snapshots: Discussion should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -29237,7 +29237,7 @@ exports[`Story Snapshots: DiscussionLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -29815,7 +29815,7 @@ exports[`Story Snapshots: DiscussionLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -30395,7 +30395,7 @@ exports[`Story Snapshots: DiscussionLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -30975,7 +30975,7 @@ exports[`Story Snapshots: DiscussionLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -31568,7 +31568,7 @@ exports[`Story Snapshots: Edited should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -32337,7 +32337,7 @@ exports[`Story Snapshots: EditedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -33107,7 +33107,7 @@ exports[`Story Snapshots: Editing should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -33579,7 +33579,7 @@ exports[`Story Snapshots: EditingLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -34050,7 +34050,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -34507,7 +34507,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -34945,7 +34945,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -35496,7 +35496,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -35959,7 +35959,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -36441,7 +36441,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -36969,7 +36969,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -37426,7 +37426,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -37864,7 +37864,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -38415,7 +38415,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -38878,7 +38878,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -39360,7 +39360,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -39888,7 +39888,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -40646,7 +40646,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -41696,7 +41696,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -42008,7 +42008,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -42507,7 +42507,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -42915,7 +42915,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -43481,7 +43481,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -44312,7 +44312,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -45070,7 +45070,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -46120,7 +46120,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -46432,7 +46432,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -46931,7 +46931,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -47339,7 +47339,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -47905,7 +47905,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -48649,7 +48649,7 @@ exports[`Story Snapshots: Error should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -49092,7 +49092,7 @@ exports[`Story Snapshots: Error should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -49548,7 +49548,7 @@ exports[`Story Snapshots: ErrorLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -49991,7 +49991,7 @@ exports[`Story Snapshots: ErrorLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -50534,7 +50534,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`] [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -51131,7 +51131,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`] [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -51728,7 +51728,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`] [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -52325,7 +52325,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`] [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -53454,7 +53454,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -54051,7 +54051,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -54648,7 +54648,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -55245,7 +55245,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -56361,7 +56361,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -57126,7 +57126,7 @@ exports[`Story Snapshots: FullName should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -57597,7 +57597,7 @@ exports[`Story Snapshots: FullNameLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -58068,7 +58068,7 @@ exports[`Story Snapshots: GroupedMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -58526,7 +58526,7 @@ exports[`Story Snapshots: GroupedMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.?format=png&size=72", }, @@ -59436,7 +59436,7 @@ exports[`Story Snapshots: GroupedMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -59907,7 +59907,7 @@ exports[`Story Snapshots: GroupedMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -60365,7 +60365,7 @@ exports[`Story Snapshots: GroupedMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.?format=png&size=93.60000000000001", }, @@ -61275,7 +61275,7 @@ exports[`Story Snapshots: GroupedMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -61743,7 +61743,7 @@ exports[`Story Snapshots: Ignored should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -62072,7 +62072,7 @@ exports[`Story Snapshots: IgnoredLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -62404,7 +62404,7 @@ exports[`Story Snapshots: InlineKatex should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -62861,7 +62861,7 @@ exports[`Story Snapshots: InlineKatexLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -63310,7 +63310,7 @@ exports[`Story Snapshots: JitsiCall should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -63843,7 +63843,7 @@ exports[`Story Snapshots: JitsiCallLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -64384,7 +64384,7 @@ exports[`Story Snapshots: Katex should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -64809,7 +64809,7 @@ exports[`Story Snapshots: KatexArray should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -65234,7 +65234,7 @@ exports[`Story Snapshots: KatexArrayLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -65659,7 +65659,7 @@ exports[`Story Snapshots: KatexLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -66088,7 +66088,7 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -66726,7 +66726,7 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -67314,7 +67314,7 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -68099,7 +68099,7 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -68737,7 +68737,7 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -69325,7 +69325,7 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -70106,7 +70106,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -70564,7 +70564,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -71059,7 +71059,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -71552,7 +71552,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -71960,7 +71960,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -72490,7 +72490,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -72979,7 +72979,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -73418,7 +73418,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -73997,7 +73997,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -74576,7 +74576,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -75254,7 +75254,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001", }, @@ -75712,7 +75712,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001", }, @@ -76207,7 +76207,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001", }, @@ -76700,7 +76700,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001", }, @@ -77108,7 +77108,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001", }, @@ -77638,7 +77638,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001", }, @@ -78127,7 +78127,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001", }, @@ -78566,7 +78566,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001", }, @@ -79145,7 +79145,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001", }, @@ -79724,7 +79724,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001", }, @@ -80402,7 +80402,7 @@ exports[`Story Snapshots: Mentions should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -81039,7 +81039,7 @@ exports[`Story Snapshots: Mentions should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -81671,7 +81671,7 @@ exports[`Story Snapshots: MentionsLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -82308,7 +82308,7 @@ exports[`Story Snapshots: MentionsLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -82940,7 +82940,7 @@ exports[`Story Snapshots: Message should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -83350,7 +83350,7 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -84103,7 +84103,7 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -84910,7 +84910,7 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -85729,7 +85729,7 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -86439,7 +86439,7 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -87341,7 +87341,7 @@ exports[`Story Snapshots: MessageWithReadReceipt should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -88087,7 +88087,7 @@ exports[`Story Snapshots: MessageWithReadReceipt should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -88846,7 +88846,7 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -89592,7 +89592,7 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -90351,7 +90351,7 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -90981,7 +90981,7 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -91918,7 +91918,7 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -92725,7 +92725,7 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -93606,7 +93606,7 @@ exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -94223,7 +94223,7 @@ exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -94923,7 +94923,7 @@ exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -95704,7 +95704,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -96321,7 +96321,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -97021,7 +97021,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -97789,7 +97789,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -98608,7 +98608,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -99318,7 +99318,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -100220,7 +100220,7 @@ exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -100850,7 +100850,7 @@ exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -101524,7 +101524,7 @@ exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -102331,7 +102331,7 @@ exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -103213,7 +103213,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -104134,7 +104134,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -104571,7 +104571,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -105008,7 +105008,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -105445,7 +105445,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -105882,7 +105882,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -106319,7 +106319,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -106756,7 +106756,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -107193,7 +107193,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -107630,7 +107630,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -107967,7 +107967,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -108888,7 +108888,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -109325,7 +109325,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -109762,7 +109762,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -110199,7 +110199,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -110636,7 +110636,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -111073,7 +111073,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -111510,7 +111510,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -111947,7 +111947,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -112384,7 +112384,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -112720,7 +112720,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/jd?format=png&size=72", }, @@ -113092,7 +113092,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/jd?format=png&size=72", }, @@ -113786,7 +113786,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/john.doe?format=png&size=72", }, @@ -114158,7 +114158,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/john.doe?format=png&size=72", }, @@ -114852,7 +114852,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -115224,7 +115224,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -115931,7 +115931,7 @@ exports[`Story Snapshots: Pinned should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -116688,7 +116688,7 @@ exports[`Story Snapshots: PinnedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -117445,7 +117445,7 @@ exports[`Story Snapshots: Reactions should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -118447,7 +118447,7 @@ exports[`Story Snapshots: Reactions should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -119918,7 +119918,7 @@ exports[`Story Snapshots: ReactionsLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -120920,7 +120920,7 @@ exports[`Story Snapshots: ReactionsLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -122392,7 +122392,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButton should m [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -123196,7 +123196,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButton should m [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -123516,7 +123516,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButton should m [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -123836,7 +123836,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButton should m [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -124173,7 +124173,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButtonLargeFont [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -124977,7 +124977,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButtonLargeFont [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -125297,7 +125297,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButtonLargeFont [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -125617,7 +125617,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButtonLargeFont [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -126067,7 +126067,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReply should ma [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -126387,7 +126387,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReply should ma [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -126707,7 +126707,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReply should ma [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -127027,7 +127027,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReply should ma [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -127477,7 +127477,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReplyLargeFont [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -127797,7 +127797,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReplyLargeFont [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -128117,7 +128117,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReplyLargeFont [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -128437,7 +128437,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReplyLargeFont [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -128773,7 +128773,7 @@ exports[`Story Snapshots: ShowButtonAsAttachment should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -129304,7 +129304,7 @@ exports[`Story Snapshots: ShowButtonAsAttachment should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -130019,7 +130019,7 @@ exports[`Story Snapshots: ShowButtonAsAttachmentLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -130550,7 +130550,7 @@ exports[`Story Snapshots: ShowButtonAsAttachmentLargeFont should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -131265,7 +131265,7 @@ exports[`Story Snapshots: StaticAvatar should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://pbs.twimg.com/profile_images/1016397063649660929/14EIApTi_400x400.jpg", }, @@ -131736,7 +131736,7 @@ exports[`Story Snapshots: StaticAvatarLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://pbs.twimg.com/profile_images/1016397063649660929/14EIApTi_400x400.jpg", }, @@ -132119,7 +132119,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -132363,7 +132363,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -132607,7 +132607,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -132854,7 +132854,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -133098,7 +133098,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -133342,7 +133342,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -133586,7 +133586,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -133830,7 +133830,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -134074,7 +134074,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -134318,7 +134318,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -134562,7 +134562,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -134806,7 +134806,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -135050,7 +135050,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -135294,7 +135294,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -135538,7 +135538,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -135782,7 +135782,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -136026,7 +136026,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -136270,7 +136270,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -136514,7 +136514,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -136758,7 +136758,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -137002,7 +137002,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -137246,7 +137246,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -137490,7 +137490,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -137734,7 +137734,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -137978,7 +137978,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -138222,7 +138222,7 @@ exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=40", }, @@ -138457,7 +138457,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -138701,7 +138701,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -138945,7 +138945,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -139192,7 +139192,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -139436,7 +139436,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -139680,7 +139680,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -139924,7 +139924,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -140168,7 +140168,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -140412,7 +140412,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -140656,7 +140656,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -140900,7 +140900,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -141144,7 +141144,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -141388,7 +141388,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -141632,7 +141632,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -141876,7 +141876,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -142120,7 +142120,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -142364,7 +142364,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -142608,7 +142608,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -142852,7 +142852,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -143096,7 +143096,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -143340,7 +143340,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -143584,7 +143584,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -143828,7 +143828,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -144072,7 +144072,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -144316,7 +144316,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -144560,7 +144560,7 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52", }, @@ -144875,7 +144875,7 @@ exports[`Story Snapshots: Temp should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -145342,7 +145342,7 @@ exports[`Story Snapshots: TempLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -145817,7 +145817,7 @@ exports[`Story Snapshots: ThumbnailFromServer should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -146499,7 +146499,7 @@ exports[`Story Snapshots: ThumbnailFromServerLargeFont should match snapshot 1`] [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -147181,7 +147181,7 @@ exports[`Story Snapshots: TimeFormat should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -147652,7 +147652,7 @@ exports[`Story Snapshots: TimeFormatLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -148124,7 +148124,7 @@ exports[`Story Snapshots: Translated should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/other.user?format=png&size=72", }, @@ -148631,7 +148631,7 @@ exports[`Story Snapshots: TranslatedLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/other.user?format=png&size=93.60000000000001", }, @@ -149137,7 +149137,7 @@ exports[`Story Snapshots: TwoShortCustomFieldsWithMarkdown should match snapshot [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -150330,7 +150330,7 @@ exports[`Story Snapshots: TwoShortCustomFieldsWithMarkdownLargeFont should match [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -151523,7 +151523,7 @@ exports[`Story Snapshots: URL should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -152117,7 +152117,7 @@ exports[`Story Snapshots: URL should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -152994,7 +152994,7 @@ exports[`Story Snapshots: URLImagePreview should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -153490,7 +153490,7 @@ exports[`Story Snapshots: URLImagePreview should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -153942,7 +153942,7 @@ exports[`Story Snapshots: URLImagePreviewLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -154438,7 +154438,7 @@ exports[`Story Snapshots: URLImagePreviewLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -154890,7 +154890,7 @@ exports[`Story Snapshots: URLLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -155484,7 +155484,7 @@ exports[`Story Snapshots: URLLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -156361,7 +156361,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/jd?format=png&size=72", }, @@ -156733,7 +156733,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/jd?format=png&size=72", }, @@ -157427,7 +157427,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/john.doe?format=png&size=72", }, @@ -157799,7 +157799,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/john.doe?format=png&size=72", }, @@ -158493,7 +158493,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -158865,7 +158865,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72", }, @@ -159572,7 +159572,7 @@ exports[`Story Snapshots: WithAlias should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -160049,7 +160049,7 @@ exports[`Story Snapshots: WithAlias should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.?format=png&size=72", }, @@ -160539,7 +160539,7 @@ exports[`Story Snapshots: WithAliasLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -161016,7 +161016,7 @@ exports[`Story Snapshots: WithAliasLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.?format=png&size=93.60000000000001", }, @@ -161506,7 +161506,7 @@ exports[`Story Snapshots: WithAudio should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -164412,7 +164412,7 @@ exports[`Story Snapshots: WithAudioLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -167308,7 +167308,7 @@ exports[`Story Snapshots: WithFile should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -168627,7 +168627,7 @@ exports[`Story Snapshots: WithFileLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -169704,7 +169704,7 @@ exports[`Story Snapshots: WithImage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -170378,7 +170378,7 @@ exports[`Story Snapshots: WithImage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -171541,7 +171541,7 @@ exports[`Story Snapshots: WithImage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -172329,7 +172329,7 @@ exports[`Story Snapshots: WithImage should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -173003,7 +173003,7 @@ exports[`Story Snapshots: WithImageLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -174031,7 +174031,7 @@ exports[`Story Snapshots: WithImageLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -174819,7 +174819,7 @@ exports[`Story Snapshots: WithImageLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -175493,7 +175493,7 @@ exports[`Story Snapshots: WithVideo should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -176481,7 +176481,7 @@ exports[`Story Snapshots: WithVideo should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -177214,7 +177214,7 @@ exports[`Story Snapshots: WithVideoLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -177846,7 +177846,7 @@ exports[`Story Snapshots: WithVideoLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, @@ -178379,7 +178379,7 @@ exports[`Story Snapshots: WithVideoLargeFont should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001", }, diff --git a/app/containers/message/components/stories/__tests__/__snapshots__/leaves.test.tsx.snap b/app/containers/message/components/stories/__tests__/__snapshots__/leaves.test.tsx.snap index d56ec17f9da..8632c395cb9 100644 --- a/app/containers/message/components/stories/__tests__/__snapshots__/leaves.test.tsx.snap +++ b/app/containers/message/components/stories/__tests__/__snapshots__/leaves.test.tsx.snap @@ -1311,7 +1311,7 @@ exports[`Story Snapshots: MessageAvatar should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, diff --git a/app/definitions/IInvite.ts b/app/definitions/IInvite.ts index 038cc985cdf..bfdb769e0fa 100644 --- a/app/definitions/IInvite.ts +++ b/app/definitions/IInvite.ts @@ -8,4 +8,5 @@ export interface IInvite extends IRocketChatRecord { createdAt: Date; expires: Date | null; uses: number; + url: string; } diff --git a/app/definitions/rest/helpers/index.ts b/app/definitions/rest/helpers/index.ts index 285b25c0907..1f585141ba0 100644 --- a/app/definitions/rest/helpers/index.ts +++ b/app/definitions/rest/helpers/index.ts @@ -26,7 +26,7 @@ type OperationsByPathPatternAndMethod< ? { pathPattern: TPathPattern; method: TMethod; - path: ReplacePlaceholders; + path: `/v1/${ReplacePlaceholders}`; params: GetParams; result: GetResult; } diff --git a/app/definitions/rest/v1/auth.ts b/app/definitions/rest/v1/auth.ts new file mode 100644 index 00000000000..32ce4fe7ecf --- /dev/null +++ b/app/definitions/rest/v1/auth.ts @@ -0,0 +1,43 @@ +import { type IUserEmail, type IUserSettings } from '../../IUser'; +import { type TUserStatus } from '../../TUserStatus'; + +export interface ILoginMeResponse { + _id: string; + name: string; + emails: IUserEmail[]; + status: TUserStatus; + statusConnection: string; + username: string; + utcOffset: number; + active: boolean; + roles: string[]; + settings: IUserSettings; + avatarUrl: string; + language?: string; + statusText?: string; + customFields?: { + [key: string]: any; + }; + statusLivechat?: string; + avatarETag?: string; + bio?: string; + nickname?: string; + requirePasswordChange?: boolean; +} + +export interface ILoginDataResponse { + authToken: string; + userId: string; + me: ILoginMeResponse; +} + +export interface ILoginResponse { + status: string; + data: ILoginDataResponse; +} + +export type AuthEndpoints = { + login: { + POST: (params: { user?: string; password?: string; resume?: string; code?: string }) => ILoginResponse; + }; +}; diff --git a/app/definitions/rest/v1/channels.ts b/app/definitions/rest/v1/channels.ts index c02e76cdac9..7c6961c3fd4 100644 --- a/app/definitions/rest/v1/channels.ts +++ b/app/definitions/rest/v1/channels.ts @@ -71,7 +71,7 @@ export type ChannelsEndpoints = { }; }; 'channels.join': { - POST: (params: { roomId: string; joinCode: string | null }) => { channel: IServerRoom }; + POST: (params: { roomId: string; joinCode?: string | null }) => { channel: IServerRoom; success: boolean }; }; 'channels.close': { POST: (params: { roomId: string }) => {}; diff --git a/app/definitions/rest/v1/chat.ts b/app/definitions/rest/v1/chat.ts index f611c829423..71b7e89ffba 100644 --- a/app/definitions/rest/v1/chat.ts +++ b/app/definitions/rest/v1/chat.ts @@ -56,6 +56,24 @@ export type ChatEndpoints = { }; }; }; + 'chat.syncMessages': { + GET: (params: { + roomId: IServerRoom['_id']; + offset?: number; + count?: number; + sort?: Record; + lastUpdate?: string; + next?: number; + previous?: number; + type?: 'DELETED' | 'UPDATED'; + }) => { + result: { + updated: IMessageFromServer[]; + deleted: IMessageFromServer[]; + cursor?: { next?: number; previous?: number }; + }; + }; + }; 'chat.delete': { POST: (params: { msgId: string; roomId: string }) => { _id: string; @@ -88,6 +106,27 @@ export type ChatEndpoints = { success: boolean; }; }; + 'chat.sendMessage': { + POST: (params: { + message: { + _id?: string; + rid: string; + msg?: string; + alias?: string; + emoji?: string; + avatar?: string; + attachments?: any[]; + tmid?: string; + tshow?: boolean; + blocks?: any[]; + customFields?: Record; + previewUrls?: string[]; + }; + }) => { + message: IMessage; + success: boolean; + }; + }; 'chat.getPinnedMessages': { GET: (params: { roomId: IServerRoom['_id']; offset: number; count: number }) => { messages: IMessageFromServer[]; diff --git a/app/definitions/rest/v1/commands.ts b/app/definitions/rest/v1/commands.ts index 375ce9fb735..0d666ce68b5 100644 --- a/app/definitions/rest/v1/commands.ts +++ b/app/definitions/rest/v1/commands.ts @@ -1,6 +1,12 @@ -import { type IPreviewItem } from '../../ISlashCommand'; +import { type IPreviewItem, type ISlashCommandResult } from '../../ISlashCommand'; export type CommandsEndpoints = { + 'commands.list': { + GET: (params?: { count?: number; offset?: number; sort?: string }) => { + commands: ISlashCommandResult[]; + success: boolean; + }; + }; 'commands.preview': { GET: (params: { command: string; params: string; roomId: string }) => { preview?: { diff --git a/app/definitions/rest/v1/e2e.ts b/app/definitions/rest/v1/e2e.ts index c4b8878ff22..0c68dbc0fd8 100644 --- a/app/definitions/rest/v1/e2e.ts +++ b/app/definitions/rest/v1/e2e.ts @@ -20,7 +20,7 @@ export type E2eEndpoints = { }; 'e2e.fetchUsersWaitingForGroupKey': { GET: (params: { roomIds: string[] }) => { - usersWaitingForE2EKeys: any; + usersWaitingForE2EKeys: Record; }; }; 'e2e.provideUsersSuggestedGroupKeys': { diff --git a/app/definitions/rest/v1/groups.ts b/app/definitions/rest/v1/groups.ts index 93ac8c9f43f..1043784ea0a 100644 --- a/app/definitions/rest/v1/groups.ts +++ b/app/definitions/rest/v1/groups.ts @@ -68,6 +68,9 @@ export type GroupsEndpoints = { 'groups.close': { POST: (params: { roomId: string }) => {}; }; + 'groups.open': { + POST: (params: { roomId: string } | { roomName: string }) => { success: boolean }; + }; 'groups.kick': { POST: (params: { roomId: string; userId: string }) => {}; }; diff --git a/app/definitions/rest/v1/index.ts b/app/definitions/rest/v1/index.ts index 0642359a1bb..79ba77385a1 100644 --- a/app/definitions/rest/v1/index.ts +++ b/app/definitions/rest/v1/index.ts @@ -1,3 +1,4 @@ +import { type AuthEndpoints } from './auth'; import { type ChannelsEndpoints } from './channels'; import { type ChatEndpoints } from './chat'; import { type CustomUserStatusEndpoints } from './customUserStatus'; @@ -20,9 +21,11 @@ import { type CommandsEndpoints } from './commands'; import { type PushEndpoints } from './push'; import { type DirectoryEndpoint } from './directory'; import { type AutoTranslateEndpoints } from './autotranslate'; +import { type MediaCallsEndpoints } from './mediaCalls'; import { type ModerationEndpoints } from './moderation'; -export type Endpoints = ChannelsEndpoints & +export type Endpoints = AuthEndpoints & + ChannelsEndpoints & ChatEndpoints & CustomUserStatusEndpoints & DmEndpoints & @@ -30,6 +33,7 @@ export type Endpoints = ChannelsEndpoints & GroupsEndpoints & ImEndpoints & InvitesEndpoints & + MediaCallsEndpoints & OmnichannelEndpoints & PermissionsEndpoints & RolesEndpoints & diff --git a/app/definitions/rest/v1/invites.ts b/app/definitions/rest/v1/invites.ts index e7779198494..b9079cc00d6 100644 --- a/app/definitions/rest/v1/invites.ts +++ b/app/definitions/rest/v1/invites.ts @@ -2,13 +2,16 @@ import type { IInvite } from '../../IInvite'; import type { IServerRoom } from '../../IRoom'; export type InvitesEndpoints = { + findOrCreateInvite: { + POST: (params: { rid: string; days: number; maxUses: number }) => IInvite; + }; listInvites: { GET: () => Array; }; 'removeInvite/:_id': { DELETE: () => void; }; - '/v1/useInviteToken': { + useInviteToken: { POST: (params: { token: string }) => { room: { rid: IServerRoom['_id']; @@ -19,7 +22,7 @@ export type InvitesEndpoints = { }; }; }; - '/v1/validateInviteToken': { + validateInviteToken: { POST: (params: { token: string }) => { valid: boolean }; }; }; diff --git a/app/definitions/rest/v1/mediaCalls.ts b/app/definitions/rest/v1/mediaCalls.ts new file mode 100644 index 00000000000..586187dece7 --- /dev/null +++ b/app/definitions/rest/v1/mediaCalls.ts @@ -0,0 +1,7 @@ +import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; + +export type MediaCallsEndpoints = { + 'media-calls.stateSignals': { + GET: (params: { contractId: string }) => { signals: ServerMediaSignal[]; success: boolean }; + }; +}; diff --git a/app/definitions/rest/v1/push.ts b/app/definitions/rest/v1/push.ts index 3062bcb90c2..2db3495cbc1 100644 --- a/app/definitions/rest/v1/push.ts +++ b/app/definitions/rest/v1/push.ts @@ -14,6 +14,7 @@ export type PushEndpoints = { userId: string; }; }; + DELETE: (params: { token: string }) => { success: boolean }; }; 'push.info': { GET: () => TPushInfo; diff --git a/app/definitions/rest/v1/teams.ts b/app/definitions/rest/v1/teams.ts index 73acf9d6083..507bbf5a670 100644 --- a/app/definitions/rest/v1/teams.ts +++ b/app/definitions/rest/v1/teams.ts @@ -3,6 +3,9 @@ import { type IServerTeamUpdateRoom, type ITeam, type TEAM_TYPE } from '../../IT import { type PaginatedResult } from '../helpers/PaginatedResult'; export type TeamsEndpoints = { + 'teams.delete': { + POST: (params: ({ teamId: string } | { teamName: string }) & { roomsToRemove?: string[] }) => { success: boolean }; + }; 'teams.removeRoom': { POST: (params: { roomId: string; teamId: string }) => { room: IServerRoom }; }; @@ -38,4 +41,7 @@ export type TeamsEndpoints = { filter?: any; }) => PaginatedResult<{ rooms: IServerTeamUpdateRoom[] }>; }; + 'teams.leave': { + POST: (params: ({ teamId: string } | { teamName: string }) & { rooms?: string[] }) => { success: boolean }; + }; }; diff --git a/app/definitions/rest/v1/users.ts b/app/definitions/rest/v1/users.ts index 6e5f75f2567..1c5e4dc2f22 100644 --- a/app/definitions/rest/v1/users.ts +++ b/app/definitions/rest/v1/users.ts @@ -1,6 +1,8 @@ import { type IAvatarSuggestion, type IProfileParams } from '../../IProfile'; import type { ITeam } from '../../ITeam'; import type { IUser, INotificationPreferences, IUserPreferences, IUserRegistered } from '../../IUser'; +import type { TUserStatus } from '../../TUserStatus'; +import type { TStatusSource } from '../../TStatusSource'; export type UsersEndpoints = { 'users.2fa.sendEmailCode': { @@ -58,7 +60,7 @@ export type UsersEndpoints = { POST: (params: { userId: string }) => {}; }; 'users.setAvatar': { - POST: (params: { avatarUrl: string }) => { success: boolean }; + POST: (params: { avatarUrl: string; userId?: string }) => { success: boolean }; }; 'users.removeOtherTokens': { POST: (params: { userId: string }) => {}; @@ -69,6 +71,23 @@ export type UsersEndpoints = { success: boolean; }; }; + 'users.presence': { + GET: (params?: { ids?: string; from?: string }) => { + users: { + _id: string; + name: string; + username: string; + status: TUserStatus; + utcOffset: number; + statusText?: string; + avatarETag?: string; + statusExpiresAt?: string; + statusSource?: TStatusSource; + }[]; + full: boolean; + success: boolean; + }; + }; 'users.deleteOwnAccount': { POST: (params: { password: string; confirmRelinquish: boolean }) => { success: boolean }; }; diff --git a/app/ee/omnichannel/lib/index.test.ts b/app/ee/omnichannel/lib/index.test.ts new file mode 100644 index 00000000000..e23aae4253c --- /dev/null +++ b/app/ee/omnichannel/lib/index.test.ts @@ -0,0 +1,183 @@ +/* eslint-disable import/first */ +jest.mock('../../../lib/services/sdk', () => ({ + __esModule: true, + default: { + get: jest.fn(), + post: jest.fn(), + methodCallWrapper: jest.fn(), + onStreamData: jest.fn(), + subscribe: jest.fn() + } +})); + +jest.mock('../../../lib/store/auxStore', () => ({ + store: { + getState: jest.fn(() => ({ + settings: {}, + login: { user: { id: 'u1', token: 't1' } }, + server: { version: '8.0.0' }, + inquiry: { queued: [] } + })), + dispatch: jest.fn() + } +})); + +jest.mock('../../../lib/methods/helpers', () => ({ + compareServerVersion: jest.fn(), + hasRole: jest.fn(() => false) +})); + +jest.mock('../../../lib/methods/helpers/events', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn(), + removeListener: jest.fn(), + emit: jest.fn() + } +})); + +jest.mock('./subscriptions/inquiry', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue({ stop: jest.fn() }) +})); + +jest.mock('../../../lib/services/restApi', () => ({ + getAgentDepartments: jest.fn().mockResolvedValue({ success: true, departments: [] }) +})); + +jest.mock('../../../lib/methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +import sdk from '../../../lib/services/sdk'; +import { store } from '../../../lib/store/auxStore'; +import { compareServerVersion } from '../../../lib/methods/helpers'; +import EventEmitter from '../../../lib/methods/helpers/events'; +import subscribeInquiry from './subscriptions/inquiry'; + +const mockSdkPost = sdk.post as jest.Mock; +const mockMethodCallWrapper = sdk.methodCallWrapper as jest.Mock; +const mockCompareServerVersion = compareServerVersion as jest.Mock; +const mockAddEventListener = EventEmitter.addEventListener as jest.Mock; +const mockSubscribeInquiry = subscribeInquiry as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + mockSdkPost.mockResolvedValue({ success: true }); + mockMethodCallWrapper.mockResolvedValue(undefined); +}); + +// We need to import after the mocks are set up, and re-require each test +// to avoid module-level side effects interfering between tests. +// The Omnichannel class constructor runs on module load, so we isolate with jest.isolateModules. + +describe('changeLivechatStatus', () => { + it('uses REST POST for server >= 8.0.0', async () => { + mockCompareServerVersion.mockReturnValue(true); + (store.getState as jest.Mock).mockReturnValue({ server: { version: '8.0.0' } }); + + let changeLivechatStatus: () => any; + jest.isolateModules(() => { + ({ changeLivechatStatus } = require('./index')); + }); + + await changeLivechatStatus!(); + + expect(mockSdkPost).toHaveBeenCalledWith('/v1/livechat/agent.status'); + expect(mockMethodCallWrapper).not.toHaveBeenCalled(); + }); + + it('uses methodCallWrapper for server < 8.0.0', async () => { + mockCompareServerVersion.mockReturnValue(false); + (store.getState as jest.Mock).mockReturnValue({ server: { version: '7.9.0' } }); + + let changeLivechatStatus: () => any; + jest.isolateModules(() => { + ({ changeLivechatStatus } = require('./index')); + }); + + await changeLivechatStatus!(); + + expect(mockMethodCallWrapper).toHaveBeenCalledWith('livechat:changeLivechatStatus'); + expect(mockSdkPost).not.toHaveBeenCalled(); + }); +}); + +describe('takeInquiry', () => { + it('uses REST POST for server >= 7.11.0', async () => { + mockCompareServerVersion.mockReturnValue(true); + + let takeInquiry: (inquiryId: string, serverVersion: string) => any; + jest.isolateModules(() => { + ({ takeInquiry } = require('./index')); + }); + + await takeInquiry!('inq1', '7.11.0'); + + expect(mockSdkPost).toHaveBeenCalledWith('/v1/livechat/inquiries.take', { inquiryId: 'inq1' }); + expect(mockMethodCallWrapper).not.toHaveBeenCalled(); + }); + + it('uses methodCallWrapper for server < 7.11.0', async () => { + mockCompareServerVersion.mockReturnValue(false); + + let takeInquiry: (inquiryId: string, serverVersion: string) => any; + jest.isolateModules(() => { + ({ takeInquiry } = require('./index')); + }); + + await takeInquiry!('inq1', '7.10.0'); + + expect(mockMethodCallWrapper).toHaveBeenCalledWith('livechat:takeInquiry', 'inq1'); + expect(mockSdkPost).not.toHaveBeenCalled(); + }); +}); + +describe('isOmnichannelStatusAvailable', () => { + it('returns true when statusLivechat is "available"', () => { + let isOmnichannelStatusAvailable: (status: string | undefined) => boolean; + jest.isolateModules(() => { + ({ isOmnichannelStatusAvailable } = require('./index')); + }); + + expect(isOmnichannelStatusAvailable!('available')).toBe(true); + }); + + it('returns false when statusLivechat is not "available"', () => { + let isOmnichannelStatusAvailable: (status: string | undefined) => boolean; + jest.isolateModules(() => { + ({ isOmnichannelStatusAvailable } = require('./index')); + }); + + expect(isOmnichannelStatusAvailable!('not-available')).toBe(false); + expect(isOmnichannelStatusAvailable!(undefined)).toBe(false); + }); +}); + +describe('Omnichannel.subscribeInquiry', () => { + it('subscribes a fresh inquiry on each INQUIRY_SUBSCRIBE event', async () => { + const firstStop = jest.fn(); + const secondStop = jest.fn(); + mockSubscribeInquiry.mockResolvedValueOnce({ stop: firstStop }).mockResolvedValueOnce({ stop: secondStop }); + + jest.isolateModules(() => { + require('./index'); + }); + + const subscribeListener = mockAddEventListener.mock.calls.find(([event]) => event === 'INQUIRY_SUBSCRIBE')?.[1]; + expect(subscribeListener).toBeDefined(); + + subscribeListener(); + await Promise.resolve(); + await Promise.resolve(); + + subscribeListener(); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockSubscribeInquiry).toHaveBeenCalledTimes(2); + expect(firstStop).not.toHaveBeenCalled(); + expect(secondStop).not.toHaveBeenCalled(); + }); +}); diff --git a/app/ee/omnichannel/lib/index.ts b/app/ee/omnichannel/lib/index.ts index 60308b3cb43..4fc1a82dcb2 100644 --- a/app/ee/omnichannel/lib/index.ts +++ b/app/ee/omnichannel/lib/index.ts @@ -10,18 +10,17 @@ export const isOmnichannelStatusAvailable = (statusLivechat: string | undefined) export const changeLivechatStatus = () => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '8.0.0')) { - return sdk.post('livechat/agent.status'); + return sdk.post('/v1/livechat/agent.status'); } // Method removed in 8.0.0 return sdk.methodCallWrapper('livechat:changeLivechatStatus'); }; // RC 2.4.0 -// @ts-ignore export const getInquiriesQueued = (serverVersion: string) => { const url = compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '7.0.0') - ? 'livechat/inquiries.queuedForUser' - : 'livechat/inquiries.queued'; + ? '/v1/livechat/inquiries.queuedForUser' + : '/v1/livechat/inquiries.queued'; return sdk.get(url); }; @@ -30,7 +29,7 @@ export const getInquiriesQueued = (serverVersion: string) => { // RC 2.4.0 export const takeInquiry = (inquiryId: string, serverVersion: string) => { if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '7.11.0')) { - return sdk.post('livechat/inquiries.take', { inquiryId }); + return sdk.post('/v1/livechat/inquiries.take', { inquiryId }); } // Method removed in 8.0.0 return sdk.methodCallWrapper('livechat:takeInquiry', inquiryId); @@ -40,7 +39,7 @@ export const takeInquiry = (inquiryId: string, serverVersion: string) => { export const takeResume = (roomId: string) => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '8.0.0')) { - return sdk.post('livechat/room.resumeOnHold', { roomId }); + return sdk.post('/v1/livechat/room.resumeOnHold', { roomId }); } // Method removed in 8.0.0 return sdk.methodCallWrapper('livechat:resumeOnHold', roomId); diff --git a/app/ee/omnichannel/lib/subscriptions/inquiry.test.ts b/app/ee/omnichannel/lib/subscriptions/inquiry.test.ts new file mode 100644 index 00000000000..4dd06f6aabb --- /dev/null +++ b/app/ee/omnichannel/lib/subscriptions/inquiry.test.ts @@ -0,0 +1,198 @@ +/* eslint-disable import/first */ +jest.mock('../../../../lib/services/sdk', () => ({ + __esModule: true, + default: { + onStreamData: jest.fn(), + subscribe: jest.fn() + } +})); + +jest.mock('../../../../lib/store/auxStore', () => ({ + store: { + getState: jest.fn(() => ({ + login: { user: { id: 'agent-1', roles: ['livechat-agent'] } }, + inquiry: { queued: [] } + })), + dispatch: jest.fn() + } +})); + +jest.mock('../../../../lib/services/restApi', () => ({ + getAgentDepartments: jest.fn() +})); + +jest.mock('../../../../lib/methods/helpers', () => ({ + hasRole: jest.fn() +})); + +jest.mock('../../../../lib/methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +import subscribeInquiry from './inquiry'; +import sdk from '../../../../lib/services/sdk'; +import { store } from '../../../../lib/store/auxStore'; +import { getAgentDepartments } from '../../../../lib/services/restApi'; +import { hasRole } from '../../../../lib/methods/helpers'; +import { inquiryQueueAdd, inquiryQueueRemove, inquiryQueueUpdate } from '../../actions/inquiry'; + +const flushPromises = () => new Promise(resolve => setImmediate(resolve)); + +describe('subscribeInquiry', () => { + let queueHandler: (msg: any) => void; + const streamStop = jest.fn(); + const deptStop = jest.fn(); + + beforeEach(() => { + streamStop.mockReset(); + deptStop.mockReset(); + (sdk.onStreamData as jest.Mock).mockReset().mockImplementation((_name: string, handler: (m: any) => void) => { + queueHandler = handler; + return Promise.resolve({ stop: streamStop }); + }); + (sdk.subscribe as jest.Mock).mockReset().mockReturnValue({ stop: deptStop }); + (getAgentDepartments as jest.Mock).mockReset().mockResolvedValue({ success: true, departments: [] }); + (hasRole as jest.Mock).mockReset().mockReturnValue(false); + (store.getState as jest.Mock).mockReturnValue({ + login: { user: { id: 'agent-1' } }, + inquiry: { queued: [] } + }); + (store.dispatch as jest.Mock).mockReset(); + }); + + it('Fix 4 regression: does NOT subscribe to a "connected" collection (was a broken DDP listener)', () => { + subscribeInquiry(); + const subscribed = (sdk.onStreamData as jest.Mock).mock.calls.map(([name]) => name); + expect(subscribed).not.toContain('connected'); + }); + + it('subscribes only to stream-livechat-inquiry-queue-observer via onStreamData', () => { + subscribeInquiry(); + expect(sdk.onStreamData).toHaveBeenCalledTimes(1); + expect(sdk.onStreamData).toHaveBeenCalledWith('stream-livechat-inquiry-queue-observer', expect.any(Function)); + }); + + it('subscribes to the public queue when the agent has no departments', async () => { + (getAgentDepartments as jest.Mock).mockResolvedValue({ success: true, departments: [] }); + subscribeInquiry(); + await flushPromises(); + expect(sdk.subscribe).toHaveBeenCalledWith('stream-livechat-inquiry-queue-observer', 'public'); + }); + + it('subscribes to the public queue when the user is a livechat-manager (even with departments)', async () => { + (getAgentDepartments as jest.Mock).mockResolvedValue({ success: true, departments: [{ departmentId: 'd1' }] }); + (hasRole as jest.Mock).mockImplementation((role: string) => role === 'livechat-manager'); + subscribeInquiry(); + await flushPromises(); + expect(sdk.subscribe).toHaveBeenCalledWith('stream-livechat-inquiry-queue-observer', 'public'); + }); + + it('subscribes to each department channel for the agent', async () => { + (getAgentDepartments as jest.Mock).mockResolvedValue({ + success: true, + departments: [{ departmentId: 'd1' }, { departmentId: 'd2' }] + }); + subscribeInquiry(); + await flushPromises(); + expect(sdk.subscribe).toHaveBeenCalledWith('stream-livechat-inquiry-queue-observer', 'department/d1'); + expect(sdk.subscribe).toHaveBeenCalledWith('stream-livechat-inquiry-queue-observer', 'department/d2'); + }); + + it('returns a stop function that unsubscribes stream + department listeners', async () => { + (getAgentDepartments as jest.Mock).mockResolvedValue({ + success: true, + departments: [{ departmentId: 'd1' }] + }); + const result = subscribeInquiry() as { stop: () => void }; + await flushPromises(); + result.stop(); + await flushPromises(); + expect(streamStop).toHaveBeenCalled(); + expect(deptStop).toHaveBeenCalled(); + }); + + it('does not create department subscriptions when stop() is called before getAgentDepartments resolves', async () => { + let resolveDepts: (v: any) => void; + (getAgentDepartments as jest.Mock).mockReturnValue( + new Promise(resolve => { + resolveDepts = resolve; + }) + ); + const result = subscribeInquiry() as { stop: () => void }; + result.stop(); + resolveDepts!({ success: true, departments: [{ departmentId: 'd1' }] }); + await flushPromises(); + // `sdk.subscribe` should never be invoked after stop() + expect(sdk.subscribe).not.toHaveBeenCalled(); + }); + + it('does not let a stale invocation push department subscriptions after being superseded by a newer one', async () => { + let resolveFirstDepts: (v: any) => void; + (getAgentDepartments as jest.Mock) + .mockReturnValueOnce( + new Promise(resolve => { + resolveFirstDepts = resolve; + }) + ) + .mockResolvedValueOnce({ success: true, departments: [] }); + + // first invocation: getAgentDepartments left pending + subscribeInquiry(); + + // second invocation supersedes the first (its own getAgentDepartments resolves immediately) + subscribeInquiry(); + await flushPromises(); + (sdk.subscribe as jest.Mock).mockClear(); + + // the first (stale) invocation's promise resolves late - it must be ignored + resolveFirstDepts!({ success: true, departments: [{ departmentId: 'stale-dept' }] }); + await flushPromises(); + + expect(sdk.subscribe).not.toHaveBeenCalledWith('stream-livechat-inquiry-queue-observer', 'department/stale-dept'); + }); + + it('rejects when the logged-in user has no id', async () => { + (store.getState as jest.Mock).mockReturnValue({ + login: { user: {} }, + inquiry: { queued: [] } + }); + await expect(subscribeInquiry() as any).rejects.toThrow('inquiry: @subscribeInquiry user.id not found'); + }); + + it('queue handler dispatches inquiryQueueAdd for new queued items', () => { + subscribeInquiry(); + queueHandler({ + fields: { args: [{ type: 'inserted', _id: 'inq-1', status: 'queued', rid: 'r1' }] } + }); + expect(store.dispatch).toHaveBeenCalledWith(inquiryQueueAdd({ _id: 'inq-1', status: 'queued', rid: 'r1' } as any)); + }); + + it('queue handler dispatches inquiryQueueUpdate for items already on the queue', () => { + (store.getState as jest.Mock).mockReturnValue({ + login: { user: { id: 'agent-1' } }, + inquiry: { queued: [{ _id: 'inq-1' }] } + }); + subscribeInquiry(); + queueHandler({ + fields: { args: [{ type: 'changed', _id: 'inq-1', status: 'queued', rid: 'r1' }] } + }); + expect(store.dispatch).toHaveBeenCalledWith(inquiryQueueUpdate({ _id: 'inq-1', status: 'queued', rid: 'r1' } as any)); + }); + + it('queue handler dispatches inquiryQueueRemove when an item leaves the queue', () => { + subscribeInquiry(); + queueHandler({ + fields: { args: [{ type: 'changed', _id: 'inq-1', status: 'taken', rid: 'r1' }] } + }); + expect(store.dispatch).toHaveBeenCalledWith(inquiryQueueRemove('inq-1')); + }); + + it('queue handler ignores "added" events (deduplicated by "changed")', () => { + subscribeInquiry(); + queueHandler({ + fields: { args: [{ type: 'added', _id: 'inq-1', status: 'queued', rid: 'r1' }] } + }); + expect(store.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/ee/omnichannel/lib/subscriptions/inquiry.ts b/app/ee/omnichannel/lib/subscriptions/inquiry.ts index 7c34f5920f5..6c16f48b8ec 100644 --- a/app/ee/omnichannel/lib/subscriptions/inquiry.ts +++ b/app/ee/omnichannel/lib/subscriptions/inquiry.ts @@ -1,6 +1,6 @@ import log from '../../../../lib/methods/helpers/log'; import { store } from '../../../../lib/store/auxStore'; -import { inquiryQueueAdd, inquiryQueueRemove, inquiryQueueUpdate, inquiryRequest } from '../../actions/inquiry'; +import { inquiryQueueAdd, inquiryQueueRemove, inquiryQueueUpdate } from '../../actions/inquiry'; import sdk from '../../../../lib/services/sdk'; import { type IOmnichannelRoom } from '../../../../definitions'; import { hasRole } from '../../../../lib/methods/helpers'; @@ -22,15 +22,16 @@ interface IDdpMessage { const removeListener = (listener: any) => listener.stop(); -let connectedListener: any; let queueListener: any; +let departmentListeners: any[] = []; +let activeGeneration = 0; const streamTopic = 'stream-livechat-inquiry-queue-observer'; export default function subscribeInquiry() { - const handleConnection = () => { - store.dispatch(inquiryRequest()); - }; + const generation = ++activeGeneration; + let stopped = false; + const isStale = () => stopped || generation !== activeGeneration; const handleQueueMessageReceived = (ddpMessage: IDdpMessage) => { const [{ type, ...sub }] = ddpMessage.fields.args; @@ -62,17 +63,21 @@ export default function subscribeInquiry() { }; const stop = () => { - if (connectedListener) { - connectedListener.then(removeListener); - connectedListener = false; - } + stopped = true; if (queueListener) { queueListener.then(removeListener); queueListener = false; } + departmentListeners.forEach(listener => { + try { + removeListener(listener); + } catch (e) { + log(e); + } + }); + departmentListeners = []; }; - connectedListener = sdk.onStreamData('connected', handleConnection); queueListener = sdk.onStreamData(streamTopic, handleQueueMessageReceived); try { @@ -82,27 +87,36 @@ export default function subscribeInquiry() { throw new Error('inquiry: @subscribeInquiry user.id not found'); } - getAgentDepartments(user.id).then(result => { - if (result.success) { - const { departments } = result; - - if (!departments.length || hasRole('livechat-manager')) { - sdk.subscribe(streamTopic, 'public').catch((e: unknown) => console.log(e)); + getAgentDepartments(user.id) + .then(result => { + if (isStale()) return; + if (result.success) { + const { departments } = result; + + const trackSubscription = (sub: ReturnType) => { + if (sub) { + departmentListeners.push(sub); + } + }; + + if (!departments?.length || hasRole('livechat-manager')) { + trackSubscription(sdk.subscribe(streamTopic, 'public')); + } + + const departmentIds = departments?.map(({ departmentId }) => departmentId) || []; + departmentIds.forEach((departmentId: string) => { + // subscribe to all departments of the agent + trackSubscription(sdk.subscribe(streamTopic, `department/${departmentId}`)); + }); } - - const departmentIds = departments.map(({ departmentId }) => departmentId); - departmentIds.forEach(departmentId => { - // subscribe to all departments of the agent - sdk.subscribe(streamTopic, `department/${departmentId}`).catch((e: unknown) => console.log(e)); - }); - } - }); + }) + .catch((e: unknown) => log(e)); return { stop: () => stop() }; } catch (e) { log(e); - return Promise.reject(); + return Promise.reject(e instanceof Error ? e : new Error('subscribeInquiry failed')); } } diff --git a/app/externalModules.d.ts b/app/externalModules.d.ts index 1b220ba2dd3..4935ca2295f 100644 --- a/app/externalModules.d.ts +++ b/app/externalModules.d.ts @@ -1,5 +1,4 @@ declare module 'remove-markdown'; -declare module '@rocket.chat/sdk'; declare module 'react-native-mime-types'; declare module 'react-native-restart'; declare module 'react-native-math-view'; diff --git a/app/lib/constants/defaultSettings.ts b/app/lib/constants/defaultSettings.ts index d137e23905b..1d0e7fc5288 100644 --- a/app/lib/constants/defaultSettings.ts +++ b/app/lib/constants/defaultSettings.ts @@ -244,7 +244,7 @@ export const defaultSettings = { type: 'valueAsBoolean' }, Accounts_AllowInvisibleStatusOption: { - type: 'valueAsString' + type: 'valueAsBoolean' }, Canned_Responses_Enable: { type: 'valueAsBoolean' diff --git a/app/lib/encryption/encryption.ts b/app/lib/encryption/encryption.ts index ffe13c69655..a7cf6fd5727 100644 --- a/app/lib/encryption/encryption.ts +++ b/app/lib/encryption/encryption.ts @@ -490,11 +490,15 @@ class Encryption { } const result = await fetchUsersWaitingForGroupKey(sampleIds); - if (!result.success || !Object.keys(result.usersWaitingForE2EKeys).length) { + if (!result?.success) { + return; + } + const { usersWaitingForE2EKeys } = result; + if (!usersWaitingForE2EKeys || !Object.keys(usersWaitingForE2EKeys).length) { return; } - const userKeysWithRooms = await this.getSuggestedE2EEKeys(result.usersWaitingForE2EKeys); + const userKeysWithRooms = await this.getSuggestedE2EEKeys(usersWaitingForE2EKeys); if (!Object.keys(userKeysWithRooms).length) { return; diff --git a/app/lib/hooks/useEndpointData.test.tsx b/app/lib/hooks/useEndpointData.test.tsx index cdb3a4ab290..bcc267af579 100644 --- a/app/lib/hooks/useEndpointData.test.tsx +++ b/app/lib/hooks/useEndpointData.test.tsx @@ -4,7 +4,7 @@ import { View, Text } from 'react-native'; import { useEndpointData } from './useEndpointData'; import sdk from '../services/sdk'; -const url = 'chat.getMessage'; +const url = '/v1/chat.getMessage'; const message = { _id: '9tYkmJ67wMwmvQouD', diff --git a/app/lib/methods/actions.test.ts b/app/lib/methods/actions.test.ts index 6bf08853022..4659e4451cb 100644 --- a/app/lib/methods/actions.test.ts +++ b/app/lib/methods/actions.test.ts @@ -21,15 +21,11 @@ jest.mock('../navigation/appNavigation', () => ({ jest.mock('../services/sdk', () => ({ __esModule: true, default: { - current: { - currentLogin: { - userId: 'user-id', - authToken: 'auth-token' - }, - client: { - host: 'https://chat.example.com' - } - } + currentLogin: { + userId: 'user-id', + authToken: 'auth-token' + }, + server: 'https://chat.example.com' } })); @@ -106,11 +102,13 @@ describe('actions', () => { expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ - 'X-Auth-Token': 'auth-token', - 'X-User-Id': 'user-id' + 'Content-Type': 'application/json' }) }) ); + const headersArg = mockedFetch.mock.calls[0][1]?.headers as Record; + expect(headersArg['X-Auth-Token']).toBeUndefined(); + expect(headersArg['X-User-Id']).toBeUndefined(); }); it('handles modal.update response', async () => { diff --git a/app/lib/methods/actions.ts b/app/lib/methods/actions.ts index 7945f9ad7a5..6d26ad54b37 100644 --- a/app/lib/methods/actions.ts +++ b/app/lib/methods/actions.ts @@ -108,8 +108,10 @@ export async function triggerAction({ const payload = rest.payload ?? rest.value; try { - const { userId, authToken } = sdk.current.currentLogin; - const { host } = sdk.current.client; + const host = sdk.server; + if (!host) { + throw new Error('SDK not initialized'); + } const interaction = toUserInteraction({ type, actionId, @@ -127,12 +129,12 @@ export async function triggerAction({ }); // we need to use fetch because this.sdk.post add /v1 to url + // fetch() already attaches the session headers ambiently for same-origin requests + // (host === sdk.server), so we don't rebuild them here. const result = await fetch(`${host}/api/apps/ui.interaction/${appId}/`, { method: 'POST', headers: { - 'Content-Type': 'application/json', - 'X-Auth-Token': authToken, - 'X-User-Id': userId + 'Content-Type': 'application/json' }, body: JSON.stringify(interaction) }); diff --git a/app/lib/methods/canOpenRoom.ts b/app/lib/methods/canOpenRoom.ts index a3a964a3a91..1a0fce399aa 100644 --- a/app/lib/methods/canOpenRoom.ts +++ b/app/lib/methods/canOpenRoom.ts @@ -30,8 +30,7 @@ async function open({ type, rid, name }: { type: ERoomTypes; rid: string; name: if (type === ERoomTypes.GROUP) { try { // RC 0.61.0 - // @ts-ignore - await sdk.post(`${restTypes[type]}.open`, params); + await sdk.post(`/v1/groups.open`, params); } catch (e: any) { if (!(e.data && /is already open/.test(e.data.error))) { return false; @@ -43,8 +42,8 @@ async function open({ type, rid, name }: { type: ERoomTypes; rid: string; name: // we'll get info from the room if ((type === ERoomTypes.CHANNEL || type === ERoomTypes.GROUP) && !rid) { // RC 0.72.0 - // @ts-ignore - const result: any = await sdk.get(`${restTypes[type]}.info`, params); + // @ts-ignore — dynamic path construction using restTypes[type] cannot be statically verified + const result: any = await sdk.get(`/v1/${restTypes[type]}.info`, params); if (result.success) { const room = result[type]; room.rid = room._id; diff --git a/app/lib/methods/getCustomEmojis.ts b/app/lib/methods/getCustomEmojis.ts index a3c986f3a7a..eb47c5d7b33 100644 --- a/app/lib/methods/getCustomEmojis.ts +++ b/app/lib/methods/getCustomEmojis.ts @@ -111,7 +111,7 @@ export function getCustomEmojis() { if (compareServerVersion(serverVersion, 'lowerThan', '0.75.0')) { // RC 0.61.0 // @ts-ignore - const result = await sdk.get('emoji-custom'); + const result = await sdk.get('/v1/emoji-custom'); // @ts-ignore let { emojis } = result; emojis = emojis.filter((emoji: TCustomEmojiModel) => !updatedSince || emoji._updatedAt.toISOString() > updatedSince); @@ -130,7 +130,7 @@ export function getCustomEmojis() { } // RC 0.75.0 - const result = await sdk.get('emoji-custom.list', params); + const result = await sdk.get('/v1/emoji-custom.list', params); if (!result.success) { return resolve(); diff --git a/app/lib/methods/getPermissions.ts b/app/lib/methods/getPermissions.ts index cf38e96e466..6692204fe46 100644 --- a/app/lib/methods/getPermissions.ts +++ b/app/lib/methods/getPermissions.ts @@ -171,7 +171,7 @@ export function getPermissions(): Promise { if (serverVersion && compareServerVersion(serverVersion, 'lowerThan', '0.73.0')) { // RC 0.66.0 // @ts-ignore - const result: any = await sdk.get('permissions.list'); + const result: any = await sdk.get('/v1/permissions.list'); if (!result.success) { return resolve(); } @@ -188,7 +188,7 @@ export function getPermissions(): Promise { params.updatedSince = updatedSince; } // RC 0.73.0 - const result = await sdk.get('permissions.listAll', params); + const result = await sdk.get('/v1/permissions.listAll', params); if (!result.success) { return resolve(); diff --git a/app/lib/methods/getRoles.ts b/app/lib/methods/getRoles.ts index a266b91222c..015ceaecf28 100644 --- a/app/lib/methods/getRoles.ts +++ b/app/lib/methods/getRoles.ts @@ -69,63 +69,59 @@ export async function onRolesChanged(ddpMessage: IRolesChanged): Promise { } } -export function getRoles(): Promise { +export async function getRoles(): Promise { const db = database.active; - return new Promise(async resolve => { - try { - // RC 0.70.0 - const result = await sdk.get('roles.list'); + try { + // RC 0.70.0 + const result = await sdk.get('/v1/roles.list'); - if (!result.success) { - return resolve(); - } + if (!result.success) { + return; + } - const { roles } = result; + const { roles } = result; - if (roles && roles.length) { - await db.write(async () => { - const rolesCollections = db.get('roles'); - const allRolesRecords = await rolesCollections.query().fetch(); + if (roles && roles.length) { + await db.write(async () => { + const rolesCollections = db.get('roles'); + const allRolesRecords = await rolesCollections.query().fetch(); - // filter roles - const filteredRolesToCreate = roles.filter(i1 => !allRolesRecords.find(i2 => i1._id === i2.id)); - const filteredRolesToUpdate = allRolesRecords.filter(i1 => roles.find(i2 => i1.id === i2._id)); + // filter roles + const filteredRolesToCreate = roles.filter(i1 => !allRolesRecords.find(i2 => i1._id === i2.id)); + const filteredRolesToUpdate = allRolesRecords.filter(i1 => roles.find(i2 => i1.id === i2._id)); - // Create - const rolesToCreate = filteredRolesToCreate.map(role => - rolesCollections.prepareCreate( - protectedFunction((r: TRoleModel) => { - r._raw = sanitizedRaw({ id: role._id }, rolesCollections.schema); - Object.assign(r, role); - }) - ) - ); + // Create + const rolesToCreate = filteredRolesToCreate.map(role => + rolesCollections.prepareCreate( + protectedFunction((r: TRoleModel) => { + r._raw = sanitizedRaw({ id: role._id }, rolesCollections.schema); + Object.assign(r, role); + }) + ) + ); - // Update - const rolesToUpdate = filteredRolesToUpdate.map(role => { - const newRole = roles.find(r => r._id === role.id); - return role.prepareUpdate( - protectedFunction((r: TRoleModel) => { - Object.assign(r, newRole); - }) - ); - }); + // Update + const rolesToUpdate = filteredRolesToUpdate.map(role => { + const newRole = roles.find(r => r._id === role.id); + return role.prepareUpdate( + protectedFunction((r: TRoleModel) => { + Object.assign(r, newRole); + }) + ); + }); - const allRecords: Model[] = [...rolesToCreate, ...rolesToUpdate]; + const allRecords: Model[] = [...rolesToCreate, ...rolesToUpdate]; - try { - await db.batch(allRecords); - } catch (e) { - log(e); - } - setRoles(); - return allRecords.length; - }); - return resolve(); - } - } catch (e) { - log(e); - return resolve(); + try { + await db.batch(allRecords); + } catch (e) { + log(e); + } + setRoles(); + return allRecords.length; + }); } - }); + } catch (e) { + log(e); + } } diff --git a/app/lib/methods/getRooms.ts b/app/lib/methods/getRooms.ts index 0c289953172..5427ec432f8 100644 --- a/app/lib/methods/getRooms.ts +++ b/app/lib/methods/getRooms.ts @@ -8,12 +8,12 @@ export function getRooms(updatedSince: Date) { // TODO: missing definitions from server return Promise.all([ // @ts-ignore - sdk.get('subscriptions.get', { updatedSince: updatedDate }), + sdk.get('/v1/subscriptions.get', { updatedSince: updatedDate }), // @ts-ignore - sdk.get('rooms.get', { updatedSince: updatedDate }) + sdk.get('/v1/rooms.get', { updatedSince: updatedDate }) ]); } // TODO: missing definitions from server // @ts-ignore - return Promise.all([sdk.get('subscriptions.get'), sdk.get('rooms.get')]); + return Promise.all([sdk.get('/v1/subscriptions.get'), sdk.get('/v1/rooms.get')]); } diff --git a/app/lib/methods/getSettings.test.ts b/app/lib/methods/getSettings.test.ts new file mode 100644 index 00000000000..0ed22ac2e92 --- /dev/null +++ b/app/lib/methods/getSettings.test.ts @@ -0,0 +1,151 @@ +import { getLoginSettings, getSettings } from './getSettings'; +import { initStore } from '../store/auxStore'; +import { mockedStore } from '../../reducers/mockedStore'; +import { selectServerClear, selectServerRequest } from '../../actions/server'; +import fetch from './helpers/fetch'; + +const STALE_SDK_HOST = 'https://old-logged-in.example'; + +jest.mock('./helpers/fetch', () => jest.fn()); +jest.mock('./helpers/log', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('./getUsersPresence', () => ({ setPresenceCap: jest.fn() })); +jest.mock('../services/sdk', () => ({ + __esModule: true, + default: { subscribe: jest.fn(), getHeaders: jest.fn(() => ({})), server: 'https://old-logged-in.example' } +})); +jest.mock('../database', () => ({ + __esModule: true, + default: { + active: { + get: () => ({ query: () => ({ fetch: () => Promise.resolve([]) }) }), + write: (cb: () => Promise) => cb(), + batch: () => Promise.resolve() + }, + servers: { + get: () => ({ find: () => Promise.resolve({ autoLock: false, autoLockTime: null, update: () => Promise.resolve() }) }), + write: (cb: () => Promise) => cb() + } + } +})); + +const mockedFetch = fetch as jest.MockedFunction; + +// URL passed to the Nth fetch call (settings are fetched with a trailing &offset=). +const fetchUrl = (callIndex: number): string => mockedFetch.mock.calls[callIndex][0] as string; + +describe('getSettings', () => { + beforeAll(() => { + initStore(mockedStore); + }); + + beforeEach(() => { + jest.clearAllMocks(); + // Reset the shared, process-global server slice so state does not bleed between tests. + mockedStore.dispatch(selectServerClear()); + // Empty settings -> the pagination loop exits on the first iteration and the DB layer + // stays a no-op, so the test only observes the fetched URL. + mockedFetch.mockResolvedValue({ json: () => Promise.resolve({ success: true, total: 0, settings: [] }) } as any); + }); + + describe('builds the URL from the redux server slice', () => { + it('uses the _id query form on server version >= 7.0.0', async () => { + mockedStore.dispatch(selectServerRequest('https://new.example', '7.0.0')); + + await getSettings(); + + expect(mockedFetch).toHaveBeenCalled(); + expect(fetchUrl(0)).toContain('https://new.example/api/v1/settings.public?_id='); + }); + + it('uses the $in query form on server version < 7.0.0', async () => { + mockedStore.dispatch(selectServerRequest('https://new.example', '6.0.0')); + + await getSettings(); + + expect(fetchUrl(0)).toContain('https://new.example/api/v1/settings.public?query='); + }); + + it('re-targets the fetch at the newly selected workspace, not the previous one', async () => { + // Switch to workspace A and fetch its settings. + mockedStore.dispatch(selectServerRequest('https://workspace-a.example', '7.0.0')); + await getSettings(); + + // Switch to workspace B (e.g. adding/changing server) and fetch again. + mockedStore.dispatch(selectServerRequest('https://workspace-b.example', '7.0.0')); + await getSettings(); + + expect(fetchUrl(0)).toContain('https://workspace-a.example/api/v1/settings.public'); + expect(fetchUrl(1)).toContain('https://workspace-b.example/api/v1/settings.public'); + expect(fetchUrl(1)).not.toContain('workspace-a.example'); + }); + + it('ignores the SDK host entirely (does not fetch the old logged-in server)', async () => { + mockedStore.dispatch(selectServerRequest('https://new.example', '7.0.0')); + + await getSettings(); + + expect(fetchUrl(0)).toContain('https://new.example/api/v1/settings.public'); + expect(fetchUrl(0)).not.toContain(STALE_SDK_HOST); + }); + }); + + describe('applies the freshly fetched settings to redux after switching servers', () => { + // settings.length must equal `total` so the pagination loop in getSettings exits after one fetch. + const respondWithSiteName = (siteName: string) => ({ + json: () => + Promise.resolve({ + success: true, + total: 1, + offset: 0, + settings: [{ _id: 'Site_Name', value: siteName }] + }) + }); + + it("writes the new server's settings into the redux settings slice", async () => { + mockedFetch.mockResolvedValueOnce(respondWithSiteName('Workspace A') as any); + mockedStore.dispatch(selectServerRequest('https://workspace-a.example', '7.0.0')); + + await getSettings(); + + expect(mockedStore.getState().settings.Site_Name).toBe('Workspace A'); + }); + + it('overwrites stale settings from the previous server with the latest fetch', async () => { + // Seed redux with workspace A's settings. + mockedFetch.mockResolvedValueOnce(respondWithSiteName('Workspace A') as any); + mockedStore.dispatch(selectServerRequest('https://workspace-a.example', '7.0.0')); + await getSettings(); + + // Switch to workspace B; the second fetch returns workspace B's value. + mockedFetch.mockResolvedValueOnce(respondWithSiteName('Workspace B') as any); + mockedStore.dispatch(selectServerRequest('https://workspace-b.example', '7.0.0')); + await getSettings(); + + expect(fetchUrl(1)).toContain('https://workspace-b.example/api/v1/settings.public'); + expect(mockedStore.getState().settings.Site_Name).toBe('Workspace B'); + }); + }); +}); + +describe('getLoginSettings', () => { + beforeAll(() => { + initStore(mockedStore); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockedStore.dispatch(selectServerClear()); + mockedFetch.mockResolvedValue({ json: () => Promise.resolve({ success: true, settings: [] }) } as any); + }); + + it('builds the URL from its server argument, not the redux server slice', async () => { + // Redux points at a different (already logged-in) server... + mockedStore.dispatch(selectServerRequest('https://other.example', '7.0.0')); + + // ...but getLoginSettings must target the workspace passed as an argument. + await getLoginSettings({ server: 'https://added.workspace', serverVersion: '7.0.0' }); + + expect(fetchUrl(0)).toContain('https://added.workspace/api/v1/settings.public?_id='); + expect(fetchUrl(0)).not.toContain('other.example'); + }); +}); diff --git a/app/lib/methods/getSettings.ts b/app/lib/methods/getSettings.ts index e1f3a7d733b..ee3fd3227b9 100644 --- a/app/lib/methods/getSettings.ts +++ b/app/lib/methods/getSettings.ts @@ -143,13 +143,13 @@ export async function setSettings(): Promise { reduxStore.dispatch(addSettings(parseSettings(parsed.slice(0, parsed.length)))); } -export function subscribeSettings(): void { +export function subscribeSettings() { return sdk.subscribe('stream-notify-all', 'public-settings-changed'); } type IData = ISettingsIcon | IPreparedSettings; -export async function getSettings(): Promise { +export async function getSettings(signal?: AbortSignal): Promise { try { const db = database.active; const settingsParams = Object.keys(defaultSettings).filter(key => !loginSettings.includes(key)); @@ -157,15 +157,15 @@ export async function getSettings(): Promise { let offset = 0; let remaining; let settings: IData[] = []; - const serverVersion = reduxStore.getState().server.version; + const { version: serverVersion, server } = reduxStore.getState().server; const url = compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '7.0.0') - ? `${sdk.current.client.host}/api/v1/settings.public?_id=${settingsParams.join(',')}` - : `${sdk.current.client.host}/api/v1/settings.public?query={"_id":{"$in":${JSON.stringify(settingsParams)}}}`; + ? `${server}/api/v1/settings.public?_id=${settingsParams.join(',')}` + : `${server}/api/v1/settings.public?query={"_id":{"$in":${JSON.stringify(settingsParams)}}}`; // Iterate over paginated results to retrieve all settings do { // TODO: why is no-await-in-loop enforced in the first place? /* eslint-disable no-await-in-loop */ - const response = await fetch(`${url}&offset=${offset}`); + const response = await fetch(`${url}&offset=${offset}`, { signal }); const result = await response.json(); if (!result.success) { diff --git a/app/lib/methods/getSlashCommands.ts b/app/lib/methods/getSlashCommands.ts index 8df67d989f4..56211702a2c 100644 --- a/app/lib/methods/getSlashCommands.ts +++ b/app/lib/methods/getSlashCommands.ts @@ -6,74 +6,68 @@ import protectedFunction from './helpers/protectedFunction'; import { type ISlashCommandResult, type TSlashCommandModel } from '../../definitions'; import sdk from '../services/sdk'; -export function getSlashCommands() { +export async function getSlashCommands(): Promise { const db = database.active; - return new Promise(async resolve => { - try { - // RC 0.60.2 - // @ts-ignore - const result = await sdk.get('commands.list'); + try { + // RC 0.60.2 + const result = await sdk.get('/v1/commands.list'); - if (!result.success) { - return resolve(); - } - // @ts-ignore - const { commands } = result; - if (commands && commands.length) { - await db.write(async () => { - const slashCommandsCollection = db.get('slash_commands'); - const allSlashCommandsRecords = await slashCommandsCollection.query().fetch(); + if (!result.success) { + return; + } + const { commands } = result; + if (commands && commands.length) { + await db.write(async () => { + const slashCommandsCollection = db.get('slash_commands'); + const allSlashCommandsRecords = await slashCommandsCollection.query().fetch(); - // filter slash commands - const filteredSlashCommandsToCreate = commands.filter( - (i1: ISlashCommandResult) => !allSlashCommandsRecords.find(i2 => i1.command === i2.id) - ); - const filteredSlashCommandsToUpdate = allSlashCommandsRecords.filter(i1 => - commands.find((i2: ISlashCommandResult) => i1.id === i2.command) - ); - const filteredSlashCommandsToDelete = allSlashCommandsRecords.filter( - i1 => - !filteredSlashCommandsToCreate.find((i2: ISlashCommandResult) => i2.command === i1.id) && - !filteredSlashCommandsToUpdate.find(i2 => i2.id === i1.id) - ); + // filter slash commands + const filteredSlashCommandsToCreate = commands.filter( + (i1: ISlashCommandResult) => !allSlashCommandsRecords.find(i2 => i1.command === i2.id) + ); + const filteredSlashCommandsToUpdate = allSlashCommandsRecords.filter(i1 => + commands.find((i2: ISlashCommandResult) => i1.id === i2.command) + ); + const filteredSlashCommandsToDelete = allSlashCommandsRecords.filter( + i1 => + !filteredSlashCommandsToCreate.find((i2: ISlashCommandResult) => i2.command === i1.id) && + !filteredSlashCommandsToUpdate.find(i2 => i2.id === i1.id) + ); - // Create - const slashCommandsToCreate = filteredSlashCommandsToCreate.map((command: ISlashCommandResult) => - slashCommandsCollection.prepareCreate( - protectedFunction((s: TSlashCommandModel) => { - s._raw = sanitizedRaw({ id: command.command }, slashCommandsCollection.schema); - Object.assign(s, command); - }) - ) - ); + // Create + const slashCommandsToCreate = filteredSlashCommandsToCreate.map((command: ISlashCommandResult) => + slashCommandsCollection.prepareCreate( + protectedFunction((s: TSlashCommandModel) => { + s._raw = sanitizedRaw({ id: command.command }, slashCommandsCollection.schema); + Object.assign(s, command); + }) + ) + ); - // Update - const slashCommandsToUpdate = filteredSlashCommandsToUpdate.map(command => { - const newCommand = commands.find((s: ISlashCommandResult) => s.command === command.id); - return command.prepareUpdate( - protectedFunction((s: TSlashCommandModel) => { - Object.assign(s, newCommand); - }) - ); - }); + // Update + const slashCommandsToUpdate = filteredSlashCommandsToUpdate.map(command => { + const newCommand = commands.find((s: ISlashCommandResult) => s.command === command.id); + return command.prepareUpdate( + protectedFunction((s: TSlashCommandModel) => { + Object.assign(s, newCommand); + }) + ); + }); - // Delete - const slashCommandsToDelete = filteredSlashCommandsToDelete.map(command => command.prepareDestroyPermanently()); + // Delete + const slashCommandsToDelete = filteredSlashCommandsToDelete.map(command => command.prepareDestroyPermanently()); - const allRecords = [...slashCommandsToCreate, ...slashCommandsToUpdate, ...slashCommandsToDelete]; + const allRecords = [...slashCommandsToCreate, ...slashCommandsToUpdate, ...slashCommandsToDelete]; - try { - await db.batch(allRecords); - } catch (e) { - log(e); - } - return allRecords.length; - }); - } - return resolve(); - } catch (e) { - log(e); - return resolve(); + try { + await db.batch(allRecords); + } catch (e) { + log(e); + } + return allRecords.length; + }); } - }); + } catch (e) { + log(e); + } } diff --git a/app/lib/methods/getUsersPresence.test.ts b/app/lib/methods/getUsersPresence.test.ts new file mode 100644 index 00000000000..943ee9e4ac3 --- /dev/null +++ b/app/lib/methods/getUsersPresence.test.ts @@ -0,0 +1,296 @@ +/* eslint-disable import/first */ +jest.mock('@nozbe/watermelondb/RawRecord', () => ({ + sanitizedRaw: jest.fn((raw: any) => raw) +})); + +jest.mock('@nozbe/watermelondb', () => ({ + Q: { + where: jest.fn(), + gt: jest.fn(), + lt: jest.fn(), + gte: jest.fn(), + lte: jest.fn(), + or: jest.fn(), + like: jest.fn(), + oneOf: jest.fn() + } +})); + +jest.mock('../services/sdk', () => ({ + __esModule: true, + default: { + subscribe: jest.fn(), + get: jest.fn() + } +})); + +jest.mock('../store/auxStore', () => ({ + store: { + getState: jest.fn(), + dispatch: jest.fn() + } +})); + +jest.mock('../database', () => ({ + __esModule: true, + default: { + active: { + get: jest.fn(() => ({ + find: jest.fn().mockRejectedValue(new Error('not found')), + query: jest.fn(() => ({ fetch: jest.fn().mockResolvedValue([]) })), + create: jest.fn() + })), + write: jest.fn((fn: any) => Promise.resolve(fn())) + } + } +})); + +jest.mock('./helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('./helpers', () => ({ + compareServerVersion: jest.requireActual('./helpers/compareServerVersion').compareServerVersion, + normalizeStatusExpiresAt: jest.fn((value: unknown) => value) +})); + +jest.mock('./userPreferences', () => ({ + __esModule: true, + default: { + getBool: jest.fn().mockReturnValue(null), + setBool: jest.fn(), + removeItem: jest.fn() + } +})); + +jest.mock('../../actions/activeUsers', () => ({ + setActiveUsers: jest.fn((users: any) => ({ type: 'SET_ACTIVE_USERS', users })) +})); + +jest.mock('../../actions/login', () => ({ + setUser: jest.fn((user: any) => ({ type: 'SET_USER', user })) +})); + +jest.mock('../../actions/app', () => ({ + setNotificationPresenceCap: jest.fn((val: boolean) => ({ type: 'SET_NOTIFICATION_PRESENCE_CAP', val })) +})); + +jest.mock('react-native', () => ({ + InteractionManager: { + runAfterInteractions: jest.fn((fn: () => void) => fn()) + } +})); + +import sdk from '../services/sdk'; +import { store as reduxStore } from '../store/auxStore'; +import { + subscribeUsersPresence, + getUsersPresence, + getUserPresence, + setPresenceCap, + _activeUsersSubTimeout +} from './getUsersPresence'; +import { setActiveUsers } from '../../actions/activeUsers'; +import { setUser } from '../../actions/login'; +import { setNotificationPresenceCap } from '../../actions/app'; +import userPreferences from './userPreferences'; + +function setVersion(version: string, extraState: Record = {}) { + (reduxStore.getState as jest.Mock).mockReturnValue({ + server: { version }, + login: { user: { id: 'u1', username: 'testuser' } }, + settings: {}, + ...extraState + }); +} + +describe('subscribeUsersPresence', () => { + beforeEach(() => { + (sdk.subscribe as jest.Mock).mockReset(); + (reduxStore.dispatch as jest.Mock).mockReset(); + // Reset the activeUsersSubTimeout between tests + if (_activeUsersSubTimeout.activeUsersSubTimeout) { + clearTimeout(_activeUsersSubTimeout.activeUsersSubTimeout as number); + _activeUsersSubTimeout.activeUsersSubTimeout = false; + } + }); + + it('subscribes stream-notify-logged user-status for server >= 1.1.0 and < 4.1.0', () => { + setVersion('3.0.0'); + subscribeUsersPresence(); + expect(sdk.subscribe).toHaveBeenCalledWith('stream-notify-logged', 'user-status'); + }); + + it('does NOT subscribe stream-notify-logged user-status for server >= 4.1.0', () => { + setVersion('4.1.0'); + subscribeUsersPresence(); + const userStatusCall = (sdk.subscribe as jest.Mock).mock.calls.find( + (call: any[]) => call[0] === 'stream-notify-logged' && call[1] === 'user-status' + ); + expect(userStatusCall).toBeUndefined(); + }); + + it('schedules activeUsers subscription for server < 1.1.0', () => { + jest.useFakeTimers(); + setVersion('1.0.0'); + subscribeUsersPresence(); + jest.runAllTimers(); + expect(sdk.subscribe).toHaveBeenCalledWith('activeUsers'); + jest.useRealTimers(); + }); + + it('always subscribes stream-notify-logged updateAvatar regardless of version', () => { + setVersion('5.0.0'); + subscribeUsersPresence(); + expect(sdk.subscribe).toHaveBeenCalledWith('stream-notify-logged', 'updateAvatar'); + }); + + it('always subscribes stream-notify-logged Users:NameChanged regardless of version', () => { + setVersion('5.0.0'); + subscribeUsersPresence(); + expect(sdk.subscribe).toHaveBeenCalledWith('stream-notify-logged', 'Users:NameChanged'); + }); +}); + +describe('getUsersPresence', () => { + beforeEach(() => { + (sdk.get as jest.Mock).mockReset(); + (sdk.subscribe as jest.Mock).mockReset(); + (reduxStore.dispatch as jest.Mock).mockReset(); + }); + + it('returns early when Presence_broadcast_disabled is true', async () => { + setVersion('4.1.0', { settings: { Presence_broadcast_disabled: true } }); + await getUsersPresence(['uid1']); + expect(sdk.get).not.toHaveBeenCalled(); + }); + + it('calls /v1/users.presence with ids for server >= 3.0.0', async () => { + setVersion('4.1.0', { settings: {} }); + (sdk.get as jest.Mock).mockResolvedValue({ success: true, users: [] }); + await getUsersPresence(['uid1', 'uid2']); + expect(sdk.get).toHaveBeenCalledWith('/v1/users.presence', { ids: 'uid1,uid2' }); + }); + + it('returns early when server >= 3.0.0 and usersParams is empty', async () => { + setVersion('4.1.0', { settings: {} }); + await getUsersPresence([]); + expect(sdk.get).not.toHaveBeenCalled(); + }); + + it('calls /v1/users.presence without ids for server 1.1.0 - 2.x', async () => { + setVersion('1.1.0', { settings: {} }); + (sdk.get as jest.Mock).mockResolvedValue({ success: true, users: [] }); + await getUsersPresence(['uid1']); + expect(sdk.get).toHaveBeenCalledWith('/v1/users.presence', {}); + }); + + it('subscribes stream-user-presence via subscribe for server >= 4.1.0 on success', async () => { + setVersion('4.1.0', { settings: {} }); + (sdk.get as jest.Mock).mockResolvedValue({ success: true, users: [] }); + await getUsersPresence(['uid1']); + expect(sdk.subscribe).toHaveBeenCalledWith('stream-user-presence', '', { added: ['uid1'] }); + }); + + it('spreads the subscribe params instead of nesting them in one array', async () => { + setVersion('4.1.0', { settings: {} }); + (sdk.get as jest.Mock).mockResolvedValue({ success: true, users: [] }); + await getUsersPresence(['user-1', 'user-2']); + expect(sdk.subscribe).toHaveBeenCalledWith('stream-user-presence', '', { added: ['user-1', 'user-2'] }); + }); + + it('dispatches setActiveUsers with user presence data', async () => { + setVersion('4.1.0', { settings: {} }); + const mockUsers = [{ _id: 'uid1', status: 'online', statusText: 'busy' }]; + (sdk.get as jest.Mock).mockResolvedValue({ success: true, users: mockUsers }); + await getUsersPresence(['uid1']); + expect(setActiveUsers).toHaveBeenCalledWith({ + uid1: { status: 'online', statusText: 'busy' } + }); + expect(reduxStore.dispatch).toHaveBeenCalled(); + }); + + it('uses offline status for users not in the response', async () => { + setVersion('4.1.0', { settings: {} }); + (sdk.get as jest.Mock).mockResolvedValue({ success: true, users: [] }); + await getUsersPresence(['uid-not-found']); + expect(setActiveUsers).toHaveBeenCalledWith({ + 'uid-not-found': { status: 'offline', statusText: '' } + }); + }); + + it('dispatches setUser when the current user is in the response', async () => { + setVersion('4.1.0', { + login: { user: { id: 'u1', username: 'testuser' } }, + settings: {} + }); + (reduxStore.getState as jest.Mock).mockReturnValue({ + server: { version: '4.1.0' }, + login: { user: { id: 'u1', username: 'testuser' } }, + settings: {} + }); + const mockUsers = [{ _id: 'u1', status: 'away', statusText: 'in a meeting' }]; + (sdk.get as jest.Mock).mockResolvedValue({ success: true, users: mockUsers }); + await getUsersPresence(['u1']); + expect(setUser).toHaveBeenCalledWith({ status: 'away', statusText: 'in a meeting' }); + }); + + it('does not call SDK when server version is < 1.1.0', async () => { + setVersion('1.0.0', { settings: {} }); + await getUsersPresence(['uid1']); + expect(sdk.get).not.toHaveBeenCalled(); + }); +}); + +describe('getUserPresence', () => { + beforeEach(() => { + jest.useFakeTimers(); + (sdk.get as jest.Mock).mockReset().mockResolvedValue({ success: true, users: [] }); + setVersion('4.1.0', { settings: {} }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('batches uid and calls getUsersPresence after 2 seconds', () => { + getUserPresence('uid-batch-1'); + getUserPresence('uid-batch-2'); + // Not called yet + expect(sdk.get).not.toHaveBeenCalled(); + jest.runAllTimers(); + // After timer fires, should have called sdk.get + expect(sdk.get).toHaveBeenCalled(); + }); +}); + +describe('setPresenceCap', () => { + beforeEach(() => { + (userPreferences.getBool as jest.Mock).mockReset(); + (userPreferences.setBool as jest.Mock).mockReset(); + (userPreferences.removeItem as jest.Mock).mockReset(); + (reduxStore.dispatch as jest.Mock).mockReset(); + }); + + it('sets the presence cap preference and dispatches when enabled and not previously set to false', async () => { + (userPreferences.getBool as jest.Mock).mockReturnValue(null); + await setPresenceCap(true); + expect(userPreferences.setBool).toHaveBeenCalledWith('NOTIFICATION_PRESENCE_CAP', true); + expect(setNotificationPresenceCap).toHaveBeenCalledWith(true); + expect(reduxStore.dispatch).toHaveBeenCalled(); + }); + + it('does not re-set the cap when it is already explicitly set to false', async () => { + (userPreferences.getBool as jest.Mock).mockReturnValue(false); + await setPresenceCap(true); + expect(userPreferences.setBool).not.toHaveBeenCalled(); + }); + + it('removes the preference and dispatches false when disabled', async () => { + await setPresenceCap(false); + expect(userPreferences.removeItem).toHaveBeenCalledWith('NOTIFICATION_PRESENCE_CAP'); + expect(setNotificationPresenceCap).toHaveBeenCalledWith(false); + expect(reduxStore.dispatch).toHaveBeenCalled(); + }); +}); diff --git a/app/lib/methods/getUsersPresence.ts b/app/lib/methods/getUsersPresence.ts index 48b9ba536a5..cf6d36957fd 100644 --- a/app/lib/methods/getUsersPresence.ts +++ b/app/lib/methods/getUsersPresence.ts @@ -7,7 +7,6 @@ import { store as reduxStore } from '../store/auxStore'; import { setActiveUsers } from '../../actions/activeUsers'; import { setUser } from '../../actions/login'; import database from '../database'; -import { type IUser } from '../../definitions'; import sdk from '../services/sdk'; import { compareServerVersion, normalizeStatusExpiresAt } from './helpers'; import log from './helpers/log'; @@ -69,17 +68,23 @@ export async function getUsersPresence(usersParams: string[]) { try { // RC 1.1.0 - const result = (await sdk.get('users.presence' as any, params as any)) as any; + const result = await sdk.get('/v1/users.presence', params); if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '4.1.0')) { - sdk.subscribeRaw('stream-user-presence', ['', { added: usersParams }]); + sdk.subscribe('stream-user-presence', '', { added: usersParams }); } if (result.success) { const { users } = result; const activeUsers = usersParams.reduce((ret: IActiveUsers, id) => { - const user = users.find((u: IUser) => u._id === id) ?? { _id: id, status: 'offline' }; + const user = users.find(u => u._id === id) ?? { + _id: id, + status: 'offline', + statusText: '', + statusExpiresAt: undefined, + statusSource: undefined + }; const { _id, status, statusText, statusExpiresAt, statusSource } = user; const normalizedStatusExpiresAt = normalizeStatusExpiresAt(statusExpiresAt); @@ -87,7 +92,7 @@ export async function getUsersPresence(usersParams: string[]) { reduxStore.dispatch(setUser({ status, statusText, statusExpiresAt: normalizedStatusExpiresAt, statusSource })); } - ret[_id] = { status, statusText, statusExpiresAt: normalizedStatusExpiresAt, statusSource }; + ret[_id] = { status, statusText: statusText || '', statusExpiresAt: normalizedStatusExpiresAt, statusSource }; return ret; }, {}); InteractionManager.runAfterInteractions(() => { @@ -96,7 +101,7 @@ export async function getUsersPresence(usersParams: string[]) { const db = database.active; const userCollection = db.get('users'); - users.forEach(async (user: IUser) => { + users.forEach(async user => { try { const userRecord = await userCollection.find(user._id); await db.write(async () => { diff --git a/app/lib/methods/helpers/defaultHeaders.ts b/app/lib/methods/helpers/defaultHeaders.ts new file mode 100644 index 00000000000..28865a08551 --- /dev/null +++ b/app/lib/methods/helpers/defaultHeaders.ts @@ -0,0 +1,17 @@ +import { Platform } from 'react-native'; +import DeviceInfo from 'react-native-device-info'; + +export interface DefaultHeaders { + 'User-Agent'?: string; + Authorization?: string; + 'Content-Type'?: string; + 'X-Auth-Token'?: string; + 'X-User-Id'?: string; +} + +// this form is required by Rocket.Chat's parser in "app/statistics/server/lib/UAParserCustom.js" +export const headers: DefaultHeaders = { + 'User-Agent': `RC Mobile; ${ + Platform.OS + } ${DeviceInfo.getSystemVersion()}; v${DeviceInfo.getVersion()} (${DeviceInfo.getBuildNumber()})` +}; diff --git a/app/lib/methods/helpers/fetch.test.ts b/app/lib/methods/helpers/fetch.test.ts new file mode 100644 index 00000000000..5563cb7624c --- /dev/null +++ b/app/lib/methods/helpers/fetch.test.ts @@ -0,0 +1,121 @@ +/* eslint-disable import/first */ +jest.mock('../../services/sdk', () => ({ + __esModule: true, + default: { + server: 'https://open.rocket.chat', + getHeaders: jest.fn(), + setBasicAuth: jest.fn() + } +})); + +import customFetch, { setBasicAuth } from './fetch'; +import sdk from '../../services/sdk'; + +const originalFetch = global.fetch; + +describe('helpers/fetch', () => { + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as any; + (sdk.getHeaders as jest.Mock).mockReset().mockReturnValue({ 'User-Agent': 'RC Mobile' }); + (sdk.setBasicAuth as jest.Mock).mockReset(); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('includes sdk.getHeaders() in every request', async () => { + await customFetch('/api/v1/test'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/test', + expect.objectContaining({ + headers: expect.objectContaining({ 'User-Agent': 'RC Mobile' }) + }) + ); + }); + + it('lets per-request headers override sdk.getHeaders() on conflict', async () => { + (sdk.getHeaders as jest.Mock).mockReturnValue({ 'X-Custom': 'base' }); + await customFetch('/api/v1/test', { headers: { 'X-Custom': 'override' } as any }); + const call = fetchMock.mock.calls[0][1]; + expect(call.headers['X-Custom']).toBe('override'); + }); + + it('forwards method and body options unchanged', async () => { + await customFetch('/api/v1/test', { method: 'POST', body: '{"a":1}' }); + const call = fetchMock.mock.calls[0][1]; + expect(call.method).toBe('POST'); + expect(call.body).toBe('{"a":1}'); + }); + + it('does not mutate the base headers object across multiple calls', async () => { + const base = { 'X-Base': 'one' }; + (sdk.getHeaders as jest.Mock).mockReturnValue(base); + await customFetch('/a', { headers: { 'X-Per-Request': 'a' } as any }); + await customFetch('/b', { headers: { 'X-Per-Request': 'b' } as any }); + expect(base).toEqual({ 'X-Base': 'one' }); + expect(fetchMock.mock.calls[0][1].headers['X-Per-Request']).toBe('a'); + expect(fetchMock.mock.calls[1][1].headers['X-Per-Request']).toBe('b'); + }); + + it('returns the underlying fetch promise unchanged', async () => { + const expected = { ok: true, status: 200 }; + fetchMock.mockResolvedValueOnce(expected); + await expect(customFetch('/api/v1/test')).resolves.toBe(expected); + }); + + it('attaches session headers (X-Auth-Token/X-User-Id) for same-origin requests', async () => { + (sdk.getHeaders as jest.Mock).mockReturnValue({ 'X-Auth-Token': 'tok', 'X-User-Id': 'uid' }); + await customFetch('https://open.rocket.chat/api/v1/settings'); + const call = fetchMock.mock.calls[0][1]; + expect(call.headers['X-Auth-Token']).toBe('tok'); + expect(call.headers['X-User-Id']).toBe('uid'); + }); + + it('omits session headers but keeps default headers for cross-origin requests', async () => { + (sdk.getHeaders as jest.Mock).mockReturnValue({ 'User-Agent': 'RC Mobile', 'X-Auth-Token': 'tok', 'X-User-Id': 'uid' }); + await customFetch('https://releases.rocket.chat/v2/server/supportedVersions'); + const call = fetchMock.mock.calls[0][1]; + expect(call.headers['X-Auth-Token']).toBeUndefined(); + expect(call.headers['X-User-Id']).toBeUndefined(); + expect(typeof call.headers['User-Agent']).toBe('string'); + expect(call.headers['User-Agent']).toMatch(/RC Mobile/); + }); + + it('strips session headers for cross-origin requests even when the caller explicitly sets them', async () => { + (sdk.getHeaders as jest.Mock).mockReturnValue({ 'User-Agent': 'RC Mobile' }); + await customFetch('https://releases.rocket.chat/v2/server/supportedVersions', { + headers: { 'X-Auth-Token': 'caller-supplied-tok', 'X-User-Id': 'caller-supplied-uid' } as any + }); + const call = fetchMock.mock.calls[0][1]; + expect(call.headers['X-Auth-Token']).toBeUndefined(); + expect(call.headers['X-User-Id']).toBeUndefined(); + }); + + it('still lets caller-supplied headers win for same-origin requests', async () => { + (sdk.getHeaders as jest.Mock).mockReturnValue({ 'X-Auth-Token': 'sdk-tok' }); + await customFetch('https://open.rocket.chat/api/v1/settings', { headers: { 'X-Auth-Token': 'caller-tok' } as any }); + const call = fetchMock.mock.calls[0][1]; + expect(call.headers['X-Auth-Token']).toBe('caller-tok'); + }); +}); + +describe('helpers/fetch setBasicAuth', () => { + it('delegates to sdk.setBasicAuth', () => { + setBasicAuth('dXNlcjpwYXNz'); + expect(sdk.setBasicAuth).toHaveBeenCalledWith('dXNlcjpwYXNz', undefined); + }); + + it('forwards null to clear basic auth', () => { + setBasicAuth(null); + expect(sdk.setBasicAuth).toHaveBeenCalledWith(null, undefined); + }); + + it('forwards an explicit server so the target-server key is used, not the sdk-tracked one', () => { + setBasicAuth('dXNlcjpwYXNz', 'https://new.example.com'); + expect(sdk.setBasicAuth).toHaveBeenCalledWith('dXNlcjpwYXNz', 'https://new.example.com'); + }); +}); diff --git a/app/lib/methods/helpers/fetch.ts b/app/lib/methods/helpers/fetch.ts index d15a050e8fc..5140d97af0c 100644 --- a/app/lib/methods/helpers/fetch.ts +++ b/app/lib/methods/helpers/fetch.ts @@ -1,54 +1,32 @@ -import { Platform } from 'react-native'; -import DeviceInfo from 'react-native-device-info'; -import { settings as RocketChatSettings } from '@rocket.chat/sdk'; +import sdk from '../../services/sdk'; +import { type DefaultHeaders, headers } from './defaultHeaders'; +import { getAuthHeaders } from './getAuthHeaders'; +import { isSameOrigin } from './isSameOrigin'; export type TMethods = 'POST' | 'GET' | 'DELETE' | 'PUT' | 'post' | 'get' | 'delete' | 'put'; -interface CustomHeaders { - 'User-Agent'?: string; - Authorization?: string; - 'Content-Type'?: string; - 'X-Auth-Token'?: string; - 'X-User-Id'?: string; -} - interface IOptions { - headers?: CustomHeaders; + headers?: DefaultHeaders; signal?: AbortSignal; method?: TMethods; body?: any; } -// this form is required by Rocket.Chat's parser in "app/statistics/server/lib/UAParserCustom.js" -export const headers: CustomHeaders = { - 'User-Agent': `RC Mobile; ${ - Platform.OS - } ${DeviceInfo.getSystemVersion()}; v${DeviceInfo.getVersion()} (${DeviceInfo.getBuildNumber()})` -}; +export { headers }; -let _basicAuth; -export const setBasicAuth = (basicAuth: string | null): void => { - _basicAuth = basicAuth; - if (basicAuth) { - RocketChatSettings.customHeaders = { ...headers, Authorization: `Basic ${_basicAuth}` }; - } else { - RocketChatSettings.customHeaders = headers; - } +export const setBasicAuth = (basicAuth: string | null, server?: string): void => { + sdk.setBasicAuth(basicAuth, server); }; -export const BASIC_AUTH_KEY = 'BASIC_AUTH_KEY'; -RocketChatSettings.customHeaders = headers; +export const BASIC_AUTH_KEY = 'BASIC_AUTH_KEY'; export default (url: string, options: IOptions = {}): Promise => { - let customOptions = { ...options, headers: RocketChatSettings.customHeaders }; - if (options && options.headers) { - customOptions = { ...customOptions, headers: { ...options.headers, ...customOptions.headers } }; + const authHeaders = getAuthHeaders(url); + const mergedHeaders: Record = { ...authHeaders, ...(options.headers || {}) }; + if (!isSameOrigin(url, sdk.server)) { + delete mergedHeaders['X-Auth-Token']; + delete mergedHeaders['X-User-Id']; } - // TODO: Check if this really works and if anyone else has complained about this problem. - // if (RocketChat.controller) { - // // @ts-ignore - // const { signal } = RocketChat.controller; - // customOptions = { ...customOptions, signal }; - // } + const customOptions = { ...options, headers: mergedHeaders }; return fetch(url, customOptions); }; diff --git a/app/lib/methods/helpers/getAuthHeaders.test.ts b/app/lib/methods/helpers/getAuthHeaders.test.ts new file mode 100644 index 00000000000..833c160b9b8 --- /dev/null +++ b/app/lib/methods/helpers/getAuthHeaders.test.ts @@ -0,0 +1,45 @@ +/* eslint-disable import/first */ +jest.mock('../../services/sdk', () => ({ + __esModule: true, + default: { + server: 'https://open.rocket.chat', + getHeaders: jest.fn() + } +})); + +import { getAuthHeaders } from './getAuthHeaders'; +import sdk from '../../services/sdk'; + +describe('getAuthHeaders', () => { + beforeEach(() => { + (sdk.getHeaders as jest.Mock).mockReset(); + }); + + it('returns full headers including the session token for same-origin URLs', () => { + (sdk.getHeaders as jest.Mock).mockReturnValue({ 'User-Agent': 'RC Mobile', 'X-Auth-Token': 'tok', 'X-User-Id': 'uid' }); + const headers = getAuthHeaders('https://open.rocket.chat/api/v1/settings'); + expect(headers['X-Auth-Token']).toBe('tok'); + expect(headers['X-User-Id']).toBe('uid'); + expect(headers['User-Agent']).toBe('RC Mobile'); + }); + + it('strips only the session token for cross-origin URLs, keeping other headers', () => { + (sdk.getHeaders as jest.Mock).mockReturnValue({ + 'User-Agent': 'RC Mobile', + Authorization: 'Basic abc', + 'X-Auth-Token': 'tok', + 'X-User-Id': 'uid' + }); + const headers = getAuthHeaders('https://releases.rocket.chat/v2/server/supportedVersions'); + expect(headers['X-Auth-Token']).toBeUndefined(); + expect(headers['X-User-Id']).toBeUndefined(); + expect(headers['User-Agent']).toBe('RC Mobile'); + expect(headers.Authorization).toBe('Basic abc'); + }); + + it('treats relative URLs as same-origin', () => { + (sdk.getHeaders as jest.Mock).mockReturnValue({ 'X-Auth-Token': 'tok' }); + const headers = getAuthHeaders('/api/v1/x'); + expect(headers['X-Auth-Token']).toBe('tok'); + }); +}); diff --git a/app/lib/methods/helpers/getAuthHeaders.ts b/app/lib/methods/helpers/getAuthHeaders.ts new file mode 100644 index 00000000000..5ca48cec027 --- /dev/null +++ b/app/lib/methods/helpers/getAuthHeaders.ts @@ -0,0 +1,17 @@ +import sdk from '../../services/sdk'; +import { isSameOrigin } from './isSameOrigin'; + +/** + * Returns the request headers for a given URL. + * On same-origin requests the full header set is sent (including the session token). + * On cross-origin requests only the session token (X-Auth-Token/X-User-Id) is stripped + */ +export const getAuthHeaders = (url: string): Record => { + if (isSameOrigin(url, sdk.server)) { + return sdk.getHeaders(); + } + const headers = { ...sdk.getHeaders() }; + delete headers['X-Auth-Token']; + delete headers['X-User-Id']; + return headers; +}; diff --git a/app/lib/methods/helpers/isSameOrigin.test.ts b/app/lib/methods/helpers/isSameOrigin.test.ts new file mode 100644 index 00000000000..e32c122741e --- /dev/null +++ b/app/lib/methods/helpers/isSameOrigin.test.ts @@ -0,0 +1,32 @@ +import { isSameOrigin } from './isSameOrigin'; + +describe('isSameOrigin', () => { + test('returns true for same-origin absolute URLs', () => { + expect(isSameOrigin('https://open.rocket.chat/api/v1/x', 'https://open.rocket.chat')).toBe(true); + }); + + test('returns false for cross-origin absolute URLs', () => { + expect(isSameOrigin('https://releases.rocket.chat/v2/server/supportedVersions', 'https://open.rocket.chat')).toBe(false); + expect(isSameOrigin('https://external.provider.com/avatar/user', 'https://open.rocket.chat')).toBe(false); + }); + + test('returns false when scheme differs', () => { + expect(isSameOrigin('http://open.rocket.chat/avatar/x', 'https://open.rocket.chat')).toBe(false); + }); + + test('returns true for relative URLs', () => { + expect(isSameOrigin('/api/v1/x', 'https://open.rocket.chat')).toBe(true); + }); + + test('returns false when origin is undefined', () => { + expect(isSameOrigin('https://releases.rocket.chat/x')).toBe(false); + }); + + test('returns false for a different path under a subpath-deployed server (same host, different workspace)', () => { + expect(isSameOrigin('https://example.com/rc-workspace-b/api/info', 'https://example.com/rc-workspace-a')).toBe(false); + }); + + test('returns true for a request under the same subpath-deployed server', () => { + expect(isSameOrigin('https://example.com/rc-workspace-a/api/v1/settings', 'https://example.com/rc-workspace-a')).toBe(true); + }); +}); diff --git a/app/lib/methods/helpers/isSameOrigin.ts b/app/lib/methods/helpers/isSameOrigin.ts new file mode 100644 index 00000000000..d3f251b2209 --- /dev/null +++ b/app/lib/methods/helpers/isSameOrigin.ts @@ -0,0 +1,17 @@ +export const isSameOrigin = (url: string, origin?: string): boolean => { + if (!origin) { + return false; + } + try { + const target = new URL(url, origin); + const base = new URL(origin); + if (target.origin !== base.origin) { + return false; + } + const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`; + const targetPath = target.pathname.endsWith('/') ? target.pathname : `${target.pathname}/`; + return targetPath.startsWith(basePath); + } catch { + return false; + } +}; diff --git a/app/lib/methods/loadMessagesForRoom.test.ts b/app/lib/methods/loadMessagesForRoom.test.ts index 5442776fe3a..9dda001bb17 100644 --- a/app/lib/methods/loadMessagesForRoom.test.ts +++ b/app/lib/methods/loadMessagesForRoom.test.ts @@ -96,7 +96,7 @@ describe('loadMessagesForRoom', () => { expect(mockedSdkGet).toHaveBeenCalledTimes(2); expect(mockedSdkGet).toHaveBeenNthCalledWith( 2, - 'channels.history', + '/v1/channels.history', expect.objectContaining({ roomId: 'ROOM_ID', latest: firstBatch[firstBatch.length - 1].ts diff --git a/app/lib/methods/loadMessagesForRoom.ts b/app/lib/methods/loadMessagesForRoom.ts index edb7152d914..567ef28d8b4 100644 --- a/app/lib/methods/loadMessagesForRoom.ts +++ b/app/lib/methods/loadMessagesForRoom.ts @@ -58,13 +58,13 @@ async function load(args: { let data; switch (apiType) { case 'channels': - data = await sdk.get('channels.history', params); + data = await sdk.get('/v1/channels.history', params); break; case 'groups': - data = await sdk.get('groups.history', params); + data = await sdk.get('/v1/groups.history', params); break; case 'im': - data = await sdk.get('im.history', params); + data = await sdk.get('/v1/im.history', params); break; default: return; diff --git a/app/lib/methods/loadMissedMessages.test.ts b/app/lib/methods/loadMissedMessages.test.ts index 8a9b9e9a9f9..92f032c5ebb 100644 --- a/app/lib/methods/loadMissedMessages.test.ts +++ b/app/lib/methods/loadMissedMessages.test.ts @@ -49,13 +49,14 @@ describe('loadMissedMessages', () => { mockedGetSubscriptionByRoomId.mockResolvedValue({ lastOpen: null, t: 'p' } as never); const deletedMessage = { _id: 'deleted-1', rid: RID, _updatedAt: new Date(Date.UTC(2024, 0, 1, 12, 0, 0)) }; mockedSdkGet.mockResolvedValue({ + success: true, result: { updated: [], deleted: [deletedMessage], cursor: { next: null } } } as never); await loadMissedMessages({ rid: RID, deletedNext: 1704110400000 }); expect(mockedSdkGet).toHaveBeenCalledTimes(1); - expect(mockedSdkGet).toHaveBeenCalledWith('chat.syncMessages', expect.objectContaining({ roomId: RID, type: 'DELETED' })); + expect(mockedSdkGet).toHaveBeenCalledWith('/v1/chat.syncMessages', expect.objectContaining({ roomId: RID, type: 'DELETED' })); expect(mockedUpdateMessages).toHaveBeenCalledWith( expect.objectContaining({ rid: RID, @@ -73,9 +74,26 @@ describe('loadMissedMessages', () => { expect(mockedSdkGet).not.toHaveBeenCalled(); }); + it('does not throw when the server omits cursor on a syncMessages response', async () => { + mockedGetSubscriptionByRoomId.mockResolvedValue({ lastOpen: new Date(Date.UTC(2024, 0, 1, 11, 0, 0)), t: 'c' } as never); + // no cursor field at all + mockedSdkGet.mockResolvedValue({ success: true, result: { updated: [], deleted: [] } } as never); + + await expect(loadMissedMessages({ rid: RID, updatedNext: 100 })).resolves.toBeUndefined(); + }); + + it('swallows an unsuccessful syncMessages response instead of persisting a cursor', async () => { + mockedGetSubscriptionByRoomId.mockResolvedValue({ lastOpen: new Date(Date.UTC(2024, 0, 1, 11, 0, 0)), t: 'c' } as never); + mockedSdkGet.mockResolvedValue({ success: false } as never); + + await loadMissedMessages({ rid: RID }); + + expect(mockedUpdateMessages).toHaveBeenCalledWith(expect.objectContaining({ update: [], remove: [] })); + expect(mockedUpdateLastOpen).toHaveBeenCalledWith(RID, []); + }); + describe('last open', () => { const CURSOR = new Date(Date.UTC(2024, 0, 1, 11, 0, 0)); - const flush = () => new Promise(resolve => setImmediate(resolve)); const message = (id: string, updatedAt: string) => ({ _id: id, rid: RID, _updatedAt: updatedAt }); @@ -85,6 +103,7 @@ describe('loadMissedMessages', () => { it('writes the Last Open from the updated payload once the cursor has drained', async () => { mockedSdkGet.mockResolvedValue({ + success: true, result: { updated: [message('a', '2024-01-01T11:30:00.000Z')], deleted: [], cursor: { next: null } } } as never); @@ -98,25 +117,23 @@ describe('loadMissedMessages', () => { const PAGE_2 = Date.UTC(2024, 0, 1, 11, 30, 0); mockedSdkGet.mockImplementation(((_endpoint: string, params: { type?: string; next?: number }) => { if (params.type === 'DELETED') { - return Promise.resolve({ result: { deleted: [], cursor: { next: null } } }); + return Promise.resolve({ success: true, result: { deleted: [], cursor: { next: null } } }); } if (params.next === PAGE_2) { return Promise.resolve({ + success: true, result: { updated: [message('b', '2024-01-01T11:45:00.000Z')], deleted: [], cursor: { next: null } } }); } return Promise.resolve({ + success: true, result: { updated: [message('a', '2024-01-01T11:30:00.000Z')], deleted: [], cursor: { next: PAGE_2 } } }); }) as never); await loadMissedMessages({ rid: RID }); - // First page still has a next cursor, so nothing may be persisted yet. - expect(mockedUpdateLastOpen).not.toHaveBeenCalled(); - - await flush(); - + // The first page still had a next cursor, so only the final page may persist: one write, not two. expect(mockedUpdateLastOpen).toHaveBeenCalledTimes(1); // Every page walked contributes its stamps, not only the last one. expect(mockedUpdateLastOpen).toHaveBeenCalledWith(RID, [ @@ -129,20 +146,21 @@ describe('loadMissedMessages', () => { const PAGE_2 = Date.UTC(2024, 0, 1, 11, 30, 0); mockedSdkGet.mockImplementation(((_endpoint: string, params: { type?: string; next?: number }) => { if (params.type === 'DELETED') { - return Promise.resolve({ result: { deleted: [], cursor: { next: null } } }); + return Promise.resolve({ success: true, result: { deleted: [], cursor: { next: null } } }); } if (params.next === PAGE_2) { return Promise.resolve({ + success: true, result: { updated: [message('b', '2024-01-01T11:10:00.000Z')], deleted: [], cursor: { next: null } } }); } return Promise.resolve({ + success: true, result: { updated: [message('a', '2024-01-01T11:59:00.000Z')], deleted: [], cursor: { next: PAGE_2 } } }); }) as never); await loadMissedMessages({ rid: RID }); - await flush(); const received = mockedUpdateLastOpen.mock.calls[0][1]; const timestamps = received.map(m => new Date(m._updatedAt as string | Date).getTime()).filter(t => !Number.isNaN(t)); @@ -151,6 +169,7 @@ describe('loadMissedMessages', () => { it('does not write again on a deleted-only continuation page', async () => { mockedSdkGet.mockResolvedValue({ + success: true, result: { updated: [], deleted: [message('gone', '2024-01-01T11:30:00.000Z')], cursor: { next: null } } } as never); @@ -161,6 +180,7 @@ describe('loadMissedMessages', () => { it('writes nothing derived from deleted rows when the payload is deleted-only', async () => { mockedSdkGet.mockResolvedValue({ + success: true, result: { updated: [], deleted: [message('gone', '2024-01-01T11:30:00.000Z')], cursor: { next: null } } } as never); @@ -172,13 +192,14 @@ describe('loadMissedMessages', () => { it('writes once on the legacy unpaginated server branch', async () => { (store.getState as jest.Mock).mockReturnValue({ server: { version: '7.0.0' } }); mockedSdkGet.mockResolvedValue({ + success: true, result: { updated: [message('a', '2024-01-01T11:30:00.000Z')], deleted: [] } } as never); await loadMissedMessages({ rid: RID }); expect(mockedSdkGet).toHaveBeenCalledWith( - 'chat.syncMessages', + '/v1/chat.syncMessages', expect.objectContaining({ lastUpdate: CURSOR.toISOString() }) ); expect(mockedUpdateLastOpen).toHaveBeenCalledTimes(1); diff --git a/app/lib/methods/loadMissedMessages.ts b/app/lib/methods/loadMissedMessages.ts index be285d1eabf..f140b5b8da3 100644 --- a/app/lib/methods/loadMissedMessages.ts +++ b/app/lib/methods/loadMissedMessages.ts @@ -10,9 +10,11 @@ import { snapshotServerTimestamps, type TServerTimestamps, updateLastOpen } from const count = 50; const syncMessages = async ({ roomId, next, type }: { roomId: string; next: number; type: 'UPDATED' | 'DELETED' }) => { - // @ts-ignore // this method dont have type - const { result } = await sdk.get('chat.syncMessages', { roomId, next, count, type }); - return result; + const response = await sdk.get('/v1/chat.syncMessages', { roomId, next, count, type }); + if (!response.success) { + return undefined; + } + return response.result; }; const getSyncMessagesFromCursor = async ( @@ -38,9 +40,9 @@ const getSyncMessagesFromCursor = async ( const [updatedMessages, deletedMessages] = await Promise.all([updatedPromise, deletedPromise]); return { deleted: deletedMessages?.deleted ?? [], - deletedNext: deletedMessages?.cursor.next, + deletedNext: deletedMessages?.cursor?.next, updated: updatedMessages?.updated ?? [], - updatedNext: updatedMessages?.cursor.next + updatedNext: updatedMessages?.cursor?.next }; }; @@ -66,9 +68,11 @@ async function load({ } // RC 0.60.0 - // @ts-ignore // this method dont have type - const { result } = await sdk.get('chat.syncMessages', { roomId, lastUpdate: cursor?.toISOString() }); - return result; + const response = await sdk.get('/v1/chat.syncMessages', { roomId, lastUpdate: cursor?.toISOString() }); + if (!response.success) { + return undefined; + } + return response.result; } export async function loadMissedMessages(args: { @@ -90,7 +94,7 @@ export async function loadMissedMessages(args: { updatedNext, deleted, deletedNext - }: { updated: ILastMessage[]; deleted: ILastMessage[]; updatedNext: number | null; deletedNext: number | null } = data; + }: { updated: ILastMessage[]; deleted: ILastMessage[]; updatedNext?: number | null; deletedNext?: number | null } = data; const serverTimestamps = [...(args.serverTimestamps ?? []), ...snapshotServerTimestamps(updated)]; @@ -98,7 +102,7 @@ export async function loadMissedMessages(args: { await updateMessages({ rid: args.rid, update: updated, remove: deleted }); if (deletedNext || updatedNext) { - loadMissedMessages({ + await loadMissedMessages({ rid: args.rid, updatedNext, deletedNext, diff --git a/app/lib/methods/logout.test.ts b/app/lib/methods/logout.test.ts new file mode 100644 index 00000000000..404cf0d21f6 --- /dev/null +++ b/app/lib/methods/logout.test.ts @@ -0,0 +1,255 @@ +/* eslint-disable import/first */ +jest.mock('@rocket.chat/ddp-client', () => ({ + DDPSDK: { + createAndConnect: jest.fn() + } +})); + +jest.mock('./userPreferences', () => ({ + __esModule: true, + default: { + getString: jest.fn(), + setString: jest.fn(), + removeItem: jest.fn() + } +})); + +jest.mock('../notifications', () => ({ + getDeviceToken: jest.fn() +})); + +jest.mock('./helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('../database', () => ({ + __esModule: true, + default: { + servers: { + get: jest.fn(() => ({ + find: jest.fn().mockRejectedValue(new Error('not found')) + })), + write: jest.fn(), + batch: jest.fn() + } + }, + getDatabase: jest.fn(() => ({ + write: jest.fn(), + unsafeResetDatabase: jest.fn() + })) +})); + +jest.mock('../services/connect', () => ({ + disconnect: jest.fn() +})); + +jest.mock('../services/sdk', () => ({ + __esModule: true, + default: { + current: undefined, + logout: jest.fn() + } +})); + +jest.mock('../services/restApi', () => ({ + removePushToken: jest.fn() +})); + +jest.mock('./subscriptions/rooms', () => ({ + roomsSubscription: { + stop: jest.fn() + } +})); + +jest.mock('./getUsersPresence', () => ({ + _activeUsersSubTimeout: { activeUsersSubTimeout: undefined } +})); + +import { DDPSDK } from '@rocket.chat/ddp-client'; + +import { removeServer, logout } from './logout'; +import UserPreferences from './userPreferences'; +import { getDeviceToken } from '../notifications'; +import log from './helpers/log'; +import sdk from '../services/sdk'; + +describe('removeServer', () => { + beforeEach(() => { + (UserPreferences.getString as jest.Mock).mockReset(); + (getDeviceToken as jest.Mock).mockReset(); + (DDPSDK.createAndConnect as jest.Mock).mockReset(); + (log as jest.Mock).mockReset(); + }); + + it('short-circuits when no stored userId is found for the server', async () => { + (UserPreferences.getString as jest.Mock).mockReturnValue(null); + await removeServer({ server: 'https://example.com' }); + expect(DDPSDK.createAndConnect).not.toHaveBeenCalled(); + }); + + it('connects, logs in with the resume token, removes push token, and logs out', async () => { + (UserPreferences.getString as jest.Mock).mockImplementation((key: string) => { + if (key === 'reactnativemeteor_usertoken-https://example.com') return 'user-id-1'; + if (key === 'reactnativemeteor_usertoken-user-id-1') return 'resume-tok'; + return null; + }); + (getDeviceToken as jest.Mock).mockReturnValue('push-tok'); + const tempSdk = { + account: { + loginWithToken: jest.fn().mockResolvedValue(undefined), + logout: jest.fn().mockResolvedValue(undefined) + }, + rest: { delete: jest.fn().mockResolvedValue({ success: true }) }, + connection: { close: jest.fn() } + }; + (DDPSDK.createAndConnect as jest.Mock).mockResolvedValue(tempSdk); + + await removeServer({ server: 'https://example.com' }); + + expect(DDPSDK.createAndConnect).toHaveBeenCalledWith('https://example.com'); + expect(tempSdk.account.loginWithToken).toHaveBeenCalledWith('resume-tok'); + expect(tempSdk.rest.delete).toHaveBeenCalledWith('/v1/push.token', { token: 'push-tok' }); + expect(tempSdk.account.logout).toHaveBeenCalled(); + expect(tempSdk.connection.close).toHaveBeenCalled(); + }); + + it('skips push-token removal when no device token exists', async () => { + (UserPreferences.getString as jest.Mock).mockImplementation((key: string) => + key === 'reactnativemeteor_usertoken-https://example.com' ? 'user-id-1' : 'resume-tok' + ); + (getDeviceToken as jest.Mock).mockReturnValue(null); + const tempSdk = { + account: { loginWithToken: jest.fn(), logout: jest.fn() }, + rest: { delete: jest.fn() }, + connection: { close: jest.fn() } + }; + (DDPSDK.createAndConnect as jest.Mock).mockResolvedValue(tempSdk); + + await removeServer({ server: 'https://example.com' }); + + expect(tempSdk.rest.delete).not.toHaveBeenCalled(); + expect(tempSdk.account.logout).toHaveBeenCalled(); + }); + + it('logs a warning when push-token deletion reports success:false', async () => { + (UserPreferences.getString as jest.Mock).mockImplementation((key: string) => + key === 'reactnativemeteor_usertoken-https://example.com' ? 'u' : 'r' + ); + (getDeviceToken as jest.Mock).mockReturnValue('push-tok'); + const tempSdk = { + account: { loginWithToken: jest.fn(), logout: jest.fn() }, + rest: { delete: jest.fn().mockResolvedValue({ success: false }) }, + connection: { close: jest.fn() } + }; + (DDPSDK.createAndConnect as jest.Mock).mockResolvedValue(tempSdk); + + await removeServer({ server: 'https://example.com' }); + expect(log).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('still closes the temp connection in finally when the connect step throws', async () => { + (UserPreferences.getString as jest.Mock).mockImplementation((key: string) => + key === 'reactnativemeteor_usertoken-https://example.com' ? 'u' : 'r' + ); + (DDPSDK.createAndConnect as jest.Mock).mockRejectedValue(new Error('conn failed')); + await removeServer({ server: 'https://example.com' }); + expect(log).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('closes the connection in finally even when logout throws', async () => { + (UserPreferences.getString as jest.Mock).mockImplementation((key: string) => + key === 'reactnativemeteor_usertoken-https://example.com' ? 'u' : 'r' + ); + (getDeviceToken as jest.Mock).mockReturnValue('push-tok'); + const close = jest.fn(); + const tempSdk = { + account: { + loginWithToken: jest.fn(), + logout: jest.fn().mockRejectedValue(new Error('logout failed')) + }, + rest: { delete: jest.fn().mockResolvedValue({ success: true }) }, + connection: { close } + }; + (DDPSDK.createAndConnect as jest.Mock).mockResolvedValue(tempSdk); + + await removeServer({ server: 'https://example.com' }); + expect(close).toHaveBeenCalled(); + }); +}); + +describe('logout', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset sdk.current to undefined before each test + (sdk as any).current = undefined; + }); + + afterEach(() => { + // Restore roomsSubscription to its original state to prevent test isolation issues + const mockRoomsSubscription = jest.requireMock('./subscriptions/rooms'); + if (!mockRoomsSubscription.roomsSubscription || !mockRoomsSubscription.roomsSubscription.stop) { + mockRoomsSubscription.roomsSubscription = { + stop: jest.fn() + }; + } + }); + + it('stops the rooms subscription, clears the presence timer, logs out of the sdk, and disconnects', async () => { + // Setup + (sdk as any).current = {}; // Make sdk.current truthy so disconnect() is called + const mockRoomsSubscription = jest.requireMock('./subscriptions/rooms'); + const mockConnect = jest.requireMock('../services/connect'); + const mockSdk = jest.requireMock('../services/sdk').default; + const mockActiveUsersTimeout = jest.requireMock('./getUsersPresence')._activeUsersSubTimeout; + + // Set up a timeout ID to be cleared + mockActiveUsersTimeout.activeUsersSubTimeout = 123; + + // Execute + await logout({ server: 'https://example.com' }); + + // Assert + expect(mockRoomsSubscription.roomsSubscription.stop).toHaveBeenCalled(); + expect(mockSdk.logout).toHaveBeenCalled(); + expect(mockConnect.disconnect).toHaveBeenCalled(); + // Verify the timeout was cleared + expect(mockActiveUsersTimeout.activeUsersSubTimeout).toBe(false); + }); + + it('handles missing roomsSubscription gracefully', async () => { + // Setup + (sdk as any).current = {}; + const mockRoomsSubscription = jest.requireMock('./subscriptions/rooms'); + const mockConnect = jest.requireMock('../services/connect'); + const mockSdk = jest.requireMock('../services/sdk').default; + + // Set roomsSubscription to null to test the optional chaining + mockRoomsSubscription.roomsSubscription = null; + + // Execute + await logout({ server: 'https://example.com' }); + + // Assert - should not throw and disconnect should still be called + expect(mockSdk.logout).toHaveBeenCalled(); + expect(mockConnect.disconnect).toHaveBeenCalled(); + }); + + it('handles missing timeout gracefully', async () => { + // Setup + (sdk as any).current = {}; + const mockSdk = jest.requireMock('../services/sdk').default; + const mockConnect = jest.requireMock('../services/connect'); + const mockActiveUsersTimeout = jest.requireMock('./getUsersPresence')._activeUsersSubTimeout; + + // Set activeUsersSubTimeout to undefined + mockActiveUsersTimeout.activeUsersSubTimeout = undefined; + + // Execute + await logout({ server: 'https://example.com' }); + + // Assert - should not throw + expect(mockSdk.logout).toHaveBeenCalled(); + expect(mockConnect.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index d27819472d4..8077ba9e94d 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -1,8 +1,7 @@ -import { Rocketchat as RocketchatClient } from '@rocket.chat/sdk'; import type Model from '@nozbe/watermelondb/Model'; +import { DDPSDK } from '@rocket.chat/ddp-client'; import { getDeviceToken } from '../notifications'; -import { isSsl } from './helpers'; import { BASIC_AUTH_KEY } from './helpers/fetch'; import database, { getDatabase } from '../database'; import log from './helpers/log'; @@ -66,18 +65,30 @@ export async function removeServer({ server }: { server: string }): Promise { try { // RC 0.60.0 - await sdk.current.logout(); + await sdk.logout(); } catch (e) { log(e); } diff --git a/app/lib/methods/readMessages.ts b/app/lib/methods/readMessages.ts index b593e9341ae..101da3e0ef6 100644 --- a/app/lib/methods/readMessages.ts +++ b/app/lib/methods/readMessages.ts @@ -34,7 +34,7 @@ export async function readMessages(rid: string): Promise { // RC 0.61.0 // @ts-ignore - await sdk.post('subscriptions.read', { rid }); + await sdk.post('/v1/subscriptions.read', { rid }); await db.write(async () => { try { diff --git a/app/lib/methods/sendFileMessage/index.ts b/app/lib/methods/sendFileMessage/index.ts index 0fe4c76b76c..e859b86ec2a 100644 --- a/app/lib/methods/sendFileMessage/index.ts +++ b/app/lib/methods/sendFileMessage/index.ts @@ -1,4 +1,4 @@ -import { type IUpload, type TSendFileMessageFileInfo, type IUser } from '../../../definitions'; +import { type IUpload, type TSendFileMessageFileInfo } from '../../../definitions'; import { store } from '../../store/auxStore'; import { compareServerVersion } from '../helpers'; import { sendFileMessage as sendFileMessageV1 } from './sendFileMessage'; @@ -9,13 +9,12 @@ export const sendFileMessage = ( fileInfo: TSendFileMessageFileInfo, tmid: string | undefined, server: string, - user: Partial>, isForceTryAgain?: boolean ): Promise => { const { version: serverVersion } = store.getState().server; if (compareServerVersion(serverVersion, 'lowerThan', '6.10.0')) { - return sendFileMessageV1(rid, fileInfo as IUpload, tmid, server, user, isForceTryAgain); + return sendFileMessageV1(rid, fileInfo as IUpload, tmid, server, isForceTryAgain); } - return sendFileMessageV2(rid, fileInfo, tmid, server, user, isForceTryAgain); + return sendFileMessageV2(rid, fileInfo, tmid, server, isForceTryAgain); }; diff --git a/app/lib/methods/sendFileMessage/sendAttachments.ts b/app/lib/methods/sendFileMessage/sendAttachments.ts index 70f0683d910..2036ef773ac 100644 --- a/app/lib/methods/sendFileMessage/sendAttachments.ts +++ b/app/lib/methods/sendFileMessage/sendAttachments.ts @@ -1,4 +1,4 @@ -import { type IShareAttachment, type IUser } from '../../../definitions'; +import { type IShareAttachment } from '../../../definitions'; import { sendFileMessage } from './index'; const ROTATED_ORIENTATIONS = ['5', '6', '7', '8']; @@ -8,7 +8,6 @@ export const sendAttachments = ({ rid, tmid, server, - user, altTextSupported, getMsg }: { @@ -16,7 +15,6 @@ export const sendAttachments = ({ rid: string; tmid: string | undefined; server: string; - user: Partial>; altTextSupported: boolean; getMsg: (attachment: IShareAttachment, index: number) => string | undefined; }): Promise => @@ -47,8 +45,7 @@ export const sendAttachments = ({ width }, tmid, - server, - user + server ); }) ); diff --git a/app/lib/methods/sendFileMessage/sendFileMessage.test.ts b/app/lib/methods/sendFileMessage/sendFileMessage.test.ts new file mode 100644 index 00000000000..75b8c3cb6c6 --- /dev/null +++ b/app/lib/methods/sendFileMessage/sendFileMessage.test.ts @@ -0,0 +1,212 @@ +/* eslint-disable import/first */ +jest.mock('../../services/sdk', () => ({ + __esModule: true, + default: { + getHeaders: jest.fn(() => ({ 'User-Agent': 'RC Mobile test' })) + } +})); + +jest.mock('../../store/auxStore', () => ({ + store: { getState: jest.fn(() => ({ server: { version: '6.10.0' } })) } +})); + +jest.mock('../helpers', () => ({ + compareServerVersion: jest.fn((_v: string, op: string, _target: string) => { + // 6.10.0 vs 6.10.0 → not lowerThan + if (op === 'lowerThan') return false; + return true; + }) +})); + +jest.mock('../../database', () => { + const update = jest.fn(); + const destroyPermanently = jest.fn(); + const uploadRecord = { + id: undefined, + update, + destroyPermanently + }; + return { + __esModule: true, + default: { + active: { + get: jest.fn(() => ({ + find: jest.fn().mockRejectedValue(new Error('not found')), + create: jest.fn((cb: any) => { + cb(uploadRecord); + return Promise.resolve(uploadRecord); + }), + schema: {} + })), + write: jest.fn((fn: any) => Promise.resolve(fn())) + } + }, + __uploadRecord: uploadRecord, + __update: update + }; +}); + +const mockSend = jest.fn().mockResolvedValue({ file: { _id: 'f1', url: '/f1' } }); +const mockState: { + progressCb?: (loaded: number, total: number) => void; + headers?: any; + url?: string; + formData?: any; +} = {}; + +jest.mock('../helpers/fileUpload', () => ({ + __esModule: true, + default: jest.fn().mockImplementation((url: string, headers: any, formData: any, progressCb: any) => { + mockState.url = url; + mockState.headers = headers; + mockState.formData = formData; + mockState.progressCb = progressCb; + return { send: mockSend }; + }) +})); + +jest.mock('./utils', () => { + const uploadRecord = { update: jest.fn(), destroyPermanently: jest.fn() }; + return { + __uploadRecord: uploadRecord, + copyFileToCacheDirectoryIfNeeded: jest.fn().mockImplementation((p: string) => Promise.resolve(p)), + getUploadPath: jest.fn(() => 'upload-path-1'), + persistUploadError: jest.fn().mockResolvedValue(undefined), + createUploadRecord: jest.fn().mockResolvedValue(['upload-path-1', uploadRecord]), + uploadQueue: {} + }; +}); + +const mockEncryptFile = jest.fn().mockResolvedValue({ + file: { path: '/p', name: 'n', type: 't' }, + getContent: jest.fn().mockResolvedValue({ algorithm: 'a' }), + fileContent: { algorithm: 'a' } +}); +jest.mock('../../encryption', () => ({ + Encryption: { + encryptFile: (...args: any[]) => mockEncryptFile(...args) + } +})); + +jest.mock('../helpers/fetch', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue({ ok: true }) +})); + +jest.mock('../helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('@nozbe/watermelondb/RawRecord', () => ({ + sanitizedRaw: jest.fn(() => ({})) +})); + +jest.mock('react-native', () => ({ + Alert: { alert: jest.fn() } +})); + +import { sendFileMessage } from './index'; +import { sendFileMessage as sendFileMessageV1 } from './sendFileMessage'; +import { sendFileMessageV2 } from './sendFileMessageV2'; +import sdk from '../../services/sdk'; +import fetch from '../helpers/fetch'; +import { store } from '../../store/auxStore'; + +beforeEach(() => { + mockSend.mockClear(); + mockState.progressCb = undefined; + mockState.headers = undefined; + mockState.url = undefined; + mockState.formData = undefined; + mockEncryptFile.mockClear(); + (fetch as jest.Mock).mockClear(); + (sdk.getHeaders as jest.Mock).mockReset().mockReturnValue({ 'User-Agent': 'RC Mobile test' }); + (store.getState as jest.Mock).mockReturnValue({ server: { version: '6.10.0' } }); +}); + +describe('sendFileMessage router', () => { + it('routes to V1 for RC < 6.10.0', async () => { + (store.getState as jest.Mock).mockReturnValue({ server: { version: '6.9.0' } }); + const { compareServerVersion } = require('../helpers'); + (compareServerVersion as jest.Mock).mockReturnValueOnce(true); + await sendFileMessage('rid', { name: 'a', type: 't', path: '/p', size: 1 } as any, undefined, 'https://x.com'); + expect(mockState.url).toBe('https://x.com/api/v1/rooms.upload/rid'); + }); + + it('routes to V2 for RC >= 6.10.0', async () => { + const { compareServerVersion } = require('../helpers'); + (compareServerVersion as jest.Mock).mockReturnValueOnce(false); + await sendFileMessage('rid', { name: 'a', type: 't', path: '/p', size: 1 } as any, undefined, 'https://x.com'); + expect(mockState.url).toBe('https://x.com/api/v1/rooms.media/rid'); + }); +}); + +describe('sendFileMessage V1 headers', () => { + it('spreads sdk.getHeaders() + Content-Type', async () => { + await sendFileMessageV1( + 'rid', + { name: 'a', type: 't', path: '/p', size: 1, store: 'Uploads', description: '' } as any, + undefined, + 'https://x.com' + ); + expect(mockState.headers).toMatchObject({ + 'User-Agent': 'RC Mobile test', + 'Content-Type': 'multipart/form-data' + }); + }); + + it('progress callback updates upload record progress field', async () => { + await sendFileMessageV1( + 'rid', + { name: 'a', type: 't', path: '/p', size: 1, store: 'Uploads', description: '' } as any, + undefined, + 'https://x.com' + ); + const { __uploadRecord } = require('./utils'); + // sendFileMessage updates the record returned by createUploadRecord, not the DB mock. + await mockState.progressCb!(50, 100); + expect(__uploadRecord.update).toHaveBeenCalledWith(expect.any(Function)); + // The update callback assigns u.progress = Math.floor((50/100)*100) = 50 + const updateFn = __uploadRecord.update.mock.calls[0][0]; + const u: any = {}; + updateFn(u); + expect(u.progress).toBe(50); + }); +}); + +describe('sendFileMessage V2', () => { + const baseFile = { name: 'a', type: 't', path: '/p', size: 1 } as any; + + it('invokes Encryption.encryptFile with (rid, fileInfo)', async () => { + await sendFileMessageV2('rid', baseFile, undefined, 'https://x.com'); + expect(mockEncryptFile).toHaveBeenCalledWith('rid', baseFile); + }); + + it('uploads to /v1/rooms.media/{rid} with merged headers', async () => { + await sendFileMessageV2('rid', baseFile, undefined, 'https://x.com'); + expect(mockState.url).toBe('https://x.com/api/v1/rooms.media/rid'); + expect(mockState.headers).toMatchObject({ + 'User-Agent': 'RC Mobile test', + 'Content-Type': 'multipart/form-data' + }); + }); + + it('confirms via /v1/rooms.mediaConfirm with JSON Content-Type override', async () => { + await sendFileMessageV2('rid', baseFile, undefined, 'https://x.com'); + expect(fetch).toHaveBeenCalledWith( + 'https://x.com/api/v1/rooms.mediaConfirm/rid/f1', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'Content-Type': 'application/json' }) + }) + ); + }); + + it('includes a content form-data entry when Encryption returns fileContent', async () => { + await sendFileMessageV2('rid', baseFile, undefined, 'https://x.com'); + const contentField = mockState.formData.find((e: any) => e.name === 'content'); + expect(contentField).toBeDefined(); + expect(JSON.parse(contentField.data)).toEqual({ algorithm: 'a' }); + }); +}); diff --git a/app/lib/methods/sendFileMessage/sendFileMessage.ts b/app/lib/methods/sendFileMessage/sendFileMessage.ts index e227633b5b5..0afbfcf1055 100644 --- a/app/lib/methods/sendFileMessage/sendFileMessage.ts +++ b/app/lib/methods/sendFileMessage/sendFileMessage.ts @@ -1,23 +1,20 @@ -import { settings as RocketChatSettings } from '@rocket.chat/sdk'; - -import { type IUser, type TSendFileMessageFileInfo, type TUploadModel } from '../../../definitions'; +import { type TSendFileMessageFileInfo, type TUploadModel } from '../../../definitions'; import database from '../../database'; import FileUpload from '../helpers/fileUpload'; import { copyFileToCacheDirectoryIfNeeded, createUploadRecord, persistUploadError, uploadQueue } from './utils'; import { type IFormData } from '../helpers/fileUpload/definitions'; +import sdk from '../../services/sdk'; export async function sendFileMessage( rid: string, fileInfo: TSendFileMessageFileInfo, tmid: string | undefined, server: string, - user: Partial>, isForceTryAgain?: boolean ): Promise { let uploadPath: string | null = ''; let uploadRecord: TUploadModel | null; try { - const { id, token } = user; const uploadUrl = `${server}/api/v1/rooms.upload/${rid}`; fileInfo.rid = rid; @@ -60,10 +57,8 @@ export async function sendFileMessage( } const headers = { - ...RocketChatSettings.customHeaders, - 'Content-Type': 'multipart/form-data', - 'X-Auth-Token': token, - 'X-User-Id': id + ...sdk.getHeaders(), + 'Content-Type': 'multipart/form-data' }; uploadQueue[uploadPath] = new FileUpload(uploadUrl, headers, formData, async (loaded, total) => { diff --git a/app/lib/methods/sendFileMessage/sendFileMessageV2.test.ts b/app/lib/methods/sendFileMessage/sendFileMessageV2.test.ts new file mode 100644 index 00000000000..428487a383f --- /dev/null +++ b/app/lib/methods/sendFileMessage/sendFileMessageV2.test.ts @@ -0,0 +1,182 @@ +/* eslint-disable import/first */ +jest.mock('../../services/sdk', () => ({ + __esModule: true, + default: { + get: jest.fn(), + post: jest.fn(), + methodCallWrapper: jest.fn(), + getHeaders: jest.fn(() => ({ 'User-Agent': 'RC Mobile test' })) + } +})); + +jest.mock('../../store/auxStore', () => ({ + store: { + getState: jest.fn(() => ({ + settings: {}, + login: { user: { id: 'u1', token: 't1' } }, + server: { version: '6.0.0' } + })), + dispatch: jest.fn() + } +})); + +jest.mock('../../database', () => ({ + __esModule: true, + default: { + active: { + get: jest.fn(() => ({ + find: jest.fn().mockRejectedValue(new Error('not found')), + schema: {} + })), + write: jest.fn((fn: any) => Promise.resolve(fn())) + } + } +})); + +const mockSend = jest.fn().mockResolvedValue({ file: { _id: 'file1', url: '/uploads/file1' } }); +const mockState: { + url?: string; + headers?: any; + formData?: any; +} = {}; + +jest.mock('../helpers/fileUpload', () => ({ + __esModule: true, + default: jest.fn().mockImplementation((url: string, headers: any, formData: any) => { + mockState.url = url; + mockState.headers = headers; + mockState.formData = formData; + return { send: mockSend }; + }) +})); + +jest.mock('./utils', () => ({ + copyFileToCacheDirectoryIfNeeded: jest.fn().mockImplementation((p: string) => Promise.resolve(p)), + getUploadPath: jest.fn(() => 'upload-path-1'), + persistUploadError: jest.fn().mockResolvedValue(undefined), + createUploadRecord: jest.fn().mockResolvedValue(['upload-path-1', { update: jest.fn(), destroyPermanently: jest.fn() }]), + uploadQueue: {} +})); + +const mockEncryptFile = jest.fn().mockResolvedValue({ + file: { path: '/p', name: 'testfile.txt', type: 'text/plain' }, + getContent: undefined, + fileContent: undefined +}); + +jest.mock('../../encryption', () => ({ + Encryption: { + encryptFile: (...args: any[]) => mockEncryptFile(...args) + } +})); + +jest.mock('../helpers/fetch', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue({ ok: true }) +})); + +jest.mock('../helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('@nozbe/watermelondb/RawRecord', () => ({ + sanitizedRaw: jest.fn(() => ({})) +})); + +jest.mock('react-native', () => ({ + Alert: { alert: jest.fn() } +})); + +import { sendFileMessageV2 } from './sendFileMessageV2'; +import sdk from '../../services/sdk'; +import fetch from '../helpers/fetch'; + +const baseFile: any = { name: 'testfile.txt', type: 'text/plain', path: '/p', size: 100 }; + +beforeEach(() => { + jest.clearAllMocks(); + mockSend.mockResolvedValue({ file: { _id: 'file1', url: '/uploads/file1' } }); + mockState.url = undefined; + mockState.headers = undefined; + mockState.formData = undefined; + mockEncryptFile.mockResolvedValue({ + file: { path: '/p', name: 'testfile.txt', type: 'text/plain' }, + getContent: undefined, + fileContent: undefined + }); + (sdk.getHeaders as jest.Mock).mockReturnValue({ 'User-Agent': 'RC Mobile test' }); + (fetch as jest.Mock).mockResolvedValue({ ok: true }); +}); + +describe('sendFileMessageV2', () => { + it('builds form data with correct file fields', async () => { + await sendFileMessageV2('rid1', baseFile, undefined, 'https://server.com'); + + expect(mockState.formData).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'file', + type: 'text/plain', + filename: 'testfile.txt', + uri: '/p' + }) + ]) + ); + }); + + it('includes headers from sdk.getHeaders()', async () => { + await sendFileMessageV2('rid1', baseFile, undefined, 'https://server.com'); + + expect(mockState.headers).toMatchObject({ + 'User-Agent': 'RC Mobile test', + 'Content-Type': 'multipart/form-data' + }); + }); + + it('includes content form-data entry when Encryption returns fileContent', async () => { + mockEncryptFile.mockResolvedValue({ + file: { path: '/p', name: 'testfile.txt', type: 'text/plain' }, + getContent: jest.fn().mockResolvedValue({ algorithm: 'rc4' }), + fileContent: { algorithm: 'rc4' } + }); + + await sendFileMessageV2('rid1', baseFile, undefined, 'https://server.com'); + + const contentField = mockState.formData?.find((e: any) => e.name === 'content'); + expect(contentField).toBeDefined(); + expect(JSON.parse(contentField.data)).toEqual({ algorithm: 'rc4' }); + }); + + it('throws and calls persistUploadError on upload failure', async () => { + mockSend.mockRejectedValue(new Error('upload failed')); + const { persistUploadError, uploadQueue } = require('./utils'); + uploadQueue['upload-path-1'] = {}; + + await expect(sendFileMessageV2('rid1', baseFile, undefined, 'https://server.com')).rejects.toThrow('upload failed'); + + expect(persistUploadError).toHaveBeenCalledWith(baseFile.path, 'rid1'); + }); + + it('confirms upload via POST to /v1/rooms.mediaConfirm', async () => { + await sendFileMessageV2('rid1', baseFile, undefined, 'https://server.com'); + + expect(fetch).toHaveBeenCalledWith( + 'https://server.com/api/v1/rooms.mediaConfirm/rid1/file1', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'Content-Type': 'application/json' }) + }) + ); + }); + + it('does not destroy the local upload record when mediaConfirm fails', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 500 }); + const { createUploadRecord } = require('./utils'); + + await expect(sendFileMessageV2('rid1', baseFile, undefined, 'https://server.com')).rejects.toThrow(); + + const [, mockUploadRecord] = await createUploadRecord.mock.results[0].value; + expect(mockUploadRecord.destroyPermanently).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/methods/sendFileMessage/sendFileMessageV2.ts b/app/lib/methods/sendFileMessage/sendFileMessageV2.ts index 30369aef2a9..4265d0b6412 100644 --- a/app/lib/methods/sendFileMessage/sendFileMessageV2.ts +++ b/app/lib/methods/sendFileMessage/sendFileMessageV2.ts @@ -1,30 +1,25 @@ -import { settings as RocketChatSettings } from '@rocket.chat/sdk'; - -import { type TSendFileMessageFileInfo, type IUser, type TUploadModel } from '../../../definitions'; +import { type TSendFileMessageFileInfo, type TUploadModel } from '../../../definitions'; import database from '../../database'; import { Encryption } from '../../encryption'; import { copyFileToCacheDirectoryIfNeeded, createUploadRecord, persistUploadError, uploadQueue } from './utils'; import FileUpload from '../helpers/fileUpload'; import { type IFormData } from '../helpers/fileUpload/definitions'; import fetch from '../helpers/fetch'; +import sdk from '../../services/sdk'; export async function sendFileMessageV2( rid: string, fileInfo: TSendFileMessageFileInfo, tmid: string | undefined, server: string, - user: Partial>, isForceTryAgain?: boolean ): Promise { let uploadPath: string | null = ''; let uploadRecord: TUploadModel | null; try { - const { id, token } = user; - const headers = { - ...RocketChatSettings.customHeaders, - 'Content-Type': 'multipart/form-data', - 'X-Auth-Token': token, - 'X-User-Id': id + const headers: Record = { + ...sdk.getHeaders(), + 'Content-Type': 'multipart/form-data' }; const db = database.active; @@ -66,7 +61,7 @@ export async function sendFileMessageV2( if (getContent) { content = await getContent(response.file._id, response.file.url); } - await fetch(`${server}/api/v1/rooms.mediaConfirm/${rid}/${response.file._id}`, { + const confirmResponse = await fetch(`${server}/api/v1/rooms.mediaConfirm/${rid}/${response.file._id}`, { method: 'POST', headers: { ...headers, @@ -80,6 +75,9 @@ export async function sendFileMessageV2( content }) }); + if (!confirmResponse.ok) { + throw new Error(`mediaConfirm failed with status ${confirmResponse.status}`); + } await db.write(async () => { await uploadRecord?.destroyPermanently(); }); diff --git a/app/lib/methods/sendMessage.ts b/app/lib/methods/sendMessage.ts index c7f4942f0db..5d37394f4a9 100644 --- a/app/lib/methods/sendMessage.ts +++ b/app/lib/methods/sendMessage.ts @@ -51,9 +51,7 @@ const changeMessageStatus = async (id: string, status: number, tmid?: string, me async function sendMessageCall(message: any) { const { _id, tmid } = message; try { - // RC 0.60.0 - // @ts-ignore - const result = await sdk.post('chat.sendMessage', { message }); + const result = await sdk.post('/v1/chat.sendMessage', { message }); if (result.success) { // @ts-ignore return changeMessageStatus(_id, messagesStatus.SENT, tmid, result.message); diff --git a/app/lib/methods/subscriptions/room.resumeSync.test.ts b/app/lib/methods/subscriptions/room.resumeSync.test.ts index 6d5004403f5..4cf66f6958c 100644 --- a/app/lib/methods/subscriptions/room.resumeSync.test.ts +++ b/app/lib/methods/subscriptions/room.resumeSync.test.ts @@ -6,7 +6,8 @@ import { getSubscriptionByRoomId } from '../../database/services/Subscription'; jest.mock('../../services/sdk', () => ({ __esModule: true, default: { - get: jest.fn() + get: jest.fn(), + subscribeRoom: jest.fn(() => Promise.resolve([])) } })); @@ -50,7 +51,8 @@ const missedMessage = { const syncMessagesResponse = ( updated: unknown[] -): { result: { updated: unknown[]; deleted: unknown[]; cursor: { next: number | null } } } => ({ +): { success: true; result: { updated: unknown[]; deleted: unknown[]; cursor: { next: number | null } } } => ({ + success: true, result: { updated, deleted: [], cursor: { next: null } } }); @@ -68,7 +70,7 @@ describe('RoomSubscription resume sync', () => { await new RoomSubscription(RID).handleConnection(); expect(mockedSdkGet).toHaveBeenCalledWith( - 'chat.syncMessages', + '/v1/chat.syncMessages', expect.objectContaining({ roomId: RID, type: 'UPDATED', next: persistedCursor.getTime() }) ); expect(mockedUpdateMessages).toHaveBeenCalledWith( diff --git a/app/lib/methods/subscriptions/room.test.ts b/app/lib/methods/subscriptions/room.test.ts index bf0b0e9576d..ca25e60f62a 100644 --- a/app/lib/methods/subscriptions/room.test.ts +++ b/app/lib/methods/subscriptions/room.test.ts @@ -1,9 +1,13 @@ import RoomSubscription from './room'; import { getMessageById } from '../../database/services/Message'; import { getThreadById } from '../../database/services/Thread'; +import { clearUserTyping } from '../../../actions/usersTyping'; +import { loadMissedMessages } from '../loadMissedMessages'; import log from '../helpers/log'; const mockSubscribeRoom = jest.fn, [string]>(() => Promise.resolve([])); +const mockOnConnectionStatus = jest.fn<() => void, [(status: string) => void]>(() => jest.fn()); +const mockOnLogin = jest.fn<() => void, [() => void]>(() => jest.fn()); const mockOnStreamData = jest.fn, [string, (...args: unknown[]) => void]>(() => Promise.resolve({ stop: jest.fn() }) ); @@ -11,16 +15,24 @@ jest.mock('../../services/sdk', () => ({ __esModule: true, default: { subscribeRoom: (rid: string) => mockSubscribeRoom(rid), + onConnectionStatus: (cb: (status: string) => void) => mockOnConnectionStatus(cb), + onLogin: (cb: () => void) => mockOnLogin(cb), onStreamData: (event: string, cb: (...args: unknown[]) => void) => mockOnStreamData(event, cb) } })); -jest.mock('../../store/auxStore', () => ({ - store: { - getState: jest.fn(() => ({})), - dispatch: jest.fn() - } -})); +jest.mock('../../store/auxStore', () => { + const __mockStoreDispatch = jest.fn(); + return { + store: { + getState: jest.fn(() => ({})), + dispatch: (action: unknown) => __mockStoreDispatch(action) + }, + __mockStoreDispatch + }; +}); + +const { __mockStoreDispatch: mockStoreDispatch } = jest.requireMock('../../store/auxStore'); jest.mock('../loadMissedMessages', () => ({ loadMissedMessages: jest.fn, [unknown]>(() => Promise.resolve()) @@ -135,6 +147,158 @@ describe('RoomSubscription', () => { expect(mockSubscribeRoom).toHaveBeenCalledTimes(1); expect(mockSubscribeRoom).toHaveBeenCalledWith(rid); }); + + it('wires handleClose through onConnectionStatus, not onStreamData', async () => { + await sub.subscribe(); + + expect(mockOnConnectionStatus).toHaveBeenCalledTimes(1); + const statusCallback = mockOnConnectionStatus.mock.calls[0][0]; + + statusCallback('closed'); + expect(mockStoreDispatch).toHaveBeenCalledWith(clearUserTyping()); + }); + + it('wires handleConnection through onLogin, not the pre-auth onConnectionStatus "connected" status', async () => { + await sub.subscribe(); + + expect(mockOnLogin).toHaveBeenCalledWith(sub.handleConnection); + + mockSubscribeRoom.mockClear(); + const statusCallback = mockOnConnectionStatus.mock.calls[0][0]; + statusCallback('connected'); + await Promise.resolve(); + expect(mockSubscribeRoom).not.toHaveBeenCalled(); + }); + }); + + describe('handleConnection', () => { + it('calls subscribeRoom, dispatches clearUserTyping, loads missed messages, and reads', async () => { + await sub.handleConnection(); + + expect(mockSubscribeRoom).toHaveBeenCalledWith(rid); + expect(mockStoreDispatch).toHaveBeenCalledWith(clearUserTyping()); + expect(loadMissedMessages).toHaveBeenCalledWith({ rid }); + }); + + it('handles subscribeRoom rejection gracefully', async () => { + mockSubscribeRoom.mockRejectedValueOnce(new Error('boom')); + + await expect(sub.handleConnection()).resolves.toBeUndefined(); + }); + }); + + describe('handleClose', () => { + it('does not call subscribeRoom or loadMissedMessages, but dispatches clearUserTyping', async () => { + await sub.handleClose(); + + expect(mockSubscribeRoom).not.toHaveBeenCalled(); + expect(loadMissedMessages).not.toHaveBeenCalled(); + expect(mockStoreDispatch).toHaveBeenCalledWith(clearUserTyping()); + }); + }); + + describe('DDP subscription recovery after reconnect', () => { + it('handleConnection re-subscribes the room to restore lost DDP subscriptions', async () => { + await sub.subscribe(); + mockSubscribeRoom.mockClear(); + + await sub.handleConnection(); + + expect(mockSubscribeRoom).toHaveBeenCalledTimes(1); + expect(mockSubscribeRoom).toHaveBeenCalledWith(rid); + }); + + it('handleClose does NOT re-subscribe (only reconnects restore subscriptions, not disconnects)', async () => { + await sub.subscribe(); + mockSubscribeRoom.mockClear(); + + await sub.handleClose(); + + expect(mockSubscribeRoom).not.toHaveBeenCalled(); + }); + + it('tears down stale subscriptions on reconnect and tracks fresh ones for later cleanup', async () => { + const staleSub = { stop: jest.fn() }; + const freshSub = { stop: jest.fn() }; + mockSubscribeRoom.mockResolvedValueOnce([staleSub]).mockResolvedValueOnce([freshSub]); + + await sub.subscribe(); + await sub.handleConnection(); + await sub.unsubscribe(); + + expect(staleSub.stop).toHaveBeenCalledTimes(1); + expect(freshSub.stop).toHaveBeenCalledTimes(1); + }); + + it('does not accumulate subscriptions across repeated handleConnection calls (simulates sequential reopen)', async () => { + const first = { stop: jest.fn() }; + const second = { stop: jest.fn() }; + mockSubscribeRoom.mockResolvedValueOnce([first]).mockResolvedValueOnce([second]); + + await sub.subscribe(); + expect(mockSubscribeRoom).toHaveBeenCalledTimes(1); + + // First reopen → tears down [first], creates [second] + await sub.handleConnection(); + expect(mockSubscribeRoom).toHaveBeenCalledTimes(2); + expect(first.stop).toHaveBeenCalledTimes(1); + expect(second.stop).not.toHaveBeenCalled(); + + // Second reopen → tears down [second], creates [] + await sub.handleConnection(); + expect(mockSubscribeRoom).toHaveBeenCalledTimes(3); + expect(second.stop).toHaveBeenCalledTimes(1); + + // Final cleanup → empty batch, no more unsubscribes + await sub.unsubscribe(); + expect(first.stop).toHaveBeenCalledTimes(1); + expect(second.stop).toHaveBeenCalledTimes(1); + }); + + it('does not call onStreamData inside handleConnection (listeners persist across reopen)', async () => { + await sub.subscribe(); + mockOnStreamData.mockClear(); + + await sub.handleConnection(); + + expect(mockOnStreamData).not.toHaveBeenCalled(); + }); + + it('survives a poisoned subscription array (undefined entry from a rejected sub) and still re-subscribes', async () => { + // A pre-auth subscribe rejected by the server (nosub) resolves to undefined inside Promise.all. + const freshSub = { stop: jest.fn() }; + mockSubscribeRoom.mockResolvedValueOnce([undefined as any]).mockResolvedValueOnce([freshSub]); + + await sub.subscribe(); + mockSubscribeRoom.mockClear(); + + await expect(sub.handleConnection()).resolves.toBeUndefined(); + expect(mockSubscribeRoom).toHaveBeenCalledTimes(1); + expect(mockSubscribeRoom).toHaveBeenCalledWith(rid); + }); + }); + + describe('isAlive guard', () => { + it('handleConnection does nothing once the subscription is no longer alive (race with unsubscribe)', async () => { + await sub.subscribe(); + await sub.unsubscribe(); + jest.clearAllMocks(); + + await sub.handleConnection(); + + expect(mockSubscribeRoom).not.toHaveBeenCalled(); + expect(loadMissedMessages).not.toHaveBeenCalled(); + expect(mockStoreDispatch).not.toHaveBeenCalled(); + }); + + it('handleConnection re-subscribes while the subscription is still alive', async () => { + await sub.subscribe(); + mockSubscribeRoom.mockClear(); + + await sub.handleConnection(); + + expect(mockSubscribeRoom).toHaveBeenCalledWith(rid); + }); }); describe('updateMessage concurrency', () => { diff --git a/app/lib/methods/subscriptions/room.ts b/app/lib/methods/subscriptions/room.ts index acfe1c74029..f19aa324f42 100644 --- a/app/lib/methods/subscriptions/room.ts +++ b/app/lib/methods/subscriptions/room.ts @@ -18,13 +18,12 @@ import { Encryption } from '../../encryption'; import { type IMessage, type TMessageModel, - type TSubscriptionModel, type TThreadMessageModel, type TThreadModel, type IDeleteMessageBulkParams } from '../../../definitions'; import { type IDDPMessage } from '../../../definitions/IDDPMessage'; -import sdk from '../../services/sdk'; +import sdk, { type DDPSubscription } from '../../services/sdk'; import { readMessages } from '../readMessages'; import { loadMissedMessages } from '../loadMissedMessages'; import markMessagesRead from '../helpers/markMessagesRead'; @@ -32,9 +31,9 @@ import markMessagesRead from '../helpers/markMessagesRead'; export default class RoomSubscription { private rid: string; private isAlive: boolean; - private promises?: Promise; - private connectedListener?: Promise; - private disconnectedListener?: Promise; + private promises?: Promise; + private connectionStatusListener?: () => void; + private loginListener?: () => void; private notifyRoomListener?: Promise; private messageReceivedListener?: Promise; @@ -50,8 +49,12 @@ export default class RoomSubscription { } this.promises = sdk.subscribeRoom(this.rid); - this.connectedListener = sdk.onStreamData('connected', this.handleConnection); - this.disconnectedListener = sdk.onStreamData('close', this.handleConnection); + this.connectionStatusListener = sdk.onConnectionStatus(status => { + if (['disconnected', 'closed'].includes(status)) { + this.handleClose(); + } + }); + this.loginListener = sdk.onLogin(this.handleConnection); this.notifyRoomListener = sdk.onStreamData('stream-notify-room', this.handleNotifyRoomReceived); this.messageReceivedListener = sdk.onStreamData('stream-room-messages', this.handleMessageReceived); if (!this.isAlive) { @@ -68,14 +71,14 @@ export default class RoomSubscription { if (this.promises) { try { const subscriptions = (await this.promises) || []; - subscriptions.forEach(sub => sub.unsubscribe().catch(() => console.log('unsubscribeRoom'))); + subscriptions.forEach(sub => sub?.stop?.()); } catch (e) { // do nothing } } reduxStore.dispatch(clearUserTyping()); - this.removeListener(this.connectedListener); - this.removeListener(this.disconnectedListener); + this.connectionStatusListener?.(); + this.loginListener?.(); this.removeListener(this.notifyRoomListener); this.removeListener(this.messageReceivedListener); }; @@ -92,15 +95,38 @@ export default class RoomSubscription { }; handleConnection = async () => { + if (!this.isAlive) { + return; + } try { + if (this.promises) { + const oldSubs = await this.promises; + oldSubs?.forEach(sub => sub?.stop?.()); + } + this.promises = sdk.subscribeRoom(this.rid); + await this.promises; + if (!this.isAlive) { + return; + } reduxStore.dispatch(clearUserTyping()); await loadMissedMessages({ rid: this.rid }); + if (!this.isAlive) { + return; + } this.read(); } catch (e) { log(e); } }; + handleClose = () => { + try { + reduxStore.dispatch(clearUserTyping()); + } catch (e) { + log(e); + } + }; + handleNotifyRoomReceived = protectedFunction(async (ddpMessage: IDDPMessage) => { const [_rid, ev] = ddpMessage.fields.eventName.split('/'); if (this.rid !== _rid) { diff --git a/app/lib/methods/subscriptions/roomCloseCursor.test.ts b/app/lib/methods/subscriptions/roomCloseCursor.test.ts index 62e3892c8fa..c4192ae3686 100644 --- a/app/lib/methods/subscriptions/roomCloseCursor.test.ts +++ b/app/lib/methods/subscriptions/roomCloseCursor.test.ts @@ -6,7 +6,8 @@ import { getSubscriptionByRoomId } from '../../database/services/Subscription'; jest.mock('../../services/sdk', () => ({ __esModule: true, default: { - get: jest.fn() + get: jest.fn(), + subscribeRoom: jest.fn(() => Promise.resolve([])) } })); @@ -74,6 +75,7 @@ const makeSubscription = (lastOpen: Date | null) => { const respondFromServer = () => mockedSdkGet.mockImplementation(((_endpoint: string, params: { next?: number; type?: string }) => Promise.resolve({ + success: true, result: { updated: params.type === 'UPDATED' && typeof params.next === 'number' && MISSED_SERVER_UPDATED_AT.getTime() >= params.next diff --git a/app/lib/methods/subscriptions/rooms.test.ts b/app/lib/methods/subscriptions/rooms.test.ts new file mode 100644 index 00000000000..1b45a236d83 --- /dev/null +++ b/app/lib/methods/subscriptions/rooms.test.ts @@ -0,0 +1,159 @@ +/* eslint-disable import/first */ +jest.mock('../../services/sdk', () => ({ + __esModule: true, + default: { + current: undefined, + onStreamData: jest.fn(), + subscribeNotifyUser: jest.fn(() => []) + } +})); + +jest.mock('../../database', () => ({ + __esModule: true, + default: { active: { get: jest.fn(), write: jest.fn(), batch: jest.fn() } } +})); + +jest.mock('../../store/auxStore', () => ({ + store: { + getState: jest.fn(() => ({ room: { subscribedRoom: undefined, rid: undefined, isDeleting: false } })), + dispatch: jest.fn() + } +})); + +jest.mock('../helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('../helpers/protectedFunction', () => ({ + __esModule: true, + default: (fn: any) => fn +})); + +jest.mock('../actions', () => ({ + handlePayloadUserInteraction: jest.fn() +})); + +jest.mock('../../encryption', () => ({ + Encryption: { + decryptPendingSubscriptions: jest.fn(), + decryptPendingMessages: jest.fn(), + getRoomInstance: jest.fn(), + stopRoom: jest.fn(), + provideRoomKeyToUser: jest.fn().mockResolvedValue(undefined), + decryptMessage: jest.fn() + } +})); + +jest.mock('../updateMessages', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(undefined) +})); + +jest.mock('../getRoom', () => ({ + getRoom: jest.fn() +})); + +import subscribeRooms from './rooms'; +import sdk from '../../services/sdk'; +import { handlePayloadUserInteraction } from '../actions'; +import { Encryption } from '../../encryption'; +import * as roomsModule from './rooms'; + +describe('subscribeRooms', () => { + let capturedHandler: (msg: any) => Promise | void; + const stopMock = jest.fn(); + const rawStop = jest.fn(); + + beforeEach(async () => { + // Clean up any prior subscription first (before resetting mocks, so its stop() doesn't pollute counts) + const prior = roomsModule.roomsSubscription; + if (prior) prior.stop(); + await Promise.resolve(); + stopMock.mockReset(); + rawStop.mockReset(); + (sdk.onStreamData as jest.Mock).mockReset().mockImplementation((_name: string, handler: (m: any) => void) => { + capturedHandler = handler; + return Promise.resolve({ stop: stopMock }); + }); + (sdk.subscribeNotifyUser as jest.Mock).mockReset().mockReturnValue([{ stop: rawStop }, { stop: rawStop }]); + (sdk as any).current = { connection: { url: 'wss://example.com/websocket' } }; + (handlePayloadUserInteraction as jest.Mock).mockReset(); + (Encryption.provideRoomKeyToUser as jest.Mock).mockClear(); + }); + + it('registers a stream-notify-user listener via sdk.onStreamData', () => { + subscribeRooms(); + expect(sdk.onStreamData).toHaveBeenCalledWith('stream-notify-user', expect.any(Function)); + }); + + it('registers per-user raw subscriptions via subscribeNotifyUser', () => { + subscribeRooms(); + expect(sdk.subscribeNotifyUser).toHaveBeenCalledTimes(1); + }); + + it('exposes a roomsSubscription with a stop() function', () => { + subscribeRooms(); + expect(roomsModule.roomsSubscription).not.toBeNull(); + expect(typeof roomsModule.roomsSubscription?.stop).toBe('function'); + }); + + it('stop() unsubscribes the stream listener and stops raw handles', async () => { + subscribeRooms(); + // Wait for the onStreamData promise to settle so streamListener is a Promise + await Promise.resolve(); + roomsModule.roomsSubscription?.stop(); + await Promise.resolve(); + expect(stopMock).toHaveBeenCalled(); + expect(rawStop).toHaveBeenCalledTimes(2); + }); + + it('stop() clears roomsSubscription to null', () => { + subscribeRooms(); + roomsModule.roomsSubscription?.stop(); + expect(roomsModule.roomsSubscription).toBeNull(); + }); + + it('captures the server URL into subServer when subscribing', () => { + subscribeRooms(); + // Drive a message that should be ignored because url matches (i.e. handler runs through) + const handlerCallsBeforeMessage = (handlePayloadUserInteraction as jest.Mock).mock.calls.length; + capturedHandler({ + msg: 'changed', + fields: { eventName: 'u1/uiInteraction', args: [{ type: 'banner.open', payload: 'p' }] } + }); + expect((handlePayloadUserInteraction as jest.Mock).mock.calls.length).toBeGreaterThan(handlerCallsBeforeMessage); + }); + + it('uiInteraction events dispatch via handlePayloadUserInteraction', async () => { + subscribeRooms(); + await capturedHandler({ + msg: 'changed', + fields: { + eventName: 'u1/uiInteraction', + args: [{ type: 'banner.open', extra: true }] + } + }); + expect(handlePayloadUserInteraction).toHaveBeenCalledWith('banner.open', { extra: true }); + }); + + it('e2ekeyRequest events invoke Encryption.provideRoomKeyToUser with (keyId, roomId)', async () => { + subscribeRooms(); + await capturedHandler({ + msg: 'changed', + fields: { eventName: 'u1/e2ekeyRequest', args: ['rid-1', 'key-1'] } + }); + expect(Encryption.provideRoomKeyToUser).toHaveBeenCalledWith('key-1', 'rid-1'); + }); + + it('drops messages from a stale server (url mismatch)', async () => { + subscribeRooms(); + // Change current server URL after subscription started + (sdk as any).current = { connection: { url: 'wss://different.com/websocket' } }; + await capturedHandler({ + msg: 'changed', + fields: { eventName: 'u1/uiInteraction', args: [{ type: 'banner.open', payload: 'p' }] } + }); + expect(handlePayloadUserInteraction).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/methods/subscriptions/rooms.ts b/app/lib/methods/subscriptions/rooms.ts index 78aeb9ca674..43533b98ef0 100644 --- a/app/lib/methods/subscriptions/rooms.ts +++ b/app/lib/methods/subscriptions/rooms.ts @@ -42,6 +42,7 @@ let streamListener: Promise | false; let subServer: string; let queue: { [key: string]: ISubscription | IRoom } = {}; let subTimer: ReturnType | null | false = null; +let subscriptionHandles: { stop: () => void }[] = []; const WINDOW_TIME = 500; export let roomsSubscription: { stop: () => void } | null = null; @@ -302,7 +303,7 @@ export default function subscribeRooms() { const db = database.active; // check if the server from variable is the same as the js sdk client - if (sdk && sdk.current.client && sdk.current.client.host !== subServer) { + if (sdk && sdk.current && sdk.current.connection && sdk.current.connection.url !== subServer) { return; } if (ddpMessage.msg === 'added') { @@ -427,6 +428,14 @@ export default function subscribeRooms() { streamListener.then(removeListener); streamListener = false; } + subscriptionHandles.forEach(handle => { + try { + handle.stop(); + } catch (e) { + log(e); + } + }); + subscriptionHandles = []; queue = {}; if (subTimer) { clearTimeout(subTimer); @@ -439,8 +448,8 @@ export default function subscribeRooms() { try { // set the server that started this task - subServer = sdk.current.client.host; - sdk.current.subscribeNotifyUser().catch((e: unknown) => console.log(e)); + subServer = sdk.current?.connection.url || ''; + subscriptionHandles = sdk.subscribeNotifyUser(); roomsSubscription = { stop: () => stop() }; return null; } catch (e) { diff --git a/app/lib/methods/uploadAvatar/uploadAvatar.test.ts b/app/lib/methods/uploadAvatar/uploadAvatar.test.ts index 9af796eda23..1ba3f011550 100644 --- a/app/lib/methods/uploadAvatar/uploadAvatar.test.ts +++ b/app/lib/methods/uploadAvatar/uploadAvatar.test.ts @@ -20,8 +20,11 @@ jest.mock('../sendFileMessage/utils', () => ({ copyFileToCacheDirectoryIfNeeded: jest.fn((p: string) => Promise.resolve(`cached:${p}`)) })); -jest.mock('@rocket.chat/sdk', () => ({ - settings: { customHeaders: { 'X-Custom': 'custom' } } +jest.mock('../../services/sdk', () => ({ + __esModule: true, + default: { + getHeaders: () => ({ 'X-Custom': 'custom', 'X-Auth-Token': 'tok1', 'X-User-Id': 'uid1' }) + } })); const baseState = { diff --git a/app/lib/methods/uploadAvatar/uploadAvatar.ts b/app/lib/methods/uploadAvatar/uploadAvatar.ts index 6dfe5c48ff9..1d14b6bf297 100644 --- a/app/lib/methods/uploadAvatar/uploadAvatar.ts +++ b/app/lib/methods/uploadAvatar/uploadAvatar.ts @@ -1,20 +1,16 @@ -import { settings as RocketChatSettings } from '@rocket.chat/sdk'; - import FileUpload from '../helpers/fileUpload'; import { type IFormData } from '../helpers/fileUpload/definitions'; import { copyFileToCacheDirectoryIfNeeded } from '../sendFileMessage/utils'; import { store as reduxStore } from '../../store/auxStore'; +import sdk from '../../services/sdk'; export const uploadUserAvatarMultipart = async (localUri: string, mimeType: string, filename: string): Promise => { const { server } = reduxStore.getState().server; - const { id, token } = reduxStore.getState().login.user; const filePath = await copyFileToCacheDirectoryIfNeeded(localUri, filename); const formData: IFormData[] = [{ name: 'image', uri: filePath, type: mimeType, filename }]; - const headers = { - ...RocketChatSettings.customHeaders, - 'Content-Type': 'multipart/form-data', - 'X-Auth-Token': token, - 'X-User-Id': id + const headers: Record = { + ...sdk.getHeaders(), + 'Content-Type': 'multipart/form-data' }; const upload = new FileUpload(`${server}/api/v1/users.setAvatar`, headers, formData); await upload.send(); diff --git a/app/lib/services/__tests__/ddpConnectionPatch.test.ts b/app/lib/services/__tests__/ddpConnectionPatch.test.ts new file mode 100644 index 00000000000..1d0138993d7 --- /dev/null +++ b/app/lib/services/__tests__/ddpConnectionPatch.test.ts @@ -0,0 +1,234 @@ +/** + * Behavioral tests for the hand-patched `ConnectionImpl` class shipped in + * `patches/@rocket.chat+ddp-client+0.3.51.patch`. + * + * `sdk.test.ts` / `connect.test.ts` only assert delegation to a *mocked* + * `connection.probe` / `reopenNow` — none of the real patched logic runs + * under test. This file imports the real compiled `ConnectionImpl` (the deep + * `dist/Connection` path, since it isn't part of the package's public API + * surface) and drives it with a fake `WebSocket` and the package's own + * `MinimalDDPClient`, so a future edit to the patch (or a `ddp-client` + * version bump that silently drops it) is caught by a real regression rather + * than a mock. + * + * Patched behaviors under test: + * - stale-`onclose` guard: `ws.onclose` bails out with `if (ws !== this.ws) return;` + * so a late close event from a socket that `reopenNow()` already replaced + * cannot clobber the live connection's status. + * - `reopenNow()` in-flight dedup: concurrent calls share one `reopenInFlight` + * promise instead of tearing the socket down twice. + * - `probe()`: only resolves true for a pong received on an open socket after + * the probe started (lastPing guard). + */ +import { ConnectionImpl } from '@rocket.chat/ddp-client/dist/Connection'; +import { MinimalDDPClient } from '@rocket.chat/ddp-client/dist/MinimalDDPClient'; + +// Minimal fake WebSocket. ConnectionImpl receives the constructor directly +// (`new this.WS(url)`) rather than reading a global, so no `global.WebSocket` +// stubbing is needed — we just pass this class in as the `WS` constructor arg. +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + + static readonly CONNECTING = 0; + + static readonly OPEN = 1; + + static readonly CLOSING = 2; + + static readonly CLOSED = 3; + + readonly CONNECTING = 0; + + readonly OPEN = 1; + + readonly CLOSING = 2; + + readonly CLOSED = 3; + + readyState = FakeWebSocket.CONNECTING; + + onopen?: () => void; + + onclose?: (ev: { code: number; reason: string }) => void; + + onmessage?: (ev: { data: string }) => void; + + onerror?: (ev: unknown) => void; + + constructor(public url: string) { + FakeWebSocket.instances.push(this); + } + + send = jest.fn(); + + close = jest.fn(() => { + this.readyState = FakeWebSocket.CLOSED; + }); + + // Test helper (not part of the WebSocket surface): simulate the transport + // finishing its handshake and firing `onopen`. + open() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } +} + +// Test helper: simulate the server replying to the DDP `connect` message with +// `connected`, which is what makes `ConnectionImpl.connect()`'s promise +// resolve and its status flip to 'connected'. Must be called after `open()` +// since `onmessage` is only wired up inside the real `onopen` handler. +const completeHandshake = (ws: FakeWebSocket, session: string) => { + ws.onmessage?.({ data: JSON.stringify({ msg: 'connected', session }) }); +}; + +const buildConnection = () => { + const client = new MinimalDDPClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const connection = new (ConnectionImpl as any)('wss://example.com', FakeWebSocket, client, { + retryCount: 0, + retryTime: 1000 + }); + return { client, connection }; +}; + +const connectUp = async (connection: any) => { + const connectPromise = connection.connect(); + const ws = FakeWebSocket.instances[0]; + ws.open(); + completeHandshake(ws, 'session-1'); + await connectPromise; + return ws; +}; + +describe('patched ConnectionImpl (patches/@rocket.chat+ddp-client+0.3.51.patch)', () => { + beforeEach(() => { + FakeWebSocket.instances = []; + }); + + it('ignores a stale onclose from a socket that reopenNow() already replaced', async () => { + const { connection } = buildConnection(); + const staleSocket = await connectUp(connection); + + // Capture the stale socket's onclose handler *before* reopenNow severs + // it, to stand in for a close event that was already in flight on the + // transport at the moment the socket got replaced — the real-world race + // the `ws !== this.ws` guard defends against. + const staleOnClose = staleSocket.onclose; + expect(typeof staleOnClose).toBe('function'); + + const reopenPromise = connection.reopenNow(); + expect(FakeWebSocket.instances).toHaveLength(2); + const freshSocket = FakeWebSocket.instances[1]; + expect(freshSocket).not.toBe(staleSocket); + + freshSocket.open(); + completeHandshake(freshSocket, 'session-2'); + await expect(reopenPromise).resolves.toBeUndefined(); + expect(connection.status).toBe('connected'); + expect(connection.ws).toBe(freshSocket); + + // The stale socket's close event arrives late. Without the guard this + // would set status to 'disconnected' and emit a 'disconnected' event + // against what is, from the app's perspective, a perfectly live + // connection. + staleOnClose?.({ code: 1006, reason: 'late close' }); + + expect(connection.status).toBe('connected'); + expect(connection.ws).toBe(freshSocket); + }); + + it('dedupes concurrent reopenNow() calls into a single in-flight reopen', async () => { + const { connection } = buildConnection(); + await connectUp(connection); + + const first = connection.reopenNow(); + const second = connection.reopenNow(); + + // The second call must return the exact same in-flight promise rather + // than tearing the socket down again. + expect(second).toBe(first); + expect(FakeWebSocket.instances).toHaveLength(2); + + const freshSocket = FakeWebSocket.instances[1]; + freshSocket.open(); + completeHandshake(freshSocket, 'session-2'); + + await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]); + // Still only one extra socket was ever opened for the two concurrent calls. + expect(FakeWebSocket.instances).toHaveLength(2); + }); + + it('tears down the replaced socket without emitting a status event', async () => { + const { connection } = buildConnection(); + const staleSocket = await connectUp(connection); + + const statusEvents: string[] = []; + connection.on('connection', (status: string) => statusEvents.push(status)); + const disconnected = jest.fn(); + connection.on('disconnected', disconnected); + + connection.reopenNow(); + expect(staleSocket.onopen).toBeNull(); + expect(staleSocket.onmessage).toBeNull(); + expect(staleSocket.onclose).toBeNull(); + expect(disconnected).toHaveBeenCalledTimes(1); + // No 'disconnected' status event from the swap: the app's redux state is + // only flipped by the fresh 'connecting'/'connected' that follows, + // avoiding a spurious disconnect clobber. + expect(statusEvents).toEqual(['connecting']); + + // Complete the fresh handshake so the reconnect bailout timer is cleared. + const freshSocket = FakeWebSocket.instances[1]; + freshSocket.open(); + completeHandshake(freshSocket, 'session-2'); + await new Promise(r => setTimeout(r, 0)); + }); + + describe('probe()', () => { + it('resolves true when a pong is received on an open socket', async () => { + const { connection } = buildConnection(); + const ws = await connectUp(connection); + + const p = connection.probe(200); + // Pongs arrive over the wire, so deliver it after the probe started — + // never synchronously, or it would land in the same millisecond as the + // probe's lastPing snapshot and be filtered by the stale-pong guard. + setTimeout(() => { + ws.onmessage?.({ data: JSON.stringify({ msg: 'pong' }) }); + }, 5); + + await expect(p).resolves.toBe(true); + }); + + it('resolves false when no pong arrives within the timeout', async () => { + const { connection } = buildConnection(); + await connectUp(connection); + + jest.useFakeTimers(); + const p = connection.probe(50); + jest.advanceTimersByTime(100); + await expect(p).resolves.toBe(false); + jest.useRealTimers(); + }); + + it('resolves false when the socket is not open', async () => { + const { connection } = buildConnection(); + await expect(connection.probe()).resolves.toBe(false); + }); + + it('ignores pongs that arrived before the probe started', async () => { + const { connection } = buildConnection(); + const ws = await connectUp(connection); + + // A heartbeat pong lands just before the probe begins; only a pong + // that advances lastPing past the probe start should count. + ws.onmessage?.({ data: JSON.stringify({ msg: 'pong' }) }); + + jest.useFakeTimers(); + const p = connection.probe(50); + jest.advanceTimersByTime(100); + await expect(p).resolves.toBe(false); + jest.useRealTimers(); + }); + }); +}); diff --git a/app/lib/services/__tests__/socketHealth.integration.test.ts b/app/lib/services/__tests__/socketHealth.integration.test.ts deleted file mode 100644 index edd77e267b0..00000000000 --- a/app/lib/services/__tests__/socketHealth.integration.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import sdk from '../sdk'; -import { recoverSocket } from '../socketHealth'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { DDPDriver } = require('@rocket.chat/sdk/lib/drivers/ddp') as { - DDPDriver: new (options: { host: string; logger: unknown }) => PatchedDriver; -}; - -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: () => void; -} - -interface WireFrame { - msg: string; - id?: string; - name?: string; - params?: string[]; -} - -interface PatchedDriver { - userId: string; - pingInterval: number; - reopenNow(): Promise; - waitForNotifyUserMediaSubs(timeoutMs?: number): Promise; - ddp: { - lastPing: number; - pingTimeout?: ReturnType; - openTimeout?: ReturnType; - open(): Promise; - send(message: Record): Promise; - subscriptions: Record; - }; -} - -const mockConnections: MockConnection[] = []; - -jest.mock('universal-websocket-client', () => - jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data) as { msg: string; id?: string }; - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } else if (message.msg === 'sub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; - }) -); - -jest.mock('../sdk', () => ({ - __esModule: true, - default: { current: undefined } -})); - -const USER_ID = 'user-id'; -const PING_INTERVAL = 10000; - -const logger = { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }; - -/** Real patched DDPDriver over a mocked WebSocket, connected and logged in. */ -async function buildConnectedDriver() { - const driver = new DDPDriver({ host: 'localhost:3000', logger }); - driver.userId = USER_ID; - const openPromise = driver.ddp.open(); - mockConnections[0].onopen(); - await jest.advanceTimersByTimeAsync(0); - await openPromise; - return driver; -} - -function addMediaSubs(driver: PatchedDriver) { - ['media-signal', 'media-calls'].forEach((name, index) => { - const id = `sub-${index}`; - driver.ddp.subscriptions[id] = { - id, - name: 'stream-notify-user', - params: [`${USER_ID}/${name}`], - unsubscribe: jest.fn() - }; - }); -} - -function backdateLastPing(driver: PatchedDriver, ageMs: number) { - driver.ddp.lastPing = Date.now() - ageMs; -} - -/** Frames of a given `msg` sent over the wire on one connection. */ -function framesOn(connection: MockConnection, msg: string) { - return connection.send.mock.calls - .map(([data]: [string]) => JSON.parse(data) as WireFrame) - .filter(message => message.msg === msg); -} - -describe('recoverSocket against the real patched socket', () => { - let driver: PatchedDriver; - - beforeEach(async () => { - jest.clearAllMocks(); - jest.useFakeTimers(); - mockConnections.length = 0; - driver = await buildConnectedDriver(); - (sdk as unknown as { current: { ddp: PatchedDriver } }).current = { ddp: driver }; - }); - - afterEach(() => { - if (driver.ddp.pingTimeout) clearTimeout(driver.ddp.pingTimeout); - if (driver.ddp.openTimeout) clearTimeout(driver.ddp.openTimeout); - jest.useRealTimers(); - }); - - it('exposes the ping interval the health classification depends on', () => { - expect(driver.pingInterval).toBe(PING_INTERVAL); - }); - - it('keeps a doubtful socket when the round trip gets a pong', async () => { - backdateLastPing(driver, PING_INTERVAL + 5000); - - const recovery = recoverSocket(); - await jest.advanceTimersByTimeAsync(0); - - await expect(recovery).resolves.toBe('confirmed-alive'); - // The round trip pinged the existing socket and the pong kept it alive. - expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0); - expect(mockConnections).toHaveLength(1); - }); - - it('reopens a doubtful socket when the round trip gets no pong', async () => { - backdateLastPing(driver, PING_INTERVAL + 5000); - // A zombie socket: still `readyState: 1`, but the server never answers. - mockConnections[0].send.mockImplementation(() => undefined); - - const recovery = recoverSocket(); - await jest.advanceTimersByTimeAsync(2000); - - // The round trip was actually attempted on the dead socket before reopening. - expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0); - expect(mockConnections).toHaveLength(2); - mockConnections[1].onopen(); - await jest.advanceTimersByTimeAsync(0); - - await expect(recovery).resolves.toBe('reopened'); - }); - - it('reopens a frozen socket whose last ping is still young', async () => { - // A young `lastPing` proves nothing: `onOpen` refreshes it before the handshake - // reply lands, so the timestamp can sit on an unusable session. - mockConnections[0].send.mockImplementation(() => undefined); - - const recovery = recoverSocket(); - await jest.advanceTimersByTimeAsync(2000); - - // The young ping bought a round trip, and the silent socket failed it. - expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0); - expect(mockConnections).toHaveLength(2); - mockConnections[1].onopen(); - await jest.advanceTimersByTimeAsync(0); - - await expect(recovery).resolves.toBe('reopened'); - }); - - it('reopens a known-dead socket without a round trip', async () => { - backdateLastPing(driver, PING_INTERVAL * 2 + 1000); - - const recovery = recoverSocket(); - await jest.advanceTimersByTimeAsync(0); - - expect(mockConnections).toHaveLength(2); - // No raw round-trip ping was sent on the dead socket. - expect(framesOn(mockConnections[0], 'ping')).toHaveLength(0); - - mockConnections[1].onopen(); - await jest.advanceTimersByTimeAsync(0); - - await expect(recovery).resolves.toBe('reopened'); - }); - - it('shares one reopen with a concurrent direct reopenNow', async () => { - backdateLastPing(driver, PING_INTERVAL * 3); - - // The foreground path reopens the dead socket while recovery does the same. - const directReopen = driver.reopenNow(); - const recovery = recoverSocket(); - - await jest.advanceTimersByTimeAsync(0); - expect(mockConnections).toHaveLength(2); - mockConnections[1].onopen(); - await jest.advanceTimersByTimeAsync(0); - - await directReopen; - await expect(recovery).resolves.toBe('reopened'); - expect(mockConnections).toHaveLength(2); - - // No queued third open fires later — the reopen really was shared. - await jest.advanceTimersByTimeAsync(60000); - expect(mockConnections).toHaveLength(2); - }); - - it('rejects an in-flight DDP method call when recovery reopens the socket', async () => { - let rejected = false; - const inFlight = driver.ddp.send({ msg: 'method', method: 'getRoomByTypeAndName', params: [] }).catch(() => { - rejected = true; - }); - await jest.advanceTimersByTimeAsync(0); - expect(rejected).toBe(false); - - // The socket dies silently after the call went out. - backdateLastPing(driver, PING_INTERVAL * 3); - - const recovery = recoverSocket(); - await jest.advanceTimersByTimeAsync(0); - await inFlight; - expect(rejected).toBe(true); - - mockConnections[1].onopen(); - await jest.advanceTimersByTimeAsync(0); - - await expect(recovery).resolves.toBe('reopened'); - }); - - it('re-sends the media subscriptions on the new socket reusing their ids', async () => { - backdateLastPing(driver, PING_INTERVAL * 3); - addMediaSubs(driver); - - const recovery = recoverSocket(); - await jest.advanceTimersByTimeAsync(0); - mockConnections[1].onopen(); - await jest.advanceTimersByTimeAsync(0); - await expect(recovery).resolves.toBe('reopened'); - - const resubscribed = driver.waitForNotifyUserMediaSubs(); - await jest.advanceTimersByTimeAsync(200); - await expect(resubscribed).resolves.toBe(true); - - // Both media subs went out on the new socket reusing their ids. - expect(framesOn(mockConnections[0], 'sub')).toHaveLength(0); - expect(framesOn(mockConnections[1], 'sub')).toEqual([ - expect.objectContaining({ id: 'sub-0', name: 'stream-notify-user', params: [`${USER_ID}/media-signal`] }), - expect.objectContaining({ id: 'sub-1', name: 'stream-notify-user', params: [`${USER_ID}/media-calls`] }) - ]); - }); -}); diff --git a/app/lib/services/__tests__/socketHealth.test.ts b/app/lib/services/__tests__/socketHealth.test.ts index 031c7fe92a2..241668664a8 100644 --- a/app/lib/services/__tests__/socketHealth.test.ts +++ b/app/lib/services/__tests__/socketHealth.test.ts @@ -1,7 +1,7 @@ jest.mock('../sdk', () => ({ __esModule: true, default: { - current: { ddp: undefined } + current: { connection: undefined } } })); @@ -10,22 +10,19 @@ import { classifySocketHealth, recoverSocket } from '../socketHealth'; const now = 1_000_000; -const sdkMock = sdk as unknown as { current: { ddp: unknown } | undefined }; +const sdkMock = sdk as unknown as { current: { connection: unknown } | undefined }; -interface MockDdp { - connected?: boolean; +interface MockConnection { + status: 'connected' | 'disconnected'; lastPing: number; - pingInterval?: number; - config?: { ping?: number }; reopenNow: jest.Mock, []>; probe: jest.Mock, [number]>; } -function makeDdp(overrides: Partial = {}): MockDdp { +function makeConnection(overrides: Partial = {}): MockConnection { return { + status: 'connected', lastPing: now, - pingInterval: 10000, - config: { ping: 10000 }, reopenNow: jest.fn, []>(() => Promise.resolve()), probe: jest.fn, [number]>(() => Promise.resolve(true)), ...overrides @@ -41,74 +38,63 @@ describe('classifySocketHealth', () => { jest.restoreAllMocks(); }); - it('returns reopen when age > 2 * pingInterval', () => { - const ddp = makeDdp({ lastPing: now - 21000 }); - expect(classifySocketHealth(ddp)).toBe('reopen'); + it('returns reopen when age > 20s (2x the old-SDK 10s ping default)', () => { + const connection = makeConnection({ lastPing: now - 21000 }); + expect(classifySocketHealth(connection)).toBe('reopen'); }); - it('returns round-trip-check when age <= 2 * pingInterval', () => { - const ddp = makeDdp({ lastPing: now - 15000 }); - expect(classifySocketHealth(ddp)).toBe('round-trip-check'); + it('returns round-trip-check when age <= 20s', () => { + const connection = makeConnection({ lastPing: now - 15000 }); + expect(classifySocketHealth(connection)).toBe('round-trip-check'); }); it('returns round-trip-check for a young ping rather than trusting it outright', () => { - const ddp = makeDdp({ lastPing: now - 5000 }); - expect(classifySocketHealth(ddp)).toBe('round-trip-check'); + const connection = makeConnection({ lastPing: now - 5000 }); + expect(classifySocketHealth(connection)).toBe('round-trip-check'); }); - it('falls back to config.ping when pingInterval is missing', () => { - // Only a 30s config.ping keeps a 21s-old ping below the reopen threshold. - const ddp = makeDdp({ pingInterval: undefined, config: { ping: 30000 }, lastPing: now - 21000 }); - expect(classifySocketHealth(ddp)).toBe('round-trip-check'); - }); - - it('uses 10000ms default when pingInterval and config.ping are missing', () => { - const ddp = makeDdp({ pingInterval: undefined, config: {}, lastPing: now - 21000 }); - expect(classifySocketHealth(ddp)).toBe('reopen'); - }); - - it('returns reopen for a closed socket even when lastPing is fresh', () => { - const ddp = makeDdp({ connected: false, lastPing: now }); - expect(classifySocketHealth(ddp)).toBe('reopen'); + it('returns reopen for a non-connected socket even when lastPing is fresh', () => { + const connection = makeConnection({ status: 'disconnected', lastPing: now }); + expect(classifySocketHealth(connection)).toBe('reopen'); }); }); describe('recoverSocket', () => { - let ddp: MockDdp; + let connection: MockConnection; beforeEach(() => { - ddp = makeDdp({ lastPing: Date.now() }); - sdkMock.current = { ddp }; + connection = makeConnection({ lastPing: Date.now() }); + sdkMock.current = { connection }; }); it('keeps a socket whose round trip answers', async () => { await expect(recoverSocket()).resolves.toBe('confirmed-alive'); - expect(ddp.reopenNow).not.toHaveBeenCalled(); + expect(connection.reopenNow).not.toHaveBeenCalled(); }); it('runs the round trip with a 2s budget', async () => { await recoverSocket(); - expect(ddp.probe).toHaveBeenCalledWith(2000); + expect(connection.probe).toHaveBeenCalledWith(2000); }); it('reopens when the round trip goes unanswered', async () => { - ddp.probe.mockResolvedValue(false); + connection.probe.mockResolvedValue(false); await expect(recoverSocket()).resolves.toBe('reopened'); - expect(ddp.reopenNow).toHaveBeenCalledTimes(1); + expect(connection.reopenNow).toHaveBeenCalledTimes(1); }); it('reopens a known-dead socket without a round trip', async () => { - ddp.connected = false; + connection.status = 'disconnected'; await expect(recoverSocket()).resolves.toBe('reopened'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).toHaveBeenCalledTimes(1); + expect(connection.probe).not.toHaveBeenCalled(); + expect(connection.reopenNow).toHaveBeenCalledTimes(1); }); - it('reports no-socket when the ddp handle is missing', async () => { - sdkMock.current = { ddp: undefined }; + it('reports no-socket when the connection handle is missing', async () => { + sdkMock.current = { connection: undefined }; await expect(recoverSocket()).resolves.toBe('no-socket'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).not.toHaveBeenCalled(); + expect(connection.probe).not.toHaveBeenCalled(); + expect(connection.reopenNow).not.toHaveBeenCalled(); }); it('reports no-socket when there is no sdk instance', async () => { @@ -117,31 +103,31 @@ describe('recoverSocket', () => { }); it('rejects when the round trip throws', async () => { - ddp.probe.mockRejectedValue(new Error('round trip failed')); + connection.probe.mockRejectedValue(new Error('round trip failed')); await expect(recoverSocket()).rejects.toThrow('round trip failed'); }); it('rejects when reopening throws', async () => { - ddp.connected = false; - ddp.reopenNow.mockRejectedValue(new Error('reopen failed')); + connection.status = 'disconnected'; + connection.reopenNow.mockRejectedValue(new Error('reopen failed')); await expect(recoverSocket()).rejects.toThrow('reopen failed'); }); it('shares one in-flight recovery between overlapping callers', async () => { const outcomes = await Promise.all([recoverSocket(), recoverSocket()]); expect(outcomes).toEqual(['confirmed-alive', 'confirmed-alive']); - expect(ddp.probe).toHaveBeenCalledTimes(1); + expect(connection.probe).toHaveBeenCalledTimes(1); }); it('starts a fresh recovery after the shared one settles', async () => { await recoverSocket(); await recoverSocket(); - expect(ddp.probe).toHaveBeenCalledTimes(2); + expect(connection.probe).toHaveBeenCalledTimes(2); }); it('abandons the aborted caller while the shared recovery runs on', async () => { let answerRoundTrip: (alive: boolean) => void = () => {}; - ddp.probe.mockImplementation(() => new Promise(resolve => (answerRoundTrip = resolve))); + connection.probe.mockImplementation(() => new Promise(resolve => (answerRoundTrip = resolve))); const controller = new AbortController(); const aborted = recoverSocket({ abortSignal: controller.signal }); @@ -152,7 +138,7 @@ describe('recoverSocket', () => { answerRoundTrip(true); await expect(other).resolves.toBe('confirmed-alive'); - expect(ddp.probe).toHaveBeenCalledTimes(1); + expect(connection.probe).toHaveBeenCalledTimes(1); }); it('abandons a pre-aborted caller without touching the socket', async () => { @@ -160,7 +146,7 @@ describe('recoverSocket', () => { controller.abort(); await expect(recoverSocket({ abortSignal: controller.signal })).resolves.toBe('abandoned'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).not.toHaveBeenCalled(); + expect(connection.probe).not.toHaveBeenCalled(); + expect(connection.reopenNow).not.toHaveBeenCalled(); }); }); diff --git a/app/lib/services/connect.test.ts b/app/lib/services/connect.test.ts index 7f1ad5edd3b..6409d70416e 100644 --- a/app/lib/services/connect.test.ts +++ b/app/lib/services/connect.test.ts @@ -1,58 +1,135 @@ -import { connect, determineAuthType, disconnect } from './connect'; +import { connect, determineAuthType, disconnect, login, loginTOTP } from './connect'; import { mediaSessionInstance } from './voip/MediaSessionInstance'; import { pendingHangups } from './voip/pendingHangups'; +import { unsubscribeRooms } from '../methods/subscribeRooms'; import { setUser } from '../../actions/login'; import database from '../database'; +import { onRolesChanged } from '../methods/getRoles'; +import { STATUSES } from '../../definitions'; jest.mock('./voip/MediaSessionInstance', () => ({ mediaSessionInstance: { reset: jest.fn(), drainPendingHangups: jest.fn() } })); -// Mock the isIOS helper jest.mock('../methods/helpers/deviceInfo', () => ({ ...jest.requireActual('../methods/helpers/deviceInfo'), isIOS: false })); -const mockOnStreamDataStops: jest.Mock[] = []; -const mockOnStreamData = jest.fn, [string, (...args: any[]) => void]>(() => { - const stop = jest.fn(); - mockOnStreamDataStops.push(stop); - return Promise.resolve({ stop }); -}); -const mockSdkConnect = jest.fn, []>(() => Promise.resolve()); -const mockSdkAbort = jest.fn(); -const mockSdkDisconnect = jest.fn(); -const mockSdkInitialize = jest.fn(); -const mockSdkCurrent = { - onStreamData: (event: string, cb: (...args: any[]) => void) => mockOnStreamData(event, cb), - connect: () => mockSdkConnect(), - abort: () => mockSdkAbort() -}; -jest.mock('./sdk', () => ({ - __esModule: true, - default: { - initialize: (server: string) => mockSdkInitialize(server), - disconnect: () => mockSdkDisconnect(), - get current() { - return mockSdkCurrent; +// --- SDK mock --- +// The new DDP SDK registers a single connection.on('connection', cb) listener. +// All event-driven tests invoke getCapturedConnectionListener() to drive it. +const mockConnectionOn = jest.fn(); +const mockConnectionConnect = jest.fn().mockResolvedValue(undefined); +const mockConnectionProbe = jest.fn().mockResolvedValue(true); +const mockConnectionReopenNow = jest.fn().mockResolvedValue(undefined); +const mockSdkInitialize = jest.fn().mockResolvedValue(undefined); +const mockSdkOnCollection = jest.fn(); +const mockSdkDisconnect = jest.fn(); + +const mockSdkLogin = jest.fn(); +const mockAccountUser: { value: any } = { value: undefined }; + +jest.mock('./sdk', () => { + const state: { + server: string | undefined; + currentEnabled: boolean; + connection: { on: any; connect: any; probe: any; reopenNow: any; status: string } | undefined; + status: string; + } = { + server: undefined, + currentEnabled: true, + connection: undefined, + status: 'idle' + }; + const makeConnection = () => ({ + on: (event: string, cb: any) => mockConnectionOn(event, cb), + connect: () => mockConnectionConnect(), + probe: () => mockConnectionProbe(), + reopenNow: () => mockConnectionReopenNow(), + get status() { + return state.status; } - } + }); + return { + __esModule: true, + default: { + get server() { + return state.server; + }, + disconnect: (...args: any[]) => { + state.connection = undefined; + state.status = 'idle'; + return mockSdkDisconnect(...args); + }, + // Mirrors the real sdk.ts: initialize() yields a brand-new connection instance each + // call, so handlers attached by a previous connect() are discarded with the old instance. + initialize: (s: string) => { + state.server = s; + state.connection = makeConnection(); + state.status = 'connecting'; + return mockSdkInitialize(s); + }, + onCollection: (...args: any[]) => mockSdkOnCollection(...args), + login: (...args: any[]) => mockSdkLogin(...args), + get current() { + if (!state.currentEnabled || !state.connection) return undefined; + return { + account: { user: mockAccountUser.value }, + connection: state.connection + }; + } + }, + __setServer: (v: string | undefined) => { + state.server = v; + }, + __setCurrentEnabled: (v: boolean) => { + state.currentEnabled = v; + }, + __setConnectionStatus: (v: string) => { + state.status = v; + }, + __getCurrentConnection: () => state.connection + }; +}); + +const mockCreateAndConnect = jest.fn(); +jest.mock('@rocket.chat/ddp-client', () => ({ + DDPSDK: { createAndConnect: (...args: unknown[]) => mockCreateAndConnect(...args) } +})); + +const mockTwoFactor = jest.fn(); +jest.mock('./twoFactor', () => ({ + twoFactor: (...args: any[]) => mockTwoFactor(...args) +})); + +jest.mock('../../ee/omnichannel/actions/inquiry', () => ({ + inquiryRequest: jest.fn().mockReturnValue({ type: 'INQUIRY_REQUEST' }) })); +const sdkMock = jest.requireMock('./sdk') as { + __setServer: (v: string | undefined) => void; + __setCurrentEnabled: (v: boolean) => void; + __setConnectionStatus: (v: string) => void; + __getCurrentConnection: () => { on: any; connect: any; probe: any; reopenNow: any; status: string } | undefined; +}; + +// --- Store mock --- type MockStoreState = { meteor: { connected: boolean }; login: { user: unknown; isAuthenticated: boolean }; settings: Record; + server?: { version: string }; }; const mockStoreGetState = jest.fn(() => ({ meteor: { connected: false }, login: { user: null, isAuthenticated: false }, - settings: {} + settings: {}, + server: { version: '6.0.0' } })); const mockStoreDispatch = jest.fn(); -const noopUnsubscribe = () => () => {}; -const mockStoreSubscribe = jest.fn<() => void, [() => void]>(noopUnsubscribe); +const mockStoreSubscribe = jest.fn<() => void, [() => void]>(() => () => undefined); + jest.mock('../store/auxStore', () => ({ store: { getState: () => mockStoreGetState(), @@ -63,31 +140,43 @@ jest.mock('../store/auxStore', () => ({ jest.mock('../database', () => ({ __esModule: true, - default: { - setActiveDB: jest.fn(), - active: { get: jest.fn() } - } -})); - -jest.mock('../methods/subscribeRooms', () => ({ - unsubscribeRooms: jest.fn() -})); - -jest.mock('../methods/getSettings', () => ({ - getSettings: jest.fn() + default: { setActiveDB: jest.fn(), active: { get: jest.fn() } } })); +jest.mock('../methods/subscribeRooms', () => ({ unsubscribeRooms: jest.fn() })); +jest.mock('../methods/getSettings', () => ({ getSettings: jest.fn().mockResolvedValue(undefined) })); jest.mock('../methods/helpers/events', () => ({ __esModule: true, default: { emit: jest.fn(), on: jest.fn(), removeListener: jest.fn() } })); +jest.mock('../methods/helpers/protectedFunction', () => ({ __esModule: true, default: (fn: any) => fn })); +jest.mock('../methods/helpers/log', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../methods/setUser', () => ({ _setUser: jest.fn(), _activeUsers: { activeUsers: {} }, _setUserTimer: {} })); +jest.mock('../methods/getRoles', () => ({ onRolesChanged: jest.fn() })); +jest.mock('../methods/getUsersPresence', () => ({ setPresenceCap: jest.fn() })); + +const mockHasRole = jest.fn(); +jest.mock('../methods/helpers', () => ({ + ...jest.requireActual('../methods/helpers'), + hasRole: (...args: any[]) => mockHasRole(...args) +})); -const mockLog = jest.fn(); -jest.mock('../methods/helpers/log', () => ({ - __esModule: true, - default: (...args: unknown[]) => mockLog(...args) +jest.mock('../../actions/connect', () => ({ + connectRequest: jest.fn().mockReturnValue({ type: 'CONNECT_REQUEST' }), + connectSuccess: jest.fn().mockReturnValue({ type: 'CONNECT_SUCCESS' }), + disconnect: jest.fn().mockReturnValue({ type: 'DISCONNECT' }) +})); +jest.mock('../../actions/login', () => ({ + loginRequest: jest.fn().mockReturnValue({ type: 'LOGIN_REQUEST' }), + logout: jest.fn().mockReturnValue({ type: 'LOGOUT' }), + setLoginServices: jest.fn().mockReturnValue({ type: 'SET_LOGIN_SERVICES' }), + setUser: jest.fn().mockReturnValue({ type: 'SET_USER' }) })); +jest.mock('../../actions/settings', () => ({ updateSettings: jest.fn().mockReturnValue({ type: 'UPDATE_SETTINGS' }) })); +jest.mock('../../actions/permissions', () => ({ updatePermission: jest.fn().mockReturnValue({ type: 'UPDATE_PERMISSION' }) })); +jest.mock('../../actions/activeUsers', () => ({ setActiveUsers: jest.fn().mockReturnValue({ type: 'SET_ACTIVE_USERS' }) })); +// --- Helpers --- const flushMicrotasks = async (): Promise => { for (let i = 0; i < 5; i += 1) { // eslint-disable-next-line no-await-in-loop @@ -95,23 +184,16 @@ const flushMicrotasks = async (): Promise => { } }; -const getHandlersByEvent = (event: string): Array<(...args: unknown[]) => void> => - mockOnStreamData.mock.calls.filter(([e]) => e === event).map(([, cb]) => cb); - -// The drain listener is the `connected` one registered after `close`, since `close` is what arms it. -const getPendingHangupsDrainRegistrationIndex = (): number => { - const closeIndex = mockOnStreamData.mock.calls.findIndex(([event]) => event === 'close'); - const indexes = mockOnStreamData.mock.calls - .map(([event], index) => (event === 'connected' && index > closeIndex ? index : -1)) - .filter(index => index !== -1); - if (indexes.length !== 1) { - throw new Error(`expected exactly one pendingHangups drain listener, found ${indexes.length}`); - } - return indexes[0]; +/** Returns the status-handler callback registered via connection.on('connection', cb). */ +const getCapturedConnectionListener = (): ((status: string) => void) => { + const call = mockConnectionOn.mock.calls.find(([event]) => event === 'connection'); + if (!call) throw new Error('connection listener was never registered'); + return call[1]; }; -const getPendingHangupsDrainHandler = (): ((...args: unknown[]) => void) => - mockOnStreamData.mock.calls[getPendingHangupsDrainRegistrationIndex()][1]; +/** Returns the collection handlers registered via sdk.onCollection(event, cb) for a given event. */ +const getHandlersByEvent = (event: string): Array<(ddpMessage: any) => void> => + mockSdkOnCollection.mock.calls.filter(([collection]) => collection === event).map(([, cb]) => cb); interface IServices { [index: string]: string | boolean; @@ -122,6 +204,7 @@ interface IServices { service: string; } +// ───────────────────────────────────────────────────────────────────────────── describe('determineAuthType', () => { beforeEach(() => { jest.clearAllMocks(); @@ -164,7 +247,7 @@ describe('determineAuthType', () => { }; const result = determineAuthType(services); - expect(result).toBe('saml'); // Should continue to next conditions + expect(result).toBe('saml'); }); it('should not return oauth_custom when custom is false', () => { @@ -177,7 +260,7 @@ describe('determineAuthType', () => { }; const result = determineAuthType(services); - expect(result).toBe('saml'); // Should continue to next conditions + expect(result).toBe('saml'); }); }); @@ -222,7 +305,7 @@ describe('determineAuthType', () => { }; const result = determineAuthType(services); - expect(result).toBe('not_supported'); // Should fall through to not_supported since isIOS is mocked as false + expect(result).toBe('not_supported'); }); it('should return not_supported when service is apple and name is empty but isIOS is false', () => { @@ -235,7 +318,7 @@ describe('determineAuthType', () => { }; const result = determineAuthType(services); - expect(result).toBe('not_supported'); // Should fall through to not_supported since isIOS is mocked as false + expect(result).toBe('not_supported'); }); }); @@ -336,7 +419,7 @@ describe('determineAuthType', () => { }; const result = determineAuthType(services); - expect(result).toBe('oauth'); // name 'github' should be used + expect(result).toBe('oauth'); }); it('should use service as authName when name is empty', () => { @@ -349,7 +432,7 @@ describe('determineAuthType', () => { }; const result = determineAuthType(services); - expect(result).toBe('oauth'); // service 'facebook' should be used + expect(result).toBe('oauth'); }); it('should use service as authName when name is null', () => { @@ -362,14 +445,14 @@ describe('determineAuthType', () => { }; const result = determineAuthType(services); - expect(result).toBe('oauth'); // service 'google' should be used + expect(result).toBe('oauth'); }); }); describe('priority order', () => { it('should prioritize oauth_custom over other types', () => { const services: IServices = { - name: 'github', // This would normally return 'oauth' + name: 'github', custom: true, showButton: true, buttonLabelText: 'Custom GitHub', @@ -377,12 +460,12 @@ describe('determineAuthType', () => { }; const result = determineAuthType(services); - expect(result).toBe('oauth_custom'); // Should return oauth_custom first + expect(result).toBe('oauth_custom'); }); it('should prioritize saml over oauth', () => { const services: IServices = { - name: 'github', // This would normally return 'oauth' + name: 'github', custom: false, showButton: true, buttonLabelText: 'SAML GitHub', @@ -390,12 +473,12 @@ describe('determineAuthType', () => { }; const result = determineAuthType(services); - expect(result).toBe('saml'); // Should return saml before checking for oauth + expect(result).toBe('saml'); }); it('should prioritize cas over oauth', () => { const services: IServices = { - name: 'github', // This would normally return 'oauth' + name: 'github', custom: false, showButton: true, buttonLabelText: 'CAS GitHub', @@ -403,11 +486,12 @@ describe('determineAuthType', () => { }; const result = determineAuthType(services); - expect(result).toBe('cas'); // Should return cas before checking for oauth + expect(result).toBe('cas'); }); }); }); +// ───────────────────────────────────────────────────────────────────────────── describe('VoIP media session lifecycle (disconnect)', () => { it('calls mediaSessionInstance.reset when disconnect runs', () => { disconnect(); @@ -415,34 +499,167 @@ describe('VoIP media session lifecycle (disconnect)', () => { }); }); -describe('connect — pendingHangups drain on reconnect', () => { +// ───────────────────────────────────────────────────────────────────────────── +describe('connect — connection status handler', () => { + const SERVER = 'https://example.com'; + beforeEach(() => { jest.clearAllMocks(); - mockOnStreamDataStops.length = 0; - mockStoreSubscribe.mockImplementation(noopUnsubscribe); - pendingHangups.clear(); + sdkMock.__setServer(undefined); + sdkMock.__setConnectionStatus('idle'); mockStoreGetState.mockReturnValue({ meteor: { connected: false }, login: { user: null, isAuthenticated: false }, - settings: {} + settings: {}, + server: { version: '6.0.0' } }); }); - it('drains pendingHangups via mediaSessionInstance after close → connected', async () => { - pendingHangups.record('call-a'); + it('returns early without initializing when server is already active', async () => { + await connect({ server: SERVER }); + mockSdkInitialize.mockClear(); + await connect({ server: SERVER }); + expect(mockSdkInitialize).not.toHaveBeenCalled(); + }); + + it('re-initializes when sdk.server matches but the SDK never finished initializing (e.g. a prior failed connect())', async () => { + // sdk.server is set synchronously inside initialize(), before loadBasicAuth()/handleTwoFactorChallenge() + // run — a throw there leaves sdk.server pointing at this server with no live connection behind it. + sdkMock.__setServer(SERVER); + await connect({ server: SERVER }); + expect(mockSdkInitialize).toHaveBeenCalledWith(SERVER); + }); + + it('bails out if a newer connect() call switched servers while getSettings() was in flight', async () => { + const getSettingsMock = jest.requireMock('../methods/getSettings').getSettings as jest.Mock; + let resolveSettingsA: () => void; + getSettingsMock.mockImplementationOnce( + () => + new Promise(resolve => { + resolveSettingsA = resolve; + }) + ); + + sdkMock.__setServer(undefined); + const connectAPromise = connect({ server: 'https://a.example.com' }); + await Promise.resolve(); // let connect(A) reach the getSettings() await + + // Simulate connect(B) having already taken over. + sdkMock.__setServer('https://b.example.com'); + + resolveSettingsA!(); + await connectAPromise; + + // connect(A)'s continuation must not have registered a connection listener for the stale call. + expect(mockConnectionOn).not.toHaveBeenCalled(); + }); + + it('aborts the previous connect() in-flight getSettings when a newer call supersedes it', async () => { + const getSettingsMock = jest.requireMock('../methods/getSettings').getSettings as jest.Mock; + let resolveSettingsA: () => void; + let capturedSignal: AbortSignal | undefined; + getSettingsMock.mockImplementationOnce((signal?: AbortSignal) => { + capturedSignal = signal; + return new Promise(resolve => { + resolveSettingsA = resolve; + }); + }); + + sdkMock.__setServer(undefined); + const connectAPromise = connect({ server: 'https://a.example.com' }); + await Promise.resolve(); // let connect(A) reach the getSettings() await + + // connect(B) supersedes connect(A) while getSettings(A) is still in flight. + // sdk.server is still 'a.example.com' here (set by connect(A)'s own initialize()) — + // connect(B) is what flips it to 'b.example.com' via its own initialize() call below. + await connect({ server: 'https://b.example.com' }); + + // The previous call's REST request must have been signalled to abort so it + // can't write server A's data into the now-active database B. + expect(capturedSignal?.aborted).toBe(true); + + resolveSettingsA!(); + await connectAPromise; + }); + + it('dispatches connectSuccess and loginRequest(resume) on reconnect when user has a token', async () => { + await connect({ server: SERVER }); + const listener = getCapturedConnectionListener(); + const connectSuccessMock = jest.requireMock('../../actions/connect').connectSuccess; + const loginRequestMock = jest.requireMock('../../actions/login').loginRequest; + + mockStoreGetState.mockReturnValue({ + meteor: { connected: false }, + login: { user: { token: 'auth-token-123' }, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } + }); + + listener('connected'); + + expect(connectSuccessMock).toHaveBeenCalled(); + expect(loginRequestMock).toHaveBeenCalledWith({ resume: 'auth-token-123' }, false); + }); + + it('does not dispatch connectSuccess when socket was already marked connected', async () => { + await connect({ server: SERVER }); + const listener = getCapturedConnectionListener(); + const connectSuccessMock = jest.requireMock('../../actions/connect').connectSuccess; + mockStoreGetState.mockReturnValue({ meteor: { connected: true }, - login: { user: null, isAuthenticated: true }, - settings: {} + login: { user: null, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } }); - await connect({ server: 'https://example.com' }); + listener('connected'); + + expect(connectSuccessMock).not.toHaveBeenCalled(); + }); + + it('does not dispatch loginRequest when user has no token', async () => { + await connect({ server: SERVER }); + const listener = getCapturedConnectionListener(); + const loginRequestMock = jest.requireMock('../../actions/login').loginRequest; - const drainHandler = getPendingHangupsDrainHandler(); - const closeHandler = getHandlersByEvent('close')[0]; + mockStoreGetState.mockReturnValue({ + meteor: { connected: false }, + login: { user: { token: null }, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } + }); + + listener('connected'); + + expect(loginRequestMock).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +describe('connect — pendingHangups drain on reconnect', () => { + const SERVER = 'https://example.com'; - closeHandler(); - drainHandler(); + beforeEach(() => { + jest.clearAllMocks(); + sdkMock.__setServer(undefined); + pendingHangups.clear(); + // isAuthenticated + connected = true so awaitDdpLoggedIn resolves immediately + mockStoreGetState.mockReturnValue({ + meteor: { connected: true }, + login: { user: null, isAuthenticated: true }, + settings: {}, + server: { version: '6.0.0' } + }); + }); + + it('drains pendingHangups via mediaSessionInstance after close → connected', async () => { + pendingHangups.record('call-a'); + await connect({ server: SERVER }); + const listener = getCapturedConnectionListener(); + + listener('closed'); + listener('connected'); await flushMicrotasks(); expect(mediaSessionInstance.drainPendingHangups).toHaveBeenCalledTimes(1); @@ -469,11 +686,9 @@ describe('connect — pendingHangups drain on reconnect', () => { await connect({ server: 'https://example.com' }); - const drainHandler = getPendingHangupsDrainHandler(); - const closeHandler = getHandlersByEvent('close')[0]; - - closeHandler(); - drainHandler(); + const listener = getCapturedConnectionListener(); + listener('closed'); + listener('connected'); await flushMicrotasks(); // Not ready yet — subscribed but not drained. @@ -493,50 +708,129 @@ describe('connect — pendingHangups drain on reconnect', () => { it('does not drain when "connected" fires without a prior "close"', async () => { pendingHangups.record('call-a'); - mockStoreGetState.mockReturnValue({ - meteor: { connected: true }, - login: { user: null, isAuthenticated: true }, - settings: {} - }); + await connect({ server: SERVER }); + const listener = getCapturedConnectionListener(); - await connect({ server: 'https://example.com' }); + listener('connected'); + await flushMicrotasks(); - const drainHandler = getPendingHangupsDrainHandler(); + expect(mediaSessionInstance.drainPendingHangups).not.toHaveBeenCalled(); + }); - drainHandler(); + it('skips drainPendingHangups when pendingHangups is empty', async () => { + await connect({ server: SERVER }); + const listener = getCapturedConnectionListener(); + + listener('closed'); + listener('connected'); await flushMicrotasks(); expect(mediaSessionInstance.drainPendingHangups).not.toHaveBeenCalled(); }); - it('skips drainPendingHangups when pendingHangups is empty', async () => { + it('stops the previous pendingHangups connected listener when connect runs again', async () => { + // Arm the drain on the first connection + pendingHangups.record('call-a'); + await connect({ server: SERVER }); + const firstListener = getCapturedConnectionListener(); + firstListener('closed'); + + // Reconnect to a new server — new closure resets pendingHangupsDrainArmed to false + sdkMock.__setServer(undefined); + mockConnectionOn.mockClear(); + await connect({ server: 'https://other.example.com' }); + const secondListener = getCapturedConnectionListener(); + + // Firing 'connected' on the new connection should NOT drain (armed state was not carried over) + secondListener('connected'); + await flushMicrotasks(); + + expect(mediaSessionInstance.drainPendingHangups).not.toHaveBeenCalled(); + }); +}); + +describe('connect — collection listeners register independently of connection.connect() outcome', () => { + const SERVER = 'https://example.com'; + + beforeEach(() => { + jest.clearAllMocks(); + sdkMock.__setServer(undefined); mockStoreGetState.mockReturnValue({ - meteor: { connected: true }, - login: { user: null, isAuthenticated: true }, - settings: {} + meteor: { connected: false }, + login: { user: null, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } }); + }); - await connect({ server: 'https://example.com' }); + it('registers collection listeners even when connection.connect() rejects', async () => { + mockConnectionConnect.mockRejectedValueOnce(new Error('socket error')); - const drainHandler = getPendingHangupsDrainHandler(); - const closeHandler = getHandlersByEvent('close')[0]; + await expect(connect({ server: SERVER })).rejects.toThrow('socket error'); - closeHandler(); - drainHandler(); + expect(mockSdkOnCollection).toHaveBeenCalledWith('stream-force_logout', expect.any(Function)); + }); + + it('registers collection listeners before a hung connection.connect() ever settles', async () => { + mockConnectionConnect.mockReturnValueOnce(new Promise(() => {})); + + // Intentionally not awaited: connection.connect() never resolves/rejects. + connect({ server: SERVER }); await flushMicrotasks(); - expect(mediaSessionInstance.drainPendingHangups).not.toHaveBeenCalled(); + expect(mockSdkOnCollection).toHaveBeenCalledWith('stream-force_logout', expect.any(Function)); }); +}); - it('stops the previous pendingHangups connected listener when connect runs again', async () => { - await connect({ server: 'https://example.com' }); - // Stops are pushed in registration order, so the drain listener's stop shares its registration index. - const firstDrainStop = mockOnStreamDataStops[getPendingHangupsDrainRegistrationIndex()]; +describe('connect — listener lifecycle across reconnects', () => { + it('attaches handlers to a fresh connection instance each connect, so old handlers are dropped (no leak)', async () => { + sdkMock.__setServer(undefined); + await connect({ server: 'https://a.example.com' }); + const connectionA = sdkMock.__getCurrentConnection(); + + sdkMock.__setServer(undefined); + await connect({ server: 'https://b.example.com' }); + const connectionB = sdkMock.__getCurrentConnection(); + + // Each connect() renders its connection.on('connection', …) on its own instance. + // Because the new SDK creates a fresh connection per connect() and disconnect() drops + // the previous one, connectionA (and the handlers attached to it) is unreachable after + // the second connect — exactly why the old `*.then(stopListener)` teardown is unnecessary. + expect(connectionA).toBeDefined(); + expect(connectionB).toBeDefined(); + expect(connectionA).not.toBe(connectionB); + expect(sdkMock.__getCurrentConnection()).toBe(connectionB); + }); +}); +// ───────────────────────────────────────────────────────────────────────────── +describe('connect — rooms subscription guard reset on close', () => { + beforeEach(() => { + jest.clearAllMocks(); + sdkMock.__setServer(undefined); + mockStoreGetState.mockReturnValue({ + meteor: { connected: false }, + login: { user: null, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } + }); + }); + + // Regression: a long background marks the DDP socket stale, so foregrounding triggers + // `recoverSocket` → `reopenNow` (socketHealth.ts), which closes the stale socket and reconnects. The rooms-list `stream-notify-user` feed only re-subscribes when the + // module-level guard in `subscribeRooms` is clear, and `unsubscribeRooms()` is what clears it. + // If the 'close' handler stops calling `unsubscribeRooms()`, the guard stays set after reconnect + // and the rooms list silently stops updating (subscriptions/favorites/reads). + it('calls unsubscribeRooms when the socket "close" fires', async () => { await connect({ server: 'https://example.com' }); - await flushMicrotasks(); - expect(firstDrainStop).toHaveBeenCalled(); + // connect() itself calls unsubscribeRooms() once while tearing down prior listeners; ignore it. + (unsubscribeRooms as jest.Mock).mockClear(); + + const listener = getCapturedConnectionListener(); + listener('closed'); + + expect(unsubscribeRooms).toHaveBeenCalledTimes(1); }); }); @@ -553,7 +847,7 @@ describe('connect — stream-notify-logged updateAvatar', () => { beforeEach(async () => { jest.clearAllMocks(); - mockOnStreamDataStops.length = 0; + sdkMock.__setServer(undefined); mockStoreGetState.mockReturnValue({ meteor: { connected: false }, login: { user: null, isAuthenticated: false }, @@ -629,3 +923,327 @@ describe('connect — stream-notify-logged updateAvatar', () => { }); // Note: Apple authentication when isIOS is true is tested in connect.ios.test.ts + +// ───────────────────────────────────────────────────────────────────────────── +describe('login', () => { + beforeEach(() => { + mockSdkLogin.mockReset(); + mockStoreGetState.mockReturnValue({ + meteor: { connected: true }, + login: { user: null, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } + }); + mockAccountUser.value = undefined; + }); + + it('throws when sdk.login result has no me', async () => { + mockSdkLogin.mockResolvedValue({}); + await expect(login({ user: 'u', password: 'p' } as any)).rejects.toThrow("Couldn't fetch user data"); + }); + + it('throws when sdk.current.account.user is missing', async () => { + mockSdkLogin.mockResolvedValue({ me: { username: 'john' } }); + mockAccountUser.value = undefined; + await expect(login({ user: 'u', password: 'p' } as any)).rejects.toThrow('Login failed: no user returned'); + }); + + it('returns an ILoggedUser combining account.user + me', async () => { + mockAccountUser.value = { id: 'u-1', token: 'tok-1' }; + mockSdkLogin.mockResolvedValue({ + me: { username: 'john', name: 'John D', language: 'en', emails: [{ address: 'j@x.com' }] } + }); + const result = await login({ user: 'john', password: 'p' } as any); + expect(result).toEqual( + expect.objectContaining({ + id: 'u-1', + token: 'tok-1', + username: 'john', + name: 'John D', + language: 'en' + }) + ); + }); + + it('reads showMessageInMainThread / enableMessageParserEarlyAdoption from me on RC < 5.0', async () => { + mockStoreGetState.mockReturnValue({ + meteor: { connected: true }, + login: { user: null, isAuthenticated: false }, + settings: {}, + server: { version: '4.9.0' } + }); + mockAccountUser.value = { id: 'u-1', token: 'tok-1' }; + mockSdkLogin.mockResolvedValue({ + me: { + username: 'john', + settings: { preferences: { enableMessageParserEarlyAdoption: false, showMessageInMainThread: true } } + } + }); + const result = await login({ user: 'john', password: 'p' } as any); + expect(result?.enableMessageParserEarlyAdoption).toBe(false); + expect(result?.showMessageInMainThread).toBe(true); + }); +}); + +describe('loginTOTP', () => { + beforeEach(() => { + mockSdkLogin.mockReset(); + mockTwoFactor.mockReset(); + mockStoreDispatch.mockReset(); + mockStoreGetState.mockReturnValue({ + meteor: { connected: true }, + login: { user: null, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } + }); + mockAccountUser.value = { id: 'u-1', token: 'tok-1' }; + }); + + it('returns the login result when no 2FA challenge is raised', async () => { + mockSdkLogin.mockResolvedValue({ me: { username: 'john' } }); + const result = await loginTOTP({ user: 'john', password: 'p' } as any); + expect(result.username).toBe('john'); + }); + + it('prompts twoFactor with details.method and retries when totp-required', async () => { + mockSdkLogin + .mockRejectedValueOnce({ data: { error: 'totp-required', details: { method: 'totp' } } }) + .mockResolvedValueOnce({ me: { username: 'john' } }); + mockTwoFactor.mockResolvedValue({ twoFactorCode: '123456', twoFactorMethod: 'totp' }); + + const result = await loginTOTP({ user: 'john', password: 'p' } as any, true); + expect(mockTwoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: false }); + expect(result.username).toBe('john'); + }); + + it('passes invalid:true when retrying after totp-invalid', async () => { + mockSdkLogin + .mockRejectedValueOnce({ + data: { error: 'totp-invalid', details: { method: 'totp', error: 'totp-invalid' } } + }) + .mockResolvedValueOnce({ me: { username: 'john' } }); + mockTwoFactor.mockResolvedValue({ twoFactorCode: '999999', twoFactorMethod: 'totp' }); + await loginTOTP({ user: 'john', password: 'p' } as any, true); + expect(mockTwoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: true }); + }); + + it('passes invalid:true when e.data.error is totp-invalid even if details.error is absent', async () => { + mockSdkLogin + .mockRejectedValueOnce({ + // details.error is intentionally absent — only e.data.error is set + data: { error: 'totp-invalid', details: { method: 'totp' } } + }) + .mockResolvedValueOnce({ me: { username: 'john' } }); + mockTwoFactor.mockResolvedValue({ twoFactorCode: '777777', twoFactorMethod: 'totp' }); + await loginTOTP({ user: 'john', password: 'p' } as any, true); + expect(mockTwoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: true }); + }); + + it('normalizes ldapPass to password on RC >= 3.9.0 before 2FA retry', async () => { + mockSdkLogin + .mockRejectedValueOnce({ data: { error: 'totp-required', details: { method: 'totp' } } }) + .mockResolvedValueOnce({ me: { username: 'john' } }); + mockTwoFactor.mockResolvedValue({ twoFactorCode: '111111', twoFactorMethod: 'totp' }); + await loginTOTP({ username: 'john', ldapPass: 'secret-ldap' } as any, true); + const retryArgs = mockSdkLogin.mock.calls[1][0]; + expect(retryArgs).toMatchObject({ user: 'john', password: 'secret-ldap', code: '111111' }); + expect(retryArgs).not.toHaveProperty('ldapPass'); + }); + + it('wraps the retry params in a totp envelope when loginEmailPassword is falsy', async () => { + mockSdkLogin + .mockRejectedValueOnce({ data: { error: 'totp-required', details: { method: 'totp' } } }) + .mockResolvedValueOnce({ me: { username: 'service' } }); + mockTwoFactor.mockResolvedValue({ twoFactorCode: '222222', twoFactorMethod: 'totp' }); + await loginTOTP({ user: 'service', password: 'p' } as any); + const retryArgs = mockSdkLogin.mock.calls[1][0]; + expect(retryArgs).toMatchObject({ totp: { login: expect.any(Object), code: '222222' } }); + }); + + it('rejects when twoFactor is cancelled', async () => { + mockSdkLogin.mockRejectedValue({ data: { error: 'totp-required', details: { method: 'totp' } } }); + mockTwoFactor.mockRejectedValue(new Error('cancelled')); + await expect(loginTOTP({ user: 'john', password: 'p' } as any, true)).rejects.toBeUndefined(); + }); + + it('rejects non-2FA errors as-is', async () => { + mockSdkLogin.mockRejectedValue(new Error('500 server')); + await expect(loginTOTP({ user: 'john', password: 'p' } as any)).rejects.toThrow('500 server'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Fix 4 regression — inquiry dispatch on reconnect +describe('connection status handler (Fix 4 regression)', () => { + beforeEach(() => { + mockConnectionOn.mockReset(); + mockStoreDispatch.mockReset(); + mockHasRole.mockReset(); + sdkMock.__setServer(undefined); + }); + + it('dispatches inquiryRequest on connected when user is a livechat-agent', async () => { + mockHasRole.mockImplementation((r: string) => r === 'livechat-agent'); + await connect({ server: 'https://x.com' }); + const listener = getCapturedConnectionListener(); + mockStoreGetState.mockReturnValue({ + meteor: { connected: false }, + login: { user: { token: 't' }, isAuthenticated: true }, + settings: {}, + server: { version: '6.0.0' } + }); + listener('connected'); + await flushMicrotasks(); + const actions = mockStoreDispatch.mock.calls.map(([action]) => (action as any).type); + expect(actions).toContain('INQUIRY_REQUEST'); + }); + + it('does NOT dispatch inquiryRequest on connected when user has no livechat role', async () => { + mockHasRole.mockReturnValue(false); + await connect({ server: 'https://no-livechat.com' }); + const listener = getCapturedConnectionListener(); + mockStoreGetState.mockReturnValue({ + meteor: { connected: false }, + login: { user: { token: 't' }, isAuthenticated: true }, + settings: {}, + server: { version: '6.0.0' } + }); + listener('connected'); + await flushMicrotasks(); + const actions = mockStoreDispatch.mock.calls.map(([action]) => (action as any).type); + expect(actions).not.toContain('INQUIRY_REQUEST'); + }); +}); + +// Regression: stream-notify-all/stream-roles/stream-notify-logged handlers are wrapped in +// protectedFunction, which only catches synchronous throws. A fieldless payload reaching an +// async handler that destructures `ddpMessage.fields` without a guard would reject instead of +// throw, producing an unhandled promise rejection. Each handler must bail out early instead. +describe('connect — collection handlers guard against fieldless payloads', () => { + beforeEach(() => { + jest.clearAllMocks(); + sdkMock.__setServer(undefined); + mockStoreGetState.mockReturnValue({ + meteor: { connected: false }, + login: { user: null, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } + }); + }); + + it('ignores a stream-notify-all message with no fields instead of throwing', async () => { + await connect({ server: 'https://example.com' }); + const [handler] = getHandlersByEvent('stream-notify-all'); + + await expect(handler({ msg: 'added' })).resolves.toBeUndefined(); + }); + + it('ignores a stream-roles message with no fields instead of throwing', async () => { + await connect({ server: 'https://example.com' }); + const [handler] = getHandlersByEvent('stream-roles'); + + expect(() => handler({ msg: 'added' })).not.toThrow(); + expect(onRolesChanged).not.toHaveBeenCalled(); + }); + + it('ignores a stream-notify-logged message with no fields instead of throwing', async () => { + await connect({ server: 'https://example.com' }); + const [handler] = getHandlersByEvent('stream-notify-logged'); + + await expect(handler({ msg: 'added' })).resolves.toBeUndefined(); + }); +}); + +describe('connect — stream-user-presence updates active users', () => { + const SERVER = 'https://example.com'; + + beforeEach(() => { + jest.clearAllMocks(); + sdkMock.__setServer(undefined); + mockStoreGetState.mockReturnValue({ + meteor: { connected: false }, + login: { user: null, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } + }); + }); + + const fireUserPresence = (uid: string, status: number, statusText: string) => { + const [handler] = getHandlersByEvent('stream-user-presence'); + handler({ + msg: 'changed', + fields: { uid, args: [[uid, status, statusText, undefined, undefined]] } + }); + }; + + it('dispatches setActiveUsers with the presence for a user who is not the logged-in user', async () => { + await connect({ server: SERVER }); + const setActiveUsersMock = jest.requireMock('../../actions/activeUsers').setActiveUsers; + + fireUserPresence('other-user-1', 1, 'online'); + + expect(setActiveUsersMock).toHaveBeenCalledWith({ + 'other-user-1': { status: STATUSES[1], statusText: 'online', statusSource: undefined, statusExpiresAt: undefined } + }); + }); + + it('still dispatches setActiveUsers for the logged-in user (in addition to setUser)', async () => { + mockStoreGetState.mockReturnValue({ + meteor: { connected: false }, + login: { user: { id: 'me' }, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } + }); + await connect({ server: SERVER }); + const setActiveUsersMock = jest.requireMock('../../actions/activeUsers').setActiveUsers; + const setUserMock = jest.requireMock('../../actions/login').setUser; + + fireUserPresence('me', 1, 'online'); + + expect(setActiveUsersMock).toHaveBeenCalledWith({ + me: { status: STATUSES[1], statusText: 'online', statusSource: undefined, statusExpiresAt: undefined } + }); + expect(setUserMock).toHaveBeenCalledWith({ + status: STATUSES[1], + statusText: 'online', + statusSource: undefined, + statusExpiresAt: undefined + }); + }); +}); + +describe('getWebsocketInfo', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns success when the probe socket connects', async () => { + const { getWebsocketInfo } = require('./connect'); + mockCreateAndConnect.mockResolvedValue({ connection: { close: jest.fn() } }); + + const result = await getWebsocketInfo({ server: 'https://example.com' }); + + expect(result).toEqual({ success: true }); + expect(mockCreateAndConnect).toHaveBeenCalledWith('https://example.com'); + }); + + it('returns a disabled message when the server rejects with 400', async () => { + const { getWebsocketInfo } = require('./connect'); + mockCreateAndConnect.mockRejectedValue(new Error('Error: 400 bad request')); + + const result = await getWebsocketInfo({ server: 'https://example.com' }); + + expect(result.success).toBe(false); + expect(result.message).toMatch(/disabled/i); + }); + + it('falls back to the error message for non-400 failures', async () => { + const { getWebsocketInfo } = require('./connect'); + mockCreateAndConnect.mockRejectedValue(new Error('boom')); + + const result = await getWebsocketInfo({ server: 'https://example.com' }); + + expect(result.success).toBe(false); + expect(result.message).toBe('boom'); + }); +}); diff --git a/app/lib/services/connect.ts b/app/lib/services/connect.ts index 1f9043efd1f..a8f6a9ec19a 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -1,7 +1,7 @@ -import { Rocketchat as RocketchatClient } from '@rocket.chat/sdk'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { InteractionManager } from 'react-native'; import { Q } from '@nozbe/watermelondb'; +import { DDPSDK } from '@rocket.chat/ddp-client'; import log from '../methods/helpers/log'; import { setActiveUsers } from '../../actions/activeUsers'; @@ -11,7 +11,7 @@ import { twoFactor } from './twoFactor'; import { store } from '../store/auxStore'; import { loginRequest, logout, setLoginServices, setUser } from '../../actions/login'; import { waitForLoginReady } from './waitForLoginReady'; -import sdk from './sdk'; +import sdk, { type ConnectionStatus } from './sdk'; import { mediaSessionInstance } from './voip/MediaSessionInstance'; import { pendingHangups } from './voip/pendingHangups'; import I18n from '../../i18n'; @@ -21,15 +21,14 @@ import { updatePermission } from '../../actions/permissions'; import EventEmitter from '../methods/helpers/events'; import { updateSettings } from '../../actions/settings'; import { defaultSettings } from '../constants/defaultSettings'; -import { unsubscribeRooms } from '../methods/subscribeRooms'; -import { getSettings } from '../methods/getSettings'; +import { compareServerVersion, hasRole, isIOS } from '../methods/helpers'; +import { inquiryRequest } from '../../ee/omnichannel/actions/inquiry'; import { onRolesChanged } from '../methods/getRoles'; +import { getSettings } from '../methods/getSettings'; import { setPresenceCap } from '../methods/getUsersPresence'; import { _setUser, type IActiveUsers, _setUserTimer, _activeUsers } from '../methods/setUser'; -import { compareServerVersion } from '../methods/helpers/compareServerVersion'; -import { isIOS } from '../methods/helpers/deviceInfo'; -import { isSsl } from '../methods/helpers/isSsl'; import { normalizeStatusExpiresAt } from '../methods/helpers/normalizeStatusExpiresAt'; +import { unsubscribeRooms } from '../methods/subscribeRooms'; import fetch from '../methods/helpers/fetch'; interface IServices { @@ -41,123 +40,87 @@ interface IServices { service: string; } -let connectingListener: any; -let connectedListener: any; -let closeListener: any; -let pendingHangupsConnectedListener: any; -let usersListener: any; -let notifyAllListener: any; -let rolesListener: any; -let notifyLoggedListener: any; -let logoutListener: any; - -function connect({ server, logoutOnError = false }: { server: string; logoutOnError?: boolean }): Promise { - return new Promise(resolve => { - // Check for running requests and abort them before connecting to the server - abort(); +let connectAbortController: AbortController | null = null; - disconnect(); - database.setActiveDB(server); - - store.dispatch(connectRequest()); - - if (connectingListener) { - connectingListener.then(stopListener); - } - - if (connectedListener) { - connectedListener.then(stopListener); - } - - if (closeListener) { - closeListener.then(stopListener); - } - - if (pendingHangupsConnectedListener) { - pendingHangupsConnectedListener.then(stopListener); - } - - if (usersListener) { - usersListener.then(stopListener); - } +const ACTIVE_CONNECTION_STATUSES: ConnectionStatus[] = ['connecting', 'connected', 'reconnecting']; - if (notifyAllListener) { - notifyAllListener.then(stopListener); - } - - if (rolesListener) { - rolesListener.then(stopListener); - } +async function connect({ server, logoutOnError = false }: { server: string; logoutOnError?: boolean }): Promise { + if (sdk.server === server && sdk.current && ACTIVE_CONNECTION_STATUSES.includes(sdk.current.connection.status)) { + return; + } - if (notifyLoggedListener) { - notifyLoggedListener.then(stopListener); - } + try { + disconnect(); + connectAbortController = new AbortController(); + const { signal } = connectAbortController; - if (logoutListener) { - logoutListener.then(stopListener); - } + database.setActiveDB(server); unsubscribeRooms(); EventEmitter.emit('INQUIRY_UNSUBSCRIBE'); - sdk.initialize(server); - getSettings(); - - sdk.current - .connect() - .then(() => { - console.log('connected'); - }) - .catch((err: unknown) => { - console.log('connect error', err); - }); - - connectingListener = sdk.current.onStreamData('connecting', () => { - store.dispatch(connectRequest()); - }); + await sdk.initialize(server); + await getSettings(signal); - connectedListener = sdk.current.onStreamData('connected', () => { - const { connected } = store.getState().meteor; - if (connected) { - return; - } - store.dispatch(connectSuccess()); - const { user } = store.getState().login; - if (user?.token) { - store.dispatch(loginRequest({ resume: user.token }, logoutOnError)); - } - }); + // A newer connect() call may have switched servers while getSettings() was in flight — + // bail out rather than wiring up listeners/dispatching against the wrong sdk instance. + if (sdk.server !== server) { + return; + } // Tracks a real disconnect so the next `'connected'` can drain hangups the user tapped while // the WebSocket was unhealthy. Local to the closure so it resets per `connect()` call. let pendingHangupsDrainArmed = false; - closeListener = sdk.current.onStreamData('close', () => { - pendingHangupsDrainArmed = true; - store.dispatch(disconnectAction()); - }); - - pendingHangupsConnectedListener = sdk.current.onStreamData('connected', async () => { - if (!pendingHangupsDrainArmed) return; - pendingHangupsDrainArmed = false; - if (pendingHangups.size === 0) return; - try { - await waitForLoginReady(5000); - await mediaSessionInstance.drainPendingHangups(); - } catch (error) { - log(error); + sdk.current?.connection.on('connection', status => { + if (['connecting', 'reconnecting'].includes(status)) { + store.dispatch(connectRequest()); + } + if (status === 'connected') { + if (pendingHangupsDrainArmed) { + pendingHangupsDrainArmed = false; + if (pendingHangups.size > 0) { + waitForLoginReady(5000) + .then(() => mediaSessionInstance.drainPendingHangups()) + .catch(error => log(error)); + } + } + const { connected } = store.getState().meteor; + if (connected) { + return; + } + store.dispatch(connectSuccess()); + const { user } = store.getState().login; + if (user?.token) { + store.dispatch(loginRequest({ resume: user.token }, logoutOnError)); + } + // Omnichannel inquiry queue must be refreshed on (re)connect — the previous SDK had a + // `'connected'` event that inquiry.ts listened to; the new DDP client's `onCollection` + // doesn't fire for connection events, so the dispatch is centralized here. + if (hasRole('livechat-agent') || hasRole('livechat-manager')) { + store.dispatch(inquiryRequest()); + } + } + if (['disconnected', 'closed', 'failed'].includes(status)) { + unsubscribeRooms(); + pendingHangupsDrainArmed = true; + store.dispatch(disconnectAction()); } }); - - usersListener = sdk.current.onStreamData( + // Registered before connect() resolves: a rejected or hung connect() must not leave the + // client without listeners once the underlying socket connects or recovers on its own. + sdk.onCollection( 'users', - protectedFunction((ddpMessage: any) => _setUser(ddpMessage)) + protectedFunction((ddpMessage: unknown) => _setUser(ddpMessage as IActiveUsers)) ); - notifyAllListener = sdk.current.onStreamData( + sdk.onCollection( 'stream-notify-all', - protectedFunction(async (ddpMessage: { fields: { args?: any; eventName: string } }) => { + protectedFunction(async (ddpMessage: { fields?: { args?: any; eventName: string } }) => { + if (!ddpMessage.fields) { + return; + } const { eventName } = ddpMessage.fields; if (/public-settings-changed/.test(eventName)) { const { _id, value } = ddpMessage.fields.args[1]; @@ -186,38 +149,47 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr } catch (e) { log(e); } - } else { - console.warn(`Setting with _id '${_id}' is not present in defaultSettings.`); } } }) ); - rolesListener = sdk.current.onStreamData( + sdk.onCollection( 'stream-roles', - protectedFunction((ddpMessage: any) => onRolesChanged(ddpMessage)) + protectedFunction((ddpMessage: any) => { + if (!ddpMessage?.fields) { + return; + } + onRolesChanged(ddpMessage); + }) ); // RC 4.1 - sdk.current.onStreamData('stream-user-presence', (ddpMessage: { fields: { args?: any; uid?: any } }) => { - const userStatus = ddpMessage.fields.args[0]; - const { uid } = ddpMessage.fields; - const [, status, statusText, statusSource, statusExpiresAtRaw] = userStatus; - const statusExpiresAt = normalizeStatusExpiresAt(statusExpiresAtRaw); - const newStatus = { status: STATUSES[status], statusText, statusSource, statusExpiresAt }; - // @ts-ignore - store.dispatch(setActiveUsers({ [uid]: newStatus })); - - const { user: loggedUser } = store.getState().login; - if (loggedUser && loggedUser.id === uid) { - // @ts-ignore - store.dispatch(setUser(newStatus)); + sdk.onCollection('stream-user-presence', ddpMessage => { + if (ddpMessage.msg === 'added' || ddpMessage.msg === 'changed') { + if (!ddpMessage.fields) { + return; + } + const userStatus = ddpMessage.fields.args[0]; + const { uid } = ddpMessage.fields; + const [, status, statusText, statusSource, statusExpiresAtRaw] = userStatus; + const statusExpiresAt = normalizeStatusExpiresAt(statusExpiresAtRaw); + const newStatus = { status: STATUSES[status], statusText, statusSource, statusExpiresAt }; + store.dispatch(setActiveUsers({ [uid]: newStatus })); + + const { user: loggedUser } = store.getState().login; + if (loggedUser && loggedUser.id === uid) { + store.dispatch(setUser(newStatus)); + } } }); - notifyLoggedListener = sdk.current.onStreamData( + sdk.onCollection( 'stream-notify-logged', - protectedFunction(async (ddpMessage: { fields: { args?: any; eventName?: any } }) => { + protectedFunction(async (ddpMessage: { fields?: { args?: any; eventName?: any } }) => { + if (!ddpMessage.fields) { + return; + } const { eventName } = ddpMessage.fields; // `user-status` event is deprecated after RC 4.1 in favor of `stream-user-presence/${uid}` @@ -306,52 +278,56 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr }) ); - logoutListener = sdk.current.onStreamData('stream-force_logout', () => store.dispatch(logout(true))); - - resolve(); - }); -} + sdk.onCollection('stream-force_logout', () => store.dispatch(logout(true))); -function stopListener(listener: any): boolean { - return listener && listener.stop(); + await sdk.current?.connection.connect(); + } catch (e) { + log(e); + throw e; + } } async function login(credentials: ICredentials): Promise { - // RC 0.64.0 - await sdk.current.login(credentials); + const result = await sdk.login(credentials); + const { me } = result; const serverVersion = store.getState().server.version; - const result = sdk.current.currentLogin?.result; + const loginUser = sdk.current?.account.user; + + if (!me) { + throw new Error("Couldn't fetch user data"); + } let enableMessageParserEarlyAdoption = true; let showMessageInMainThread = false; if (compareServerVersion(serverVersion, 'lowerThan', '5.0.0')) { - enableMessageParserEarlyAdoption = result.me.settings?.preferences?.enableMessageParserEarlyAdoption ?? true; - showMessageInMainThread = result.me.settings?.preferences?.showMessageInMainThread ?? true; + enableMessageParserEarlyAdoption = me.settings?.preferences?.enableMessageParserEarlyAdoption ?? true; + showMessageInMainThread = me.settings?.preferences?.showMessageInMainThread ?? true; } - if (result) { + if (loginUser) { const user: ILoggedUser = { - id: result.userId, - token: result.authToken, - username: result.me.username, - name: result.me.name, - language: result.me.language, - status: result.me.status, - statusText: result.me.statusText, - customFields: result.me.customFields, - statusLivechat: result.me.statusLivechat, - emails: result.me.emails, - roles: result.me.roles, - avatarETag: result.me.avatarETag, + id: loginUser.id, + token: loginUser.token as string, + username: me.username as string, + name: me.name, + language: me.language, + status: me.status as ILoggedUser['status'], + statusText: me.statusText, + customFields: me.customFields, + statusLivechat: me.statusLivechat, + emails: me.emails, + roles: me.roles, + avatarETag: me.avatarETag, showMessageInMainThread, enableMessageParserEarlyAdoption, - alsoSendThreadToChannel: result.me.settings?.preferences?.alsoSendThreadToChannel, - bio: result.me.bio, - nickname: result.me.nickname, - requirePasswordChange: result.me.requirePasswordChange + alsoSendThreadToChannel: me.settings?.preferences?.alsoSendThreadToChannel, + bio: me.bio, + nickname: me.nickname, + requirePasswordChange: me.requirePasswordChange }; return user; } + throw new Error('Login failed: no user returned'); } function loginTOTP(params: ICredentials, loginEmailPassword?: boolean): Promise { @@ -363,13 +339,9 @@ function loginTOTP(params: ICredentials, loginEmailPassword?: boolean): Promise< } } catch (e: any) { if (e.data?.error && (e.data.error === 'totp-required' || e.data.error === 'totp-invalid')) { - const { details, error } = e.data; + const { details } = e.data; try { - const code = await twoFactor({ - params, - method: details?.method || 'totp', - invalid: (details.error || error) === 'totp-invalid' - }); + const code = await twoFactor({ method: details?.method || 'totp', invalid: e.data.error === 'totp-invalid' }); if (loginEmailPassword) { store.dispatch(setUser({ username: params.user || params.username })); @@ -433,13 +405,8 @@ async function loginOAuthOrSso(params: ICredentials) { store.dispatch(loginRequest({ resume: result.token }, false)); } -function abort() { - if (sdk.current) { - return sdk.current.abort(); - } -} - function disconnect() { + connectAbortController?.abort(); const result = sdk.disconnect(); mediaSessionInstance.reset(); return result; @@ -450,24 +417,27 @@ async function getWebsocketInfo({ }: { server: string; }): Promise<{ success: true } | { success: false; message: string }> { - const websocketSdk = new RocketchatClient({ host: server, protocol: 'ddp', useSsl: isSsl(server) }); - + let probeSdk: DDPSDK | undefined; try { - await websocketSdk.connect(); + probeSdk = await DDPSDK.createAndConnect(server); + return { + success: true + }; } catch (err: any) { - if (err.message && err.message.includes('400')) { + if (err?.message?.includes('400')) { return { success: false, message: I18n.t('Websocket_disabled', { contact: I18n.t('Contact_your_server_admin') }) }; } - } - - websocketSdk.disconnect(); - return { - success: true - }; + return { + success: false, + message: err?.message || I18n.t('Invalid_URL') + }; + } finally { + probeSdk?.connection.close(); + } } async function getLoginServices(server: string) { @@ -494,7 +464,7 @@ async function getLoginServices(server: string) { store.dispatch(setLoginServices({})); } } catch (error) { - console.log(error); + log(error); store.dispatch(setLoginServices({})); } } @@ -530,11 +500,9 @@ export { loginTOTP, loginWithPassword, loginOAuthOrSso, - abort, connect, disconnect, getWebsocketInfo, - stopListener, getLoginServices, determineAuthType, waitForLoginReady diff --git a/app/lib/services/ddpSocket.test.ts b/app/lib/services/ddpSocket.test.ts deleted file mode 100644 index 44acd6e7320..00000000000 --- a/app/lib/services/ddpSocket.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { Socket, DDPDriver } = require('@rocket.chat/sdk/lib/drivers/ddp'); - -const mockConnections: any[] = []; -const trackedSockets: any[] = []; - -jest.mock('universal-websocket-client', () => { - return jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data); - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; - }); -}); - -const buildSocket = () => { - const socket = new Socket({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - timeout: 10000 - }); - trackedSockets.push(socket); - const send = jest.fn(); - const close = jest.fn(); - socket.connection = { - send, - close, - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - return { socket, send, close }; -}; - -const trackSocket = (socket: any) => { - trackedSockets.push(socket); - return socket; -}; - -beforeEach(() => { - mockConnections.length = 0; - trackedSockets.length = 0; -}); - -afterEach(() => { - trackedSockets.forEach(socket => { - if (socket.openTimeout) clearTimeout(socket.openTimeout as any); - if (socket.pingTimeout) clearTimeout(socket.pingTimeout as any); - }); -}); - -describe('Socket.probe', () => { - afterEach(() => { - jest.useRealTimers(); - }); - - it('resolves true when pong arrives within deadline', async () => { - const { socket } = buildSocket(); - const probePromise = socket.probe(); - socket.lastPing += 1; - socket.emit('pong'); - await expect(probePromise).resolves.toBe(true); - }); - - it('resolves false when no pong arrives within 2s deadline', async () => { - jest.useFakeTimers(); - const { socket } = buildSocket(); - const probePromise = socket.probe(); - await jest.advanceTimersByTimeAsync(2000); - await expect(probePromise).resolves.toBe(false); - }); - - it('resolves false when raw connection.send throws', async () => { - const { socket, send } = buildSocket(); - send.mockImplementation(() => { - throw new Error('boom'); - }); - await expect(socket.probe()).resolves.toBe(false); - }); - - it('resolves false when readyState is not open', async () => { - const { socket } = buildSocket(); - socket.connection.readyState = 2; - await expect(socket.probe()).resolves.toBe(false); - }); - - it('ignores a stale pong that does not advance lastPing', async () => { - jest.useFakeTimers(); - const { socket } = buildSocket(); - const initialLastPing = Date.now() - 1000; - socket.lastPing = initialLastPing; - - const probePromise = socket.probe(); - socket.emit('pong'); - - await jest.advanceTimersByTimeAsync(2000); - await expect(probePromise).resolves.toBe(false); - }); -}); - -describe('Socket.reopenNow', () => { - afterEach(() => { - jest.useRealTimers(); - }); - - it('preserves subscriptions and subscribeAll re-sends them', async () => { - const { socket } = buildSocket(); - const subscription = { - id: 'sub-1', - name: 'stream-room-messages', - params: ['rid'], - unsubscribe: jest.fn() - }; - socket.subscriptions['sub-1'] = subscription; - - const sendSpy = jest.spyOn(socket, 'send').mockResolvedValue({ subs: ['sub-1'] }); - - const reopenPromise = socket.reopenNow(); - mockConnections[0].onopen(); - await reopenPromise; - - expect(socket.subscriptions['sub-1']).toBe(subscription); - - await socket.subscribeAll(); - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - msg: 'sub', - id: 'sub-1', - name: 'stream-room-messages', - params: ['rid'] - }) - ); - }); - - it("emits 'disconnected' and rejects in-flight send()", async () => { - const { socket } = buildSocket(); - const disconnectedListener = jest.fn(); - socket.on('disconnected', disconnectedListener); - const sendPromise = socket.send({ msg: 'ping' }); - - const reopenPromise = socket.reopenNow(); - - expect(disconnectedListener).toHaveBeenCalledTimes(1); - await expect(sendPromise).rejects.toBeUndefined(); - - mockConnections[0].onopen(); - await reopenPromise; - }); - - it('concurrent calls create exactly one new WebSocket', async () => { - const socket = trackSocket( - new Socket({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - timeout: 10000 - }) - ); - - const a = socket.reopenNow(); - const b = socket.reopenNow(); - - expect(mockConnections).toHaveLength(1); - - mockConnections[0].onopen(); - - await Promise.all([a, b]); - }); - - it('times out and clears in-flight state so a later reopenNow retries', async () => { - jest.useFakeTimers(); - const socket = trackSocket( - new Socket({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - timeout: 10000 - }) - ); - - const promise = socket.reopenNow(); - expect(socket.reopenPromise).toBeTruthy(); - - await jest.advanceTimersByTimeAsync(10000); - await promise; - - expect(socket.reopenPromise).toBeUndefined(); - - const secondPromise = socket.reopenNow(); - expect(mockConnections).toHaveLength(2); - - mockConnections[1].onopen(); - await jest.runOnlyPendingTimersAsync(); - await secondPromise; - }); - - it('forces a reconnect on an already healthy socket', async () => { - const { socket } = buildSocket(); - const initialConnection = socket.connection; - - const promise = socket.reopenNow(); - - expect(mockConnections).toHaveLength(1); - expect(initialConnection.close).toHaveBeenCalled(); - - mockConnections[0].onopen(); - await promise; - - expect(socket.connection).toBe(mockConnections[0]); - }); - - it('serializes against concurrent open(): no second socket, no closing in-flight one', async () => { - const socket = trackSocket( - new Socket({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - timeout: 10000 - }) - ); - - const reopenPromise = socket.reopenNow(); - const inFlightConnection = mockConnections[0]; - - const openPromise = socket.open(); - expect(mockConnections).toHaveLength(1); - expect(inFlightConnection.close).not.toHaveBeenCalled(); - - mockConnections[0].onopen(); - await reopenPromise; - await openPromise; - }); -}); - -describe('Socket.send disconnected listener', () => { - it('cleans up the disconnected listener after send resolves', async () => { - const { socket, send } = buildSocket(); - const baseline = socket._listeners.disconnected?.length || 0; - send.mockImplementation(() => { - setImmediate(() => socket.emit('pong', { msg: 'pong' })); - }); - - await socket.send({ msg: 'ping' }); - - expect(socket._listeners.disconnected?.length || 0).toBe(baseline); - }); -}); - -describe('DDPDriver.waitForNotifyUserMediaSubs', () => { - afterEach(() => { - jest.useRealTimers(); - }); - - const makeDriver = () => - new DDPDriver({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() } - }); - - it('resolves true when media subs are present and server acks', async () => { - const driver = makeDriver(); - driver.userId = 'uid'; - driver.ddp.subscriptions['sub-ms'] = { - id: 'sub-ms', - name: 'stream-notify-user', - params: ['uid/media-signal'], - unsubscribe: jest.fn() - }; - driver.ddp.subscriptions['sub-mc'] = { - id: 'sub-mc', - name: 'stream-notify-user', - params: ['uid/media-calls'], - unsubscribe: jest.fn() - }; - jest.spyOn(driver.ddp, 'subscribe').mockResolvedValue({}); - - await expect(driver.waitForNotifyUserMediaSubs(1000)).resolves.toBe(true); - expect(driver.ddp.subscribe).toHaveBeenCalledWith('stream-notify-user', ['uid/media-signal'], undefined, 'sub-ms'); - expect(driver.ddp.subscribe).toHaveBeenCalledWith('stream-notify-user', ['uid/media-calls'], undefined, 'sub-mc'); - }); - - it('waits for media subs to appear before re-subscribing', async () => { - jest.useFakeTimers(); - const driver = makeDriver(); - driver.userId = 'uid'; - jest.spyOn(driver.ddp, 'subscribe').mockResolvedValue({}); - - const promise = driver.waitForNotifyUserMediaSubs(1000); - driver.ddp.subscriptions['sub-ms'] = { - id: 'sub-ms', - name: 'stream-notify-user', - params: ['uid/media-signal'], - unsubscribe: jest.fn() - }; - driver.ddp.subscriptions['sub-mc'] = { - id: 'sub-mc', - name: 'stream-notify-user', - params: ['uid/media-calls'], - unsubscribe: jest.fn() - }; - - await jest.advanceTimersByTimeAsync(100); - await expect(promise).resolves.toBe(true); - expect(driver.ddp.subscribe).toHaveBeenCalledTimes(2); - }); - - it('stays pending while only one of the media subs is present', async () => { - jest.useFakeTimers(); - const driver = makeDriver(); - driver.userId = 'uid'; - jest.spyOn(driver.ddp, 'subscribe').mockResolvedValue({}); - - let resolved: boolean | undefined; - const promise = driver.waitForNotifyUserMediaSubs(1000).then((value: boolean) => { - resolved = value; - return value; - }); - - driver.ddp.subscriptions['sub-ms'] = { - id: 'sub-ms', - name: 'stream-notify-user', - params: ['uid/media-signal'], - unsubscribe: jest.fn() - }; - - await jest.advanceTimersByTimeAsync(100); - expect(resolved).toBeUndefined(); - expect(driver.ddp.subscribe).not.toHaveBeenCalled(); - - driver.ddp.subscriptions['sub-mc'] = { - id: 'sub-mc', - name: 'stream-notify-user', - params: ['uid/media-calls'], - unsubscribe: jest.fn() - }; - - await jest.advanceTimersByTimeAsync(100); - await expect(promise).resolves.toBe(true); - }); - - it('resolves false if media subs never appear before the timeout', async () => { - jest.useFakeTimers(); - const driver = makeDriver(); - driver.userId = 'uid'; - - const promise = driver.waitForNotifyUserMediaSubs(500); - await jest.advanceTimersByTimeAsync(500); - - await expect(promise).resolves.toBe(false); - }); - - it('resolves false when userId is missing', async () => { - const driver = makeDriver(); - await expect(driver.waitForNotifyUserMediaSubs(1000)).resolves.toBe(false); - }); -}); diff --git a/app/lib/services/restApi.test.ts b/app/lib/services/restApi.test.ts index 8dc0dcf8fba..ffe8951a9f4 100644 --- a/app/lib/services/restApi.test.ts +++ b/app/lib/services/restApi.test.ts @@ -1,10 +1,11 @@ import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; import { Platform } from 'react-native'; -import { mediaCallsStateSignals } from './restApi'; +import { findOrCreateInvite, mediaCallsStateSignals } from './restApi'; const mockSdkGet = jest.fn(); const mockSdkPost = jest.fn(); +const mockSdkDelete = jest.fn(); let mockSdkCurrent: unknown = {}; jest.mock('./sdk', () => ({ @@ -12,6 +13,7 @@ jest.mock('./sdk', () => ({ default: { get: (...args: unknown[]) => mockSdkGet(...args), post: (...args: unknown[]) => mockSdkPost(...args), + delete: (...args: unknown[]) => mockSdkDelete(...args), get current() { return mockSdkCurrent; } @@ -83,7 +85,7 @@ describe('mediaCallsStateSignals', () => { const result = await mediaCallsStateSignals('device-contract-id-123'); - expect(mockSdkGet).toHaveBeenCalledWith('media-calls.stateSignals', { contractId: 'device-contract-id-123' }); + expect(mockSdkGet).toHaveBeenCalledWith('/v1/media-calls.stateSignals', { contractId: 'device-contract-id-123' }); expect(result).toEqual({ signals: [], success: true }); }); @@ -96,8 +98,9 @@ describe('mediaCallsStateSignals', () => { const result = await mediaCallsStateSignals('device-id'); - expect(result.signals).toHaveLength(2); expect(result.success).toBe(true); + if (!result.success) return; + expect(result.signals).toHaveLength(2); }); it('returns empty signals and success false when sdk.get throws', async () => { @@ -105,8 +108,7 @@ describe('mediaCallsStateSignals', () => { const result = await mediaCallsStateSignals('device-id'); - expect(result.signals).toEqual([]); - expect(result.success).toBe(false); + expect(result).toEqual({ signals: [], success: false }); }); it('returns empty signals and success false when sdk.get returns an error response', async () => { @@ -114,8 +116,31 @@ describe('mediaCallsStateSignals', () => { const result = await mediaCallsStateSignals('device-id'); - expect(result.signals).toEqual([]); - expect(result.success).toBe(false); + expect(result).toEqual({ signals: [], success: false }); + }); +}); + +describe('findOrCreateInvite', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an invite with a url field', async () => { + mockSdkPost.mockResolvedValueOnce({ + success: true, + _id: 'inv1', + days: 1, + maxUses: 0, + rid: 'room1', + url: 'https://example.com/invite/abc' + }); + + const invite = await findOrCreateInvite({ rid: 'room1', days: 1, maxUses: 0 }); + + expect(mockSdkPost).toHaveBeenCalledWith('/v1/findOrCreateInvite', { rid: 'room1', days: 1, maxUses: 0 }); + expect(invite.success).toBe(true); + if (!invite.success) return; + expect(invite.url).toBe('https://example.com/invite/abc'); }); }); @@ -164,7 +189,7 @@ describe('registerPushToken', () => { expect(mockSdkPost).toHaveBeenCalledTimes(1); expect(mockSdkPost).toHaveBeenCalledWith( - 'push.token', + '/v1/push.token', expect.objectContaining({ id: 'unique-device-id', value: 'apns-token', @@ -185,7 +210,7 @@ describe('registerPushToken', () => { expect(mockSdkPost).toHaveBeenCalledTimes(1); expect(mockSdkPost).toHaveBeenCalledWith( - 'push.token', + '/v1/push.token', expect.objectContaining({ id: 'unique-device-id', value: 'fcm-token', @@ -216,7 +241,7 @@ describe('registerPushToken', () => { await registerPushToken(); expect(mockSdkPost).toHaveBeenCalledWith( - 'push.token', + '/v1/push.token', expect.objectContaining({ id: 'unique-device-id', value: 'apns-token', @@ -256,7 +281,7 @@ describe('registerPushToken', () => { await registerPushToken(); expect(mockSdkPost).toHaveBeenCalledWith( - 'push.token', + '/v1/push.token', expect.objectContaining({ id: 'unique-device-id' }) @@ -265,3 +290,41 @@ describe('registerPushToken', () => { expect(Object.prototype.hasOwnProperty.call(payload, 'voipToken')).toBe(false); }); }); + +describe('usersAutoComplete', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('JSON-stringifies the selector before sending it', () => { + const { usersAutoComplete } = require('./restApi'); + usersAutoComplete({ term: 'abc', exceptions: [] }); + expect(mockSdkGet).toHaveBeenCalledWith('/v1/users.autocomplete', { + selector: JSON.stringify({ term: 'abc', exceptions: [] }) + }); + }); +}); + +describe('removePushToken', () => { + let removePushToken: () => Promise; + + beforeEach(() => { + jest.resetModules(); + mockSdkDelete.mockClear(); + mockSdkDelete.mockResolvedValue({ success: true }); + (require('../notifications').getDeviceToken as jest.Mock).mockReturnValue('device-token'); + removePushToken = require('./restApi').removePushToken; + }); + + it('sends a DELETE to /v1/push.token with the token in the body', async () => { + const result = await removePushToken(); + expect(result).toBe(true); + expect(mockSdkDelete).toHaveBeenCalledWith('/v1/push.token', { token: 'device-token' }); + }); + + it('returns undefined and does not call delete when there is no device token', async () => { + (require('../notifications').getDeviceToken as jest.Mock).mockReturnValue(undefined); + expect(await removePushToken()).toBeUndefined(); + expect(mockSdkDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index 0103b150a64..c642f97e9d1 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1,6 +1,4 @@ -import { settings as RocketChatSettings } from '@rocket.chat/sdk'; import { getUniqueId } from 'react-native-device-info'; -import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; import { type IAvatarSuggestion, @@ -20,7 +18,7 @@ import { type TParams } from '../../definitions/ILivechatEditView'; import { type ILivechatTag } from '../../definitions/ILivechatTag'; import { type ISpotlight } from '../../definitions/ISpotlight'; import { TEAM_TYPE } from '../../definitions/ITeam'; -import { type OperationParams, type ResultFor } from '../../definitions/rest/helpers'; +import { type OperationParams } from '../../definitions/rest/helpers'; import { type SubscriptionsEndpoints } from '../../definitions/rest/v1/subscriptions'; import { Encryption } from '../encryption'; import { type RoomTypes, roomTypeToApiType } from '../methods/roomTypeToApiType'; @@ -61,12 +59,12 @@ export const createChannel = ({ ...(teamId && { teamId }) } }; - return sdk.post(type ? 'groups.create' : 'channels.create', params); + return sdk.post(type ? '/v1/groups.create' : '/v1/channels.create', params); }; export const e2eSetUserPublicAndPrivateKeys = (public_key: string, private_key: string, force: boolean = false) => // RC 2.2.0 - sdk.post('e2e.setUserPublicAndPrivateKeys', { public_key, private_key, ...(force && { force: true }) }); + sdk.post('/v1/e2e.setUserPublicAndPrivateKeys', { public_key, private_key, ...(force && { force: true }) }); export const e2eRequestSubscriptionKeys = (): Promise => // RC 0.72.0 @@ -74,49 +72,47 @@ export const e2eRequestSubscriptionKeys = (): Promise => export const e2eGetUsersOfRoomWithoutKey = (rid: string) => // RC 0.70.0 - sdk.get('e2e.getUsersOfRoomWithoutKey', { rid }); + sdk.get('/v1/e2e.getUsersOfRoomWithoutKey', { rid }); export const e2eSetRoomKeyID = (rid: string, keyID: string) => // RC 0.70.0 - sdk.post('e2e.setRoomKeyID', { rid, keyID }); + sdk.post('/v1/e2e.setRoomKeyID', { rid, keyID }); export const e2eUpdateGroupKey = (uid: string, rid: string, key: string): any => // RC 0.70.0 - sdk.post('e2e.updateGroupKey', { uid, rid, key }); + sdk.post('/v1/e2e.updateGroupKey', { uid, rid, key }); export const e2eRequestRoomKey = (rid: string, e2eKeyId: string): Promise<{ message: { msg?: string }; success: boolean }> => // RC 0.70.0 sdk.methodCall('stream-notify-room-users', `${rid}/e2ekeyRequest`, rid, e2eKeyId); -export const e2eAcceptSuggestedGroupKey = (rid: string): Promise<{ success: boolean }> => - // RC 5.5 - sdk.post('e2e.acceptSuggestedGroupKey', { rid }); +// RC 5.5 +export const e2eAcceptSuggestedGroupKey = (rid: string) => sdk.post('/v1/e2e.acceptSuggestedGroupKey', { rid }); -export const e2eRejectSuggestedGroupKey = (rid: string): Promise<{ success: boolean }> => - // RC 5.5 - sdk.post('e2e.rejectSuggestedGroupKey', { rid }); +// RC 5.5 +export const e2eRejectSuggestedGroupKey = (rid: string) => sdk.post('/v1/e2e.rejectSuggestedGroupKey', { rid }); -export const fetchUsersWaitingForGroupKey = (roomIds: string[]) => sdk.get('e2e.fetchUsersWaitingForGroupKey', { roomIds }); +export const fetchUsersWaitingForGroupKey = (roomIds: string[]) => sdk.get('/v1/e2e.fetchUsersWaitingForGroupKey', { roomIds }); export const provideUsersSuggestedGroupKeys = (usersSuggestedGroupKeys: any) => - sdk.post('e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); + sdk.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); export const updateJitsiTimeout = (roomId: string) => // RC 0.74.0 - sdk.post('video-conference/jitsi.update-timeout', { roomId }); + sdk.post('/v1/video-conference/jitsi.update-timeout', { roomId }); export const register = (credentials: { name: string; email: string; pass: string; username: string }) => // RC 0.50.0 - sdk.post('users.register', credentials); + sdk.post('/v1/users.register', credentials); export const forgotPassword = (email: string) => // RC 0.64.0 - sdk.post('users.forgotPassword', { email }); + sdk.post('/v1/users.forgotPassword', { email }); export const sendConfirmationEmail = (email: string): Promise<{ success: boolean }> => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '8.0.0')) { - return sdk.post('users.sendConfirmationEmail', { email }); + return sdk.post('/v1/users.sendConfirmationEmail', { email }); } return sdk.methodCallWrapper('sendConfirmationEmail', email); @@ -135,7 +131,7 @@ export const spotlight = ( export const createDirectMessage = (username: string) => // RC 0.59.0 - sdk.post('im.create', { username }); + sdk.post('/v1/im.create', { username }); export const createDiscussion = ({ prid, @@ -153,7 +149,7 @@ export const createDiscussion = ({ encrypted?: boolean; }) => // RC 1.0.0 - sdk.post('rooms.createDiscussion', { + sdk.post('/v1/rooms.createDiscussion', { prid, pmid, t_name, @@ -180,7 +176,7 @@ export const getDiscussions = ({ ...(text && { text }) }; // RC 2.4.0 - return sdk.get('chat.getDiscussions', params); + return sdk.get('/v1/chat.getDiscussions', params); }; export const createTeam = ({ @@ -211,21 +207,19 @@ export const createTeam = ({ } }; // RC 3.13.0 - return sdk.post('teams.create', params); + return sdk.post('/v1/teams.create', params); }; export const addRoomsToTeam = ({ teamId, rooms }: { teamId: string; rooms: string[] }) => // RC 3.13.0 - sdk.post('teams.addRooms', { teamId, rooms }); + sdk.post('/v1/teams.addRooms', { teamId, rooms }); export const removeTeamRoom = ({ roomId, teamId }: { roomId: string; teamId: string }) => // RC 3.13.0 - sdk.post('teams.removeRoom', { roomId, teamId }); + sdk.post('/v1/teams.removeRoom', { roomId, teamId }); export const leaveTeam = ({ teamId, rooms }: { teamId: string; rooms: string[] }): any => // RC 3.13.0 - // TODO: missing definitions from server - // @ts-ignore - sdk.post('teams.leave', { + sdk.post('/v1/teams.leave', { teamId, // RC 4.2.0 ...(rooms?.length && { rooms }) @@ -233,7 +227,7 @@ export const leaveTeam = ({ teamId, rooms }: { teamId: string; rooms: string[] } export const removeTeamMember = ({ teamId, userId, rooms }: { teamId: string; userId: string; rooms: string[] }) => // RC 3.13.0 - sdk.post('teams.removeMember', { + sdk.post('/v1/teams.removeMember', { teamId, userId, // RC 4.2.0 @@ -242,17 +236,15 @@ export const removeTeamMember = ({ teamId, userId, rooms }: { teamId: string; us export const updateTeamRoom = ({ roomId, isDefault }: { roomId: string; isDefault: boolean }) => // RC 3.13.0 - sdk.post('teams.updateRoom', { roomId, isDefault }); + sdk.post('/v1/teams.updateRoom', { roomId, isDefault }); export const deleteTeam = ({ teamId, roomsToRemove }: { teamId: string; roomsToRemove: string[] }): any => // RC 3.13.0 - // TODO: missing definitions from server - // @ts-ignore - sdk.post('teams.delete', { teamId, roomsToRemove }); + sdk.post('/v1/teams.delete', { teamId, roomsToRemove }); export const teamListRoomsOfUser = ({ teamId, userId }: { teamId: string; userId: string }) => // RC 3.13.0 - sdk.get('teams.listRoomsOfUser', { teamId, userId }); + sdk.get('/v1/teams.listRoomsOfUser', { teamId, userId }); export const convertChannelToTeam = ({ rid, name, type }: { rid: string; name: string; type: 'c' | 'p' }) => { const serverVersion = reduxStore.getState().server.version; @@ -272,7 +264,7 @@ export const convertChannelToTeam = ({ rid, name, type }: { rid: string; name: s }; } - return sdk.post(type === 'c' ? 'channels.convertToTeam' : 'groups.convertToTeam', params); + return sdk.post(type === 'c' ? '/v1/channels.convertToTeam' : '/v1/groups.convertToTeam', params); }; export const convertTeamToChannel = ({ teamId, selected }: { teamId: string; selected: string[] }) => { @@ -280,7 +272,7 @@ export const convertTeamToChannel = ({ teamId, selected }: { teamId: string; sel teamId, ...(selected.length && { roomsToRemove: selected }) }; - return sdk.post('teams.convertToChannel', params); + return sdk.post('/v1/teams.convertToChannel', params); }; export const joinRoom = (roomId: string, joinCode: string | null, type: 'c' | 'p') => { @@ -288,54 +280,55 @@ export const joinRoom = (roomId: string, joinCode: string | null, type: 'c' | 'p if (type === 'p') { return sdk.methodCallWrapper('joinRoom', roomId) as Promise; } - return sdk.post('channels.join', { roomId, joinCode }); + const params = { roomId, ...(joinCode && { joinCode }) }; + return sdk.post('/v1/channels.join', params); }; export const deleteMessage = (messageId: string, rid: string) => // RC 0.48.0 - sdk.post('chat.delete', { msgId: messageId, roomId: rid }); + sdk.post('/v1/chat.delete', { msgId: messageId, roomId: rid }); export const markAsUnread = ({ messageId }: { messageId: string }) => // RC 0.65.0 - sdk.post('subscriptions.unread', { firstUnreadMessage: { _id: messageId } }); + sdk.post('/v1/subscriptions.unread', { firstUnreadMessage: { _id: messageId } }); export const toggleStarMessage = (messageId: string, starred?: boolean) => { if (starred) { // RC 0.59.0 - return sdk.post('chat.unStarMessage', { messageId }); + return sdk.post('/v1/chat.unStarMessage', { messageId }); } // RC 0.59.0 - return sdk.post('chat.starMessage', { messageId }); + return sdk.post('/v1/chat.starMessage', { messageId }); }; export const togglePinMessage = (messageId: string, pinned?: boolean) => { if (pinned) { // RC 0.59.0 - return sdk.post('chat.unPinMessage', { messageId }); + return sdk.post('/v1/chat.unPinMessage', { messageId }); } // RC 0.59.0 - return sdk.post('chat.pinMessage', { messageId }); + return sdk.post('/v1/chat.pinMessage', { messageId }); }; export const reportUser = (userId: string, description: string) => // RC 6.4.0 - sdk.post('moderation.reportUser', { userId, description }); + sdk.post('/v1/moderation.reportUser', { userId, description }); export const reportMessage = (messageId: string) => // RC 0.64.0 - sdk.post('chat.reportMessage', { messageId, description: 'Message reported by user' }); + sdk.post('/v1/chat.reportMessage', { messageId, description: 'Message reported by user' }); export const setUserPreferences = (userId: string, data: Partial) => // RC 0.62.0 - sdk.post('users.setPreferences', { userId, data }); + sdk.post('/v1/users.setPreferences', { userId, data }); export const setUserStatus = (status: string, message: string, expiresAt?: string | null) => // RC 1.2.0 - sdk.post('users.setStatus', { status, message, ...(expiresAt !== undefined && { expiresAt }) }); + sdk.post('/v1/users.setStatus', { status, message, ...(expiresAt !== undefined && { expiresAt }) }); export const setReaction = (emoji: string, messageId: string) => // RC 0.62.2 - sdk.post('chat.react', { emoji, messageId }); + sdk.post('/v1/chat.react', { emoji, messageId }); /** * Toggles the read status of a room. @@ -345,11 +338,7 @@ export const setReaction = (emoji: string, messageId: string) => * @param includeThreads - Optional flag to include threads when marking as read. * @returns A promise from the sdk post method. */ -export const toggleReadStatus = ( - isRead: boolean, - roomId: string, - includeThreads?: boolean -): Promise> => { +export const toggleReadStatus = (isRead: boolean, roomId: string, includeThreads?: boolean) => { let endpoint: keyof SubscriptionsEndpoints; let payload: OperationParams<'POST', keyof SubscriptionsEndpoints> = { roomId }; @@ -363,7 +352,7 @@ export const toggleReadStatus = ( } } - return sdk.post(endpoint, payload); + return sdk.post(`/v1/${endpoint}`, payload); }; export const getRoomCounters = ( @@ -371,26 +360,26 @@ export const getRoomCounters = ( t: SubscriptionType.CHANNEL | SubscriptionType.GROUP | SubscriptionType.OMNICHANNEL ) => // RC 0.65.0 - sdk.get(`${roomTypeToApiType(t)}.counters`, { roomId }); + sdk.get(`/v1/${roomTypeToApiType(t)}.counters`, { roomId }); export const getChannelInfo = (roomId: string) => // RC 0.48.0 - sdk.get('channels.info', { roomId }); + sdk.get('/v1/channels.info', { roomId }); -export const getUserPreferences = (userId: string) => +export const getUserPreferences = () => // RC 0.62.0 - sdk.get('users.getPreferences', { userId }); + sdk.get('/v1/users.getPreferences'); export const getRoomInfo = (roomId: string) => // RC 0.72.0 - sdk.get('rooms.info', { roomId }); + sdk.get('/v1/rooms.info', { roomId }); export const getRoomByTypeAndName = (roomType: RoomType, roomName: string): Promise => sdk.methodCallWrapper('getRoomByTypeAndName', roomType, roomName); export const getVisitorInfo = (visitorId: string) => // RC 2.3.0 - sdk.get('livechat/visitors.info', { visitorId }); + sdk.get('/v1/livechat/visitors.info', { visitorId }); export const setUserPresenceAway = () => sdk.methodCall('UserPresence:away'); @@ -420,7 +409,7 @@ export const getTeamListRoom = ({ params.filter = filter; } // RC 3.13.0 - return sdk.get('teams.listRooms', params); + return sdk.get('/v1/teams.listRooms', params); }; export const closeLivechat = (rid: string, comment?: string, tags?: string[]) => { @@ -431,7 +420,7 @@ export const closeLivechat = (rid: string, comment?: string, tags?: string[]) => params = { tags }; } if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '8.0.0')) { - return sdk.post('livechat/room.closeByUser', { rid, comment, ...params }); + return sdk.post('/v1/livechat/room.closeByUser', { rid, comment, ...params }); } // RC 0.29.0 return sdk.methodCallWrapper('livechat:closeRoom', rid, comment, { clientAction: true, ...params }); @@ -444,26 +433,26 @@ export const editLivechat = (userData: TParams, roomData: TParams): Promise<{ er return sdk.methodCallWrapper('livechat:saveInfo', userData, roomData); } // RC 5.3.0 - return sdk.post('livechat/room.saveInfo', { guestData: userData, roomData }) as any; + return sdk.post('/v1/livechat/room.saveInfo', { guestData: userData, roomData }) as any; }; export const returnLivechat = (rid: string, departmentId?: string): Promise => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '7.12.0')) { - return sdk.post('livechat/inquiries.returnAsInquiry', { roomId: rid, departmentId }); + return sdk.post('/v1/livechat/inquiries.returnAsInquiry', { roomId: rid, departmentId }); } // RC 0.72.0 return sdk.methodCallWrapper('livechat:returnAsInquiry', rid); }; -export const onHoldLivechat = (roomId: string) => sdk.post('livechat/room.onHold', { roomId }); +export const onHoldLivechat = (roomId: string) => sdk.post('/v1/livechat/room.onHold', { roomId }); export const forwardLivechat = (transferData: any) => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '8.0.0')) { - return sdk.post('livechat/room.forward', transferData); + return sdk.post('/v1/livechat/room.forward', transferData); } // RC 0.36.0 return sdk.methodCallWrapper('livechat:transfer', transferData); @@ -471,7 +460,7 @@ export const forwardLivechat = (transferData: any) => { export const getDepartmentInfo = (departmentId: string) => // RC 2.2.0 - sdk.get(`livechat/department/${departmentId}?includeAgents=false`); + sdk.get(`/v1/livechat/department/${departmentId}?includeAgents=false`); export const getDepartments = (args?: { count: number; offset: number; text: string }) => { let params; @@ -483,12 +472,12 @@ export const getDepartments = (args?: { count: number; offset: number; text: str }; } // RC 2.2.0 - return sdk.get('livechat/department', params); + return sdk.get('/v1/livechat/department', params); }; export const usersAutoComplete = (selector: any) => // RC 2.4.0 - sdk.get('users.autocomplete', { selector }); + sdk.get('/v1/users.autocomplete', { selector: JSON.stringify(selector) }); export const getRoutingConfig = async (): Promise<{ previewRoom: boolean; @@ -501,7 +490,7 @@ export const getRoutingConfig = async (): Promise<{ }> => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '7.11.0')) { - const result = await sdk.get('livechat/config/routing'); + const result = await sdk.get('/v1/livechat/config/routing'); if (result.success) { return result.config; } @@ -514,7 +503,7 @@ export const getRoutingConfig = async (): Promise<{ export const getTagsList = async (): Promise => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '8.0.0')) { - const result = await sdk.get('livechat/tags'); + const result = await sdk.get('/v1/livechat/tags'); if (result.success) { return result.tags || []; } @@ -526,11 +515,11 @@ export const getTagsList = async (): Promise => { export const getAgentDepartments = (uid: string) => // RC 2.4.0 - sdk.get(`livechat/agents/${uid}/departments?enabledDepartmentsOnly=true`); + sdk.get(`/v1/livechat/agents/${uid}/departments?enabledDepartmentsOnly=true`); export const getCustomFields = () => // RC 2.2.0 - sdk.get('livechat/custom-fields'); + sdk.get('/v1/livechat/custom-fields'); export const getListCannedResponse = ({ scope = '', departmentId = '', offset = 0, count = 25, text = '' }) => { const params = { @@ -542,7 +531,7 @@ export const getListCannedResponse = ({ scope = '', departmentId = '', offset = }; // RC 3.17.0 - return sdk.get('canned-responses', params); + return sdk.get('/v1/canned-responses', params); }; export const toggleBlockUser = (rid: string, blocked: string, block: boolean): Promise => { @@ -556,16 +545,16 @@ export const toggleBlockUser = (rid: string, blocked: string, block: boolean): P export const leaveRoom = (roomId: string, t: RoomTypes) => // RC 0.48.0 - sdk.post(`${roomTypeToApiType(t)}.leave`, { roomId }); + sdk.post(`/v1/${roomTypeToApiType(t)}.leave`, { roomId }); export const deleteRoom = (roomId: string, t: RoomTypes) => // RC 0.49.0 - sdk.post(`${roomTypeToApiType(t)}.delete`, { roomId }); + sdk.post(`/v1/${roomTypeToApiType(t)}.delete`, { roomId }); export const toggleMuteUserInRoom = (rid: string, username: string, userId: string, mute: boolean) => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '6.8.0')) { - return sdk.post(mute ? 'rooms.muteUser' : 'rooms.unmuteUser', { roomId: rid, userId }); + return sdk.post(mute ? '/v1/rooms.muteUser' : '/v1/rooms.unmuteUser', { roomId: rid, userId }); } // RC 0.51.0 return sdk.methodCallWrapper(mute ? 'muteUserInRoom' : 'unmuteUserInRoom', { rid, username }); @@ -585,10 +574,10 @@ export const toggleRoomOwner = ({ const type = t as SubscriptionType.CHANNEL; if (isOwner) { // RC 0.49.4 - return sdk.post(`${roomTypeToApiType(type)}.addOwner`, { roomId, userId }); + return sdk.post(`/v1/${roomTypeToApiType(type)}.addOwner`, { roomId, userId }); } // RC 0.49.4 - return sdk.post(`${roomTypeToApiType(type)}.removeOwner`, { roomId, userId }); + return sdk.post(`/v1/${roomTypeToApiType(type)}.removeOwner`, { roomId, userId }); }; export const toggleRoomLeader = ({ @@ -605,10 +594,10 @@ export const toggleRoomLeader = ({ const type = t as SubscriptionType.CHANNEL; if (isLeader) { // RC 0.58.0 - return sdk.post(`${roomTypeToApiType(type)}.addLeader`, { roomId, userId }); + return sdk.post(`/v1/${roomTypeToApiType(type)}.addLeader`, { roomId, userId }); } // RC 0.58.0 - return sdk.post(`${roomTypeToApiType(type)}.removeLeader`, { roomId, userId }); + return sdk.post(`/v1/${roomTypeToApiType(type)}.removeLeader`, { roomId, userId }); }; export const toggleRoomModerator = ({ @@ -625,33 +614,33 @@ export const toggleRoomModerator = ({ const type = t as SubscriptionType.CHANNEL; if (isModerator) { // RC 0.49.4 - return sdk.post(`${roomTypeToApiType(type)}.addModerator`, { roomId, userId }); + return sdk.post(`/v1/${roomTypeToApiType(type)}.addModerator`, { roomId, userId }); } // RC 0.49.4 - return sdk.post(`${roomTypeToApiType(type)}.removeModerator`, { roomId, userId }); + return sdk.post(`/v1/${roomTypeToApiType(type)}.removeModerator`, { roomId, userId }); }; export const removeUserFromRoom = ({ roomId, t, userId }: { roomId: string; t: RoomTypes; userId: string }) => // RC 0.48.0 - sdk.post(`${roomTypeToApiType(t)}.kick`, { roomId, userId }); + sdk.post(`/v1/${roomTypeToApiType(t)}.kick`, { roomId, userId }); export const ignoreUser = ({ rid, userId, ignore }: { rid: string; userId: string; ignore: boolean }) => // RC 0.64.0 - sdk.get('chat.ignoreUser', { rid, userId, ignore }); + sdk.get('/v1/chat.ignoreUser', { rid, userId, ignore }); export const toggleArchiveRoom = (roomId: string, t: SubscriptionType, archive: boolean) => { const type = t as SubscriptionType.CHANNEL | SubscriptionType.GROUP; if (archive) { // RC 0.48.0 - return sdk.post(`${roomTypeToApiType(type)}.archive`, { roomId }); + return sdk.post(`/v1/${roomTypeToApiType(type)}.archive`, { roomId }); } // RC 0.48.0 - return sdk.post(`${roomTypeToApiType(type)}.unarchive`, { roomId }); + return sdk.post(`/v1/${roomTypeToApiType(type)}.unarchive`, { roomId }); }; export const hideRoom = (roomId: string, t: RoomTypes) => // RC 0.48.0 - sdk.post(`${roomTypeToApiType(t)}.close`, { roomId }); + sdk.post(`/v1/${roomTypeToApiType(t)}.close`, { roomId }); export const saveRoomSettings = ( rid: string, @@ -687,33 +676,33 @@ export const saveUserProfile = ( customFields?: { [key: string | number]: string } ) => // RC 0.62.2 - sdk.post('users.updateOwnBasicInfo', { data, customFields }); + sdk.post('/v1/users.updateOwnBasicInfo', { data, customFields }); export const saveUserPreferences = (data: Partial) => // RC 0.62.0 - sdk.post('users.setPreferences', { data }); + sdk.post('/v1/users.setPreferences', { data }); export const saveNotificationSettings = (roomId: string, notifications: IRoomNotifications) => // RC 0.63.0 - sdk.post('rooms.saveNotification', { roomId, notifications }); + sdk.post('/v1/rooms.saveNotification', { roomId, notifications }); export const getSingleMessage = (msgId: string) => // RC 0.47.0 - sdk.get('chat.getMessage', { msgId }); + sdk.get('/v1/chat.getMessage', { msgId }); export const getRoomRoles = ( roomId: string, type: SubscriptionType.CHANNEL | SubscriptionType.GROUP | SubscriptionType.OMNICHANNEL ) => // RC 0.65.0 - sdk.get(`${roomTypeToApiType(type)}.roles`, { roomId }); + sdk.get(`/v1/${roomTypeToApiType(type)}.roles`, { roomId }); export const getAvatarSuggestion = async (): Promise<{ [service: string]: IAvatarSuggestion }> => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '5.4.0')) { // RC 5.4.0 - const result = await sdk.get('users.getAvatarSuggestion'); + const result = await sdk.get('/v1/users.getAvatarSuggestion'); if (result.success && 'suggestions' in result) { return result.suggestions; } @@ -726,7 +715,7 @@ export const getAvatarSuggestion = async (): Promise<{ [service: string]: IAvata export const resetAvatar = (userId: string) => // RC 0.55.0 - sdk.post('users.resetAvatar', { userId }); + sdk.post('/v1/users.resetAvatar', { userId }); const isHttpAvatarUrl = (value: string | undefined): value is string => typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://')); @@ -755,7 +744,7 @@ export const setAvatarFromService = async ({ // RC 8.0.0 — REST users.setAvatar (multipart image or JSON avatarUrl) if (service === 'url' && typeof data === 'string') { - await sdk.post('users.setAvatar', { avatarUrl: data }); + await sdk.post('/v1/users.setAvatar', { avatarUrl: data }); return; } @@ -765,7 +754,7 @@ export const setAvatarFromService = async ({ } if (isHttpUrl) { - await sdk.post('users.setAvatar', { avatarUrl: url }); + await sdk.post('/v1/users.setAvatar', { avatarUrl: url }); return; } @@ -774,15 +763,15 @@ export const setAvatarFromService = async ({ export const getUsernameSuggestion = () => // RC 0.65.0 - sdk.get('users.getUsernameSuggestion'); + sdk.get('/v1/users.getUsernameSuggestion'); export const getFiles = (roomId: string, type: SubscriptionType, offset: number) => { const t = type as SubscriptionType.DIRECT | SubscriptionType.CHANNEL | SubscriptionType.GROUP; // RC 0.59.0 - return sdk.get(`${roomTypeToApiType(t)}.files`, { + return sdk.get(`/v1/${roomTypeToApiType(t)}.files`, { roomId, offset, - sort: { uploadedAt: -1 } + sort: JSON.stringify({ uploadedAt: -1 }) }); }; @@ -808,7 +797,7 @@ export const getMessages = ({ const params: any = { roomId, offset, - sort: { ts: -1 } + sort: JSON.stringify({ ts: -1 }) }; if (mentionIds && mentionIds.length > 0) { @@ -823,32 +812,37 @@ export const getMessages = ({ params.pinned = pinned; } - return sdk.get(`${roomTypeToApiType(t)}.messages`, params); + return sdk.get(`/v1/${roomTypeToApiType(t)}.messages`, params); } const params: any = { roomId, offset, - sort: { ts: -1 } + sort: JSON.stringify({ ts: -1 }) }; + const query: any = {}; if (mentionIds && mentionIds.length > 0) { - params.query = { ...params.query, 'mentions._id': { $in: mentionIds } }; + query['mentions._id'] = { $in: mentionIds }; } if (starredIds && starredIds.length > 0) { - params.query = { ...params.query, 'starred._id': { $in: starredIds } }; + query['starred._id'] = { $in: starredIds }; } if (pinned) { - params.query = { ...params.query, pinned: true }; + query.pinned = true; + } + + if (Object.keys(query).length > 0) { + params.query = JSON.stringify(query); } // RC 0.59.0 - return sdk.get(`${roomTypeToApiType(t)}.messages`, params); + return sdk.get(`/v1/${roomTypeToApiType(t)}.messages`, params); }; export const getPinnedMessages = ({ roomId, offset, count }: { roomId: string; offset: number; count: number }) => - sdk.get('chat.getPinnedMessages', { + sdk.get('/v1/chat.getPinnedMessages', { roomId, offset, count @@ -856,13 +850,13 @@ export const getPinnedMessages = ({ roomId, offset, count }: { roomId: string; o export const getReadReceipts = (messageId: string) => // RC 0.63.0 - sdk.get('chat.getMessageReadReceipts', { + sdk.get('/v1/chat.getMessageReadReceipts', { messageId }); export const searchMessages = (roomId: string, searchText: string, count: number, offset: number) => // RC 0.60.0 - sdk.get('chat.search', { + sdk.get('/v1/chat.search', { roomId, searchText, count, @@ -872,9 +866,9 @@ export const searchMessages = (roomId: string, searchText: string, count: number export const toggleFollowMessage = (mid: string, follow: boolean) => { // RC 1.0 if (follow) { - return sdk.post('chat.followMessage', { mid }); + return sdk.post('/v1/chat.followMessage', { mid }); } - return sdk.post('chat.unfollowMessage', { mid }); + return sdk.post('/v1/chat.unfollowMessage', { mid }); }; export const getThreadsList = ({ rid, count, offset, text }: { rid: string; count: number; offset: number; text?: string }) => { @@ -888,19 +882,19 @@ export const getThreadsList = ({ rid, count, offset, text }: { rid: string; coun } // RC 1.0 - return sdk.get('chat.getThreadsList', params); + return sdk.get('/v1/chat.getThreadsList', params); }; export const getSyncThreadsList = ({ rid, updatedSince }: { rid: string; updatedSince: string }) => // RC 1.0 - sdk.get('chat.syncThreadsList', { + sdk.get('/v1/chat.syncThreadsList', { rid, updatedSince }); export const runSlashCommand = (command: string, roomId: string, params: string, triggerId?: string, tmid?: string) => // RC 0.60.2 - sdk.post('commands.run', { + sdk.post('/v1/commands.run', { command, roomId, params, @@ -910,7 +904,7 @@ export const runSlashCommand = (command: string, roomId: string, params: string, export const getCommandPreview = (command: string, roomId: string, params: string) => // RC 0.65.0 - sdk.get('commands.preview', { + sdk.get('/v1/commands.preview', { command, roomId, params @@ -925,7 +919,7 @@ export const executeCommandPreview = ( tmid?: string ) => // RC 0.65.0 - sdk.post('commands.preview', { + sdk.post('/v1/commands.preview', { command, params, roomId, @@ -953,16 +947,16 @@ export const getDirectory = ({ const params: any = { count, offset, - sort + sort: JSON.stringify(sort) }; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '7.0.0')) { params.text = text; params.type = type; params.workspace = workspace; } else { - params.query = { text, type, workspace }; + params.query = JSON.stringify({ text, type, workspace }); } - return sdk.get('directory', params); + return sdk.get('/v1/directory', params); }; export const saveAutoTranslate = ({ @@ -981,25 +975,19 @@ export const getSupportedLanguagesAutoTranslate = (): Promise<{ language: string sdk.methodCallWrapper('autoTranslate.getSupportedLanguages', 'en'); export const translateMessage = (messageId: string, targetLanguage: string) => - sdk.post('autotranslate.translateMessage', { messageId, targetLanguage }); + sdk.post('/v1/autotranslate.translateMessage', { messageId, targetLanguage }); -export const findOrCreateInvite = ({ rid, days, maxUses }: { rid: string; days: number; maxUses: number }): any => +export const findOrCreateInvite = ({ rid, days, maxUses }: { rid: string; days: number; maxUses: number }) => // RC 2.4.0 - // TODO: missing definitions from server - // @ts-ignore - sdk.post('findOrCreateInvite', { rid, days, maxUses }); + sdk.post('/v1/findOrCreateInvite', { rid, days, maxUses }); -export const validateInviteToken = (token: string): any => +export const validateInviteToken = (token: string) => // RC 2.4.0 - // TODO: missing definitions from server - // @ts-ignore - sdk.post('validateInviteToken', { token }); + sdk.post('/v1/validateInviteToken', { token }); export const inviteToken = (token: string): any => // RC 2.4.0 - // TODO: missing definitions from server - // @ts-ignore - sdk.post('useInviteToken', { token }); + sdk.post('/v1/useInviteToken', { token }); export const readThreads = (tmid: string): Promise => { const serverVersion = reduxStore.getState().server.version; @@ -1015,7 +1003,7 @@ export const createGroupChat = () => { const usernames = users.map(u => u.name).join(','); // RC 3.1.0 - return sdk.post('im.create', { usernames }); + return sdk.post('/v1/im.create', { usernames }); }; export const addUsersToRoom = (rid: string): Promise => { @@ -1042,12 +1030,11 @@ export function e2eResetOwnKey(): Promise<{ success?: boolean }> { unsubscribeRooms(); // RC 3.6.0 - return sdk.post('users.resetE2EKey'); + return sdk.post('/v1/users.resetE2EKey'); } -export function e2eResetRoomKey(rid: string, e2eKey: string, e2eKeyId: string): Promise { - // RC ? - return sdk.post('e2e.resetRoomKey', { rid, e2eKey, e2eKeyId }); +export function e2eResetRoomKey(rid: string, e2eKey: string, e2eKeyId: string) { + return sdk.post('/v1/e2e.resetRoomKey', { rid, e2eKey, e2eKeyId }); } export const editMediaMessage = async ( @@ -1055,16 +1042,15 @@ export const editMediaMessage = async ( fileId: string, body: { description?: string; filename: string; msg?: string } ) => { - const { login, server } = reduxStore.getState(); - const { user } = login; + const serverUrl = sdk.server; + if (!serverUrl) { + throw new Error('SDK not initialized'); + } // RC 8.4.0 - const response = await fetch(`${server.server}/api/v1/rooms.mediaConfirm/${rid}/${fileId}`, { + const response = await fetch(`${serverUrl}/api/v1/rooms.mediaConfirm/${rid}/${fileId}`, { method: 'POST', headers: { - ...RocketChatSettings.customHeaders, - 'Content-Type': 'application/json', - 'X-Auth-Token': user.token, - 'X-User-Id': user.id + 'Content-Type': 'application/json' }, body: JSON.stringify({ description: body.description, @@ -1095,7 +1081,7 @@ export const editMessage = async (message: Pick => { try { // RC 0.60.0 - await sdk.post('push.token', data); + await sdk.post('/v1/push.token', data); lastToken = token; lastVoipToken = voipToken; } catch (e) { @@ -1173,25 +1155,30 @@ export const registerPushToken = async (): Promise => { }; // TODO: add voip token removal -export const removePushToken = (): Promise => { +export const removePushToken = async (): Promise => { const token = getDeviceToken(); if (token) { lastToken = ''; lastVoipToken = ''; - // RC 0.60.0 - return sdk.current.del('push.token', { token }); + try { + // RC 0.60.0 + const response = await sdk.delete('/v1/push.token', { token }); + return response?.success === true; + } catch (e) { + log(e); + return false; + } } - return Promise.resolve(); }; // RC 6.6.0 -export const pushTest = () => sdk.post('push.test'); +export const pushTest = () => sdk.post('/v1/push.test'); // RC 6.5.0 -export const pushInfo = () => sdk.get('push.info'); +export const pushInfo = () => sdk.get('/v1/push.info'); // RC 3.1.0 -export const sendEmailCode = (emailOrUsername: string) => sdk.post('users.2fa.sendEmailCode', { emailOrUsername }); +export const sendEmailCode = (emailOrUsername: string) => sdk.post('/v1/users.2fa.sendEmailCode', { emailOrUsername }); export const getRoomMembers = async ({ rid, @@ -1221,9 +1208,9 @@ export const getRoomMembers = async ({ ...(filter && { filter }) }; // RC 3.16.0 - const result = await sdk.get(`${roomTypeToApiType(t)}.members`, params); + const result = await sdk.get(`/v1/${roomTypeToApiType(t)}.members`, params); if (result.success) { - return result?.members; + return result.members; } } // RC 0.42.0 @@ -1233,46 +1220,47 @@ export const getRoomMembers = async ({ export const e2eFetchMyKeys = async () => { // RC 0.70.0 - const result = await sdk.get('e2e.fetchMyKeys'); + const result = await sdk.get('/v1/e2e.fetchMyKeys'); // snake_case -> camelCase - if (result.success) { + if (result?.success === true && result.public_key && result.private_key) { return { - success: result.success, + success: true, publicKey: result.public_key, privateKey: result.private_key }; } - return result; + return { success: false }; }; export const logoutOtherLocations = () => { const { id } = reduxStore.getState().login.user; - return sdk.post('users.removeOtherTokens', { userId: id as string }); + return sdk.post('/v1/users.removeOtherTokens', { userId: id as string }); }; export function getUserInfo(userId: string) { // RC 0.48.0 - return sdk.get('users.info', { userId }); + return sdk.get('/v1/users.info', { userId }); } -export const toggleFavorite = (roomId: string, favorite: boolean) => sdk.post('rooms.favorite', { roomId, favorite }); +export const toggleFavorite = (roomId: string, favorite: boolean) => sdk.post('/v1/rooms.favorite', { roomId, favorite }); -export const sendInvitationReply = (roomId: string, action: 'accept' | 'reject') => sdk.post('rooms.invite', { roomId, action }); +export const sendInvitationReply = (roomId: string, action: 'accept' | 'reject') => + sdk.post('/v1/rooms.invite', { roomId, action }); export const videoConferenceJoin = (callId: string, cam?: boolean, mic?: boolean) => - sdk.post('video-conference.join', { callId, state: { cam: !!cam, mic: mic === undefined ? true : mic } }); + sdk.post('/v1/video-conference.join', { callId, state: { cam: !!cam, mic: mic === undefined ? true : mic } }); -export const videoConferenceGetCapabilities = () => sdk.get('video-conference.capabilities'); +export const videoConferenceGetCapabilities = () => sdk.get('/v1/video-conference.capabilities'); -export const videoConferenceStart = (roomId: string) => sdk.post('video-conference.start', { roomId, allowRinging: true }); +export const videoConferenceStart = (roomId: string) => sdk.post('/v1/video-conference.start', { roomId, allowRinging: true }); -export const videoConferenceCancel = (callId: string) => sdk.post('video-conference.cancel', { callId }); +export const videoConferenceCancel = (callId: string) => sdk.post('/v1/video-conference.cancel', { callId }); export const deleteOwnAccount = (password: string, confirmRelinquish = false): any => // RC 0.67.0 - sdk.post('users.deleteOwnAccount', { password, confirmRelinquish }); + sdk.post('/v1/users.deleteOwnAccount', { password, confirmRelinquish }); -export const postMessage = (roomId: string, text: string) => sdk.post('chat.postMessage', { roomId, text }); +export const postMessage = (roomId: string, text: string) => sdk.post('/v1/chat.postMessage', { roomId, text }); export const notifyUser = (type: string, params: Record): Promise => sdk.methodCall('stream-notify-user', type, params); @@ -1281,7 +1269,7 @@ export const getUsersRoles = async (): Promise => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '7.10.0')) { // RC 7.10.0 - const response = await sdk.get('roles.getUsersInPublicRoles'); + const response = await sdk.get('/v1/roles.getUsersInPublicRoles'); if (response.success) { return response.users; } @@ -1294,12 +1282,9 @@ export const getUsersRoles = async (): Promise => { export const getSupportedVersionsCloud = (uniqueId?: string, domain?: string) => fetch(`https://releases.rocket.chat/v2/server/supportedVersions?uniqueId=${uniqueId}&domain=${domain}&source=mobile`); -export const mediaCallsStateSignals = async (contractId: string): Promise<{ signals: ServerMediaSignal[]; success: boolean }> => { +export const mediaCallsStateSignals = async (contractId: string) => { try { - const result = await ( - sdk.get as unknown as (path: string, params?: object) => Promise<{ signals: ServerMediaSignal[]; success: boolean }> - )('media-calls.stateSignals', { contractId }); - return result; + return await sdk.get('/v1/media-calls.stateSignals', { contractId }); } catch { return { signals: [], success: false }; } diff --git a/app/lib/services/sdk.test.ts b/app/lib/services/sdk.test.ts index f82d0df02dc..0d73b158c18 100644 --- a/app/lib/services/sdk.test.ts +++ b/app/lib/services/sdk.test.ts @@ -1,88 +1,1065 @@ -import sdk from './sdk'; +/* eslint-disable import/first */ +jest.mock('./twoFactor', () => ({ + twoFactor: jest.fn() +})); -const mockInnerMethodCall = jest.fn(); -const mockTwoFactor = jest.fn(); +jest.mock('../store/auxStore', () => ({ + store: { + getState: jest.fn(() => ({ + settings: { API_Use_REST_For_DDP_Calls: false }, + login: { user: { id: 'u1', token: 't1' } }, + server: { version: '6.0.0' } + })), + dispatch: jest.fn() + } +})); -jest.mock('@rocket.chat/sdk', () => ({ - Rocketchat: jest.fn().mockImplementation(() => ({ - methodCall: (...args: unknown[]) => mockInnerMethodCall(...args) - })), - settings: { customHeaders: {} } +jest.mock('../methods/userPreferences', () => ({ + __esModule: true, + default: { + getString: jest.fn(), + setString: jest.fn(), + removeItem: jest.fn() + } })); -jest.mock('./twoFactor', () => ({ - twoFactor: (...args: unknown[]) => mockTwoFactor(...args) +jest.mock('../methods/helpers/log', () => ({ __esModule: true, default: jest.fn() })); + +jest.mock('@rocket.chat/ddp-client', () => ({ + DDPSDK: { + create: jest.fn(), + createAndConnect: jest.fn() + } })); +import { DDPSDK } from '@rocket.chat/ddp-client'; + +import sdk, { NOTIFY_USER_EVENTS, normalizeResponseError } from './sdk'; +import { twoFactor } from './twoFactor'; +import { store as reduxStore } from '../store/auxStore'; +import UserPreferences from '../methods/userPreferences'; + +const setInternalSdk = (value: any) => { + (sdk as unknown as { sdk: any }).sdk = value; +}; + +const buildFakeConnection = (overrides: Partial<{ probe: jest.Mock; reopenNow: jest.Mock; close: jest.Mock }> = {}) => ({ + probe: jest.fn().mockResolvedValue(true), + reopenNow: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + ...overrides +}); + beforeEach(() => { - jest.clearAllMocks(); - sdk.initialize('https://example.com'); + (twoFactor as jest.Mock).mockReset(); + (reduxStore.getState as jest.Mock).mockReset().mockReturnValue({ + settings: { API_Use_REST_For_DDP_Calls: false }, + login: { user: { id: 'u1', token: 't1' } }, + server: { version: '6.0.0' } + }); + (UserPreferences.getString as jest.Mock).mockReset(); + (UserPreferences.setString as jest.Mock).mockReset(); + (UserPreferences.removeItem as jest.Mock).mockReset(); + (DDPSDK.create as jest.Mock).mockReset(); + (DDPSDK.createAndConnect as jest.Mock).mockReset(); + (sdk as any).serverUrl = undefined; +}); + +afterEach(() => { + setInternalSdk(undefined); + jest.useRealTimers(); +}); + +describe('Sdk.probe', () => { + it('returns false when sdk.current is undefined', async () => { + setInternalSdk(undefined); + await expect(sdk.probe()).resolves.toBe(false); + }); + + it('delegates to connection.probe() and forwards the timeout', async () => { + const connection = buildFakeConnection(); + setInternalSdk({ connection }); + await expect(sdk.probe(1500)).resolves.toBe(true); + expect(connection.probe).toHaveBeenCalledWith(1500); + }); + + it('returns the result from connection.probe()', async () => { + const connection = buildFakeConnection({ probe: jest.fn().mockResolvedValue(false) }); + setInternalSdk({ connection }); + await expect(sdk.probe()).resolves.toBe(false); + }); +}); + +describe('Sdk.reopenNow', () => { + it('resolves when sdk.current is undefined', async () => { + setInternalSdk(undefined); + await expect(sdk.reopenNow()).resolves.toBeUndefined(); + }); + + it('delegates to connection.reopenNow()', async () => { + const connection = buildFakeConnection(); + setInternalSdk({ connection }); + await expect(sdk.reopenNow()).resolves.toBeUndefined(); + expect(connection.reopenNow).toHaveBeenCalledTimes(1); + }); +}); + +describe('Sdk.subscribeNotifyUser', () => { + const buildFakeSdkWithSubscribe = (user: { id: string } | undefined) => { + const subscribe = jest.fn((_name: string, _key: string) => ({ id: `sub-${_key}`, stop: jest.fn() })); + return { + client: { subscribe }, + account: { user }, + __subscribe: subscribe + }; + }; + + it('returns [] when sdk.current is undefined', () => { + setInternalSdk(undefined); + expect(sdk.subscribeNotifyUser()).toEqual([]); + }); + + it('returns [] when no logged-in user and no userId arg', () => { + setInternalSdk(buildFakeSdkWithSubscribe(undefined)); + expect(sdk.subscribeNotifyUser()).toEqual([]); + }); + + it('subscribes to all 10 user-notify events using the logged-in user id', () => { + const fake = buildFakeSdkWithSubscribe({ id: 'user-42' }); + setInternalSdk(fake); + const handles = sdk.subscribeNotifyUser(); + expect(handles).toHaveLength(NOTIFY_USER_EVENTS.length); + NOTIFY_USER_EVENTS.forEach(event => { + expect(fake.__subscribe).toHaveBeenCalledWith('stream-notify-user', `user-42/${event}`); + }); + }); + + it('includes the patched media-signal and media-calls events', () => { + const fake = buildFakeSdkWithSubscribe({ id: 'user-42' }); + setInternalSdk(fake); + sdk.subscribeNotifyUser(); + expect(fake.__subscribe).toHaveBeenCalledWith('stream-notify-user', 'user-42/media-signal'); + expect(fake.__subscribe).toHaveBeenCalledWith('stream-notify-user', 'user-42/media-calls'); + }); + + it('prefers the explicit userId argument over the logged-in user id', () => { + const fake = buildFakeSdkWithSubscribe({ id: 'logged-in-user' }); + setInternalSdk(fake); + sdk.subscribeNotifyUser('override-user'); + expect(fake.__subscribe).toHaveBeenCalledWith('stream-notify-user', 'override-user/message'); + expect(fake.__subscribe).not.toHaveBeenCalledWith('stream-notify-user', 'logged-in-user/message'); + }); + + it('uses the explicit userId even when no user is logged in', () => { + // Guards the reconnect/reopenNow timing gap: callers pass userId explicitly + // so subscriptions are established even before account.user is re-populated. + const fake = buildFakeSdkWithSubscribe(undefined); + setInternalSdk(fake); + const handles = sdk.subscribeNotifyUser('explicit-user'); + expect(handles).toHaveLength(NOTIFY_USER_EVENTS.length); + expect(fake.__subscribe).toHaveBeenCalledWith('stream-notify-user', 'explicit-user/message'); + }); }); -describe('sdk.methodCall', () => { - it('no 2FA in progress → exact args, no trailing junk', async () => { - mockInnerMethodCall.mockResolvedValue('ok'); +describe('Sdk.login', () => { + const buildFakeSdkWithLogin = ( + postResult: any, + loginWithToken = jest.fn().mockResolvedValue(undefined), + logout = jest.fn().mockResolvedValue(undefined) + ) => { + const post = jest.fn().mockResolvedValue(postResult); + return { + client: { ddp: {} }, + connection: buildFakeConnection(), + account: { loginWithToken, logout }, + rest: { post, handleTwoFactorChallenge: jest.fn() }, + __post: post, + __loginWithToken: loginWithToken + }; + }; + + it('rejects when sdk is not initialized', async () => { + setInternalSdk(undefined); + await expect(sdk.login({ user: 'test', password: 'test' })).rejects.toThrow(); + }); + + it('calls loginWithToken with the authToken from the REST response', async () => { + const fake = buildFakeSdkWithLogin({ + status: 'success', + data: { authToken: 'tok-abc', userId: 'uid-1', me: { username: 'john' } } + }); + setInternalSdk(fake); + const result = await sdk.login({ user: 'john', password: 'secret' }); + expect(fake.__loginWithToken).toHaveBeenCalledWith('tok-abc'); + expect(result.authToken).toBe('tok-abc'); + }); + + it('rejects when the REST response has status: error', async () => { + const fake = buildFakeSdkWithLogin({ status: 'error' }); + setInternalSdk(fake); + await expect(sdk.login({ user: 'john', password: 'wrong' })).rejects.toThrow('Invalid response from server'); + }); + + it('rejects and normalizes a raw fetch Response error', async () => { + const fake = buildFakeSdkWithLogin(null); + fake.__post.mockRejectedValue(new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })); + setInternalSdk(fake); + const err: any = await sdk.login({ user: 'john', password: 'bad' }).catch(e => e); + expect(err.status).toBe(401); + expect(err.data.error).toBe('Unauthorized'); + }); + + it('sets X-Auth-Token and X-User-Id in getHeaders after successful login', async () => { + const fake = buildFakeSdkWithLogin({ + status: 'success', + data: { authToken: 'tok-abc', userId: 'uid-1', me: { username: 'john' } } + }); + setInternalSdk(fake); + await sdk.login({ user: 'john', password: 'secret' }); + const headers = sdk.getHeaders(); + expect(headers['X-Auth-Token']).toBe('tok-abc'); + expect(headers['X-User-Id']).toBe('uid-1'); + }); + + it('clears X-Auth-Token and X-User-Id from getHeaders after logout', async () => { + const fake = buildFakeSdkWithLogin({ + status: 'success', + data: { authToken: 'tok-abc', userId: 'uid-1', me: { username: 'john' } } + }); + setInternalSdk(fake); + await sdk.login({ user: 'john', password: 'secret' }); + await sdk.logout(); + const headers = sdk.getHeaders(); + expect(headers).not.toHaveProperty('X-Auth-Token'); + expect(headers).not.toHaveProperty('X-User-Id'); + }); + + it('sets the auth headers from a resume-token (deep link) login response', async () => { + // Deep link logins reach sdk.login with `{ resume }` instead of `{ user, password }`, + // but the header centralization reads authToken/userId from the same /v1/login response. + const fake = buildFakeSdkWithLogin({ + status: 'success', + data: { authToken: 'tok-deep', userId: 'uid-deep', me: { username: 'jane' } } + }); + setInternalSdk(fake); + await sdk.login({ resume: 'resume-token-xyz' }); + expect(fake.__post).toHaveBeenCalledWith( + '/v1/login', + { resume: 'resume-token-xyz' }, + expect.objectContaining({ headers: expect.any(Object) }) + ); + const headers = sdk.getHeaders(); + expect(headers['X-Auth-Token']).toBe('tok-deep'); + expect(headers['X-User-Id']).toBe('uid-deep'); + }); + + it('uses each server own token/user after switching servers', async () => { + // Switching servers goes through initialize(), which resets headers to defaults. + // Login on server1 → token1/user1; re-initialize for server2 → headers cleared; + // login on server2 → token2/user2 (server1 credentials never leak). + const server1Sdk = buildFakeSdkWithLogin({ + status: 'success', + data: { authToken: 'tok-1', userId: 'uid-1', me: { username: 'user1' } } + }); + (DDPSDK.create as jest.Mock).mockReturnValueOnce(server1Sdk); + sdk.initialize('https://server1.com'); + await sdk.login({ user: 'user1', password: 'pass1' }); + expect(sdk.getHeaders()['X-Auth-Token']).toBe('tok-1'); + expect(sdk.getHeaders()['X-User-Id']).toBe('uid-1'); + + const server2Sdk = buildFakeSdkWithLogin({ + status: 'success', + data: { authToken: 'tok-2', userId: 'uid-2', me: { username: 'user2' } } + }); + (DDPSDK.create as jest.Mock).mockReturnValueOnce(server2Sdk); + sdk.initialize('https://server2.com'); + // initialize() must wipe server1 credentials before the new login. + expect(sdk.getHeaders()['X-Auth-Token']).toBeUndefined(); + expect(sdk.getHeaders()['X-User-Id']).toBeUndefined(); + + await sdk.login({ user: 'user2', password: 'pass2' }); + expect(sdk.getHeaders()['X-Auth-Token']).toBe('tok-2'); + expect(sdk.getHeaders()['X-User-Id']).toBe('uid-2'); + }); + + it('does not write stale headers if the server switches while loginWithToken() is still in flight', async () => { + let resolveLoginWithToken: () => void = () => {}; + const loginWithToken = jest.fn( + () => + new Promise(resolve => { + resolveLoginWithToken = resolve; + }) + ); + const server1Sdk = buildFakeSdkWithLogin( + { status: 'success', data: { authToken: 'tok-1', userId: 'uid-1', me: { username: 'user1' } } }, + loginWithToken + ); + (DDPSDK.create as jest.Mock).mockReturnValueOnce(server1Sdk); + sdk.initialize('https://server1.com'); + + // server1's login suspends inside the loginWithToken() await. + const loginPromise = sdk.login({ user: 'user1', password: 'pass1' }); - const result = await sdk.methodCall('loadSurroundingMessages', { messageId: 'x' }, false); + // The app switches to server2 while server1's login is still in flight. + const server2Sdk = buildFakeSdkWithLogin({ + status: 'success', + data: { authToken: 'tok-2', userId: 'uid-2', me: { username: 'user2' } } + }); + (DDPSDK.create as jest.Mock).mockReturnValueOnce(server2Sdk); + sdk.initialize('https://server2.com'); + await sdk.login({ user: 'user2', password: 'pass2' }); + expect(sdk.getHeaders()['X-Auth-Token']).toBe('tok-2'); - expect(result).toBe('ok'); - expect(mockInnerMethodCall).toHaveBeenCalledWith('loadSurroundingMessages', { messageId: 'x' }, false); - expect(mockInnerMethodCall.mock.calls[0]).toHaveLength(3); + // server1's suspended login now resumes — it must reject, not clobber server2's headers. + resolveLoginWithToken(); + await expect(loginPromise).rejects.toThrow('Server switched during login'); + expect(sdk.getHeaders()['X-Auth-Token']).toBe('tok-2'); + expect(sdk.getHeaders()['X-User-Id']).toBe('uid-2'); }); - it('totp-required → prompt, retry with the code appended', async () => { - const totpObject = { twoFactorCode: 'CODE', twoFactorMethod: 'totp' }; - mockInnerMethodCall.mockRejectedValueOnce({ - error: 'totp-required', - details: { method: 'totp' } + it('switches to the deep link server credentials when already logged in on another server', async () => { + // Already authenticated on server1. + const server1Sdk = buildFakeSdkWithLogin({ + status: 'success', + data: { authToken: 'tok-1', userId: 'uid-1', me: { username: 'user1' } } }); - mockInnerMethodCall.mockResolvedValueOnce('ok'); - mockTwoFactor.mockResolvedValue(totpObject); + (DDPSDK.create as jest.Mock).mockReturnValueOnce(server1Sdk); + sdk.initialize('https://server1.com'); + await sdk.login({ user: 'user1', password: 'pass1' }); + expect(sdk.getHeaders()['X-Auth-Token']).toBe('tok-1'); + + // A deep link targeting server2 re-initializes on the new host, then logs in with the resume token. + const server2Sdk = buildFakeSdkWithLogin({ + status: 'success', + data: { authToken: 'tok-2', userId: 'uid-2', me: { username: 'user2' } } + }); + (DDPSDK.create as jest.Mock).mockReturnValueOnce(server2Sdk); + sdk.initialize('https://server2.com'); + await sdk.login({ resume: 'deeplink-resume-token' }); + + const headers = sdk.getHeaders(); + expect(headers['X-Auth-Token']).toBe('tok-2'); + expect(headers['X-User-Id']).toBe('uid-2'); + // server1 credentials must not survive the deep link switch. + expect(headers['X-Auth-Token']).not.toBe('tok-1'); + expect(headers['X-User-Id']).not.toBe('uid-1'); + }); +}); + +describe('Sdk.logout', () => { + it('logs when the account.logout() call times out instead of completing', async () => { + jest.useFakeTimers(); + const log = jest.requireMock('../methods/helpers/log').default; + const fake = { account: { logout: jest.fn(() => new Promise(() => {})) } }; // never resolves + setInternalSdk(fake); + + const logoutPromise = sdk.logout(); + jest.advanceTimersByTime(5000); + await logoutPromise; + + expect(log).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('timed out') })); + jest.useRealTimers(); + }); + + it('still clears auth headers when account.logout() throws', async () => { + const log = jest.requireMock('../methods/helpers/log').default; + const fake = { account: { logout: jest.fn().mockRejectedValue(new Error('socket closed')) } }; + setInternalSdk(fake); + (sdk as any).headers = { ...(sdk as any).headers, 'X-Auth-Token': 'tok-abc', 'X-User-Id': 'uid-1' }; + + await sdk.logout(); + + expect(log).toHaveBeenCalledWith(expect.objectContaining({ message: 'socket closed' })); + const headers = sdk.getHeaders(); + expect(headers).not.toHaveProperty('X-Auth-Token'); + expect(headers).not.toHaveProperty('X-User-Id'); + }); +}); + +describe('normalizeResponseError', () => { + it('parses a JSON body and returns { status, data }', async () => { + const response = new Response(JSON.stringify({ error: 'not-found' }), { status: 404 }); + const result = await normalizeResponseError(response); + expect(result).toEqual({ status: 404, data: { error: 'not-found' } }); + }); + + it('returns { status, data: {} } when the body is not valid JSON', async () => { + const response = new Response('plain text error', { status: 500 }); + const result = await normalizeResponseError(response); + expect(result).toEqual({ status: 500, data: {} }); + }); + + it('does not consume the original response (uses clone)', async () => { + const response = new Response(JSON.stringify({ ok: true }), { status: 200 }); + await normalizeResponseError(response); + const bodyStillReadable = await response.text(); + expect(bodyStillReadable).toBe(JSON.stringify({ ok: true })); + }); +}); + +const buildFakeSdkWithRest = (overrides: any = {}) => ({ + client: { ddp: {}, ...(overrides.client || {}) }, + connection: buildFakeConnection(overrides.connection), + account: overrides.account || { user: { id: 'u1', token: 't1' } }, + rest: { + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + handleTwoFactorChallenge: jest.fn(), + ...(overrides.rest || {}) + } +}); - const result = await sdk.methodCall('someMethod', 'arg1'); +describe('Sdk.post', () => { + it('posts to a REST endpoint and returns the result', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.post.mockResolvedValue({ success: true, data: { id: 'abc' } }); + setInternalSdk(fake); + const result = await (sdk as any).post('/v1/channels.create', { name: 'test' }); + expect(fake.rest.post).toHaveBeenCalledWith( + '/v1/channels.create', + { name: 'test' }, + expect.objectContaining({ headers: expect.any(Object) }) + ); + expect(result).toEqual({ success: true, data: { id: 'abc' } }); + }); + + it('normalizes a fetch Response error to { status, data }', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.post.mockRejectedValue(new Response(JSON.stringify({ error: 'bad' }), { status: 400 })); + setInternalSdk(fake); + await expect((sdk as any).post('/v1/channels.create', {})).rejects.toEqual({ status: 400, data: { error: 'bad' } }); + }); + + it('rethrows non-Response errors as-is', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.post.mockRejectedValue(new Error('network')); + setInternalSdk(fake); + await expect((sdk as any).post('/v1/channels.create', {})).rejects.toThrow('network'); + }); + + it('unwraps result.message for /v1/method.call endpoints (Fix 1 regression)', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.post.mockResolvedValue({ message: JSON.stringify({ msg: 'result', result: { ok: true } }) }); + setInternalSdk(fake); + const result = await (sdk as any).post('/v1/method.call/someMethod', { message: '{}' }); + expect(result).toEqual({ ok: true }); + }); - expect(result).toBe('ok'); - expect(mockTwoFactor).toHaveBeenCalledTimes(1); - expect(mockTwoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: false }); - // Second call should have the TOTP object appended - expect(mockInnerMethodCall.mock.calls[1]).toEqual(['someMethod', 'arg1', totpObject]); + it('unwraps result.message for /v1/method.callAnon endpoints', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.post.mockResolvedValue({ message: JSON.stringify({ msg: 'result', result: 'anon-ok' }) }); + setInternalSdk(fake); + const result = await (sdk as any).post('/v1/method.callAnon/someMethod', { message: '{}' }); + expect(result).toBe('anon-ok'); }); - it('the code does not leak into the next call', async () => { - const totpObject = { twoFactorCode: 'CODE', twoFactorMethod: 'totp' }; - // First call: rejection that prompts for 2FA - mockInnerMethodCall.mockRejectedValueOnce({ - error: 'totp-required', - details: { method: 'totp' } + it('throws the inner error from method-call responses', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.post.mockResolvedValue({ + message: JSON.stringify({ msg: 'result', error: { error: 'app-error', details: {} } }) + }); + setInternalSdk(fake); + await expect((sdk as any).post('/v1/method.call/myMethod', { message: '{}' })).rejects.toEqual({ + error: 'app-error', + details: {} }); - // Retry with 2FA code - mockInnerMethodCall.mockResolvedValueOnce('ok'); - // Second unrelated call - mockInnerMethodCall.mockResolvedValueOnce('ok2'); - mockTwoFactor.mockResolvedValue(totpObject); + }); + + it('retries with 2FA when method call returns totp-required', async () => { + const fake = buildFakeSdkWithRest(); + const err = { message: JSON.stringify({ msg: 'result', error: { error: 'totp-required', details: { method: 'totp' } } }) }; + const ok = { message: JSON.stringify({ msg: 'result', result: { ok: true } }) }; + fake.rest.post.mockResolvedValueOnce(err).mockResolvedValueOnce(ok); + (twoFactor as jest.Mock).mockResolvedValue({ twoFactorCode: '123456', twoFactorMethod: 'totp' }); + setInternalSdk(fake); + const result = await (sdk as any).post('/v1/method.call/myMethod', { message: '{}' }); + expect(fake.rest.post).toHaveBeenCalledTimes(2); + expect(twoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: false }); + expect(result).toEqual({ ok: true }); + }); + + it('returns {} when 2FA is cancelled during method-call retry', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.post.mockResolvedValue({ + message: JSON.stringify({ msg: 'result', error: { error: 'totp-required', details: { method: 'totp' } } }) + }); + (twoFactor as jest.Mock).mockRejectedValue(new Error('cancelled')); + setInternalSdk(fake); + const result = await (sdk as any).post('/v1/method.call/myMethod', { message: '{}' }); + expect(result).toEqual({}); + }); - // First call with 2FA prompt - await sdk.methodCall('method1', 'a'); + it('retries with 2FA when normal API returns totp-required', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.post + .mockRejectedValueOnce( + new Response(JSON.stringify({ errorType: 'totp-required', details: { method: 'totp' } }), { status: 401 }) + ) + .mockResolvedValueOnce({ success: true }); + (twoFactor as jest.Mock).mockResolvedValue({ twoFactorCode: '999888', twoFactorMethod: 'totp' }); - // Second call should NOT have the code appended - const result = await sdk.methodCall('other/method', 'a'); + setInternalSdk(fake); + const result = await (sdk as any).post('/v1/channels.create', { name: 'test' }); - expect(result).toBe('ok2'); - // Third call (the second method call after 2FA) should have exactly 2 args - expect(mockInnerMethodCall.mock.calls[2]).toEqual(['other/method', 'a']); - expect(mockInnerMethodCall.mock.calls[2]).toHaveLength(2); + expect(fake.rest.post).toHaveBeenCalledTimes(2); + expect(twoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: false }); + expect(result).toEqual({ success: true }); }); - it('twoFactor canceled → resolves to {}', async () => { - mockInnerMethodCall.mockRejectedValue({ - error: 'totp-required', - details: { method: 'totp' } + it('passes invalid:true to twoFactor when normal API returns totp-invalid', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.post + .mockRejectedValueOnce( + new Response(JSON.stringify({ errorType: 'totp-invalid', details: { method: 'email' } }), { status: 401 }) + ) + .mockResolvedValueOnce({ success: true }); + (twoFactor as jest.Mock).mockResolvedValue({ twoFactorCode: '111222', twoFactorMethod: 'email' }); + + setInternalSdk(fake); + await (sdk as any).post('/v1/channels.create', { name: 'test' }); + + expect(twoFactor).toHaveBeenCalledWith({ method: 'email', invalid: true }); + expect(fake.rest.post).toHaveBeenCalledTimes(2); + }); + + it('prompts twoFactor twice when first code is wrong and retries a third time', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.post + .mockRejectedValueOnce( + new Response(JSON.stringify({ errorType: 'totp-required', details: { method: 'totp' } }), { status: 401 }) + ) + .mockRejectedValueOnce( + new Response(JSON.stringify({ errorType: 'totp-invalid', details: { method: 'totp' } }), { status: 401 }) + ) + .mockResolvedValueOnce({ success: true }); + (twoFactor as jest.Mock) + .mockResolvedValueOnce({ twoFactorCode: '111111', twoFactorMethod: 'totp' }) + .mockResolvedValueOnce({ twoFactorCode: '222222', twoFactorMethod: 'totp' }); + + setInternalSdk(fake); + const result = await (sdk as any).post('/v1/channels.create', { name: 'test' }); + + expect(fake.rest.post).toHaveBeenCalledTimes(3); + expect(twoFactor).toHaveBeenCalledTimes(2); + expect(twoFactor).toHaveBeenNthCalledWith(1, { method: 'totp', invalid: false }); + expect(twoFactor).toHaveBeenNthCalledWith(2, { method: 'totp', invalid: true }); + expect(result).toEqual({ success: true }); + }); + + it('does not leak x-2fa-code into a concurrent request while a 2FA retry is in flight', async () => { + // The retried call is held open on this gate so we can read shared headers while it is still in flight. + let releaseRetry!: (value: any) => void; + const retryGate = new Promise(resolve => { + releaseRetry = resolve; }); - mockTwoFactor.mockRejectedValue(new Error('Canceled')); - const result = await sdk.methodCall('m'); + const post = jest.fn(); + post.mockRejectedValueOnce({ data: { errorType: 'totp-required', details: { method: 'totp' } } }); + post.mockImplementationOnce(() => retryGate); + + const fake = buildFakeSdkWithRest({ rest: { post } }); + setInternalSdk(fake); + (twoFactor as jest.Mock).mockResolvedValueOnce({ twoFactorCode: '123456', twoFactorMethod: 'totp' }); + + const retryPromise = (sdk as any).post('/v1/rooms.info', {}); + + // Let the retry run up to the point where it has fired its retried network call (still pending on retryGate). + await new Promise(resolve => setTimeout(resolve, 0)); + + // A concurrent, unrelated call reads the shared headers while the 2FA retry is still in flight. + const concurrentHeaders = sdk.getHeaders(); + + releaseRetry({ success: true }); + const result = await retryPromise; + + expect(result).toEqual({ success: true }); + expect(concurrentHeaders['x-2fa-code']).toBeUndefined(); + }); +}); + +describe('Sdk.methodCall', () => { + const buildFakeSdkWithMethod = (callAsyncWithOptions: jest.Mock) => ({ + client: { callAsyncWithOptions }, + connection: buildFakeConnection(), + account: { user: { id: 'u1', token: 't1' } } + }); + + it('forwards method + params to callAsyncWithOptions', async () => { + const callAsync = jest.fn().mockResolvedValue({ result: 'ok' }); + setInternalSdk(buildFakeSdkWithMethod(callAsync)); + const result = await (sdk as any).methodCall('myMethod', 'arg1', 42); + expect(callAsync).toHaveBeenCalledWith('myMethod', {}, 'arg1', 42); + expect(result).toEqual({ result: 'ok' }); + }); + + it('appends the resolved TOTP code to params when a retry is in flight', async () => { + const callAsync = jest + .fn() + .mockRejectedValueOnce({ error: 'totp-required', details: { method: 'totp' } }) + .mockResolvedValueOnce({}); + setInternalSdk(buildFakeSdkWithMethod(callAsync)); + (twoFactor as jest.Mock).mockResolvedValue({ twoFactorCode: '654321', twoFactorMethod: 'totp' }); + await (sdk as any).methodCall('myMethod', 'arg1'); + expect(callAsync).toHaveBeenNthCalledWith(2, 'myMethod', {}, 'arg1', { twoFactorCode: '654321', twoFactorMethod: 'totp' }); + }); + + it('does not leak a pending 2FA code into a concurrent, unrelated methodCall() that never needed 2FA', async () => { + const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; + + const attempt1A = createDeferred(); + const retryA = createDeferred(); + const challengeA = createDeferred(); + const callAsync = jest + .fn() + .mockImplementationOnce(() => attempt1A.promise) // methodA attempt 1 + .mockImplementationOnce(() => retryA.promise) // methodA retry (kept pending) + .mockImplementationOnce((_method: string, _opts: any, ...rest: any[]) => Promise.resolve({ methodCArgs: rest })); // methodC attempt 1 + setInternalSdk(buildFakeSdkWithMethod(callAsync)); + (twoFactor as jest.Mock).mockReturnValueOnce(challengeA.promise); + + const callAPromise = (sdk as any).methodCall('methodA'); + attempt1A.reject({ error: 'totp-required', details: { method: 'totp' } }); + await Promise.resolve().then(() => Promise.resolve()); // let the catch handler call twoFactor() + challengeA.resolve({ twoFactorCode: 'a-code', twoFactorMethod: 'totp' }); + await Promise.resolve().then(() => Promise.resolve()); // let this.code-equivalent retry kick off (now pending on retryA) + + const callCResult = await (sdk as any).methodCall('methodC'); // unrelated, never hits a 2FA error + expect(callCResult.methodCArgs).toEqual([]); + + retryA.resolve({ ok: true }); + await callAPromise; + }); + + it('handles totp-required by prompting twoFactor and retrying', async () => { + const callAsync = jest + .fn() + .mockRejectedValueOnce({ error: 'totp-required', details: { method: 'totp' } }) + .mockResolvedValueOnce({ result: 'ok' }); + setInternalSdk(buildFakeSdkWithMethod(callAsync)); + (twoFactor as jest.Mock).mockResolvedValue({ twoFactorCode: '123456', twoFactorMethod: 'totp' }); + const result = await (sdk as any).methodCall('myMethod'); + expect(twoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: false }); + expect(result).toEqual({ result: 'ok' }); + }); + + it('marks invalid:true when retrying after totp-invalid', async () => { + const callAsync = jest + .fn() + .mockRejectedValueOnce({ error: 'totp-invalid', details: { method: 'totp' } }) + .mockResolvedValueOnce({ result: 'ok' }); + setInternalSdk(buildFakeSdkWithMethod(callAsync)); + (twoFactor as jest.Mock).mockResolvedValue({ twoFactorCode: '999999', twoFactorMethod: 'totp' }); + await (sdk as any).methodCall('myMethod'); + expect(twoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: true }); + }); + + it('uses the second code — not the first — when the first TOTP attempt is invalid', async () => { + // Flow: totp-required → code '111111' stored → totp-invalid → code '222222' stored → 3rd call uses '222222' + const callAsync = jest + .fn() + .mockRejectedValueOnce({ error: 'totp-required', details: { method: 'totp' } }) + .mockRejectedValueOnce({ error: 'totp-invalid', details: { method: 'totp' } }) + .mockResolvedValueOnce({ result: 'ok' }); + setInternalSdk(buildFakeSdkWithMethod(callAsync)); + (twoFactor as jest.Mock) + .mockResolvedValueOnce({ twoFactorCode: '111111', twoFactorMethod: 'totp' }) + .mockResolvedValueOnce({ twoFactorCode: '222222', twoFactorMethod: 'totp' }); + + await (sdk as any).methodCall('myMethod'); + expect(callAsync).toHaveBeenCalledTimes(3); + // 2nd call carries the first code + expect(callAsync).toHaveBeenNthCalledWith(2, 'myMethod', {}, { twoFactorCode: '111111', twoFactorMethod: 'totp' }); + // 3rd call must carry the NEW code, not the stale first one + expect(callAsync).toHaveBeenNthCalledWith(3, 'myMethod', {}, { twoFactorCode: '222222', twoFactorMethod: 'totp' }); + }); + + it('returns {} when twoFactor is cancelled mid-retry', async () => { + const callAsync = jest.fn().mockRejectedValue({ error: 'totp-required', details: { method: 'totp' } }); + setInternalSdk(buildFakeSdkWithMethod(callAsync)); + (twoFactor as jest.Mock).mockRejectedValue(new Error('cancelled')); + const result = await (sdk as any).methodCall('myMethod'); expect(result).toEqual({}); - expect(mockInnerMethodCall).toHaveBeenCalledTimes(1); + }); + + it('rejects non-2FA errors', async () => { + const callAsync = jest.fn().mockRejectedValue(new Error('some error')); + setInternalSdk(buildFakeSdkWithMethod(callAsync)); + await expect((sdk as any).methodCall('myMethod')).rejects.toThrow('some error'); + }); + + it('rejects when sdk is not initialized', async () => { + setInternalSdk(undefined); + await expect((sdk as any).methodCall('myMethod')).rejects.toThrow('SDK not initialized'); + }); +}); + +describe('Sdk.methodCallWrapper', () => { + it('uses methodCall (WebSocket) when API_Use_REST_For_DDP_Calls is false', async () => { + (reduxStore.getState as jest.Mock).mockReturnValue({ + settings: { API_Use_REST_For_DDP_Calls: false }, + login: { user: { id: 'u1' } } + }); + const methodCallSpy = jest.spyOn(sdk as any, 'methodCall').mockResolvedValue('ws-result'); + const result = await (sdk as any).methodCallWrapper('myMethod', 'arg1'); + expect(methodCallSpy).toHaveBeenCalledWith('myMethod', 'arg1'); + expect(result).toBe('ws-result'); + methodCallSpy.mockRestore(); + }); + + it('uses /v1/method.call (REST) when API_Use_REST_For_DDP_Calls is true and user is present', async () => { + (reduxStore.getState as jest.Mock).mockReturnValue({ + settings: { API_Use_REST_For_DDP_Calls: true }, + login: { user: { id: 'u1' } } + }); + const postSpy = jest.spyOn(sdk as any, 'post').mockResolvedValue('rest-result'); + const result = await (sdk as any).methodCallWrapper('myMethod', { x: 1 }); + expect(postSpy).toHaveBeenCalledWith('/v1/method.call/myMethod', expect.objectContaining({ message: expect.any(String) })); + expect(result).toBe('rest-result'); + postSpy.mockRestore(); + }); + + it('uses /v1/method.callAnon when user is empty and REST is enabled', async () => { + (reduxStore.getState as jest.Mock).mockReturnValue({ + settings: { API_Use_REST_For_DDP_Calls: true }, + login: { user: {} } + }); + const postSpy = jest.spyOn(sdk as any, 'post').mockResolvedValue('anon'); + await (sdk as any).methodCallWrapper('myMethod'); + expect(postSpy).toHaveBeenCalledWith('/v1/method.callAnon/myMethod', expect.any(Object)); + postSpy.mockRestore(); + }); + + it('converts Date params to { $date: ms } for the WebSocket path', async () => { + (reduxStore.getState as jest.Mock).mockReturnValue({ + settings: { API_Use_REST_For_DDP_Calls: false }, + login: { user: { id: 'u1' } } + }); + const methodCallSpy = jest.spyOn(sdk as any, 'methodCall').mockResolvedValue(undefined); + const ts = 1704067200000; + await (sdk as any).methodCallWrapper('myMethod', new Date(ts)); + expect(methodCallSpy).toHaveBeenCalledWith('myMethod', { $date: ts }); + methodCallSpy.mockRestore(); + }); + + it('propagates rejection from post() when the REST method call errors', async () => { + (reduxStore.getState as jest.Mock).mockReturnValue({ + settings: { API_Use_REST_For_DDP_Calls: true }, + login: { user: { id: 'u1' } } + }); + const postSpy = jest.spyOn(sdk as any, 'post').mockRejectedValue({ error: 'app-error' }); + await expect((sdk as any).methodCallWrapper('myMethod')).rejects.toEqual({ error: 'app-error' }); + postSpy.mockRestore(); + }); +}); + +describe('Sdk.get', () => { + it('forwards params and headers to rest.get', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.get.mockResolvedValue({ users: [] }); + setInternalSdk(fake); + const result = await (sdk as any).get('/v1/users.list', { count: 10 }); + expect(fake.rest.get).toHaveBeenCalledWith( + '/v1/users.list', + { count: 10 }, + expect.objectContaining({ headers: expect.any(Object) }) + ); + expect(result).toEqual({ users: [] }); + }); + + it('normalizes fetch Response errors', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.get.mockRejectedValue(new Response('{}', { status: 500 })); + setInternalSdk(fake); + await expect((sdk as any).get('/v1/users.list')).rejects.toEqual({ status: 500, data: {} }); + }); + + it('rethrows non-Response errors', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.get.mockRejectedValue(new Error('boom')); + setInternalSdk(fake); + await expect((sdk as any).get('/v1/users.list')).rejects.toThrow('boom'); + }); +}); + +describe('Sdk.delete', () => { + it('forwards params and headers to rest.delete', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.delete.mockResolvedValue({ success: true }); + setInternalSdk(fake); + const result = await (sdk as any).delete('/v1/push.token', { token: 'abc' }); + expect(fake.rest.delete).toHaveBeenCalledWith( + '/v1/push.token', + { token: 'abc' }, + expect.objectContaining({ headers: expect.any(Object) }) + ); + expect(result).toEqual({ success: true }); + }); + + it('normalizes a Response error', async () => { + const fake = buildFakeSdkWithRest(); + fake.rest.delete.mockRejectedValue(new Response(JSON.stringify({ error: 'nope' }), { status: 403 })); + setInternalSdk(fake); + await expect((sdk as any).delete('/v1/push.token', { token: 'abc' })).rejects.toEqual({ + status: 403, + data: { error: 'nope' } + }); + }); +}); + +describe('Sdk header lifecycle', () => { + it('includes a User-Agent by default', () => { + const headers = sdk.getHeaders(); + expect(headers['User-Agent']).toMatch(/^RC Mobile/); + }); + + it('adds Authorization on setBasicAuth(token) and persists to UserPreferences', () => { + (sdk as any).serverUrl = 'https://example.com'; + sdk.setBasicAuth('dXNlcjpwYXNz'); + expect(sdk.getHeaders().Authorization).toBe('Basic dXNlcjpwYXNz'); + expect(UserPreferences.setString).toHaveBeenCalledWith('BASIC_AUTH_KEY-https://example.com', 'dXNlcjpwYXNz'); + }); + + it('removes Authorization on setBasicAuth(null) and clears UserPreferences', () => { + (sdk as any).serverUrl = 'https://example.com'; + sdk.setBasicAuth('dXNlcjpwYXNz'); + sdk.setBasicAuth(null); + expect(sdk.getHeaders().Authorization).toBeUndefined(); + expect(UserPreferences.removeItem).toHaveBeenCalledWith('BASIC_AUTH_KEY-https://example.com'); + }); + + it('getHeaders returns a copy — mutating it does not affect internal headers', () => { + (sdk as any).serverUrl = 'https://example.com'; + sdk.setBasicAuth('dXNlcjpwYXNz'); + const headers = sdk.getHeaders(); + delete (headers as any).Authorization; // mutate the returned copy + expect(sdk.getHeaders().Authorization).toBe('Basic dXNlcjpwYXNz'); + }); + + it('persists setBasicAuth under an explicitly passed server, not the stale this.serverUrl (switch-server regression)', () => { + (sdk as any).serverUrl = 'https://old.example.com'; + sdk.setBasicAuth('dXNlcjpwYXNz', 'https://new.example.com'); + expect(UserPreferences.setString).toHaveBeenCalledWith('BASIC_AUTH_KEY-https://new.example.com', 'dXNlcjpwYXNz'); + expect(UserPreferences.setString).not.toHaveBeenCalledWith('BASIC_AUTH_KEY-https://old.example.com', expect.anything()); + }); + + it('clears setBasicAuth(null) under an explicitly passed server, not the stale this.serverUrl', () => { + (sdk as any).serverUrl = 'https://old.example.com'; + sdk.setBasicAuth(null, 'https://new.example.com'); + expect(UserPreferences.removeItem).toHaveBeenCalledWith('BASIC_AUTH_KEY-https://new.example.com'); + expect(UserPreferences.removeItem).not.toHaveBeenCalledWith('BASIC_AUTH_KEY-https://old.example.com'); + }); +}); + +describe('Sdk.onStreamData', () => { + it('resolves with a noop stop when sdk.current is undefined', async () => { + setInternalSdk(undefined); + const { stop } = await sdk.onStreamData('any', jest.fn()); + expect(typeof stop).toBe('function'); + expect(() => stop()).not.toThrow(); + }); + + it('subscribes via client.onCollection and exposes the handle as stop', async () => { + const stopHandle = jest.fn(); + const onCollection = jest.fn().mockReturnValue(stopHandle); + setInternalSdk({ client: { onCollection } }); + const { stop } = await sdk.onStreamData('stream-notify-user', jest.fn()); + expect(onCollection).toHaveBeenCalledWith('stream-notify-user', expect.any(Function)); + expect(stop).toBe(stopHandle); + }); + + it('invokes the callback only when ddpMessage has fields', async () => { + const cb = jest.fn(); + const onCollection = jest.fn((_: string, handler: (m: any) => void) => { + handler({ msg: 'changed' }); + handler({ msg: 'changed', fields: { eventName: 'x' } }); + return jest.fn(); + }); + setInternalSdk({ client: { onCollection } }); + await sdk.onStreamData('test', cb); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith({ msg: 'changed', fields: { eventName: 'x' } }); + }); +}); + +describe('Sdk.onLogin', () => { + it('returns a noop unsubscribe when sdk.current is undefined', () => { + setInternalSdk(undefined); + const stop = sdk.onLogin(jest.fn()); + expect(typeof stop).toBe('function'); + expect(() => stop()).not.toThrow(); + }); + + it('subscribes via account.on("uid", ...) and exposes the handle as stop', () => { + const stopHandle = jest.fn(); + const on = jest.fn().mockReturnValue(stopHandle); + setInternalSdk({ account: { on } }); + const stop = sdk.onLogin(jest.fn()); + expect(on).toHaveBeenCalledWith('uid', expect.any(Function)); + expect(stop).toBe(stopHandle); + }); + + it('invokes the callback only when uid is set (skips the logout emission)', () => { + const callback = jest.fn(); + const on = jest.fn((_: string, handler: (uid?: string) => void) => { + handler(undefined); + handler('u1'); + return jest.fn(); + }); + setInternalSdk({ account: { on } }); + sdk.onLogin(callback); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); + +describe('Sdk.initialize', () => { + it('calls DDPSDK.create with the server URL and stores it', () => { + const handleTwoFactorChallenge = jest.fn(); + const fake = { rest: { handleTwoFactorChallenge } } as any; + (DDPSDK.create as jest.Mock).mockReturnValue(fake); + sdk.initialize('https://example.com'); + expect(DDPSDK.create).toHaveBeenCalledWith('https://example.com'); + expect(sdk.server).toBe('https://example.com'); + }); + + it('attaches subscribeNotifyUser onto the DDPSDK instance', () => { + const handleTwoFactorChallenge = jest.fn(); + const fake = { rest: { handleTwoFactorChallenge } } as any; + (DDPSDK.create as jest.Mock).mockReturnValue(fake); + sdk.initialize('https://example.com'); + expect(typeof fake.subscribeNotifyUser).toBe('function'); + }); + + it('registers handleTwoFactorChallenge on the REST client', () => { + const handleTwoFactorChallenge = jest.fn(); + (DDPSDK.create as jest.Mock).mockReturnValue({ rest: { handleTwoFactorChallenge } }); + sdk.initialize('https://example.com'); + expect(handleTwoFactorChallenge).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('resolves the registered 2FA challenge handler with the code from twoFactor() — covers GET/DELETE 2FA', async () => { + const handleTwoFactorChallenge = jest.fn(); + (DDPSDK.create as jest.Mock).mockReturnValue({ rest: { handleTwoFactorChallenge } }); + sdk.initialize('https://example.com'); + const registeredHandler = handleTwoFactorChallenge.mock.calls[0][0]; + + (twoFactor as jest.Mock).mockResolvedValueOnce({ twoFactorCode: '654321', twoFactorMethod: 'totp' }); + + const code = await registeredHandler({ method: 'totp', invalidAttempt: false }); + + expect(code).toBe('654321'); + expect(twoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: false }); + }); + + it('propagates a cancellation from the registered 2FA challenge handler', async () => { + const handleTwoFactorChallenge = jest.fn(); + (DDPSDK.create as jest.Mock).mockReturnValue({ rest: { handleTwoFactorChallenge } }); + sdk.initialize('https://example.com'); + const registeredHandler = handleTwoFactorChallenge.mock.calls[0][0]; + + (twoFactor as jest.Mock).mockRejectedValueOnce(new Error('cancelled')); + + await expect(registeredHandler({ method: 'totp', invalidAttempt: true })).rejects.toThrow('cancelled'); + expect(twoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: true }); + }); + + it('resets headers to defaults — does not carry Authorization across server switches', () => { + (sdk as any).serverUrl = 'https://old'; + sdk.setBasicAuth('dXNlcjpwYXNz'); + expect(sdk.getHeaders().Authorization).toBe('Basic dXNlcjpwYXNz'); + (DDPSDK.create as jest.Mock).mockReturnValue({ rest: { handleTwoFactorChallenge: jest.fn() } }); + sdk.initialize('https://new'); + expect(sdk.getHeaders().Authorization).toBeUndefined(); + }); + + it('loads Basic Auth from UserPreferences on initialize when present', () => { + (UserPreferences.getString as jest.Mock).mockImplementation((key: string) => + key === 'BASIC_AUTH_KEY-https://example.com' ? 'cmVzdW1lOnRva2Vu' : null + ); + (DDPSDK.create as jest.Mock).mockReturnValue({ rest: { handleTwoFactorChallenge: jest.fn() } }); + sdk.initialize('https://example.com'); + expect(sdk.getHeaders().Authorization).toBe('Basic cmVzdW1lOnRva2Vu'); + }); +}); + +describe('Sdk.subscribeRoom', () => { + const buildRoomSubscribeMock = () => { + const subscribe = jest.fn((..._args: any[]) => ({ id: 'sub', stop: jest.fn() })); + return { client: { subscribe }, __subscribe: subscribe }; + }; + + it('returns [] when sdk.current is undefined', async () => { + setInternalSdk(undefined); + await expect(sdk.subscribeRoom('rid-1')).resolves.toEqual([]); + }); + + it('subscribes to user-activity for RC >= 4.0', async () => { + (reduxStore.getState as jest.Mock).mockReturnValue({ server: { version: '4.0.0' } }); + const fake = buildRoomSubscribeMock(); + setInternalSdk(fake); + await sdk.subscribeRoom('rid-1'); + expect(fake.__subscribe).toHaveBeenCalledWith('stream-notify-room', 'rid-1/user-activity'); + }); + + it('falls back to typing for RC < 4.0', async () => { + (reduxStore.getState as jest.Mock).mockReturnValue({ server: { version: '3.9.0' } }); + const fake = buildRoomSubscribeMock(); + setInternalSdk(fake); + await sdk.subscribeRoom('rid-1'); + expect(fake.__subscribe).toHaveBeenCalledWith('stream-notify-room', 'rid-1/typing'); + }); + + it('subscribes to all 5 room channels', async () => { + (reduxStore.getState as jest.Mock).mockReturnValue({ server: { version: '6.0.0' } }); + const fake = buildRoomSubscribeMock(); + setInternalSdk(fake); + const subs = await sdk.subscribeRoom('rid-1'); + expect(fake.__subscribe).toHaveBeenCalledWith('stream-room-messages', 'rid-1'); + expect(fake.__subscribe).toHaveBeenCalledWith('stream-notify-room', 'rid-1/deleteMessage'); + expect(fake.__subscribe).toHaveBeenCalledWith('stream-notify-room', 'rid-1/deleteMessageBulk'); + expect(fake.__subscribe).toHaveBeenCalledWith('stream-notify-room', 'rid-1/messagesRead'); + expect(subs).toHaveLength(5); + }); + + it('logs the error when subscribing throws, instead of swallowing it silently', async () => { + const log = jest.requireMock('../methods/helpers/log').default; + const fake = { + client: { + subscribe: jest.fn(() => { + throw new Error('socket not ready'); + }) + } + }; + setInternalSdk(fake); + const result = await sdk.subscribeRoom('room-1'); + expect(result).toEqual([]); + expect(log).toHaveBeenCalledWith(expect.any(Error)); + }); +}); + +describe('Sdk.disconnect', () => { + it('closes the connection and clears server when sdk is initialized', () => { + const close = jest.fn(); + setInternalSdk({ connection: { close } }); + (sdk as any).serverUrl = 'https://x.com'; + expect(sdk.disconnect()).toBeNull(); + expect(close).toHaveBeenCalledTimes(1); + expect(sdk.server).toBeUndefined(); + }); + + it('returns null without throwing when sdk is not initialized', () => { + setInternalSdk(undefined); + (sdk as any).serverUrl = 'https://x.com'; + expect(sdk.disconnect()).toBeNull(); + expect(sdk.server).toBeUndefined(); }); }); diff --git a/app/lib/services/sdk.ts b/app/lib/services/sdk.ts index d8be776f45a..31ef2d06570 100644 --- a/app/lib/services/sdk.ts +++ b/app/lib/services/sdk.ts @@ -1,10 +1,14 @@ -import { Rocketchat } from '@rocket.chat/sdk'; import EJSON from 'ejson'; import isEmpty from 'lodash/isEmpty'; +import { type ClientStream, DDPSDK } from '@rocket.chat/ddp-client'; import { twoFactor } from './twoFactor'; -import { isSsl } from '../methods/helpers/isSsl'; import { store as reduxStore } from '../store/auxStore'; +import { compareServerVersion, random } from '../methods/helpers'; +import log from '../methods/helpers/log'; +import UserPreferences from '../methods/userPreferences'; +import { headers as defaultHeaders } from '../methods/helpers/defaultHeaders'; +import { BASIC_AUTH_KEY } from '../methods/helpers/fetch'; import { type Serialized, type MatchPathPattern, @@ -12,41 +16,127 @@ import { type PathFor, type ResultFor } from '../../definitions/rest/helpers'; -import { compareServerVersion, random } from '../methods/helpers'; +import { type ILoginDataResponse, type ILoginResponse } from '../../definitions/rest/v1/auth'; -class Sdk { - private sdk: typeof Rocketchat; - private code: any; +export async function normalizeResponseError(response: Response): Promise<{ status: number; data: any }> { + try { + const data = await response.clone().json(); + return { status: response.status, data }; + } catch { + return { status: response.status, data: {} }; + } +} + +// Mirrors @rocket.chat/ddp-client's internal `ConnectionStatus`. It isn't exported by the package yet, +// so we keep a single source of truth here instead of repeating the union inline. +export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'failed' | 'closed' | 'disconnected' | 'reconnecting'; - private initializeSdk(server: string): typeof Rocketchat { - // The app can't reconnect if reopen interval is 5s while in development - return new Rocketchat({ host: server, protocol: 'ddp', useSsl: isSsl(server), reopen: __DEV__ ? 20000 : 5000 }); +export type DDPSubscription = ReturnType; + +export const NOTIFY_USER_EVENTS = [ + 'message', + 'notification', + 'rooms-changed', + 'subscriptions-changed', + 'uiInteraction', + 'e2ekeyRequest', + 'userData', + 'video-conference', + 'media-signal', + 'media-calls' +] as const; + +/** + * Preserve the old-SDK call shape `sdk.current?.subscribeNotifyUser?.()` by + * declaring the method on `DDPSDK`. The implementation lives on our `Sdk` + * wrapper; `initialize()` glues it onto the DDPSDK instance at runtime. + */ +declare module '@rocket.chat/ddp-client' { + interface DDPSDK { + subscribeNotifyUser?: () => ReturnType; } +} + +class Sdk { + private sdk: DDPSDK | undefined; + private serverUrl: string | undefined; + private headers: Record = { ...defaultHeaders } as Record; + + initialize(server: string): DDPSDK { + this.headers = { ...defaultHeaders } as Record; + this.sdk = DDPSDK.create(server); - // TODO: We need to stop returning the SDK after all methods are dehydrated - initialize(server: string) { - this.code = null; - this.sdk = this.initializeSdk(server); + this.sdk.subscribeNotifyUser = () => this.subscribeNotifyUser(); + this.serverUrl = server; + this.loadBasicAuth(); + this.sdk.rest.handleTwoFactorChallenge(this.twoFactorHandler.bind(this)); return this.sdk; } + get server(): string | undefined { + return this.serverUrl; + } + + private ensureInitialized(): DDPSDK { + if (!this.current || !this.current.client) { + throw new Error('SDK not initialized'); + } + return this.current; + } + get current() { return this.sdk; } - /** - * TODO: evaluate the need for assigning "null" to this.sdk - * I'm returning "null" because we need to remove both instances of this.sdk here and on rocketchat.js - */ - disconnect() { + disconnect(): null { if (this.sdk) { - this.sdk.disconnect(); - this.sdk = null; + this.sdk.connection.close(); + this.sdk = undefined; } + this.serverUrl = undefined; return null; } - get>( + probe(timeoutMs = 2000): Promise { + return this.current?.connection.probe(timeoutMs) ?? Promise.resolve(false); + } + + reopenNow(): Promise { + return this.current?.connection.reopenNow() ?? Promise.resolve(); + } + + private loadBasicAuth(): void { + const basicAuth = UserPreferences.getString(`${BASIC_AUTH_KEY}-${this.serverUrl}`); + if (basicAuth) { + this.setHeaders({ Authorization: `Basic ${basicAuth}` }); + } + } + + private setHeaders(headers: Record): void { + this.headers = { ...this.headers, ...headers }; + } + + getHeaders(): Record { + return { ...this.headers }; + } + + setBasicAuth(basicAuth: string | null, server: string | undefined = this.serverUrl): void { + if (basicAuth) { + if (server) { + UserPreferences.setString(`${BASIC_AUTH_KEY}-${server}`, basicAuth); + } + this.setHeaders({ Authorization: `Basic ${basicAuth}` }); + } else { + if (server) { + UserPreferences.removeItem(`${BASIC_AUTH_KEY}-${server}`); + } + const next = { ...this.headers }; + delete next.Authorization; + this.headers = next; + } + } + + async get>( endpoint: TPath, params: void extends OperationParams<'GET', MatchPathPattern> ? void @@ -57,10 +147,18 @@ class Sdk { ? void : Serialized>> ): Promise>>> { - return this.current.get(endpoint, params); + const sdk = this.ensureInitialized(); + try { + // @ts-ignore + return await sdk.rest.get(endpoint, params, { + headers: this.headers + }); + } catch (e: any) { + throw e instanceof Response ? await normalizeResponseError(e) : e; + } } - post>( + async post>( endpoint: TPath, params: void extends OperationParams<'POST', MatchPathPattern> ? void @@ -69,68 +167,139 @@ class Sdk { MatchPathPattern > ? void - : Serialized>> + : Serialized>>, + extraHeaders: Record = {} ): Promise>> { - return new Promise(async (resolve, reject) => { - const isMethodCall = endpoint?.startsWith('method.call/'); - try { - const result = await this.current.post(endpoint, params); - - /** - * if API_Use_REST_For_DDP_Calls is enabled and it's a method call, - * responses have a different object structure - */ - if (isMethodCall) { - const response = JSON.parse(result.message); - if (response?.error) { - throw response.error; - } - return resolve(response.result); + const isMethodCall = !!endpoint?.includes('/v1/method.call'); + try { + const sdk = this.ensureInitialized(); + // @ts-ignore + const result = await sdk.rest.post(endpoint, params, { headers: { ...this.headers, ...extraHeaders } }); + + /** + * if API_Use_REST_For_DDP_Calls is enabled and it's a method call, + * responses have a different object structure + */ + if (isMethodCall) { + // @ts-ignore + const response = JSON.parse(result.message); + if (response?.error) { + throw response.error; } - return resolve(result); - } catch (e: any) { - const errorType = isMethodCall ? e?.error : e?.data?.errorType; - const totpInvalid = 'totp-invalid'; - const totpRequired = 'totp-required'; - if ([totpInvalid, totpRequired].includes(errorType)) { - const { details } = isMethodCall ? e : e?.data; - try { - await twoFactor({ method: details?.method, invalid: errorType === totpInvalid }); - return resolve(this.post(endpoint, params)); - } catch { - // twoFactor was canceled - return resolve({} as any); - } - } else { - reject(e); + return response.result; + } + return result; + } catch (e: any) { + const normalizedResponse = e instanceof Response ? await normalizeResponseError(e) : null; + const normalized = isMethodCall ? normalizedResponse?.data ?? e : normalizedResponse ?? e; + const errorType = isMethodCall ? normalized?.error : normalized?.data?.errorType; + const totpInvalid = 'totp-invalid'; + const totpRequired = 'totp-required'; + if ([totpInvalid, totpRequired].includes(errorType)) { + const { details } = isMethodCall ? normalized : normalized?.data; + try { + const totpResult = await twoFactor({ method: details?.method, invalid: errorType === totpInvalid }); + // Recurse so a retry that itself gets challenged again keeps using per-call headers, + // without ever touching the shared `this.headers`. + return await this.post(endpoint, params, { + 'x-2fa-code': totpResult.twoFactorCode, + 'x-2fa-method': totpResult.twoFactorMethod + }); + } catch { + // twoFactor was canceled + return {} as ResultFor<'POST', MatchPathPattern>; } + } else { + throw normalized; } - }); + } + } + + async delete>( + endpoint: TPath, + params: void extends OperationParams<'DELETE', MatchPathPattern> + ? void + : Serialized>> = undefined as void extends OperationParams< + 'DELETE', + MatchPathPattern + > + ? void + : Serialized>> + ): Promise>>> { + const sdk = this.ensureInitialized(); + try { + // @ts-ignore + return await sdk.rest.delete(endpoint, params, { + headers: this.headers + }); + } catch (e: any) { + throw e instanceof Response ? await normalizeResponseError(e) : e; + } + } + + async twoFactorHandler({ + method, + invalidAttempt + }: { + method: 'totp' | 'email' | 'password'; + invalidAttempt?: boolean; + }): Promise { + const result = await twoFactor({ method, invalid: !!invalidAttempt }); + return result.twoFactorCode; + } + + async login(credentials: any): Promise { + try { + const { server } = this; + const loginResult = (await this.post('/v1/login', credentials)) as unknown as ILoginResponse; + if (loginResult?.status !== 'success' || !loginResult.data) { + return Promise.reject(new Error('Invalid response from server')); + } + if (this.server !== server) { + return Promise.reject(new Error('Server switched during login')); + } + await this.current?.account.loginWithToken(loginResult.data.authToken); + + if (this.server !== server) { + return Promise.reject(new Error('Server switched during login')); + } + this.setHeaders({ + 'X-Auth-Token': loginResult.data.authToken, + 'X-User-Id': loginResult.data.userId + }); + + return loginResult.data; + } catch (e: any) { + if (e instanceof Response) { + return Promise.reject(await normalizeResponseError(e)); + } + return Promise.reject(e); + } } methodCall(...args: any[]): Promise { - return new Promise(async (resolve, reject) => { - try { - // Clear the 2FA code after use — a stale trailing arg breaks typed method signatures - const { code } = this; - this.code = null; - const result = await this.current.methodCall(...args, ...(code ? [code] : [])); - return resolve(result); - } catch (e: any) { - if (e.error && (e.error === 'totp-required' || e.error === 'totp-invalid')) { - const { details } = e; - try { - this.code = await twoFactor({ method: details?.method, invalid: e.error === 'totp-invalid' }); - return resolve(this.methodCall(...args)); - } catch { - // twoFactor was canceled - return resolve({}); - } - } else { - reject(e); + return this.callMethod(args); + } + + private async callMethod(args: any[], code?: { twoFactorCode: string; twoFactorMethod: string }): Promise { + try { + const sdk = this.ensureInitialized(); + const [method, ...params] = args; + return await sdk.client.callAsyncWithOptions(method, {}, ...params, ...(code ? [code] : [])); + } catch (e: any) { + if (e.error && (e.error === 'totp-required' || e.error === 'totp-invalid')) { + const { details } = e; + try { + const totpResult = await twoFactor({ method: details?.method, invalid: e.error === 'totp-invalid' }); + return await this.callMethod(args, totpResult); + } catch { + // twoFactor was canceled + return {}; } + } else { + throw e; } - }); + } } methodCallWrapper(method: string, ...params: any[]): Promise { @@ -138,10 +307,10 @@ class Sdk { const { user } = reduxStore.getState().login; if (API_Use_REST_For_DDP_Calls) { const url = isEmpty(user) ? 'method.callAnon' : 'method.call'; - // @ts-ignore - return this.post(`${url}/${method}`, { + const endpoint = `/v1/${url}/${method}` as PathFor<'POST'>; + return this.post(endpoint, { message: EJSON.stringify({ msg: 'method', id: random(10), method, params }) - }); + } as any); } const parsedParams = params.map(param => { if (param instanceof Date) { @@ -152,41 +321,119 @@ class Sdk { return this.methodCall(method, ...parsedParams); } - subscribe(...args: any[]) { - return this.current.subscribe(...args); + subscribe(...args: Parameters) { + return this.ensureInitialized().client.subscribe(...args); + } + + subscribeRoom(rid: string): Promise { + const subscriptions: DDPSubscription[] = []; + if (!this.current) { + return Promise.resolve(subscriptions); + } + try { + const { version: serverVersion } = reduxStore.getState().server; + const topic = 'stream-notify-room'; + const typingEvent = compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '4.0.0') ? 'user-activity' : 'typing'; + subscriptions.push(this.current.client.subscribe('stream-room-messages', rid)); + subscriptions.push(this.current.client.subscribe(topic, `${rid}/${typingEvent}`)); + subscriptions.push(this.current.client.subscribe(topic, `${rid}/deleteMessage`)); + subscriptions.push(this.current.client.subscribe(topic, `${rid}/deleteMessageBulk`)); + subscriptions.push(this.current.client.subscribe(topic, `${rid}/messagesRead`)); + return Promise.resolve(subscriptions); + } catch (e) { + log(e); + return Promise.resolve(subscriptions); + } } - subscribeRaw(...args: any[]) { - return this.current.subscribeRaw(...args); + onCollection(...args: Parameters) { + return this.current?.client.onCollection(...args); } - subscribeRoom(...args: any[]) { - const { server } = reduxStore.getState(); - const { version: serverVersion } = server; - const topic = 'stream-notify-room'; - let eventUserTyping; - if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '4.0.0')) { - eventUserTyping = this.subscribe(topic, `${args[0]}/user-activity`, ...args); - } else { - eventUserTyping = this.subscribe(topic, `${args[0]}/typing`, ...args); + onStreamData(name: string, callback: (...data: any) => void): Promise<{ stop: () => void }> { + return new Promise(resolve => { + if (!this.current) { + resolve({ stop: () => {} }); + return; + } + const listener = this.current.client.onCollection(name, (ddpMessage: any) => { + // DDP stream events come in the format: { msg, collection, id, fields: { eventName, args } } + if (ddpMessage && ddpMessage.fields) { + callback(ddpMessage); + } + }); + resolve({ stop: listener }); + }); + } + + onConnectionStatus(callback: (status: ConnectionStatus) => void): () => void { + if (!this.current) { + return () => {}; + } + return this.current.connection.on('connection', callback); + } + + onLogin(callback: () => void): () => void { + if (!this.current) { + return () => {}; } + return this.current.account.on('uid', uid => { + if (uid) { + callback(); + } + }); + } + + stream(...args: Parameters) { + return this.current?.stream(...args); + } + + subscribeNotifyUser(userId?: string) { + const client = this.current?.client; + const uid = userId ?? this.current?.account.user?.id; + if (!client || !uid) { + return []; + } + return NOTIFY_USER_EVENTS.map(event => client.subscribe('stream-notify-user', `${uid}/${event}`)); + } - // Taken from https://github.com/RocketChat/Rocket.Chat.js.SDK/blob/454b4ba784095057b8de862eb99340311b672e15/lib/drivers/ddp.ts#L555 - return Promise.all([ - this.subscribe('stream-room-messages', args[0], ...args), - eventUserTyping, - this.subscribe(topic, `${args[0]}/deleteMessage`, ...args), - this.subscribe(topic, `${args[0]}/deleteMessageBulk`, ...args), - this.subscribe(topic, `${args[0]}/messagesRead`, ...args) - ]); + get currentLogin() { + const user = this.current?.account.user; + if (!user) { + return { userId: '', authToken: '' }; + } + return { + userId: user.id, + authToken: user.token || '' + }; } - unsubscribe(subscription: any[]) { - return this.current.unsubscribe(subscription); + get client() { + return this.current?.client; } - onStreamData(...args: any[]) { - return this.current.onStreamData(...args); + async logout(): Promise { + const next = { ...this.headers }; + delete next['X-Auth-Token']; + delete next['X-User-Id']; + try { + if (this.current?.account) { + const TIMEOUT = Symbol('logout-timeout'); + // account.logout() can hang indefinitely on a dead socket; cap it so app-level logout always completes. + const result = await Promise.race([ + this.current.account.logout(), + new Promise(resolve => setTimeout(() => resolve(TIMEOUT), 5000)) + ]); + if (result === TIMEOUT) { + log(new Error('Sdk.logout(): account.logout() timed out after 5s; server session may still be valid')); + } + } + } catch (e) { + log(e); + } finally { + // Always clear auth headers, even if logout throws — otherwise stale X-Auth-Token/X-User-Id leak into later calls. + this.headers = next; + } } } diff --git a/app/lib/services/setAvatarFromService.test.ts b/app/lib/services/setAvatarFromService.test.ts index d52901a81a9..06812c223e3 100644 --- a/app/lib/services/setAvatarFromService.test.ts +++ b/app/lib/services/setAvatarFromService.test.ts @@ -60,7 +60,7 @@ describe('setAvatarFromService', () => { service: 'url', url: 'https://example.com/a.png' }); - expect(sdk.post).toHaveBeenCalledWith('users.setAvatar', { + expect(sdk.post).toHaveBeenCalledWith('/v1/users.setAvatar', { avatarUrl: 'https://example.com/a.png' }); expect(sdk.methodCallWrapper).not.toHaveBeenCalled(); @@ -85,7 +85,7 @@ describe('setAvatarFromService', () => { service: 'upload', url: 'https://example.com/remote.jpg' }); - expect(sdk.post).toHaveBeenCalledWith('users.setAvatar', { + expect(sdk.post).toHaveBeenCalledWith('/v1/users.setAvatar', { avatarUrl: 'https://example.com/remote.jpg' }); expect(uploadUserAvatarMultipart).not.toHaveBeenCalled(); @@ -98,7 +98,7 @@ describe('setAvatarFromService', () => { service: 'google', url: 'https://lh3.googleusercontent.com/a/abc' }); - expect(sdk.post).toHaveBeenCalledWith('users.setAvatar', { + expect(sdk.post).toHaveBeenCalledWith('/v1/users.setAvatar', { avatarUrl: 'https://lh3.googleusercontent.com/a/abc' }); expect(uploadUserAvatarMultipart).not.toHaveBeenCalled(); diff --git a/app/lib/services/socketHealth.ts b/app/lib/services/socketHealth.ts index aa7d4ceb5f1..38c67d6632e 100644 --- a/app/lib/services/socketHealth.ts +++ b/app/lib/services/socketHealth.ts @@ -1,16 +1,15 @@ import { onAbort } from '../methods/helpers/onAbort'; -import sdk from './sdk'; +import sdk, { type ConnectionStatus } from './sdk'; /** - * The slice of the patched DDP driver this module reads. - * The only guard is `sdk.current?.ddp` being undefined — the patch is guaranteed - * at runtime, so there are no per-method typeof checks. + * The slice of the patched ddp-client connection this module reads + * (`probe`/`reopenNow`/`lastPing` come from patches/@rocket.chat+ddp-client+0.3.51.patch). + * The only guard is `sdk.current?.connection` being undefined — the patch is + * guaranteed at runtime, so there are no per-method typeof checks. */ -interface SocketHealthDdp { - connected?: boolean; +interface SocketHealthConnection { + status: ConnectionStatus; lastPing: number; - pingInterval?: number; - config?: { ping?: number }; reopenNow(): Promise; probe(timeoutMs: number): Promise; } @@ -26,14 +25,15 @@ interface SocketHealthDdp { */ export type SocketRecoveryPlan = 'reopen' | 'round-trip-check'; -export function classifySocketHealth(ddp: SocketHealthDdp): SocketRecoveryPlan { +export function classifySocketHealth(connection: SocketHealthConnection): SocketRecoveryPlan { // Ping age can't vouch for a socket the OS already closed. - if (ddp.connected === false) { + if (connection.status !== 'connected') { return 'reopen'; } - const pingInterval = (ddp.pingInterval ?? ddp.config?.ping) || 10000; - const age = Date.now() - ddp.lastPing; - if (age > pingInterval * 2) { + // The ddp-client heartbeats every 30s (TimeoutControl); a lastPing older + // than 2x the old SDK's 10s default means the transport is stale. + const age = Date.now() - connection.lastPing; + if (age > 20000) { return 'reopen'; } // Anything younger is verified by a round trip, never trusted outright: onOpen @@ -62,20 +62,20 @@ function shareRecovery(): Promise { if (inFlightRecovery) { return inFlightRecovery; } - const ddp = sdk.current?.ddp as SocketHealthDdp | undefined; - if (!ddp) { + const connection = sdk.current?.connection as SocketHealthConnection | undefined; + if (!connection) { return Promise.resolve('no-socket'); } const recovery = (async (): Promise => { - if (classifySocketHealth(ddp) === 'reopen') { - await ddp.reopenNow(); + if (classifySocketHealth(connection) === 'reopen') { + await connection.reopenNow(); return 'reopened'; } - const alive = await ddp.probe(2000); + const alive = await connection.probe(2000); if (alive) { return 'confirmed-alive'; } - await ddp.reopenNow(); + await connection.reopenNow(); return 'reopened'; })(); inFlightRecovery = recovery; @@ -129,7 +129,7 @@ export function recoverSocket(options?: { abortSignal?: AbortSignal }): Promise< * Vocabulary: * - socket health — the classification concern (`classifySocketHealth`). * - recovery plan — `SocketRecoveryPlan`, the decision. - * - round trip — the liveness check (`ddp.probe` stays as the SDK - * method name; our terms say round trip). + * - round trip — the liveness check (`connection.probe`; our terms + * say round trip). * - recovery outcome — `SocketRecoveryOutcome`, what callers see. */ diff --git a/app/lib/services/twoFactor.test.ts b/app/lib/services/twoFactor.test.ts new file mode 100644 index 00000000000..453c530bb11 --- /dev/null +++ b/app/lib/services/twoFactor.test.ts @@ -0,0 +1,70 @@ +/* eslint-disable import/first */ +jest.mock('../methods/helpers/events', () => ({ + __esModule: true, + default: { emit: jest.fn() } +})); + +jest.mock('../../containers/TwoFactor', () => ({ + TWO_FACTOR: 'TWO_FACTOR' +})); + +import { twoFactor } from './twoFactor'; +import EventEmitter from '../methods/helpers/events'; + +describe('twoFactor', () => { + beforeEach(() => { + (EventEmitter.emit as jest.Mock).mockReset(); + }); + + it('emits a TWO_FACTOR event with method, invalid, params, submit, cancel', () => { + twoFactor({ method: 'totp', invalid: false }); + expect(EventEmitter.emit).toHaveBeenCalledWith( + 'TWO_FACTOR', + expect.objectContaining({ + method: 'totp', + invalid: false, + submit: expect.any(Function), + cancel: expect.any(Function) + }) + ); + }); + + it('forwards params through the emitted event', () => { + const params = { user: 'john', password: 'p' }; + twoFactor({ method: 'email', invalid: false, params }); + const emitted = (EventEmitter.emit as jest.Mock).mock.calls[0][1]; + expect(emitted.params).toBe(params); + }); + + it('resolves with { twoFactorCode, twoFactorMethod } when submit is called', async () => { + const promise = twoFactor({ method: 'email', invalid: false }); + const emitted = (EventEmitter.emit as jest.Mock).mock.calls[0][1]; + emitted.submit('123456'); + await expect(promise).resolves.toEqual({ twoFactorCode: '123456', twoFactorMethod: 'email' }); + }); + + it('preserves the method on the resolved value even when the prompt is for a different method', async () => { + const promise = twoFactor({ method: 'totp', invalid: true }); + const emitted = (EventEmitter.emit as jest.Mock).mock.calls[0][1]; + emitted.submit('999999'); + await expect(promise).resolves.toEqual({ twoFactorCode: '999999', twoFactorMethod: 'totp' }); + }); + + it('rejects (with no value) when cancel is called', async () => { + const promise = twoFactor({ method: 'totp', invalid: false }); + const emitted = (EventEmitter.emit as jest.Mock).mock.calls[0][1]; + emitted.cancel(); + await expect(promise).rejects.toBeUndefined(); + }); + + it('emits a fresh handler pair per call (concurrent prompts are independent)', async () => { + const p1 = twoFactor({ method: 'totp', invalid: false }); + const p2 = twoFactor({ method: 'email', invalid: false }); + const e1 = (EventEmitter.emit as jest.Mock).mock.calls[0][1]; + const e2 = (EventEmitter.emit as jest.Mock).mock.calls[1][1]; + e1.submit('111111'); + e2.cancel(); + await expect(p1).resolves.toEqual({ twoFactorCode: '111111', twoFactorMethod: 'totp' }); + await expect(p2).rejects.toBeUndefined(); + }); +}); diff --git a/app/lib/services/twoFactor.ts b/app/lib/services/twoFactor.ts index f8b47609b46..1da5cc3f325 100644 --- a/app/lib/services/twoFactor.ts +++ b/app/lib/services/twoFactor.ts @@ -1,5 +1,3 @@ -import { settings } from '@rocket.chat/sdk'; - import { TWO_FACTOR } from '../../containers/TwoFactor'; import EventEmitter from '../methods/helpers/events'; import { type ICredentials } from '../../definitions'; @@ -18,11 +16,6 @@ export const twoFactor = ({ method, invalid, params }: ITwoFactor): Promise<{ tw params, cancel: () => reject(), submit: (code: string) => { - settings.customHeaders = { - ...settings.customHeaders, - 'x-2fa-code': code, - 'x-2fa-method': method - }; resolve({ twoFactorCode: code, twoFactorMethod: method }); } }); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 4c7df62da10..7769eab607d 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -76,8 +76,11 @@ class MediaSessionInstance { return; } try { - const { signals } = await mediaCallsStateSignals(getUniqueIdSync()); - for (const signal of signals) { + const res = await mediaCallsStateSignals(getUniqueIdSync()); + if (!res.success) { + throw new Error('Failed to fetch media calls state signals'); + } + for (const signal of res.signals) { // Sequential replay: signals depend on prior state mutations. // eslint-disable-next-line no-await-in-loop await this.instance.processSignal(signal); @@ -134,7 +137,7 @@ class MediaSessionInstance { this.instance = mediaSessionStore.getInstance(userId); }); - this.mediaSignalListener = sdk.onStreamData('stream-notify-user', async (ddpMessage: IDDPMessage) => { + this.mediaSignalListener = await sdk.onStreamData('stream-notify-user', async (ddpMessage: IDDPMessage) => { if (!this.instance) { return; } diff --git a/app/lib/services/voip/acceptNativeCall.integration.test.ts b/app/lib/services/voip/acceptNativeCall.integration.test.ts index ef988bc3825..2840f0a2cae 100644 --- a/app/lib/services/voip/acceptNativeCall.integration.test.ts +++ b/app/lib/services/voip/acceptNativeCall.integration.test.ts @@ -5,7 +5,6 @@ import { terminateNativeCall } from './terminateNativeCall'; import { useCallStore } from './useCallStore'; import { initStore } from '../../store/auxStore'; import { recoverSocket } from '../socketHealth'; -import sdk from '../sdk'; import type { IApplicationState } from '../../../definitions'; jest.mock('./terminateNativeCall', () => ({ @@ -22,11 +21,6 @@ jest.mock('../socketHealth', () => ({ recoverSocket: jest.fn() })); -jest.mock('../sdk', () => ({ - __esModule: true, - default: { current: undefined } -})); - jest.mock('../../methods/helpers/log', () => ({ __esModule: true, default: jest.fn() @@ -55,22 +49,6 @@ function makeMediaSession(): IMediaSession { }; } -/** Media Signal subs that ack `delayMs` after the gate starts waiting. */ -function mediaSubsAckAfter(delayMs: number) { - return { - waitForNotifyUserMediaSubs: jest.fn(() => new Promise(resolve => setTimeout(() => resolve(true), delayMs))) - }; -} - -/** Media Signal subs that never ack: the wait ends on its own timeout. */ -function mediaSubsNeverAck() { - return { - waitForNotifyUserMediaSubs: jest.fn( - (timeoutMs: number) => new Promise(resolve => setTimeout(() => resolve(false), timeoutMs)) - ) - }; -} - /** * Minimal redux surface so `waitForLoginReady` runs for real: it reads * `login.isAuthenticated` / `meteor.connected` and subscribes for changes. @@ -105,7 +83,6 @@ describe('acceptNativeCallWithReadiness against real login readiness', () => { initStore(redux.store); mockGetCallState.mockReturnValue({ call: null, resetNativeCallId: jest.fn() }); mockRecoverSocket.mockResolvedValue('reopened'); - (sdk as any).current = { ddp: mediaSubsAckAfter(100) }; }); afterEach(() => { @@ -148,14 +125,13 @@ describe('acceptNativeCallWithReadiness against real login readiness', () => { }); it('runs the failure ladder once and leaves nothing behind when readiness never lands', async () => { - (sdk as any).current = { ddp: mediaSubsNeverAck() }; const resetNativeCallId = jest.fn(); mockGetCallState.mockReturnValue({ call: null, resetNativeCallId }); const mediaSession = makeMediaSession(); const gate = acceptNativeCallWithReadiness(CALL_ID, mediaSession); - // Login never authenticates and the media subs never ack. + // Login never authenticates, so the readiness wait times out. await jest.advanceTimersByTimeAsync(READINESS_TIMEOUT); await gate; diff --git a/app/lib/services/voip/acceptNativeCall.test.ts b/app/lib/services/voip/acceptNativeCall.test.ts index 026f4e9dc0b..74b6a10edce 100644 --- a/app/lib/services/voip/acceptNativeCall.test.ts +++ b/app/lib/services/voip/acceptNativeCall.test.ts @@ -3,13 +3,11 @@ import { useCallStore } from './useCallStore'; import { terminateNativeCall } from './terminateNativeCall'; import { waitForLoginReady } from '../waitForLoginReady'; import { recoverSocket } from '../socketHealth'; -import sdk from '../sdk'; const mockWaitForLoginReady = waitForLoginReady as jest.MockedFunction; const mockRecoverSocket = recoverSocket as jest.MockedFunction; const mockGetState = useCallStore.getState as jest.Mock; const mockTerminateNativeCall = terminateNativeCall as jest.Mock; -const mockDdp = () => sdk.current?.ddp as any; jest.mock('./useCallStore', () => ({ useCallStore: { @@ -21,13 +19,6 @@ jest.mock('./terminateNativeCall', () => ({ terminateNativeCall: jest.fn() })); -jest.mock('../sdk', () => ({ - __esModule: true, - default: { - current: { ddp: {} } - } -})); - jest.mock('../socketHealth', () => ({ recoverSocket: jest.fn() })); @@ -59,13 +50,6 @@ function makeMediaSession(overrides: Partial = {}): IMediaSession }; } -function makeDdp(overrides: Record = {}) { - return { - waitForNotifyUserMediaSubs: jest.fn(() => Promise.resolve(true)), - ...overrides - }; -} - function makeStoreState(overrides: Record = {}) { return { call: null, @@ -80,7 +64,6 @@ describe('acceptNativeCallWithReadiness', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - (sdk as any).current = { ddp: makeDdp() }; mockRecoverSocket.mockResolvedValue('confirmed-alive'); mockWaitForLoginReady.mockResolvedValue(true); mockGetState.mockReturnValue(makeStoreState()); @@ -162,20 +145,6 @@ describe('acceptNativeCallWithReadiness', () => { expect(mediaSession.applyRestStateSignals).not.toHaveBeenCalled(); }); - it('terminates and ends the call when media-subscription ack times out', async () => { - mockDdp().waitForNotifyUserMediaSubs = jest.fn(() => Promise.resolve(false)); - const mediaSession = makeMediaSession(); - const resetNativeCallId = jest.fn(); - mockGetState.mockReturnValue(makeStoreState({ resetNativeCallId })); - - await acceptNativeCallWithReadiness(CALL_ID, mediaSession); - - expect(mockTerminateNativeCall).toHaveBeenCalledWith(CALL_ID); - expect(resetNativeCallId).toHaveBeenCalled(); - expect(mediaSession.endCall).toHaveBeenCalledWith(CALL_ID); - expect(mediaSession.applyRestStateSignals).not.toHaveBeenCalled(); - }); - it('terminates and ends the call when the media session is not initialized', async () => { const mediaSession = makeMediaSession({ isInitialized: jest.fn(() => false) }); const resetNativeCallId = jest.fn(); @@ -189,19 +158,6 @@ describe('acceptNativeCallWithReadiness', () => { expect(mediaSession.applyRestStateSignals).not.toHaveBeenCalled(); }); - it('terminates and ends the call when the SDK socket is unavailable for media subscriptions', async () => { - (sdk as any).current = {}; - const mediaSession = makeMediaSession(); - const resetNativeCallId = jest.fn(); - mockGetState.mockReturnValue(makeStoreState({ resetNativeCallId })); - - await acceptNativeCallWithReadiness(CALL_ID, mediaSession); - - expect(mockTerminateNativeCall).toHaveBeenCalledWith(CALL_ID); - expect(resetNativeCallId).toHaveBeenCalled(); - expect(mediaSession.endCall).toHaveBeenCalledWith(CALL_ID); - }); - it('does not call answerCall when applyRestStateSignals already answered', async () => { const mediaSession = makeMediaSession(); mockGetState.mockReturnValue(makeStoreState({ call: { callId: CALL_ID } })); diff --git a/app/lib/services/voip/acceptNativeCall.ts b/app/lib/services/voip/acceptNativeCall.ts index 7aab96d7d67..80bfc6d4a84 100644 --- a/app/lib/services/voip/acceptNativeCall.ts +++ b/app/lib/services/voip/acceptNativeCall.ts @@ -1,6 +1,4 @@ import log from '../../methods/helpers/log'; -import { onAbort } from '../../methods/helpers/onAbort'; -import sdk from '../sdk'; import { waitForLoginReady } from '../waitForLoginReady'; import { recoverSocket } from '../socketHealth'; import { terminateNativeCall } from './terminateNativeCall'; @@ -13,34 +11,8 @@ export interface NativeCallMediaSession { isInitialized(): boolean; } -/** The slice of the patched DDP driver the accept path reads: Media Signal subscription readiness. */ -interface MediaSignalDdp { - waitForNotifyUserMediaSubs(timeoutMs: number): Promise; -} - const activeGates = new Map(); -async function waitForMediaSignalSubs(ddp: MediaSignalDdp, timeoutMs: number, abortSignal?: AbortSignal): Promise { - if (typeof ddp.waitForNotifyUserMediaSubs !== 'function') { - return false; - } - if (abortSignal?.aborted) { - return false; - } - - const ready = ddp.waitForNotifyUserMediaSubs(timeoutMs); - const aborted = new Promise(resolve => { - onAbort(abortSignal, () => resolve(false)); - }); - - try { - return await Promise.race([ready, aborted]); - } catch (error) { - log(error); - return false; - } -} - function handleFailure(callId: string, mediaSession: NativeCallMediaSession): void { terminateNativeCall(callId); useCallStore.getState().resetNativeCallId(); @@ -74,21 +46,16 @@ export async function acceptNativeCallWithReadiness(callId: string, mediaSession return; } - const ddp = sdk.current?.ddp as MediaSignalDdp | undefined; - if (!ddp) { - return handleFailure(callId, mediaSession); - } - - const [loginReady, mediaSubsReady] = await Promise.all([ - waitForLoginReady(8000, controller.signal), - waitForMediaSignalSubs(ddp, 8000, controller.signal) - ]); + // Media-signal/media-calls subscriptions are established with the user's + // login (subscribeNotifyUser in subscribeRooms), so login readiness is + // the only media-readiness gate needed here. + const loginReady = await waitForLoginReady(8000, controller.signal); if (controller.signal.aborted) { return; } - if (!loginReady || !mediaSubsReady || !mediaSession.isInitialized()) { + if (!loginReady || !mediaSession.isInitialized()) { return handleFailure(callId, mediaSession); } diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index e6ceccde958..64ae2f740e3 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -35,11 +35,8 @@ jest.mock('../../lib/services/connect', () => ({ jest.mock('../../lib/services/sdk', () => ({ __esModule: true, default: { - current: { - client: { - host: '' - } - } + server: undefined, + current: undefined } })); @@ -98,7 +95,9 @@ import createSagaMiddleware from 'redux-saga'; import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLinking'; import { loginSuccess } from '../../actions/login'; import { selectServerSuccess } from '../../actions/server'; +import { connectSuccess } from '../../actions/connect'; import { appStart } from '../../actions/app'; +import * as types from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; import reducers from '../../reducers'; import deepLinkingRoot from '../deepLinking'; @@ -112,6 +111,8 @@ import { loginOAuthOrSso } from '../../lib/services/connect'; import sdk from '../../lib/services/sdk'; import database from '../../lib/database'; import EventEmitter from '../../lib/methods/helpers/events'; +import { localAuthenticate } from '../../lib/methods/helpers/localAuthentication'; +import { CURRENT_SERVER, TOKEN_KEY } from '../../lib/constants/keys'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -130,6 +131,19 @@ function setupStore(preloadedState?: PreloadedState) { return store; } +/** setupStore that also records dispatched actions (incl. saga puts) for assertions. */ +function setupRecordingStore(preloadedState?: PreloadedState) { + const actions: { type: string }[] = []; + const recorder = () => (next: (a: any) => any) => (action: any) => { + actions.push(action); + return next(action); + }; + const sagaMiddleware = createSagaMiddleware(); + const store = createStore(reducers, preloadedState, applyMiddleware(recorder, sagaMiddleware)); + sagaMiddleware.run(deepLinkingRoot); + return { store, actions }; +} + // ─── Factories ──────────────────────────────────────────────────────────────── const HOST = 'https://open.rocket.chat'; @@ -217,6 +231,11 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // Saga is now waiting for METEOR.SUCCESS ('connected') before dispatching + // loginRequest, so it never logs in on a still-connecting socket. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + // Saga is now waiting for LOGIN.SUCCESS expect(jest.mocked(goRoom)).not.toHaveBeenCalled(); @@ -236,6 +255,56 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); + // Ordering race: socket connects before SERVER.SELECT_SUCCESS; the guard must + // skip the already-fired METEOR.SUCCESS take instead of hanging. + it('completes the chain when METEOR.SUCCESS fires before SERVER.SELECT_SUCCESS', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + // Socket connects first — before SERVER.SELECT_SUCCESS is dispatched. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); + await flushSagaMicrotasks(); + + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); + }); + + // loginRequest must not fire until the socket is connected (locks the gate). + it('does not dispatch loginRequest until METEOR.SUCCESS', async () => { + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === types.LOGIN.REQUEST); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + // Server selected but socket not connected yet → still parked at the gate. + expect(loginRequested()).toBe(false); + + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + // Socket connected → gate released, loginRequest dispatched. + expect(loginRequested()).toBe(true); + }); + /** * Regression negative: dispatch SERVER.SELECT_SUCCESS, LOGIN.SUCCESS. * Flush microtasks. Assert goRoom NOT yet called. @@ -253,6 +322,10 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // Release the METEOR.SUCCESS gate (socket connected) so loginRequest can fire. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); @@ -285,6 +358,10 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // Release the METEOR.SUCCESS gate (socket connected) so loginRequest can fire. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + // Dispatch LOGIN.SUCCESS AND APP.START(ROOT_INSIDE) synchronously before any flush. // The reducer processes both dispatches before the saga's select runs, // so the select sees ROOT_INSIDE and skips the take. @@ -314,6 +391,10 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // Release the METEOR.SUCCESS gate (socket connected) so loginRequest can fire. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); @@ -349,6 +430,10 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // Release the METEOR.SUCCESS gate (socket connected) so loginRequest can fire. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); @@ -399,13 +484,13 @@ describe('deepLinking saga — server already connected, should skip changing se jest.mocked(goRoom).mockResolvedValue(undefined); // Key setup: SDK websocket is already open to HOST - (sdk.current as any).client.host = HOST; + (sdk as any).server = HOST; }); afterEach(() => { jest.useRealTimers(); // Reset so other describe blocks see the default empty host - (sdk.current as any).client.host = ''; + (sdk as any).server = undefined; }); /** @@ -511,12 +596,121 @@ describe('deepLinking saga — handleClickCallPush (new server + token + call ro store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // Release the METEOR.SUCCESS gate (socket connected) so loginRequest can fire. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); await flushSagaMicrotasks(); expect(jest.mocked(navigateToRoom)).toHaveBeenCalledTimes(1); }); + + // Ordering race: socket connects before SERVER.SELECT_SUCCESS; the guard must + // skip the already-fired METEOR.SUCCESS take instead of hanging. + it('completes the call-room chain when METEOR.SUCCESS fires before SERVER.SELECT_SUCCESS', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingClickCallPush(makeCallParams())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + // Socket connects first — before SERVER.SELECT_SUCCESS is dispatched. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(navigateToRoom)).toHaveBeenCalledTimes(1); + }); + + // loginRequest must not fire until the socket is connected (locks the gate). + it('does not dispatch loginRequest until METEOR.SUCCESS', async () => { + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === types.LOGIN.REQUEST); + + store.dispatch(deepLinkingClickCallPush(makeCallParams())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + // Server selected but socket not connected yet → still parked at the gate. + expect(loginRequested()).toBe(false); + + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + // Socket connected → gate released, loginRequest dispatched. + expect(loginRequested()).toBe(true); + }); +}); + +describe('deepLinking saga — handleShareExtension server-already-connected guard', () => { + const SERVER = 'https://my.server.com'; + const USER_TOKEN = 'user-token-123'; + + beforeEach(() => { + jest.mocked(UserPreferences.getString).mockImplementation((key: string) => { + if (key === CURRENT_SERVER) return SERVER; + if (key === `${TOKEN_KEY}-${SERVER}`) return USER_TOKEN; + return null; + }); + jest.mocked(getServerById).mockResolvedValue({ id: SERVER, version: '6.0.0' } as any); + jest.mocked(localAuthenticate).mockResolvedValue(undefined); + (sdk as any).server = undefined; + }); + + afterEach(() => { + (sdk as any).server = undefined; + }); + + /** + * Critical regression: when the SDK is already connected to the server, + * selectServer short-circuits without emitting LOGIN.SUCCESS. + * The saga MUST NOT block on take(LOGIN.SUCCESS) in that case. + */ + it('navigates to ROOT_SHARE_EXTENSION without waiting for LOGIN.SUCCESS when sdk.server === server', async () => { + (sdk as any).server = SERVER; // already connected + const store = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_SHARE_EXTENSION); + }); + + /** + * Normal path: SDK is connected to a different server. The saga must + * wait for LOGIN.SUCCESS before navigating to ROOT_SHARE_EXTENSION. + */ + it('blocks on take(LOGIN.SUCCESS) then navigates when sdk.server !== server', async () => { + (sdk as any).server = 'https://other.server.com'; // different server + const store = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + // Still blocked — LOGIN.SUCCESS not yet dispatched + expect(store.getState().app.root).not.toBe(RootEnum.ROOT_SHARE_EXTENSION); + + store.dispatch(loginSuccess({ id: 'u1', token: USER_TOKEN } as any)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_SHARE_EXTENSION); + }); }); // ─── handleOAuth — single-use credentialToken dedup guard ──────────────────── diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index fac9e3292db..a3c11a9cb96 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -159,7 +159,7 @@ const handleShareExtension = function* handleOpen({ params }) { return; } yield put(selectServerRequest(server, serverRecord.version)); - if (sdk.current?.client?.host !== server) { + if (sdk.server !== server) { yield take(types.LOGIN.SUCCESS); } yield put(shareSetParams(params)); @@ -233,7 +233,7 @@ const handleOpen = function* handleOpen({ params }) { return; } // if the host is different from the current one, we need to connect to it before navigating - const hostAlreadyConnected = sdk.current?.client?.host === host; + const hostAlreadyConnected = sdk.server === host; if (!hostAlreadyConnected) { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); yield put(serverInitAdd(server)); @@ -244,6 +244,11 @@ const handleOpen = function* handleOpen({ params }) { if (params.token) { if (!hostAlreadyConnected) { yield take(types.SERVER.SELECT_SUCCESS); + // SERVER.SELECT_SUCCESS doesn't mean 'connected'; skip the take if it already is. + const connected = yield select(state => state.meteor.connected); + if (!connected) { + yield take(types.METEOR.SUCCESS); + } } yield put(loginRequest({ resume: params.token }, true)); yield take(types.LOGIN.SUCCESS); @@ -339,6 +344,11 @@ const handleClickCallPush = function* handleClickCallPush({ params }) { EventEmitter.emit('NewServer', { server: host }); if (params.token) { yield take(types.SERVER.SELECT_SUCCESS); + // SERVER.SELECT_SUCCESS doesn't mean 'connected'; skip the take if it already is. + const connected = yield select(state => state.meteor.connected); + if (!connected) { + yield take(types.METEOR.SUCCESS); + } yield put(loginRequest({ resume: params.token }, true)); yield take(types.LOGIN.SUCCESS); yield handleNavigateCallRoom({ params }); diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 5373f6b0fcf..bc9d870648d 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -137,10 +137,13 @@ const getServerInfoSaga = function* getServerInfoSaga({ server, raiseError = tru const handleSelectServer = function* handleSelectServer({ server, version, fetchVersion }: ISelectServerAction) { try { - if (sdk.current?.client?.host === server) { - yield put(appStart({ root: RootEnum.ROOT_INSIDE })); - yield put(selectServerCancel()); - return; + if (sdk.server === server) { + const isLoggedIn = yield* appSelector(state => state.login.isAuthenticated && state.meteor.connected); + if (isLoggedIn) { + yield put(appStart({ root: RootEnum.ROOT_INSIDE })); + yield put(selectServerCancel()); + return; + } } // SSL Pinning - Read certificate alias and set it to be used by network requests const certificate = UserPreferences.getString(`${CERTIFICATE_KEY}-${server}`); @@ -179,7 +182,7 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch } const basicAuth = UserPreferences.getString(`${BASIC_AUTH_KEY}-${server}`); - setBasicAuth(basicAuth); + setBasicAuth(basicAuth, server); if (user) { yield put(clearSettings()); diff --git a/app/sagas/videoConf.ts b/app/sagas/videoConf.ts index e87491ad655..60b5d62ea38 100644 --- a/app/sagas/videoConf.ts +++ b/app/sagas/videoConf.ts @@ -190,6 +190,7 @@ function* initCall({ payload: { mic, cam, direct, rid } }: { payload: TCallProps } } catch (e) { yield put(setCalling(false)); + yield call(hideActionSheetRef); showErrorAlert(i18n.t('error-init-video-conf')); log(e); } diff --git a/app/views/CallView/__snapshots__/index.test.tsx.snap b/app/views/CallView/__snapshots__/index.test.tsx.snap index 0b830f93ff6..1f01a1906af 100644 --- a/app/views/CallView/__snapshots__/index.test.tsx.snap +++ b/app/views/CallView/__snapshots__/index.test.tsx.snap @@ -126,7 +126,7 @@ exports[`Story Snapshots: ConnectedCall should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -1000,7 +1000,7 @@ exports[`Story Snapshots: ConnectingCall should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -1880,7 +1880,7 @@ exports[`Story Snapshots: MutedAndOnHold should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -2754,7 +2754,7 @@ exports[`Story Snapshots: MutedCall should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -3628,7 +3628,7 @@ exports[`Story Snapshots: OnHoldCall should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -4502,7 +4502,7 @@ exports[`Story Snapshots: SpeakerOn should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -5359,7 +5359,7 @@ exports[`Story Snapshots: TabletConnectedCall should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -6205,7 +6205,7 @@ exports[`Story Snapshots: TabletConnectingCall should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -7057,7 +7057,7 @@ exports[`Story Snapshots: TabletMutedAndOnHold should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -7903,7 +7903,7 @@ exports[`Story Snapshots: TabletMutedCall should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -8749,7 +8749,7 @@ exports[`Story Snapshots: TabletOnHoldCall should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -9595,7 +9595,7 @@ exports[`Story Snapshots: TabletSpeakerOn should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -10441,7 +10441,7 @@ exports[`Story Snapshots: TabletWithDialpad should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -11304,7 +11304,7 @@ exports[`Story Snapshots: WithDialpad should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, diff --git a/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap b/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap index 37430109eed..49eb2a245b1 100644 --- a/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap +++ b/app/views/CallView/components/__snapshots__/CallerInfo.test.tsx.snap @@ -98,7 +98,7 @@ exports[`Story Snapshots: Default should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/bob.burnquist?format=png&size=240", }, @@ -253,7 +253,7 @@ exports[`Story Snapshots: UsernameOnly should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/john.doe?format=png&size=240", }, diff --git a/app/views/DiscussionsView/__snapshots__/Item.test.tsx.snap b/app/views/DiscussionsView/__snapshots__/Item.test.tsx.snap index 792d2047ad4..25a2a278ff3 100644 --- a/app/views/DiscussionsView/__snapshots__/Item.test.tsx.snap +++ b/app/views/DiscussionsView/__snapshots__/Item.test.tsx.snap @@ -143,7 +143,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -526,7 +526,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -909,7 +909,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -1294,7 +1294,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -1677,7 +1677,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -2080,7 +2080,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -2450,7 +2450,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -2820,7 +2820,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, diff --git a/app/views/NewMessageView/__snapshots__/Item.test.tsx.snap b/app/views/NewMessageView/__snapshots__/Item.test.tsx.snap index 7f6dd412fe7..66eb252d698 100644 --- a/app/views/NewMessageView/__snapshots__/Item.test.tsx.snap +++ b/app/views/NewMessageView/__snapshots__/Item.test.tsx.snap @@ -99,7 +99,7 @@ exports[`NewMessageView Item should match snapshot when hasMediaCallPermission i [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/john.doe?format=png&size=60", }, @@ -260,7 +260,7 @@ exports[`Story Snapshots: Default should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/john.doe?format=png&size=60", }, @@ -483,7 +483,7 @@ exports[`Story Snapshots: LongName should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/long.name?format=png&size=60", }, @@ -706,7 +706,7 @@ exports[`Story Snapshots: NoVoiceCallPermission should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/john.doe?format=png&size=60", }, diff --git a/app/views/NewServerView/methods/basicAuth.ts b/app/views/NewServerView/methods/basicAuth.ts index 80e8d2de8c7..e46999e01c0 100644 --- a/app/views/NewServerView/methods/basicAuth.ts +++ b/app/views/NewServerView/methods/basicAuth.ts @@ -10,7 +10,7 @@ const basicAuth = (server: string, text: string) => { if (parsedUrl.auth.length) { const credentials = Base64.encode(parsedUrl.auth); UserPreferences.setString(`${BASIC_AUTH_KEY}-${server}`, credentials); - setBasicAuth(credentials); + setBasicAuth(credentials, server); } } catch { // do nothing diff --git a/app/views/RoomInfoView/components/__snapshots__/RoomInfoViewAvatar.test.tsx.snap b/app/views/RoomInfoView/components/__snapshots__/RoomInfoViewAvatar.test.tsx.snap index 1baf0606fea..c12b1a2834b 100644 --- a/app/views/RoomInfoView/components/__snapshots__/RoomInfoViewAvatar.test.tsx.snap +++ b/app/views/RoomInfoView/components/__snapshots__/RoomInfoViewAvatar.test.tsx.snap @@ -49,7 +49,9 @@ exports[`Story Snapshots: Default should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/john.doe?format=png&size=240", }, ] @@ -117,7 +119,9 @@ exports[`Story Snapshots: WithEdit should match snapshot 1`] = ` source={ [ { - "headers": undefined, + "headers": { + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", + }, "uri": "https://open.rocket.chat/avatar/john.doe?format=png&size=240", }, ] diff --git a/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap b/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap index 0bc989b37bd..ad058dd6156 100644 --- a/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap +++ b/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap @@ -773,7 +773,7 @@ exports[`Story Snapshots: BlackTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -1895,7 +1895,7 @@ exports[`Story Snapshots: BlackTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -2805,7 +2805,7 @@ exports[`Story Snapshots: BlackTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -3382,7 +3382,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -4504,7 +4504,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -5414,7 +5414,7 @@ exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -5991,7 +5991,7 @@ exports[`Story Snapshots: LightTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -7113,7 +7113,7 @@ exports[`Story Snapshots: LightTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, @@ -8023,7 +8023,7 @@ exports[`Story Snapshots: LightTheme should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72", }, diff --git a/app/views/RoomView/UploadProgress.tsx b/app/views/RoomView/UploadProgress.tsx index 696f39f2e3b..213353ec940 100644 --- a/app/views/RoomView/UploadProgress.tsx +++ b/app/views/RoomView/UploadProgress.tsx @@ -155,7 +155,7 @@ class UploadProgress extends Component { - const { rid, baseUrl: server, user } = this.props; + const { rid, baseUrl: server } = this.props; try { const db = database.active; @@ -164,7 +164,7 @@ class UploadProgress extends Component => { + searchMessages = async (searchText: string) => { if (!searchText) { return []; } diff --git a/app/views/ShareView/index.tsx b/app/views/ShareView/index.tsx index 3f8ef84aae8..4d286881d80 100644 --- a/app/views/ShareView/index.tsx +++ b/app/views/ShareView/index.tsx @@ -282,7 +282,6 @@ class ShareView extends Component { rid: room.rid, tmid: this.getThreadId(thread), server, - user: { id: user.id, token: user.token }, altTextSupported: isAltTextSupported, getMsg: ({ description }) => (isAltTextSupported ? description || msg : msg) }); diff --git a/app/views/ThreadMessagesView/__snapshots__/Item.test.tsx.snap b/app/views/ThreadMessagesView/__snapshots__/Item.test.tsx.snap index 389f9a62f8d..f7ecdacf765 100644 --- a/app/views/ThreadMessagesView/__snapshots__/Item.test.tsx.snap +++ b/app/views/ThreadMessagesView/__snapshots__/Item.test.tsx.snap @@ -143,7 +143,7 @@ exports[`Story Snapshots: Badge should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -667,7 +667,7 @@ exports[`Story Snapshots: Badge should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -1191,7 +1191,7 @@ exports[`Story Snapshots: Badge should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -1702,7 +1702,7 @@ exports[`Story Snapshots: Badge should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -2246,7 +2246,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -2754,7 +2754,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -3262,7 +3262,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -3770,7 +3770,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -4278,7 +4278,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -4806,7 +4806,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -5317,7 +5317,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, @@ -5828,7 +5828,7 @@ exports[`Story Snapshots: Themes should match snapshot 1`] = ` [ { "headers": { - "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + "User-Agent": "RC Mobile; ios 14.0; v1.0.0 (1)", }, "uri": "https://open.rocket.chat/avatar/rocket.cat?format=png&size=72", }, diff --git a/app/views/ThreadMessagesView/index.tsx b/app/views/ThreadMessagesView/index.tsx index 50f838344dd..eeba863c92d 100644 --- a/app/views/ThreadMessagesView/index.tsx +++ b/app/views/ThreadMessagesView/index.tsx @@ -351,6 +351,8 @@ class ThreadMessagesView extends Component { useEffect(() => { async function getPreferences() { try { - const result = await getUserPreferences(userId); + const result = await getUserPreferences(); if (result.success) { setLoading(false); setPreferences(result.preferences); diff --git a/jest.config.js b/jest.config.js index 8dcdf6d5a6f..501f2c9d656 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,7 @@ module.exports = { modulePathIgnorePatterns: ['/.*worktrees/'], testPathIgnorePatterns: ['e2e', 'node_modules', '/.*worktrees/', '/__tests__/testHelpers\\.tsx$'], transformIgnorePatterns: [ - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@rocket.chat/ui-kit|@rocket.chat/sdk|@rocket.chat/message-parser|tiny-events)' + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@rocket.chat/ui-kit|@rocket.chat/ddp-client|@rocket.chat/message-parser|tiny-events)' ], preset: './jest.preset.js', cacheDirectory: '/.jest-cache', diff --git a/package.json b/package.json index 73238f637d2..da1b8539f3e 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,10 @@ "@react-navigation/native": "^7.3.3", "@react-navigation/native-stack": "^7.17.5", "@rocket.chat/media-signaling": "1.0.0-rc.1", + "@rocket.chat/core-typings": "^8.3.2", + "@rocket.chat/ddp-client": "^0.3.50", "@rocket.chat/message-parser": "0.31.36", "@rocket.chat/mobile-crypto": "RocketChat/rocket.chat-mobile-crypto#main", - "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#mobile", "@rocket.chat/ui-kit": "^0.39.0", "@zoontek/react-native-navigation-bar": "^1.1.1", "axios": "0.30.3", @@ -145,7 +146,9 @@ "pnpm": { "overrides": { "ua-parser-js": "1.0.2", - "jpeg-js": "0.4.4" + "jpeg-js": "0.4.4", + "@rocket.chat/core-typings": "8.3.2", + "@rocket.chat/media-signaling": "1.0.0-rc.1" }, "onlyBuiltDependencies": [ "@bugsnag/cli", diff --git a/patches/@rocket.chat+api-client+0.2.56.patch b/patches/@rocket.chat+api-client+0.2.56.patch new file mode 100644 index 00000000000..a746d699219 --- /dev/null +++ b/patches/@rocket.chat+api-client+0.2.56.patch @@ -0,0 +1,29 @@ +--- a/node_modules/@rocket.chat/api-client/dist/index.js ++++ b/node_modules/@rocket.chat/api-client/dist/index.js +@@ -33,7 +33,7 @@ + } + if (typeof data === 'object' && !(data instanceof File)) { + Object.keys(data).forEach((key) => { +- buildFormData(formData, data[key], parentKey ? `${parentKey}[${key}]` : key); ++ buildFormData(data[key], formData, parentKey ? `${parentKey}[${key}]` : key); + }); + } + else { +@@ -128,9 +128,14 @@ + return response.json(); + }); + } +- delete(endpoint_1, _params_1) { +- return __awaiter(this, arguments, void 0, function* (endpoint, _params, options = {}) { +- const response = yield this.send(endpoint, 'DELETE', options !== null && options !== void 0 ? options : {}); ++ delete(endpoint_1, params_1) { ++ return __awaiter(this, arguments, void 0, function* (endpoint, params, _a = {}) { ++ var { headers } = _a, options = __rest(_a, ["headers"]); ++ const isFormData = checkIfIsFormData(params); ++ const response = yield this.send(endpoint, 'DELETE', Object.assign({ body: isFormData ? buildFormData(params) : JSON.stringify(params), headers: Object.assign(Object.assign({ Accept: 'application/json' }, (!isFormData && params !== undefined && { 'Content-Type': 'application/json' })), headers) }, options)); ++ if (response.status === 204) { ++ return {}; ++ } + return response.json(); + }); + } diff --git a/patches/@rocket.chat+ddp-client+0.3.51.patch b/patches/@rocket.chat+ddp-client+0.3.51.patch new file mode 100644 index 00000000000..86706879c78 --- /dev/null +++ b/patches/@rocket.chat+ddp-client+0.3.51.patch @@ -0,0 +1,165 @@ +diff --git a/node_modules/@rocket.chat/ddp-client/dist/Connection.js b/node_modules/@rocket.chat/ddp-client/dist/Connection.js +index c6c3edf..15cd6be 100644 +--- a/node_modules/@rocket.chat/ddp-client/dist/Connection.js ++++ b/node_modules/@rocket.chat/ddp-client/dist/Connection.js +@@ -11,6 +11,8 @@ class ConnectionImpl extends emitter_1.Emitter { + this.status = 'idle'; + this.retryCount = 0; + this.queue = new Set(); ++ this.lastPing = Date.now(); ++ this.reopenInFlight = null; + this.ssl = url.startsWith('https') || url.startsWith('wss'); + this.url = url.replace(/^https?:\/\//, '').replace(/^wss?:\/\//, ''); + this.client.onDispatchMessage((message) => { +@@ -51,7 +53,9 @@ class ConnectionImpl extends emitter_1.Emitter { + this.ws = ws; + return new Promise((resolve, reject) => { + ws.onopen = () => { ++ this.lastPing = Date.now(); + ws.onmessage = (event) => { ++ this.lastPing = Date.now(); + this.client.handleMessage(String(event.data)); + }; + // The server may send an initial message which is a JSON object lacking a msg key. If so, the client should ignore it. The client does not have to wait for this message. +@@ -91,6 +95,12 @@ class ConnectionImpl extends emitter_1.Emitter { + }); + }; + ws.onclose = () => { ++ // Ignore close events from a socket we've already replaced. ++ // Without this, a zombie socket's late onclose clobbers the live ++ // connection's status and may trigger a spurious reconnect. ++ if (ws !== this.ws) { ++ return; ++ } + clearTimeout(this.retryOptions.retryTimer); + if (this.status === 'closed') { + return; +@@ -109,11 +119,97 @@ class ConnectionImpl extends emitter_1.Emitter { + }); + } + close() { +- var _a; + this.status = 'closed'; +- (_a = this.ws) === null || _a === void 0 ? void 0 : _a.close(); ++ if (this.ws) { ++ // Sever all handlers before closing so the dying socket cannot ++ // deliver late messages or fire onclose on the live connection. ++ try { ++ this.ws.onopen = null; ++ this.ws.onmessage = null; ++ this.ws.onerror = null; ++ this.ws.onclose = null; ++ } ++ catch (_) { ++ // some WebSocket impls throw on null assignment — safe to ignore ++ } ++ this.ws.close(); ++ } + this.emitStatus(); + } ++ probe(timeoutMs = 2000) { ++ return new Promise((resolve) => { ++ if (!this.ws || this.ws.readyState !== this.ws.OPEN) { ++ resolve(false); ++ return; ++ } ++ const lastPingAtStart = this.lastPing; ++ let settled = false; ++ let off; ++ const cleanup = () => { ++ if (settled) return; ++ settled = true; ++ if (off) off(); ++ clearTimeout(timer); ++ }; ++ const onPong = () => { ++ if (this.lastPing <= lastPingAtStart) return; ++ cleanup(); ++ resolve(true); ++ }; ++ off = this.client.on('pong', onPong); ++ const timer = setTimeout(() => { ++ cleanup(); ++ resolve(false); ++ }, timeoutMs); ++ try { ++ this.ws.send(JSON.stringify({ msg: 'ping' })); ++ } ++ catch (_a) { ++ cleanup(); ++ resolve(false); ++ } ++ }); ++ } ++ reopenNow() { ++ if (this.reopenInFlight) return this.reopenInFlight; ++ clearTimeout(this.retryOptions.retryTimer); ++ this.emit('disconnected'); ++ if (this.ws) { ++ try { ++ this.ws.onopen = null; ++ this.ws.onmessage = null; ++ this.ws.onerror = null; ++ this.ws.onclose = null; ++ } ++ catch (_a) { ++ // some WebSocket impls throw on null assignment — safe to ignore ++ } ++ try { ++ this.ws.close(); ++ } ++ catch (_b) { ++ // ignore — we're rebuilding anyway ++ } ++ } ++ this.status = 'disconnected'; ++ const p = new Promise((resolve) => { ++ let settled = false; ++ const finish = () => { ++ if (settled) return; ++ settled = true; ++ clearTimeout(timer); ++ resolve(); ++ }; ++ const timer = setTimeout(finish, 10000); ++ this.connect().then(finish, finish); ++ }); ++ this.reopenInFlight = p; ++ const clear = () => { ++ if (this.reopenInFlight === p) this.reopenInFlight = null; ++ }; ++ p.then(clear, clear); ++ return p; ++ } + static create(url, webSocketImpl, client, retryOptions = { retryCount: 0, retryTime: 1000 }) { + return new ConnectionImpl(url, webSocketImpl, client, retryOptions); + } +diff --git a/node_modules/@rocket.chat/ddp-client/dist/Connection.d.ts b/node_modules/@rocket.chat/ddp-client/dist/Connection.d.ts +index 5b312fa..fabff3e 100644 +--- a/node_modules/@rocket.chat/ddp-client/dist/Connection.d.ts ++++ b/node_modules/@rocket.chat/ddp-client/dist/Connection.d.ts +@@ -21,6 +21,8 @@ export interface Connection extends Emitter<{ + connect(): Promise; + reconnect(): Promise; + close(): void; ++ probe(timeoutMs?: number): Promise; ++ reopenNow(): Promise; + } + interface WebSocketConstructor { + new (url: string | URL, protocols?: string | string[]): WebSocket; +@@ -43,11 +45,15 @@ export declare class ConnectionImpl extends Emitter<{ + ws: WebSocket | undefined; + retryCount: number; + queue: Set; ++ private lastPing; ++ private reopenInFlight; + constructor(url: string, WS: WebSocketConstructor, client: DDPClient, retryOptions?: RetryOptions); + private emitStatus; + reconnect(): Promise; + connect(): Promise; + close(): void; ++ probe(timeoutMs?: number): Promise; ++ reopenNow(): Promise; + static create(url: string, webSocketImpl: WebSocketConstructor, client: DDPClient, retryOptions?: RetryOptions): ConnectionImpl; + } + export {}; diff --git a/patches/@rocket.chat+rest-typings+8.4.1.patch b/patches/@rocket.chat+rest-typings+8.4.1.patch new file mode 100644 index 00000000000..333b88793d3 --- /dev/null +++ b/patches/@rocket.chat+rest-typings+8.4.1.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/@rocket.chat/rest-typings/dist/v1/push.d.ts b/node_modules/@rocket.chat/rest-typings/dist/v1/push.d.ts +--- a/node_modules/@rocket.chat/rest-typings/dist/v1/push.d.ts ++++ b/node_modules/@rocket.chat/rest-typings/dist/v1/push.d.ts +@@ -1,4 +1,4 @@ +-import type { IMessage, IPushNotificationConfig, IPushTokenTypes } from '@rocket.chat/core-typings'; ++import type { IMessage, IPushNotificationConfig, IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings'; + type PushTokenProps = { + id?: string; + type: IPushTokenTypes; +@@ -26,6 +26,16 @@ + success: true; + }; + }; ++ '/v1/push.token': { ++ POST: (payload: PushTokenProps) => { ++ result: IPushToken; ++ }; ++ DELETE: (payload: { ++ token: string; ++ }) => { ++ success: boolean; ++ }; ++ }; + }; + export {}; + //# sourceMappingURL=push.d.ts.map diff --git a/patches/@rocket.chat+sdk+1.3.3-mobile.patch b/patches/@rocket.chat+sdk+1.3.3-mobile.patch deleted file mode 100644 index e0e2d0b4465..00000000000 --- a/patches/@rocket.chat+sdk+1.3.3-mobile.patch +++ /dev/null @@ -1,329 +0,0 @@ -diff --git a/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts b/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts -index 19d31ae..068b61e 100644 ---- a/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts -+++ b/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts -@@ -55,6 +55,7 @@ export class Socket extends EventEmitter { - connection?: WebSocket - session?: string - logger: ILogger -+ reopenPromise?: Promise - - /** Create a websocket handler */ - constructor ( -@@ -82,18 +83,13 @@ export class Socket extends EventEmitter { - } - - /** -- * Open websocket connection, with optional retry interval. -- * Stores connection, setting up handlers for open/close/message events. -- * Resumes login if given token. -+ * Create a new WebSocket, tear down any previous one, and wire up handlers. -+ * Emits 'connecting' exactly once per actual new socket. - */ -- open = (ms: number = this.config.reopen) => { -- return new Promise(async (resolve, reject) => { -+ private createConnection = (): Promise => { -+ return new Promise((resolve, reject) => { - let connection: WebSocket - -- if (this.connected) { -- return resolve() -- } -- - try { - connection = new WebSocket(this.host, null, { headers: settings.customHeaders }) - connection.onerror = reject -@@ -101,14 +97,53 @@ export class Socket extends EventEmitter { - this.logger.error(err) - return reject(err) - } -+ // Tear down the previous connection before replacing it. -+ // Callers only reach here when the existing socket isn't healthy, so -+ // detaching its handlers and closing it stops a stale or still-connecting -+ // socket from later firing onClose and clobbering the live connection. -+ if (this.connection) { -+ try { -+ this.connection.onopen = null as any -+ this.connection.onmessage = null as any -+ this.connection.onerror = null as any -+ this.connection.onclose = null as any -+ this.connection.close(userDisconnectCloseCode) -+ } catch (err) { -+ this.logger.debug(`[ddp] open: previous connection teardown failed: ${(err as Error).message}`) -+ } -+ } - this.connection = connection - this.connection.onmessage = this.onMessage.bind(this) -- this.connection.onclose = this.onClose.bind(this) -+ this.connection.onclose = (ev: any) => this.onClose(ev, connection) // pass closing socket so onClose can compare identity - this.connection.onopen = this.onOpen.bind(this, resolve) - this.emit('connecting') - }) - } - -+ /** -+ * Open websocket connection, with optional retry interval. -+ * Stores connection, setting up handlers for open/close/message events. -+ * Resumes login if given token. -+ */ -+ open = (ms: number = this.config.reopen) => { -+ return new Promise(async (resolve, reject) => { -+ if (this.connected) { -+ return resolve() -+ } -+ -+ if (this.reopenPromise) { -+ return this.reopenPromise.then(() => resolve(this.connection)).catch(reject) -+ } -+ -+ try { -+ await this.createConnection() -+ resolve(this.connection) -+ } catch (err) { -+ reject(err) -+ } -+ }) -+ } -+ - /** Send handshake message to confirm connection, start pinging. */ - onOpen = async (callback: Function) => { - this.lastPing = Date.now() -@@ -125,7 +160,14 @@ export class Socket extends EventEmitter { - } - - /** Emit close event so it can be used for promise resolve in close() */ -- onClose = (e: any) => { -+ onClose = (e: any, closedConnection?: WebSocket) => { -+ // Ignore close events from a socket we've already replaced (an -+ // orphan). Only the current connection's close should flip app state or trigger a -+ // reopen; otherwise a zombie socket's late close clobbers the live connection and -+ // the app falsely shows "Waiting for network". -+ if (closedConnection && closedConnection !== this.connection) { -+ return -+ } - this.emit('close', e) - try { - if (e?.code !== userDisconnectCloseCode) { -@@ -201,6 +243,85 @@ export class Socket extends EventEmitter { - }, this.config.reopen); - } - -+ /** -+ * Force an immediate reconnect. Shared across concurrent callers so only one -+ * new WebSocket is created. Emits 'disconnected' to unblock in-flight sends, -+ * then creates the connection directly so a concurrent open() cannot tear it -+ * down. Unhandled creation errors are swallowed because cleanup already runs -+ * via the open/timeout paths. -+ */ -+ reopenNow = (): Promise => { -+ if (this.reopenPromise) { -+ return this.reopenPromise -+ } -+ -+ this.reopenPromise = new Promise(resolve => { -+ this.openTimeout && clearTimeout(this.openTimeout as any) -+ this.lastPing = 0 -+ this.emit('disconnected') -+ -+ let settled = false -+ const cleanup = () => { -+ if (settled) return -+ settled = true -+ this.off('open', cleanup) -+ if (timeout) clearTimeout(timeout as any) -+ delete this.reopenPromise -+ resolve() -+ } -+ -+ this.once('open', cleanup) -+ -+ this.createConnection().catch(() => {}) -+ -+ const timeout = setTimeout(() => cleanup(), 10000) -+ }) -+ -+ return this.reopenPromise -+ } -+ -+ /** -+ * Bounded liveness check for a socket in the gray zone. Returns true only if -+ * the socket is open and the server answers the ping within the deadline. -+ */ -+ probe = (timeoutMs = 2000): Promise => { -+ return new Promise(resolve => { -+ if (!this.connection || this.connection.readyState !== 1) { -+ return resolve(false) -+ } -+ -+ const lastPingAtStart = this.lastPing -+ -+ let settled = false -+ const cleanup = () => { -+ if (settled) return -+ settled = true -+ this.off('pong', onPong) -+ if (timeout) clearTimeout(timeout as any) -+ } -+ -+ const onPong = () => { -+ if (this.lastPing <= lastPingAtStart) return -+ cleanup() -+ resolve(true) -+ } -+ -+ this.once('pong', onPong) -+ -+ const timeout = setTimeout(() => { -+ cleanup() -+ resolve(false) -+ }, timeoutMs) -+ -+ try { -+ this.connection.send(JSON.stringify({ msg: 'ping' })) -+ } catch { -+ cleanup() -+ resolve(false) -+ } -+ }) -+ } -+ - /** Check if websocket connected and ready. */ - get connected () { - return !!( -@@ -254,7 +375,7 @@ export class Socket extends EventEmitter { - return resolve() - } - this.once(listener, (result: any) => { -- this.off('disconnect', reject) -+ this.off('disconnected', reject) - return (result.error ? reject(result.error) : resolve({ ...(/connect|ping|pong/.test(obj.msg) ? {} : { id }) , ...result })) - }) - }) -@@ -447,7 +568,7 @@ export class DDPDriver extends EventEmitter implements ISocket, IDriver { - ...config, - ...moreConfigs, - host: host.replace(/(^\w+:|^)\/\//, ''), -- timeout: 20000 -+ timeout: 10000 - // reopen: number - // ping: number - // close: number -@@ -503,6 +624,22 @@ export class DDPDriver extends EventEmitter implements ISocket, IDriver { - return this.ddp.checkAndReopen() - } - -+ reopenNow = (): Promise => { -+ return this.ddp.reopenNow() -+ } -+ -+ probe = (timeoutMs?: number): Promise => { -+ return this.ddp.probe(timeoutMs) -+ } -+ -+ get lastPing (): number { -+ return this.ddp.lastPing -+ } -+ -+ get pingInterval (): number { -+ return this.ddp.config.ping -+ } -+ - subscribe = (topic: string, eventname: string, ...args: any[]): Promise => { - this.logger.info(`[DDP driver] Subscribing to ${topic} | ${JSON.stringify(args)}`) - return this.ddp.subscribe(topic, [eventname, { 'useCollection': false, 'args': args }]) -@@ -549,10 +686,70 @@ export class DDPDriver extends EventEmitter implements ISocket, IDriver { - 'uiInteraction', - 'e2ekeyRequest', - 'userData', -- 'video-conference' -+ 'video-conference', -+ 'media-signal', -+ 'media-calls' - ].map(event => this.subscribe(topic, `${this.userId}/${event}`, false))) - } - -+ /** -+ * Re-send the user's media-signal and media-calls subscriptions on the current -+ * socket and resolve when the server acks them with `ready`. This gives the app -+ * an observable readiness signal after a forced reconnect. -+ * -+ * If the subscriptions are not yet present (e.g. immediately after reopenNow), -+ * it polls the socket subscription map until they appear or the timeout expires. -+ */ -+ waitForNotifyUserMediaSubs = (timeoutMs = 8000): Promise => { -+ if (!this.userId) { -+ return Promise.resolve(false) -+ } -+ const topic = 'stream-notify-user' -+ const names = ['media-signal', 'media-calls'] -+ const userId = this.userId -+ const findSubs = () => Object.keys(this.ddp.subscriptions || {}) -+ .map(id => this.ddp.subscriptions[id]) -+ .filter((sub: any) => ( -+ sub && -+ sub.name === topic && -+ names.some(name => sub.params?.[0] === `${userId}/${name}`) -+ )) -+ // Go through the raw socket: the driver's subscribe() wrapper reshapes its -+ // arguments and would drop the subscription id, making the server treat the -+ // resubscribe as a brand new subscription. -+ const resubscribe = (subs: any[]) => Promise.all( -+ subs.map((sub: any) => this.ddp.subscribe(topic, sub.params, undefined, sub.id)) -+ ) -+ .then(() => true) -+ .catch(() => false) -+ return new Promise(resolve => { -+ let settled = false -+ let inFlight = false -+ const finish = (value: boolean) => { -+ if (settled) return -+ settled = true -+ clearInterval(poll) -+ clearTimeout(deadline) -+ resolve(value) -+ } -+ const attempt = () => { -+ if (inFlight) return -+ const subs = findSubs() -+ const allPresent = names.every(name => subs.some((sub: any) => sub.params?.[0] === `${userId}/${name}`)) -+ if (allPresent) { -+ inFlight = true -+ resubscribe(subs).then(value => { -+ inFlight = false -+ finish(value) -+ }) -+ } -+ } -+ const deadline = setTimeout(() => finish(false), timeoutMs) -+ const poll = setInterval(attempt, 100) -+ attempt() -+ }) -+ } -+ - subscribeRoom = (rid: string, ...args: any[]): Promise => { - const topic = 'stream-notify-room' - return Promise.all([ -diff --git a/node_modules/@rocket.chat/sdk/lib/clients/Rocketchat.ts b/node_modules/@rocket.chat/sdk/lib/clients/Rocketchat.ts -index 591c1b9..82165c0 100644 ---- a/node_modules/@rocket.chat/sdk/lib/clients/Rocketchat.ts -+++ b/node_modules/@rocket.chat/sdk/lib/clients/Rocketchat.ts -@@ -6,6 +6,7 @@ export default class RocketChatClient extends ClientRest implements ISocket { - userId: string = '' - logger: ILogger = Logger - socket: Promise -+ ddp?: any - config: any - - constructor ({ logger, allPublic, rooms, integrationId, protocol = Protocols.DDP, ...config }: any) { -@@ -16,7 +17,10 @@ export default class RocketChatClient extends ClientRest implements ISocket { - // this.socket = import(/* webpackChunkName: 'mqtt' */ '../drivers/mqtt').then(({ MQTTDriver }) => new MQTTDriver({ ...config, logger })) - // break - case Protocols.DDP: -- this.socket = import(/* webpackChunkName: 'ddp' */ '../drivers/ddp').then(({ DDPDriver }) => new DDPDriver({ ...config, logger })) -+ this.socket = import(/* webpackChunkName: 'ddp' */ '../drivers/ddp').then(({ DDPDriver }) => { -+ this.ddp = new DDPDriver({ ...config, logger }) -+ return this.ddp -+ }) - break - default: - throw new Error(`Invalid Protocol: ${protocol}, valids: ${Object.keys(Protocols).join()}`) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b93a331e95..cf2b0153ec2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,8 @@ settings: overrides: ua-parser-js: 1.0.2 jpeg-js: 0.4.4 + '@rocket.chat/core-typings': 8.3.2 + '@rocket.chat/media-signaling': 1.0.0-rc.1 importers: @@ -81,6 +83,12 @@ importers: '@react-navigation/native-stack': specifier: ^7.17.5 version: 7.17.5(20e22ec03db44899c27c93ee7866e802) + '@rocket.chat/core-typings': + specifier: 8.3.2 + version: 8.3.2(@types/node@25.0.3)(typescript@5.9.3) + '@rocket.chat/ddp-client': + specifier: ^0.3.50 + version: 0.3.51(@rocket.chat/emitter@0.32.0)(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3) '@rocket.chat/media-signaling': specifier: 1.0.0-rc.1 version: 1.0.0-rc.1 @@ -90,9 +98,6 @@ importers: '@rocket.chat/mobile-crypto': specifier: RocketChat/rocket.chat-mobile-crypto#main version: https://codeload.github.com/RocketChat/rocket.chat-mobile-crypto/tar.gz/69a0a250dd7c6ff0808eb659d7202be1cae7fa1c(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - '@rocket.chat/sdk': - specifier: RocketChat/Rocket.Chat.js.SDK#mobile - version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f '@rocket.chat/ui-kit': specifier: ^0.39.0 version: 0.39.0(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3) @@ -2428,6 +2433,17 @@ packages: '@redux-saga/types@1.3.1': resolution: {integrity: sha512-YRCrJdhQLobGIQ8Cj1sta3nn6DrZDTSUnrIYhS2e5V590BmfVDleKoAquclAiKSBKWJwmuXTb+b4BL6rSHnahw==} + '@rocket.chat/api-client@0.2.56': + resolution: {integrity: sha512-ua6f3JRg/WZDfq9b1c0codJzg3XnbzcJxZgDfmhKLuK7vEl1HvzjjdCh1yN3ZqDoec7MZ2IdlF11KUgSRYyENQ==} + + '@rocket.chat/core-typings@8.3.2': + resolution: {integrity: sha512-/Oxd9qrkrOBlE9oibDhDppoydYXVSSFw6cGB8uBjryWwfNzuXYJR82prHBA9G0XMqCh/p9I3qiqNJXWVgpA3Tg==} + + '@rocket.chat/ddp-client@0.3.51': + resolution: {integrity: sha512-U2BtHpddvlsCqMZB5MegCB5M9wJ94SFD/AAXyJmxDRxhKdeZABEJ0t4FEdTGk3lePeTytnJaJkyvsEONRMI9Og==} + peerDependencies: + '@rocket.chat/emitter': '*' + '@rocket.chat/emitter@0.32.0': resolution: {integrity: sha512-QCXNGDm5xjJPePMeP9GknMAZne5xhNmLLD/ZVVqNgOyeWdfQKddwBUqtMiWDaFCRq1bC22kNf3WQLqt89CO6fg==} @@ -2449,22 +2465,33 @@ packages: engines: {node: 22.16.0} '@rocket.chat/mobile-crypto@https://codeload.github.com/RocketChat/rocket.chat-mobile-crypto/tar.gz/69a0a250dd7c6ff0808eb659d7202be1cae7fa1c': - resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/rocket.chat-mobile-crypto/tar.gz/69a0a250dd7c6ff0808eb659d7202be1cae7fa1c} + resolution: {gitHosted: true, integrity: sha512-v3uaVCYlj9gGZkAZe3JWQkhlGaRyahmnagbMSKtSnl+6WuLNxaPOf0GjJKVuxBdqkXPPhr23JIihVlch3FVeCQ==, tarball: https://codeload.github.com/RocketChat/rocket.chat-mobile-crypto/tar.gz/69a0a250dd7c6ff0808eb659d7202be1cae7fa1c} version: 0.4.0 peerDependencies: react: '*' react-native: '*' - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f': - resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f} - version: 1.3.3-mobile - engines: {node: '> 8.0.0', npm: '> 5.0.0'} + '@rocket.chat/rest-typings@7.13.7': + resolution: {integrity: sha512-RYxlcwCKeB4KHayKLzAvSPySQDrIVhJSfnN5hrf2eUvC37KMcHZhM0AVRTWD73ZLphKC4BC8ONxqx4eftRDZSw==} + + '@rocket.chat/rest-typings@8.4.1': + resolution: {integrity: sha512-8QB/MiIXdOSHIJJF3eAiDTl2rActcaMgGcKMHcDnjq7jeQW4WkSMKFo/GuJi5TipnZX31wemjGmTrOJ2kkARsA==} + + '@rocket.chat/ui-kit@0.38.0': + resolution: {integrity: sha512-meF64T8J0JcijPltxAxWFY6LH9gI7oXNHlVYxz7sTET18qrBCgGz3H8mtg8Uv9F0iCmyc3HIK44uAFT4nURi9A==} + peerDependencies: + '@rocket.chat/icons': '*' '@rocket.chat/ui-kit@0.39.0': resolution: {integrity: sha512-kdzZsR74DsUNpBQEKyhV8z0jaRsPe4u/VgR9tqHfevP4gQ/dx1GeDtl/LHkw/BADmqTn7nZpwEaelECKhtBNyQ==} peerDependencies: '@rocket.chat/icons': '*' + '@rocket.chat/ui-kit@1.0.0': + resolution: {integrity: sha512-NHAWtFbB1LK/09BnwCqO0XamZ9CAJgo1esz/qhqAvjGGeIF5hDSZv56iHOWE+YlOeCFM7U6IQNNLbpdd0Xf+KA==} + peerDependencies: + '@rocket.chat/icons': '*' + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -3128,6 +3155,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: @@ -4607,6 +4642,10 @@ packages: resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} engines: {node: '>=0.10.0'} + filter-obj@3.0.0: + resolution: {integrity: sha512-oQZM+QmVni8MsYzcq9lgTHD/qeLqaG8XaOPOW7dzuSafVxSUlH1+1ZDefj2OD9f2XsmG5lFl2Euc9NI4jgwFWg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + finalhandler@1.1.2: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} @@ -5633,9 +5672,6 @@ packages: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} - lru-cache@4.1.5: - resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5654,10 +5690,6 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - map-age-cleaner@0.1.3: - resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==} - engines: {node: '>=6'} - map-or-similar@1.5.0: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} @@ -5679,10 +5711,6 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} - mem@4.3.0: - resolution: {integrity: sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==} - engines: {node: '>=6'} - memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -6034,14 +6062,6 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - p-defer@1.0.0: - resolution: {integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==} - engines: {node: '>=4'} - - p-is-promise@2.1.0: - resolution: {integrity: sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==} - engines: {node: '>=6'} - p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -6253,9 +6273,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pseudomap@1.0.2: - resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} - psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} @@ -6419,7 +6436,7 @@ packages: react-native: '*' react-native-image-crop-picker@https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/f028aac24373d05166747ef6d9e59bb037fe3224: - resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/f028aac24373d05166747ef6d9e59bb037fe3224} + resolution: {gitHosted: true, integrity: sha512-KEHtYIXO1DkDAVoPW34xKOzho8UF6F+HZBbHQOtQ2ERPGlsVi1kY4+cOS5+thBEPNbXq/3VvH5bSEBAdZEOX0A==, tarball: https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/f028aac24373d05166747ef6d9e59bb037fe3224} version: 0.51.0 peerDependencies: react: '*' @@ -6437,7 +6454,7 @@ packages: react-native: '*' react-native-katex@https://codeload.github.com/RocketChat/react-native-katex/tar.gz/37e579804fe238732d8a4b06dec073610ab062b1: - resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/react-native-katex/tar.gz/37e579804fe238732d8a4b06dec073610ab062b1} + resolution: {gitHosted: true, integrity: sha512-ovD0cOy8/Ef2F0kmYF7XfMwPje94Fu+q6gO9ffiJbEPGj5ivGeshiTFGoWiPFxvqtlvujBCVOPyY323Apt8hGQ==, tarball: https://codeload.github.com/RocketChat/react-native-katex/tar.gz/37e579804fe238732d8a4b06dec073610ab062b1} version: 1.3.0 peerDependencies: react: '>=16' @@ -6995,6 +7012,10 @@ packages: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} + split-on-first@3.0.0: + resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} + engines: {node: '>=12'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -7260,9 +7281,6 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - tiny-events@1.0.1: - resolution: {integrity: sha512-QuhRLBsUWwrj+7mVvffHWmtHmMjt4GihlCN8/WucyHBqDINW9n9K5xsdfK3MdIeJIHRlmI4zI6izU1jbD3kn6Q==} - tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -7480,9 +7498,6 @@ packages: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} - universal-websocket-client@1.0.3: - resolution: {integrity: sha512-ZQF2hSasp4IM7Y3E+IAkA40c6kclsfhpligALvnuecEnU1dTJQZ5XlgXWLt3C8+uizDw5x48kCMBDPnLg/EVzA==} - universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -7772,9 +7787,6 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - yallist@2.1.2: - resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -10444,6 +10456,42 @@ snapshots: '@redux-saga/types@1.3.1': {} + '@rocket.chat/api-client@0.2.56(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3)': + dependencies: + '@rocket.chat/core-typings': 8.3.2(@types/node@25.0.3)(typescript@5.9.3) + '@rocket.chat/rest-typings': 8.4.1(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3) + filter-obj: 3.0.0 + query-string: 7.1.3 + split-on-first: 3.0.0 + strict-uri-encode: 2.0.0 + transitivePeerDependencies: + - '@rocket.chat/icons' + - '@types/node' + - typescript + + '@rocket.chat/core-typings@8.3.2(@types/node@25.0.3)(typescript@5.9.3)': + dependencies: + '@rocket.chat/icons': 0.47.0 + '@rocket.chat/message-parser': 0.31.36 + '@rocket.chat/ui-kit': 1.0.0(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3) + typia: 9.7.2(@types/node@25.0.3)(typescript@5.9.3) + zod: 4.3.6 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@rocket.chat/ddp-client@0.3.51(@rocket.chat/emitter@0.32.0)(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3)': + dependencies: + '@rocket.chat/api-client': 0.2.56(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3) + '@rocket.chat/core-typings': 8.3.2(@types/node@25.0.3)(typescript@5.9.3) + '@rocket.chat/emitter': 0.32.0 + '@rocket.chat/media-signaling': 1.0.0-rc.1 + '@rocket.chat/rest-typings': 7.13.7(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3) + transitivePeerDependencies: + - '@rocket.chat/icons' + - '@types/node' + - typescript + '@rocket.chat/emitter@0.32.0': {} '@rocket.chat/eslint-config@0.4.0(eslint-plugin-import@2.32.0)(eslint@8.57.1)': @@ -10467,16 +10515,37 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0) - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f': + '@rocket.chat/rest-typings@7.13.7(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3)': dependencies: - js-sha256: 0.9.0 - lru-cache: 4.1.5 - mem: 4.3.0 - tiny-events: 1.0.1 - universal-websocket-client: 1.0.3 + '@rocket.chat/core-typings': 8.3.2(@types/node@25.0.3)(typescript@5.9.3) + '@rocket.chat/message-parser': 0.31.36 + '@rocket.chat/ui-kit': 0.38.0(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) transitivePeerDependencies: - - bufferutil - - utf-8-validate + - '@rocket.chat/icons' + - '@types/node' + - typescript + + '@rocket.chat/rest-typings@8.4.1(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3)': + dependencies: + '@rocket.chat/core-typings': 8.3.2(@types/node@25.0.3)(typescript@5.9.3) + '@rocket.chat/message-parser': 0.31.36 + '@rocket.chat/ui-kit': 1.0.0(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + transitivePeerDependencies: + - '@rocket.chat/icons' + - '@types/node' + - typescript + + '@rocket.chat/ui-kit@0.38.0(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3)': + dependencies: + '@rocket.chat/icons': 0.47.0 + typia: 9.7.2(@types/node@25.0.3)(typescript@5.9.3) + transitivePeerDependencies: + - '@types/node' + - typescript '@rocket.chat/ui-kit@0.39.0(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3)': dependencies: @@ -10486,6 +10555,14 @@ snapshots: - '@types/node' - typescript + '@rocket.chat/ui-kit@1.0.0(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@5.9.3)': + dependencies: + '@rocket.chat/icons': 0.47.0 + typia: 9.7.2(@types/node@25.0.3)(typescript@5.9.3) + transitivePeerDependencies: + - '@types/node' + - typescript + '@rtsao/scc@1.1.0': {} '@samchon/openapi@4.7.2': {} @@ -11212,6 +11289,10 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv-keywords@5.1.0(ajv@8.18.0): dependencies: ajv: 8.18.0 @@ -12878,6 +12959,8 @@ snapshots: filter-obj@1.1.0: {} + filter-obj@3.0.0: {} + finalhandler@1.1.2: dependencies: debug: 2.6.9 @@ -14186,11 +14269,6 @@ snapshots: lru-cache@11.2.4: {} - lru-cache@4.1.5: - dependencies: - pseudomap: 1.0.2 - yallist: 2.1.2 - lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -14209,10 +14287,6 @@ snapshots: dependencies: tmpl: 1.0.5 - map-age-cleaner@0.1.3: - dependencies: - p-defer: 1.0.0 - map-or-similar@1.5.0: {} marky@1.3.0: {} @@ -14230,12 +14304,6 @@ snapshots: media-typer@0.3.0: {} - mem@4.3.0: - dependencies: - map-age-cleaner: 0.1.3 - mimic-fn: 2.1.0 - p-is-promise: 2.1.0 - memoize-one@5.2.1: {} memoizerific@1.11.3: @@ -14687,10 +14755,6 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - p-defer@1.0.0: {} - - p-is-promise@2.1.0: {} - p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -14908,8 +14972,6 @@ snapshots: proxy-from-env@1.1.0: {} - pseudomap@1.0.2: {} - psl@1.15.0: dependencies: punycode: 2.3.1 @@ -15744,6 +15806,8 @@ snapshots: split-on-first@1.1.0: {} + split-on-first@3.0.0: {} + sprintf-js@1.0.3: {} sql-escape-string@1.1.0: {} @@ -16064,8 +16128,6 @@ snapshots: through@2.3.8: {} - tiny-events@1.0.1: {} - tiny-invariant@1.3.3: {} tinyglobby@0.2.15: @@ -16268,13 +16330,6 @@ snapshots: dependencies: crypto-random-string: 2.0.0 - universal-websocket-client@1.0.3: - dependencies: - ws: 8.18.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - universalify@0.1.2: {} universalify@0.2.0: {} @@ -16580,8 +16635,6 @@ snapshots: y18n@5.0.8: {} - yallist@2.1.2: {} - yallist@3.1.1: {} yallist@4.0.0: {}