Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 | 125x 125x 125x 125x 125x 125x 125x 125x 125x 143x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 50x 50x 50x 50x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 124x 124x 124x 124x 124x 124x 124x 124x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 252x 125x 125x 125x 125x 125x 125x 125x 125x 134x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 250x 250x 250x 250x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 125x 125x 125x 125x 125x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 134x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x | /* eslint-disable @typescript-eslint/no-this-alias */
import {
css,
html,
nothing,
PropertyValueMap,
PropertyValues,
TemplateResult
} from 'lit';
import { property } from 'lit/decorators.js';
import { msg, str } from '@lit/localize';
import {
Contact,
CustomEventType,
NamedUser,
Ticket,
User
} from '../interfaces';
import {
fetchResults,
generateUUIDv7,
getUrl,
postJSON,
postUrl,
WebResponse
} from '../utils';
import { ContactStoreElement } from './ContactStoreElement';
import { Compose, ComposeValue } from '../form/Compose';
import { ContactHistoryPage } from '../events';
import {
Chat,
MessageType,
ContactEvent,
MsgEvent,
TypingEvent as RenderedTypingEvent
} from '../display/Chat';
import { DEFAULT_AVATAR } from '../webchat/assets';
import { UserSelect } from '../form/select/UserSelect';
import { Select } from '../form/select/Select';
import {
Events,
renderEvent,
renderTicketAction,
renderTicketAssigneeChanged
} from '../events/eventRenderers';
import { publishToSocket } from './SocketService';
import {
ContactHistoryEvent,
RealtimeSubscription,
subscribeToContactHistory,
TypingEvent
} from './Realtime';
import { applyContactEvent, watchContact } from './ContactWatch';
import { Icon } from '../Icons';
import { designTokens } from '../styles/designTokens';
import { DateTime } from 'luxon';
// how often we re-publish typing_started while composing - just inside the
// tightest platform sustain interval (Telegram's indicator lapses after 5s)
const TYPING_PULSE_INTERVAL = 4000;
// how often we re-render idle conversations so the last seen duration
// doesn't go stale
const STATE_REFRESH_INTERVAL = 60000;
// re-export for backwards compatibility
export { renderTicketAction, renderTicketAssigneeChanged };
export interface SearchResult {
uuid: string;
type: string;
created_on: string;
ticket_uuid?: string;
msg?: any;
_user?: any;
[key: string]: any;
}
export class ContactChat extends ContactStoreElement {
// we run our own firehose subscription below (history rendering, typing,
// ephemeral state) with its own contact/ticket channel choreography, so we
// opt out of the central watcher entirely - our data loads and syncs
// through the store as before
protected watchTypes: string[] = null;
public static get styles() {
return css`
${designTokens}
:host {
flex-grow: 1;
display: flex;
flex-direction: column;
min-height: 0;
margin-top: var(--gap);
--compose-shadow: none;
--compose-border: none;
--compose-padding: 3px;
--compose-curvature: none;
}
.chat-wrapper {
display: flex;
flex-grow: 1;
flex-direction: column;
min-height: 0;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
box-shadow: var(--shadow-2);
overflow: hidden;
}
temba-contact-history {
flex-grow: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.compose {
display: flex;
flex-direction: column;
--compose-border: 1px solid #e1e1e1;
--compose-curvature: 6px;
margin: 0.5em;
/* white keeps the input readable as a card against the tinted
chat box region behind it - rounded to match the compose
container so the corners don't bleed past its border */
background: #fff;
border-radius: var(--compose-curvature);
}
/* The chat box focuses with a solid outline and no exterior
glow - just this control, other widgets keep the design-system
halo. The focus vars must be set on temba-compose itself - its
shadow :host declares them via the design tokens, which beats
anything inherited from .compose. */
.compose temba-compose {
--color-focus: var(--focus, rgb(91, 156, 229));
--widget-box-shadow-focused: none;
}
/* While the contact is in a flow the compose outline carries the
flow hue: resting border matches the flow pill border, focus
goes solid flow green. The pre-mixed static comes first — a
custom property can't fall back per-declaration, so the
derived version needs the @supports gate. */
.in-flow .compose {
--compose-border: 1px solid #c5e8d2;
}
@supports (color: color-mix(in srgb, red, red)) {
.in-flow .compose {
--compose-border: 1px solid
color-mix(in srgb, var(--flow, #16a34a) 25%, white);
}
}
.in-flow .compose temba-compose {
--color-focus: var(--flow, #16a34a);
}
.closed-footer {
padding: 1em;
background: #f2f2f2;
border-top: 3px solid #e1e1e1;
display: flex;
flex-direction: column;
align-items: center;
}
a {
color: var(--color-link-primary);
}
a:hover {
text-decoration: underline;
color: var(--color-link-primary-hover);
}
temba-button {
margin: 0.1em 0.25em;
}
temba-completion {
--widget-box-shadow: none;
--color-widget-border: transparent;
--widget-box-shadow-focused: none;
--color-focus: transparent;
--color-widget-bg-focused: transparent;
}
.error-gutter {
display: flex;
padding: 0.5em 1em;
background: #f9f9f9;
item-align: center;
}
.error-message {
color: var(--color-error);
padding-right: 1em;
flex-grow: 1;
align-self: center;
}
/* The history draws no bottom border - each strip below it
(ticket bar, status row, chat box) owns its top border
instead, so the separator always belongs to the strip it
introduces. The status row's goes flow-green while the
contact is in a flow, which places the green line below the
ticket bar when there is one and directly below the history
when there isn't. */
temba-chat {
background: linear-gradient(0deg, #fff, #fff);
--color-chat-out: var(--color-message);
/* nothing floats over the bottom of the history anymore - the
status area below holds that content, so no reserved space */
--chat-bottom-padding: 1em;
transition: opacity 0.15s ease;
}
.chat-container {
position: relative;
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 0;
}
/* The search bar rides above the chat card — outside its border —
so sliding it in shrinks the card to make room instead of
overlaying the history. It borrows the list pages' searchbar
layout (bare single-line input + trailing icon controls) but
wears the card chrome (surface + border + shadow) instead of the
sunken strip — the page background here is grey, so a sunken bar
would blend right into it. The gap below it is the layout's
spacing unit, matching the gap between the chat and the cards. */
@keyframes search-slide-in {
from {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
}
to {
max-height: 3em;
opacity: 1;
padding-top: 4px;
padding-bottom: 4px;
margin-bottom: var(--layout-spacing, 8px);
}
}
@keyframes search-slide-out {
from {
max-height: 3em;
opacity: 1;
padding-top: 4px;
padding-bottom: 4px;
margin-bottom: var(--layout-spacing, 8px);
}
to {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
}
}
.searchbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
margin-bottom: var(--layout-spacing, 8px);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
box-shadow: var(--shadow-2);
color: var(--text-3);
font-family: var(--font);
font-size: 13.5px;
overflow: hidden;
animation: search-slide-in 0.15s ease-out;
}
.searchbar.closing {
animation: search-slide-out 0.15s ease-in forwards;
}
.searchbar input {
flex: 1 1 auto;
border: 0;
background: transparent;
outline: 0;
font: inherit;
color: var(--text-1);
min-width: 0;
/* match the stepper buttons' height so the bar doesn't grow
when results (and their page buttons) appear */
height: 22px;
}
.searchbar input::placeholder {
color: var(--text-3);
}
/* "↵ to search" hint — fades in alongside the run-search icon
only while there's a pending draft to apply. */
.searchbar .search-hint {
flex: 0 0 auto;
font-size: 0.75em;
color: var(--text-3);
white-space: nowrap;
user-select: none;
}
.searchbar .search-hint .enter-key {
position: relative;
top: 2px;
}
/* Standard clickable icons — bare glyph at rest, a circular wash
on hover. The default wash is near-white and disappears against
the white bar, so each carries a more saturated one; run search
only renders when a draft is pending, so it's always the
primary action and its wash warms to accent. */
.searchbar .search-go,
.searchbar .search-cancel {
flex: 0 0 auto;
margin-left: 5px;
--icon-color: var(--text-3);
--icon-color-circle-hover: rgba(15, 22, 36, 0.1);
}
.searchbar .search-go {
--icon-color-circle-hover: var(--accent-200);
}
/* Match stepper — the list pager's chevron-only page buttons
bracketing a quiet "n of N" count, stepping through matches
instead of pages. */
.match-pager {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 2px;
}
.pager-status {
padding: 0 4px;
color: var(--text-3);
font-size: 12.5px;
white-space: nowrap;
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--r-sm);
color: var(--text-3);
cursor: pointer;
user-select: none;
}
/* enter-target lights the button Enter would fire — the older-
match step while the input holds the already-searched query. */
.page-btn:hover,
.page-btn.enter-target {
background: var(--sunken);
color: var(--text-1);
}
.page-btn temba-icon {
--icon-color: currentColor;
}
.search-no-results {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-3);
font-family: var(--font);
font-size: 13.5px;
text-align: center;
pointer-events: none;
z-index: 5;
}
/* The search trigger floats over the history's top-right corner,
styled as the list pages' Search action chip — bordered icon +
label — but on an opaque surface (with a soft shadow) since it
sits over chat content rather than a header. */
.search-toggle {
position: absolute;
top: 0.75em;
right: 1.5em;
z-index: 5;
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
box-sizing: border-box;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
background: var(--surface);
color: var(--text-2);
font-family: var(--font);
font-size: 13px;
cursor: pointer;
user-select: none;
box-shadow: var(--shadow-1);
}
.search-toggle:hover {
background: var(--sunken);
color: var(--text-1);
}
.search-toggle temba-icon {
--icon-color: currentColor;
}
/* The single status area above the chat box. Holds the ticket
controls (assignee / topic / close) and the contact status
(last seen, current flow) as stacked rows sharing one tint so
everything about the conversation's state lives in one place,
between the history and the compose. */
.status-area {
background: rgba(0, 0, 0, 0.02);
}
/* the compose drops its top margin so the status area hugs the
chat box instead of floating above it */
.status-area + .chat-box .compose {
margin-top: 0;
}
/* Insets match .ticket-controls (px, not em - the rows have
different font sizes and em padding would drift the edges
apart) so the last seen lines up with the assignee widget box
and the Interrupt button sits flush with the Close button. */
.contact-status {
display: flex;
align-items: center;
justify-content: space-between;
/* px so the guaranteed breathing room between last seen and
the flow doesn't scale down with the row's small font - the
flow name ellipsizes before this gap ever compresses */
gap: 24px;
padding: 5px 8px;
font-size: 11.5px;
color: #8e8e93;
border-top: 1px solid #ddd;
}
/* While the contact is in a flow the row takes the flow pill's
wash and foreground (see pillVariants .pill-flow) so it reads
as flow-tinted - the ticket controls above keep the plain
status-area grey. Fallbacks match the --flow design token for
pages without tokens. */
.in-flow .contact-status {
/* pre-mixed statics first for browsers without color-mix() */
background: #e3f4e9;
color: var(--flow, #16a34a);
--icon-color: var(--flow, #16a34a);
border-top-color: #c5e8d2;
}
@supports (color: color-mix(in srgb, red, red)) {
.in-flow .contact-status {
background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
border-top-color: color-mix(in srgb, var(--flow, #16a34a) 25%, white);
}
}
/* compact the interrupt button to the row's caption scale */
.contact-status temba-button {
--button-small-height: 20px;
--button-small-x-pad: 6px;
--button-small-font-size: 11px;
}
.contact-status .last-seen {
white-space: nowrap;
/* last seen always reads in full - the flow side gives way */
flex-shrink: 0;
}
.contact-status .current-flow {
display: flex;
align-items: center;
gap: 0.5em;
/* the flow name can be long - let it ellipsize instead of
crowding out the last seen side */
min-width: 0;
margin-left: auto;
}
.contact-status .current-flow .flow-link {
display: flex;
align-items: center;
gap: 0.35em;
color: inherit;
font-weight: 500;
text-decoration: none;
min-width: 0;
}
.contact-status .current-flow .flow-link .flow-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* shrinks with the bar but keeps a couple of characters plus
the ellipsis so the name never collapses entirely */
min-width: 2em;
}
.contact-status .current-flow .flow-link:hover .flow-name {
text-decoration: underline;
}
.contact-status .current-flow temba-button {
flex-shrink: 0;
margin-left: 0.25em;
}
/* The compose region shares the status area tint so the panel is
framed by the same treatment top and bottom. */
.chat-box {
background: rgba(0, 0, 0, 0.02);
border-top: 1px solid #ddd;
}
/* the status row and chat box share one tinted region - no line
between them */
.status-area + .chat-box {
border-top: none;
}
.in-flow .chat-box {
/* pre-mixed static first for browsers without color-mix() */
background: #e3f4e9;
}
@supports (color: color-mix(in srgb, red, red)) {
.in-flow .chat-box {
background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
}
}
.ticket-controls {
padding: 8px;
text-align: center;
align-items: center;
display: flex;
border-top: 1px solid #ddd;
}
/* Keep the assignment + topic controls the same height as the
Close button so the row reads as one strip. Shrink the user
avatars (--temba-scale) so they fit in the smaller box. */
.ticket-controls temba-user-select,
.ticket-controls temba-select {
--temba-select-min-height: 28px;
}
.ticket-controls temba-user-select {
--temba-scale: 0.75;
}
temba-user {
border: 1px solid #ddd;
padding: 0.2em 0.5em;
border-radius: var(--curvature);
min-width: 10em;
background: #fff;
}
temba-user:hover {
border: 1px solid #ddd;
background: #f9f9f9;
}
.assign-button {
--button-mask: #ebebeb;
color: #333;
margin: 0.25em;
}
temba-user-select {
width: 250px;
}
temba-button {
--button-border: 1px solid #ddd;
}
`;
}
@property({ type: String, attribute: 'ticket' })
ticketUUID: string;
@property({ type: String })
contactsEndpoint = '/api/v2/contacts.json';
@property({ type: String })
currentNote = '';
@property({ type: Boolean })
showDetails = true;
@property({ type: Object })
currentTicket: Ticket = null;
@property({ type: Object })
currentContact: Contact = null;
@property({ type: String })
agent = '';
// uuid of the logged-in user, used to filter out our own typing events
// (publications are echoed to all subscribers, including the publisher)
@property({ type: String, attribute: 'user' })
userUuid = '';
@property({ type: Boolean })
blockFetching = false;
@property({ type: Boolean })
showInterrupt = false;
@property({ type: Boolean })
disableAssign = false;
@property({ type: Boolean })
disableReply = false;
@property({ type: Boolean })
showSearch = false;
@property({ type: String })
avatar = DEFAULT_AVATAR;
@property({ type: String })
set showMessageLogsAfter(value: string) {
const oldValue = this._showMessageLogsAfter;
this._showMessageLogsAfter = value ? new Date(value) : null;
this.requestUpdate('showMessageLogsAfter', oldValue);
}
get showMessageLogsAfter(): Date {
return this._showMessageLogsAfter;
}
private _showMessageLogsAfter: Date = null;
@property({ type: String })
errorMessage: string;
@property({ type: Boolean, attribute: false })
searchMode = false;
@property({ type: String, attribute: false })
searchQuery = '';
@property({ type: Array, attribute: false })
searchResults: SearchResult[] = [];
@property({ type: Number, attribute: false })
searchIndex = -1;
@property({ type: Boolean, attribute: false })
searchLoading = false;
@property({ type: Boolean, attribute: false })
searchFocused = false;
@property({ type: Boolean, attribute: false })
searchClosing = false;
@property({ type: Boolean, attribute: false })
searchNoResults = false;
// http promise to monitor for completeness
public httpComplete: Promise<void>;
private chat: Chat;
ticket = null;
beforeUUID: string = null; // for scrolling back through history
afterUUID: string = null; // newest event seen, for catch-up fetches
// live history subscriptions by topic for the current contact (and ticket)
private subscriptions = new Map<string, RealtimeSubscription>();
private fetchingMissed = false;
// the contact's most recent incoming message, tracked for the external id
// that typing publications carry (WhatsApp expresses typing as an operation
// on the contact's last incoming message)
private lastIncomingMsgOn: Date = null;
private lastIncomingMsgExternalId: string = null;
// typing publication state - the pulse interval while the agent is
// composing, and whether publishing was denied for this conversation
private typingChannel: string = null;
private typingPulse: number = null;
private typingDisabled = false;
// periodic re-render keeping the last seen duration fresh while the
// conversation is idle
private stateRefresh: number = null;
constructor() {
super();
}
public willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
if (changed.has('data')) {
this.currentContact = this.data;
}
if (changed.has('currentContact') && this.currentContact) {
const prev = changed.get('currentContact') as Contact | undefined;
if (this.currentContact.uuid !== prev?.uuid) {
this.blockFetching = false;
this.errorMessage = null;
}
}
}
public firstUpdated(
changed: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void {
super.firstUpdated(changed);
}
public connectedCallback() {
super.connectedCallback();
this.chat = this.shadowRoot.querySelector('temba-chat');
// a search handed off before we had a chat to run it in only waits on
// the contact, which may already be loaded
this.tryPendingSearch();
this.updateSubscriptions();
this.stateRefresh = window.setInterval(
() => this.requestUpdate(),
STATE_REFRESH_INTERVAL
);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.stopTyping();
this.unsubscribeAll();
window.clearInterval(this.stateRefresh);
this.stateRefresh = null;
}
public updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (
changedProperties.has('currentContact') ||
changedProperties.has('currentTicket')
) {
this.updateSubscriptions();
}
if (changedProperties.has('currentContact') && this.currentContact) {
this.chat = this.shadowRoot.querySelector('temba-chat');
if (
this.currentContact.uuid !==
changedProperties.get('currentContact')?.uuid
) {
this.reset();
} else {
this.fetchMissedEvents();
}
this.fetchPreviousMessages();
this.tryPendingSearch();
}
}
private unsubscribeAll() {
this.subscriptions.forEach((sub) => sub.unsubscribe());
this.subscriptions.clear();
}
/**
* Keeps our socket subscriptions in sync with the current contact and
* ticket. New events arrive as they happen on "history:<contact-uuid>";
* ticket detail events (notes, assignment, topic) only arrive on
* "history:<contact-uuid>:<ticket-uuid>" so we subscribe to both when
* viewing a ticket. Only the channels that actually changed are touched,
* so switching tickets on the same contact leaves the contact channel
* continuously subscribed with no gap in delivery.
*/
private updateSubscriptions() {
const topics = new Map<string, { contact: string; ticket: string }>();
if (this.isConnected && this.currentContact) {
const contact = this.currentContact.uuid;
topics.set(contact, { contact, ticket: null });
// on a contact switch the new ticket is set before the new contact has
// finished fetching, so hold off on the ticket channel until they match
// - a ticket paired with another contact's channel is denied server-side
const contactPending = this.contact && this.contact !== contact;
if (this.currentTicket && !contactPending) {
topics.set(`${contact}:${this.currentTicket.uuid}`, {
contact,
ticket: this.currentTicket.uuid
});
}
}
// drop subscriptions we no longer need
this.subscriptions.forEach((sub, key) => {
if (!topics.has(key)) {
sub.unsubscribe();
this.subscriptions.delete(key);
}
});
// add any new ones
topics.forEach((topic, key) => {
if (!this.subscriptions.has(key)) {
this.subscriptions.set(
key,
// the contact's own channel is owned by the central watcher, which
// every other contact component on the page shares - we take it as
// a stream and keep rendering history ourselves. Ticket detail
// events aren't contact state and stay a direct subscription
topic.ticket
? subscribeToContactHistory(
topic.contact,
topic.ticket,
(data: any) => this.handleSocketEvent(data),
// on every (re)subscribe fetch anything we might have missed
() => this.fetchMissedEvents()
)
: watchContact(
topic.contact,
'*',
(event: any) => this.handleSocketEvent(event),
() => this.fetchMissedEvents()
)
);
}
});
}
private handleSocketEvent(event: ContactHistoryEvent) {
if (!this.currentContact) {
return;
}
// ephemeral contact state updates - they don't touch the history view
// so they apply even while searching
if (
event.type === Events.CONTACT_LAST_SEEN_CHANGED ||
event.type === Events.CONTACT_FLOW_CHANGED
) {
this.handleContactStateEvent(event);
return;
}
// while searching we're viewing an arbitrary point in history, new
// events will be picked up by the refetch when search closes
if (!this.chat || this.searchMode) {
return;
}
// typing events are ephemeral indicator state, not history
if (
event.type === Events.TYPING_STARTED ||
event.type === Events.TYPING_STOPPED
) {
this.handleTypingEvent(event as TypingEvent);
return;
}
// createMessages parses the wire event into the rendered form the page
// type describes, dates and all
const messages = this.createMessages({
events: [this.copyWireEvent(event) as unknown as ContactEvent],
next: null
});
if (messages.length > 0) {
this.chat.addMessages(messages, null, true);
}
}
/**
* Ephemeral events that update contact state rather than record history.
* They are never persisted, so applying them is the only way they show up.
* How each one applies is the central watcher's to define - we just run it
* against our copy, which is the store's cached contact, so applying it in
* place also keeps the cache fresh for later readers.
*/
private handleContactStateEvent(event: ContactHistoryEvent) {
applyContactEvent(this.currentContact, event);
this.requestUpdate();
}
private getLastSeen(): { duration: string; title: string } | null {
const lastSeenOn = this.currentContact?.last_seen_on;
if (!lastSeenOn || !this.store) {
return null;
}
// unparseable values and zero-value times (e.g. go's zero time for a
// contact that has never been seen) mean there's no last seen to show
const lastSeen = DateTime.fromISO(lastSeenOn);
if (!lastSeen.isValid || lastSeen.year <= 1) {
return null;
}
// recent activity speaks for itself in the chat - only surface last
// seen once the contact has been quiet for at least an hour
const minutes = DateTime.now().diff(lastSeen, 'minutes').minutes;
if (minutes < 60) {
return null;
}
return {
duration: this.store.getShortDuration(lastSeen),
title: lastSeen.toLocaleString(DateTime.DATETIME_SHORT)
};
}
private handleTypingEvent(event: TypingEvent) {
// our own publications are echoed back to us - ignore them
if (event._user && this.userUuid && event._user.uuid === this.userUuid) {
return;
}
// the wire event is handed to every subscriber on this channel, so render
// into our own copy rather than parsing its date in place
const typing = {
...this.copyWireEvent(event),
created_on: new Date(event.created_on)
} as RenderedTypingEvent;
this.resolveUserAvatar(typing);
if (event.type === Events.TYPING_STARTED) {
this.chat.setTyping(typing);
} else {
this.chat.clearTyping(typing);
}
}
/**
* Fired as the agent edits the compose box. Composing (any text present)
* keeps typing_started pulses going on the contact's history channel;
* emptying the box (including via a send) publishes typing_stopped.
*/
private handleComposeChanged(evt: CustomEvent) {
// temba-compose's ContentChanged detail is its langValues map keyed by
// language, and it drops a language entry when that value empties. Derive
// composing across all languages so this doesn't hinge on a hardcoded key.
const composing = Object.values(evt.detail || {}).some((v: any) =>
v?.text?.trim()
);
if (composing) {
this.startTyping();
} else {
this.stopTyping();
}
}
private getTypingPayload(type: string) {
const payload: any = { type };
if (this.lastIncomingMsgExternalId) {
payload.msg_external_id = this.lastIncomingMsgExternalId;
}
return payload;
}
private startTyping() {
if (this.typingDisabled || this.typingPulse || !this.currentContact) {
return;
}
// publications are only allowed on the contact-level channel
this.typingChannel = `history:${this.currentContact.uuid}`;
this.sendTypingPulse();
this.typingPulse = window.setInterval(
() => this.sendTypingPulse(),
TYPING_PULSE_INTERVAL
);
}
private sendTypingPulse() {
// the interval can fire once after clearTypingPulse() nulled the channel
if (!this.typingChannel) {
return;
}
const channel = this.typingChannel;
publishToSocket(channel, this.getTypingPayload('typing_started')).catch(
(error: any) => {
// a temporary server error just costs us this pulse - a denial means
// something is genuinely wrong with this conversation, so silently
// stop pulsing for it. The handler runs async, so don't clobber a
// fresh conversation's state with an error for a stale one.
if (!error?.temporary && this.isCurrentTypingChannel(channel)) {
this.typingDisabled = true;
this.clearTypingPulse();
}
}
);
}
// whether the channel belongs to the contact currently being viewed
private isCurrentTypingChannel(channel: string) {
return channel === `history:${this.currentContact?.uuid}`;
}
private clearTypingPulse() {
if (this.typingPulse) {
window.clearInterval(this.typingPulse);
this.typingPulse = null;
}
this.typingChannel = null;
}
private stopTyping() {
if (!this.typingPulse) {
return;
}
const channel = this.typingChannel;
this.clearTypingPulse();
publishToSocket(channel, this.getTypingPayload('typing_stopped')).catch(
(error: any) => {
// on a contact switch this stop targets the old conversation - a
// denial for it must not disable typing for the new one
if (!error?.temporary && this.isCurrentTypingChannel(channel)) {
this.typingDisabled = true;
}
}
);
}
private reset() {
if (this.chat) {
this.chat.reset();
}
this.ticket = null;
this.beforeUUID = null;
this.afterUUID = null;
this.fetchingMissed = false;
// let the old conversation know we stopped composing and start the new
// one with a clean typing slate
this.stopTyping();
this.typingDisabled = false;
this.lastIncomingMsgOn = null;
this.lastIncomingMsgExternalId = null;
const compose = this.shadowRoot.querySelector('temba-compose') as Compose;
if (compose) {
compose.reset();
}
}
/**
* Clears the state of the current search - its query, results and the
* flags driving the search bar. Opening a fresh search keeps whatever
* lastSearchedQuery was, so its results can be re-run; closing or
* starting a new one drops it too.
*/
private resetSearchState(query = '', clearLastSearched = false) {
this.searchQuery = query;
this.searchResults = [];
this.searchIndex = -1;
this.searchLoading = false;
this.searchNoResults = false;
if (clearLastSearched) {
this.lastSearchedQuery = '';
}
}
/** Focuses the search input once the search bar has rendered. */
private focusSearchInput() {
window.setTimeout(() => {
const input = this.shadowRoot.querySelector('.search-input') as any;
if (input) {
input.focus();
}
}, 50);
}
private handleSearchToggle() {
// an opening or closing search supersedes any hand-off still waiting
// on a contact to load
this.pendingSearch = null;
if (this.searchMode) {
this.handleSearchClose();
} else {
this.searchMode = true;
this.resetSearchState();
this.focusSearchInput();
}
}
/** Drops any search positioning — highlights, block flags, paging
* anchors — and reloads the history at its normal, most-recent view. */
private restoreUnsearchedView() {
const chat = this.chat;
if (!chat) {
return;
}
chat.searchHighlight = null;
chat.highlightMessageUuid = null;
chat.reset();
this.blockFetching = false;
this.blockFetchingNewer = false;
this.fetchingNewer = false;
this.beforeUUID = null;
this.afterUUID = null;
this.fetchPreviousMessages();
}
private handleSearchClose() {
// supersede any in-flight match navigation right away — its chain
// must not re-assert highlights over the restored view below
this.searchGeneration++;
// and a hand-off waiting on a contact must not reopen search behind
// the user once it lands
this.pendingSearch = null;
this.searchClosing = true;
window.setTimeout(() => {
this.searchClosing = false;
this.searchMode = false;
// if a hand-off forced the search bar onto a conversation the host
// had opted out of, put the host's choice back
if (this.forcedShowSearch) {
this.forcedShowSearch = false;
this.showSearch = false;
}
this.resetSearchState('', true);
this.restoreUnsearchedView();
}, 150);
}
private handleSearchInput(e: Event) {
const input = e.target as HTMLInputElement;
this.searchQuery = input.value;
// the user typing their own query supersedes any hand-off still
// waiting on a contact to load
this.pendingSearch = null;
// any edit away from the searched query invalidates its results —
// drop them and put the history back at its unsearched view rather
// than staying parked at a stale match; backspacing to nothing is
// just an empty search. Clearing lastSearchedQuery also means
// retyping the same query and hitting Enter re-runs the search.
if (this.searchQuery.trim() !== this.lastSearchedQuery) {
// only reload the view when a search had actually run — while
// typing a fresh query the history is already showing it
const hadSearch = this.lastSearchedQuery !== '';
this.searchGeneration++;
this.searchResults = [];
this.searchIndex = -1;
this.searchNoResults = false;
this.lastSearchedQuery = '';
if (hadSearch) {
this.restoreUnsearchedView();
}
}
}
// tracks the query that produced the current searchResults
private lastSearchedQuery = '';
// a search requested (e.g. from the cross-ticket search modal) before the
// contact had loaded — executed once that contact and the chat are ready
private pendingSearch: {
query: string;
event: SearchResult;
contactUuid: string;
} = null;
// whether startSearch turned showSearch on for a hand-off, so closing the
// search can restore the host's opt-out
private forcedShowSearch = false;
/**
* Opens search mode and executes the given query, landing on the given
* event — which is inserted into the results if the endpoint's matches
* don't reach back far enough to include it. Safe to call before the
* contact has finished loading — the search runs once it has.
*/
public startSearch(query: string, event: SearchResult = null): void {
if (!query || !query.trim()) {
return;
}
this.searchMode = true;
// hosts only turn search on for some conversations (e.g. once a contact
// has been seen), but a hand-off always needs the bar - it carries the
// match stepper and the only way back out of the searched view. Track
// when we forced it so closing the search restores the host's choice
if (!this.showSearch) {
this.showSearch = true;
this.forcedShowSearch = true;
}
this.resetSearchState(query, true);
// the contact the host has asked us to show, which may still be
// loading — the search belongs to it and nobody else
this.pendingSearch = {
query: query.trim(),
event,
contactUuid: this.contact
};
this.focusSearchInput();
this.tryPendingSearch();
}
private tryPendingSearch(): void {
const pending = this.pendingSearch;
if (!pending || !this.currentContact || !this.chat) {
return;
}
// the search was requested for a specific contact — hold it while that
// contact is still loading, but drop it once the host has moved on to
// another one rather than running it against the wrong history
if (
pending.contactUuid &&
pending.contactUuid !== this.currentContact.uuid
) {
if (pending.contactUuid !== this.contact) {
this.pendingSearch = null;
}
return;
}
// executeSearch declines while another search is still in flight — keep
// the hand-off queued in that case so a later update can run it rather
// than dropping it on the floor
this.searchQuery = pending.query;
if (this.executeSearch(pending.event)) {
this.pendingSearch = null;
}
}
// bumped whenever the current search is superseded (new search, query
// edit, close) — navigateToResult's async fade/load chain captures the
// value at entry and bails at each step once it goes stale, so a chain
// already in flight can't re-assert highlights or scroll position over
// a view the user has since moved on from
private searchGeneration = 0;
private handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
const currentQuery = this.searchQuery.trim();
if (currentQuery !== this.lastSearchedQuery) {
this.executeSearch();
} else if (e.shiftKey) {
this.handleSearchPrev();
} else {
this.handleSearchNext();
}
} else if (e.key === 'Escape') {
this.handleSearchClose();
e.preventDefault();
}
}
/** Runs the current query, returning whether it was actually started. */
private executeSearch(targetEvent: SearchResult = null): boolean {
const query = this.searchQuery.trim();
if (!query || !this.currentContact || this.searchLoading) {
return false;
}
// a fresh search supersedes any navigation still in flight
this.searchGeneration++;
this.searchLoading = true;
this.searchResults = [];
this.searchIndex = -1;
this.searchNoResults = false;
this.lastSearchedQuery = query;
if (this.chat) {
this.chat.searchHighlight = null;
this.chat.highlightMessageUuid = null;
this.chat.reset();
}
// the chat always shows the contact's full history — even when viewing
// a ticket — so search always covers all of the contact's messages
const url = `/contact/chat_search/${this.currentContact.uuid}/?text=${encodeURIComponent(query)}`;
getUrl(url)
.then((response: WebResponse) => {
this.searchLoading = false;
// ignore a response the user has already moved past — editing the
// query (handleSearchInput clears lastSearchedQuery) or closing search
// (handleSearchClose flips searchMode off) can land before a slow
// response; applying it here would repopulate results for and scroll to
// a superseded query.
if (this.lastSearchedQuery !== query || !this.searchMode) {
return;
}
// the stepper walks newest → oldest ("older match" steps forward
// through the list), so order the results newest-first here rather
// than depending on the endpoint's ordering (uuid v7 sorts
// chronologically, matching the uuid comparisons used elsewhere)
const results = ((response.json.results || []) as SearchResult[]).sort(
(a, b) => b.uuid.toLowerCase().localeCompare(a.uuid.toLowerCase())
);
// the endpoint caps how many matches it returns, so the event we
// were handed off may not be among them — splice it into its own
// spot in the ordering so the hand-off always lands on it
if (targetEvent && !results.some((r) => r.uuid === targetEvent.uuid)) {
const older = results.findIndex(
(r) =>
r.uuid
.toLowerCase()
.localeCompare(targetEvent.uuid.toLowerCase()) < 0
);
results.splice(older === -1 ? results.length : older, 0, targetEvent);
}
this.searchResults = results;
if (this.searchResults.length > 0) {
this.searchNoResults = false;
const targetIndex = targetEvent
? this.searchResults.findIndex((r) => r.uuid === targetEvent.uuid)
: -1;
const index = targetIndex !== -1 ? targetIndex : 0;
this.searchIndex = index;
this.navigateToResult(index);
} else {
this.searchNoResults = true;
this.searchIndex = -1;
}
})
.catch(() => {
this.searchLoading = false;
if (this.lastSearchedQuery !== query || !this.searchMode) {
return;
}
this.searchResults = [];
this.searchIndex = -1;
this.searchNoResults = false;
});
return true;
}
private navigateToResult(index: number) {
if (index < 0 || index >= this.searchResults.length) {
return;
}
this.searchIndex = index;
const result = this.searchResults[index];
const FADE_MS = 120;
if (!this.chat) {
return;
}
const endpoint = this.getEndpoint();
if (!endpoint) {
return;
}
// the chain below spans fade timers and fetches — capture the current
// generation so each step can bail if the search is superseded
// (closed, edited, or re-run) while it's still in flight
const generation = this.searchGeneration;
const stale = () => generation !== this.searchGeneration;
// highlight the query that produced these results, not the current input
// text — a draft edit mid-navigation must not mislabel the highlights.
this.chat.searchHighlight = this.lastSearchedQuery;
// start fetches immediately (in parallel with fade-out)
const beforePromise = fetchContactHistory(
endpoint,
this.currentTicket?.uuid,
result.uuid,
null
);
const afterPromise = fetchContactHistory(
endpoint,
this.currentTicket?.uuid,
null,
result.uuid
);
// fade out, then hide completely before swapping content
this.chat.style.opacity = '0';
window.setTimeout(() => {
// superseded while fading out — leave the view to whatever
// superseded us and just undo our fade
if (stale()) {
this.chat.style.opacity = '1';
return;
}
// fully hidden (including scrollbar) during content swap
this.chat.style.visibility = 'hidden';
this.chat.reset();
this.blockFetching = false;
this.blockFetchingNewer = false;
this.fetchingNewer = false;
this.beforeUUID = null;
this.afterUUID = null;
const matchedEvent: ContactEvent = {
...result,
created_on: new Date(result.created_on)
};
let navigationCompleted = false;
Promise.all([beforePromise, afterPromise])
.then(([beforePage, afterPage]) => {
// superseded while the pages were loading — the finally below
// restores visibility since navigationCompleted is still false
if (stale()) {
return;
}
const afterMessages = this.createMessages(afterPage);
afterMessages.reverse();
const beforeMessages = this.createMessages(beforePage);
beforeMessages.reverse();
const matchPage = {
events: [matchedEvent],
next: null
} as ContactHistoryPage;
const matchMessages = this.createMessages(matchPage);
matchMessages.reverse();
if (beforePage.next) {
this.beforeUUID = beforePage.next;
} else if (beforeMessages.length > 0) {
const oldestBefore =
beforePage.events[beforePage.events.length - 1];
this.beforeUUID = oldestBefore.uuid;
} else {
this.blockFetching = true;
const allEvents = [...afterMessages, ...matchMessages];
if (allEvents.length > 0) {
const oldest = allEvents[allEvents.length - 1];
this.chat.setEndOfHistory(new Date(oldest.created_on));
}
}
// synchronously load all messages (no internal timeouts)
const allMessages = [
...beforeMessages,
...matchMessages,
...afterMessages
];
this.chat.loadMessages(allMessages);
// wait for Lit to render the DOM, then position scroll, then reveal
// uses setTimeout instead of requestAnimationFrame for test compatibility
navigationCompleted = true;
this.chat.updateComplete.then(() => {
window.setTimeout(() => {
if (!this.chat) return;
// superseded after the messages rendered — don't re-assert
// the highlight or scroll over the restored view, but do
// undo our hide (navigationCompleted is already true, so
// the finally won't)
if (stale()) {
this.chat.style.visibility = 'visible';
this.chat.style.opacity = '1';
return;
}
// position internal scroll only — never use scrollIntoView
// which can move ancestor containers
this.chat.highlightMessageUuid = result.uuid;
const scroll = this.chat.shadowRoot?.querySelector(
'.scroll'
) as HTMLElement;
const row = this.chat.shadowRoot?.querySelector(
`.row[data-uuid="${result.uuid}"]`
) as HTMLElement;
if (scroll && row) {
// calculate position to center the row within the scroll area
// column-reverse: scrollTop=0 is bottom, negative is up
const scrollRect = scroll.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const rowCenter = rowRect.top + rowRect.height / 2;
const scrollCenter = scrollRect.top + scrollRect.height / 2;
const offset = rowCenter - scrollCenter;
scroll.scrollTop = scroll.scrollTop + offset;
} else {
// fallback: just go to bottom
if (scroll) {
scroll.scrollTop = 0;
}
}
// let scroll settle, then fade in
window.setTimeout(() => {
if (this.chat) {
this.chat.style.visibility = 'visible';
this.chat.style.opacity = '0';
window.setTimeout(() => {
if (this.chat) {
this.chat.style.opacity = '1';
}
}, 16);
}
}, 16);
}, 16);
});
})
.catch(() => {
// superseded — whatever superseded us already owns the reload
if (stale()) {
return;
}
this.blockFetching = false;
this.blockFetchingNewer = false;
this.fetchingNewer = false;
this.beforeUUID = null;
this.afterUUID = null;
this.fetchPreviousMessages();
})
.finally(() => {
if (!navigationCompleted && this.chat) {
this.chat.style.visibility = 'visible';
this.chat.style.opacity = '1';
}
});
}, FADE_MS);
}
private handleSearchPrev() {
if (this.searchResults.length === 0) {
return;
}
const newIndex =
this.searchIndex <= 0
? this.searchResults.length - 1
: this.searchIndex - 1;
this.navigateToResult(newIndex);
}
private handleSearchNext() {
if (this.searchResults.length === 0) {
return;
}
const newIndex =
this.searchIndex >= this.searchResults.length - 1
? 0
: this.searchIndex + 1;
this.navigateToResult(newIndex);
}
private handleInterrupt() {
this.fireCustomEvent(CustomEventType.Interrupt, {
contact: this.currentContact
});
}
private handleRetry() {
const compose = this.shadowRoot.querySelector('temba-compose') as Compose;
compose.triggerSend();
}
private handleSend(evt: CustomEvent) {
this.errorMessage = null;
const composeEle = evt.currentTarget as Compose;
const compose = evt.detail.langValues['und'] as ComposeValue;
const payload = {
contact: this.currentContact.uuid
};
const text = compose.text;
if (text && text.length > 0) {
payload['text'] = text;
}
const attachments = compose.attachments;
if (attachments && attachments.length > 0) {
const attachment_uuids = attachments.map((attachment) => attachment.uuid);
payload['attachments'] = attachment_uuids;
}
if (this.currentTicket) {
payload['ticket'] = this.currentTicket.uuid;
}
const genericError = 'Send failed, please try again.';
postJSON(`/contact/chat/${this.currentContact.uuid}/`, payload)
.then((response) => {
if (response.status < 400) {
const event = response.json.event;
event.created_on = new Date(event.created_on);
this.resolveUserAvatar(event);
this.chat.addMessages([event], null, true);
composeEle.reset();
this.fireCustomEvent(CustomEventType.MessageSent, {
msg: payload,
response
});
} else {
this.errorMessage = genericError;
}
})
.catch(() => {
this.errorMessage = genericError;
});
}
private getEndpoint() {
if (this.contact) {
return `/contact/chat/${this.contact}/`;
}
return null;
}
public prerender(event: ContactEvent) {
// use the unified renderEvent function with isSimulation = false
const rendered = renderEvent(event, false);
if (rendered) {
event._rendered = {
html: rendered,
type: MessageType.Inline
};
}
}
/**
* Keeps the store's avatar cache fresh from server-hydrated user refs and
* fills in refs that arrive without one - events published over sockets
* carry only a user's uuid and name. Ticket assignment events carry a
* second user ref (the assignee) whose avatar feeds the event's hover
* tooltip.
*/
/**
* A copy of a wire event we can parse and resolve into. The event object is
* handed to every subscriber on the channel, and resolveUserAvatar writes an
* avatar onto the user objects hanging off it, so a shallow spread isn't
* enough on its own - those ride through it aliased.
*/
private copyWireEvent(event: any): any {
const copy = { ...event };
if (copy._user) {
copy._user = { ...copy._user };
}
if (copy.assignee) {
copy.assignee = { ...copy.assignee };
}
return copy;
}
private resolveUserAvatar(event: any) {
for (const user of [event._user, event.assignee]) {
if (user && user.uuid && this.store) {
if (user.avatar) {
this.store.setUserAvatar(user.uuid, user.avatar);
} else {
user.avatar = this.store.getUserAvatar(user.uuid);
}
}
}
}
private createMessages(page: ContactHistoryPage): ContactEvent[] {
if (page.events) {
const messages: ContactEvent[] = [];
page.events.forEach((event) => {
this.resolveUserAvatar(event);
// track the UUID of the newest event for polling
if (
!this.afterUUID ||
event.uuid.toLowerCase() > this.afterUUID.toLowerCase()
) {
this.afterUUID = event.uuid;
}
// convert to dates
event.created_on = new Date(event.created_on);
// track the contact's most recent incoming message for the external
// id our typing publications carry
if (event.type === 'msg_received') {
if (
!this.lastIncomingMsgOn ||
event.created_on > this.lastIncomingMsgOn
) {
this.lastIncomingMsgOn = event.created_on;
this.lastIncomingMsgExternalId =
(event as MsgEvent).msg?.external_id || null;
}
}
if (
event.type === 'msg_created' ||
event.type === 'msg_received' ||
event.type === 'ivr_created' ||
event.type === 'ticket_note_added'
) {
// Notes render as chat-style bubbles (see Chat.ts), so push them
// through directly rather than prerendering into an inline event.
messages.push(event);
} else {
this.prerender(event);
if (event._rendered) {
messages.push(event);
}
}
});
// remove any messages we don't recognize
return messages.filter((msg) => !!msg);
}
return [];
}
/**
* Fetches any events newer than the last one we've seen. New events
* normally arrive over the socket, so this is only needed to cover gaps -
* events published between our initial history fetch and the subscription
* becoming active, or while a dropped connection was reconnecting.
*/
private fetchMissedEvents() {
// we are already working on it or in search mode
if (this.fetchingMissed || this.searchMode) {
return;
}
const chat = this.chat;
if (this.currentContact && this.afterUUID) {
this.fetchingMissed = true;
const endpoint = this.getEndpoint();
if (!endpoint) {
this.fetchingMissed = false;
return;
}
const fetchContact = this.currentContact.uuid;
const fetchTicket = this.currentTicket?.uuid;
fetchContactHistory(endpoint, fetchTicket, null, this.afterUUID).then(
(page: ContactHistoryPage) => {
this.fetchingMissed = false;
// things may have changed while we were fetching
if (
this.searchMode ||
fetchContact !== this.currentContact?.uuid ||
fetchTicket !== this.currentTicket?.uuid
) {
return;
}
const messages = this.createMessages(page);
messages.reverse();
chat.addMessages(messages, null, true);
}
);
}
}
private fetchPreviousMessages() {
const chat = this.chat;
const contactChat = this;
if (!chat || chat.fetching || contactChat.blockFetching) {
return;
}
chat.fetching = true;
if (this.currentContact) {
const endpoint = this.getEndpoint();
if (!endpoint) {
// nothing to fetch — don't leave the chat wedged as fetching
chat.fetching = false;
return;
}
// initialize anchor UUID if not set (first fetch)
if (!this.beforeUUID && !this.afterUUID) {
// generate a UUID v7 for current time as the anchor
const anchorUUID = generateUUIDv7();
this.beforeUUID = anchorUUID;
this.afterUUID = anchorUUID;
}
const requestedBefore = this.beforeUUID;
fetchContactHistory(
endpoint,
this.currentTicket?.uuid,
this.beforeUUID,
null
).then((page: ContactHistoryPage) => {
// the view was repositioned while this page was in flight (e.g. a
// search navigated to a match and re-anchored the history) — drop
// it rather than stacking the old view's messages on the new one
if (this.beforeUUID !== requestedBefore) {
chat.fetching = false;
return;
}
const messages = this.createMessages(page);
messages.reverse();
if (messages.length === 0) {
contactChat.blockFetching = true;
} else if (page.next) {
// update beforeUUID for next fetch of older messages
this.beforeUUID = page.next;
} else {
// no more history, mark end and show oldest event date
contactChat.blockFetching = true;
if (page.events && page.events.length > 0) {
const oldestEvent = page.events[page.events.length - 1];
chat.setEndOfHistory(new Date(oldestEvent.created_on));
}
}
chat.addMessages(messages);
});
}
}
private fetchComplete() {
if (this.chat) {
this.chat.fetching = false;
}
this.fetchingNewer = false;
}
private blockFetchingNewer = false;
private fetchingNewer = false;
private fetchNewerMessages() {
if (
!this.searchMode ||
!this.afterUUID ||
!this.chat ||
this.fetchingNewer ||
this.blockFetchingNewer
) {
return;
}
this.fetchingNewer = true;
const endpoint = this.getEndpoint();
if (!endpoint) {
this.fetchingNewer = false;
return;
}
fetchContactHistory(
endpoint,
this.currentTicket?.uuid,
null,
this.afterUUID
)
.then((page: ContactHistoryPage) => {
if (!this.chat) {
this.fetchingNewer = false;
return;
}
const messages = this.createMessages(page);
messages.reverse();
if (messages.length === 0) {
this.blockFetchingNewer = true;
this.fetchingNewer = false;
return;
}
// maintainScroll=true keeps the user's visual position stable
// so they must actively scroll down to trigger the next fetch
// fetchingNewer is reset in fetchComplete after scroll settles
this.chat.addMessages(messages, null, true, true);
})
.catch(() => {
this.fetchingNewer = false;
});
}
private getTembaCompose(): TemplateResult {
if (this.currentTicket) {
if (this.currentContact && this.currentContact.status !== 'active') {
//no chatbox for archived, blocked, or stopped contacts
return null;
} else {
if (!this.currentTicket.closed_on) {
// hide compose if agent can't reply to unassigned tickets
if (
this.disableReply &&
(!this.currentTicket.assignee ||
this.currentTicket.assignee.email !== this.agent)
) {
return null;
}
//chatbox for active contacts with an open ticket
return this.getCompose();
} else {
return null;
}
}
}
if (this.currentContact && this.currentContact.status !== 'active') {
//no chatbox for archived, blocked, or stopped contacts
return null;
} else {
//chatbox for active contacts
return this.getCompose();
}
}
private getCompose(): TemplateResult {
return html`<div class="chat-box">
<div class="compose">
<temba-compose
attachments
counter
autogrow
shortcuts
min-height="75"
@temba-submitted=${this.handleSend.bind(this)}
@temba-content-changed=${this.handleComposeChanged.bind(this)}
>
</temba-compose>
${this.errorMessage
? html` <div class="error-gutter">
<div class="error-message">${this.errorMessage}</div>
<temba-button
name="Retry"
@click=${this.handleRetry}
></temba-button>
</div>`
: null}
</div>
</div>`;
}
private handleAssignmentChanged(evt: CustomEvent) {
const users = evt.currentTarget as UserSelect;
const assignee = users.values[0];
this.assignTicket(assignee ? assignee.email : null);
users.blur();
}
private handleTopicChanged(evt: CustomEvent) {
const select = evt.target as Select<any>;
const topic = select.values[0];
if (this.currentTicket.topic.uuid !== topic.uuid) {
postJSON(`/api/v2/ticket_actions.json`, {
tickets: [this.currentTicket.uuid],
action: 'change_topic',
topic: topic.uuid
})
.then(() => {
this.refreshTicket();
})
.catch((response: any) => {
console.error(response);
});
}
}
public assignTicket(email: string) {
if (this.disableAssign) {
return;
}
// if its already assigned to use, it's a noop
if (
(this.currentTicket.assignee &&
this.currentTicket.assignee.email === email) ||
(this.currentTicket.assignee === null && email === null)
) {
return;
}
postJSON(`/api/v2/ticket_actions.json`, {
tickets: [this.currentTicket.uuid],
action: 'assign',
assignee: email
})
.then(() => {
this.refreshTicket();
})
.catch((response: any) => {
console.error(response);
});
return true;
}
public refreshTicket() {
if (this.currentTicket) {
fetchResults(`/api/v2/tickets.json?uuid=${this.currentTicket.uuid}`).then(
(values) => {
if (values.length > 0) {
this.fireCustomEvent(CustomEventType.TicketUpdated, {
ticket: values[0],
previous: this.currentTicket
});
this.currentTicket = values[0];
}
}
);
}
}
private handleReopen() {
const uuid = this.currentTicket.uuid;
postJSON(`/api/v2/ticket_actions.json`, {
tickets: [uuid],
action: 'reopen'
})
.then(() => {
this.refreshTicket();
})
.catch((response: any) => {
console.error(response);
});
}
private handleClose() {
const uuid = this.currentTicket.uuid;
postJSON(`/api/v2/ticket_actions.json`, {
tickets: [uuid],
action: 'close'
})
.then(() => {
this.refreshTicket();
})
.catch((response: any) => {
console.error(response);
});
}
/** The search bar rendered above the chat card while searching —
* mirrors the list pages' searchbar: a bare input on a sunken strip
* with an ↵-to-search hint + run icon while a draft is pending, and
* a pager-style match stepper once results are in. */
private renderSearchBar(): TemplateResult {
const trimmed = this.searchQuery.trim();
const pendingDraft = !!trimmed && trimmed !== this.lastSearchedQuery;
return html`<div class="searchbar ${this.searchClosing ? 'closing' : ''}">
<input
class="search-input"
type="text"
placeholder="Search messages"
aria-label="Search messages"
.value=${this.searchQuery}
@input=${this.handleSearchInput}
@keydown=${this.handleSearchKeydown}
@focus=${() => (this.searchFocused = true)}
@blur=${() => (this.searchFocused = false)}
/>
${this.searchLoading
? html`<temba-loading size="8"></temba-loading>`
: pendingDraft
? html`<span class="search-hint">
<span class="enter-key">↵</span> to search
</span>
<temba-icon
class="search-go"
name=${Icon.search}
size="1.1"
clickable
title="Search"
aria-label="Run search"
@click=${() => this.executeSearch()}
></temba-icon>`
: this.searchResults.length > 0
? html`<div class="match-pager">
<span class="pager-status"
>${this.searchIndex + 1} of ${this.searchResults.length}</span
>
<span
class="page-btn ${this.searchFocused &&
trimmed === this.lastSearchedQuery
? 'enter-target'
: ''}"
@click=${this.handleSearchNext}
title="Older match (Enter)"
aria-label="Older match"
>
<temba-icon name=${Icon.up} size="1"></temba-icon>
</span>
<span
class="page-btn"
@click=${this.handleSearchPrev}
title="Newer match (Shift+Enter)"
aria-label="Newer match"
>
<temba-icon name=${Icon.down} size="1"></temba-icon>
</span>
</div>`
: null}
<temba-icon
class="search-cancel"
name=${Icon.close}
size="1.1"
clickable
title="Close search (Escape)"
aria-label="Close search"
@click=${this.handleSearchClose}
></temba-icon>
</div>`;
}
public render(): TemplateResult {
const inFlow = this.currentContact && this.currentContact.flow;
const lastSeen = this.getLastSeen();
const inTicket = this.currentTicket;
const ticketClosed = this.currentTicket && this.currentTicket.closed_on;
return html`${this.currentContact && this.showSearch && this.searchMode
? this.renderSearchBar()
: null}
<div class="chat-wrapper ${inFlow ? 'in-flow' : ''}">
${this.currentContact
? html`<div class="chat-container">
${this.showSearch && !this.searchMode
? html`<button
class="search-toggle"
@click=${this.handleSearchToggle}
title="Search messages"
>
<temba-icon name=${Icon.search} size="0.95"></temba-icon>
Search
</button>`
: null}
<temba-chat
@temba-scroll-threshold=${this.fetchPreviousMessages}
@temba-scroll-threshold-bottom=${this.fetchNewerMessages}
@temba-fetch-complete=${this.fetchComplete}
avatar=${this.avatar}
contactName=${this.currentContact?.name ?? nothing}
contactUuid=${this.currentContact?.uuid ?? nothing}
agent
avatars
.showMessageLogsAfter=${this.showMessageLogsAfter}
>
</temba-chat>
${this.searchNoResults
? html`<div class="search-no-results">
No results found for
<strong>${this.lastSearchedQuery}</strong>
</div>`
: null}
</div>
${inTicket || inFlow || lastSeen
? html`<div class="status-area">
${inTicket
? html`<div class="ticket-controls">
<temba-user-select
placeholder="Assign to.."
searchable
searchOnFocus
clearable
.values=${this.currentTicket.assignee
? [this.currentTicket.assignee]
: []}
@change=${this.handleAssignmentChanged}
?disabled=${ticketClosed || this.disableAssign}
></temba-user-select>
<temba-select
style="margin:0 0.5em; flex-grow:1"
endpoint="/api/v2/topics.json"
searchable
valuekey="uuid"
.values=${[this.currentTicket.topic]}
@change=${this.handleTopicChanged}
?disabled=${ticketClosed}
></temba-select>
${this.currentTicket.closed_on
? html`
<temba-button
name="Reopen"
@click=${this.handleReopen}
></temba-button>
`
: html`
<temba-button
name="Close"
destructive
@click=${this.handleClose}
></temba-button>
`}
</div>`
: null}
${inFlow || lastSeen
? html`<div class="contact-status">
${lastSeen
? html`<div
class="last-seen"
title=${lastSeen.title}
>
${msg(str`Last seen ${lastSeen.duration}`)}
</div>`
: null}
${inFlow
? html`<div class="current-flow">
<a
class="flow-link"
href="/flow/editor/${this.currentContact.flow
.uuid}/"
onclick="goto(event, this)"
title=${this.currentContact.flow.name}
>
<temba-icon name="flow"></temba-icon>
<div class="flow-name">
${this.currentContact.flow.name}
</div>
</a>
${this.showInterrupt
? html`<temba-button
small
light
name=${msg('Interrupt')}
@click=${this.handleInterrupt}
></temba-button>`
: null}
</div>`
: null}
</div>`
: null}
</div>`
: null}
${this.getTembaCompose()}`
: null}
</div>`;
}
}
export const closeTicket = (uuid: string): Promise<WebResponse> => {
const formData = new FormData();
formData.append('status', 'C');
return postUrl(`/ticket/update/${uuid}/?_format=json`, formData);
};
export const fetchContact = (endpoint: string): Promise<Contact> => {
return new Promise<Contact>((resolve, reject) => {
fetchResults(endpoint).then((contacts: Contact[]) => {
if (contacts && contacts.length === 1) {
resolve(contacts[0]);
} else {
reject('No contact found');
}
});
});
};
export const fetchContactHistory = (
endpoint: string,
ticket: string = undefined,
before: string = undefined,
after: string = undefined
): Promise<ContactHistoryPage> => {
return new Promise<ContactHistoryPage>((resolve) => {
const emptyPage: ContactHistoryPage = {
events: [],
next: null
};
const controller = new AbortController();
pendingRequests.push(controller);
const clearController = () => {
pendingRequests = pendingRequests.filter(
(pendingController: AbortController) => pendingController !== controller
);
};
let url = endpoint;
const params = [];
if (before) {
params.push(`before=${before}`);
}
if (after) {
params.push(`after=${after}`);
}
if (ticket) {
params.push(`ticket=${ticket}`);
}
if (params.length > 0) {
url += (url.includes('?') ? '&' : '?') + params.join('&');
}
getUrl(url, controller)
.then((response: WebResponse) => {
clearController();
const page = response.json as ContactHistoryPage;
resolve(page || emptyPage);
})
.catch(() => {
clearController();
resolve(emptyPage);
});
});
};
export const getDisplayName = (user: User) => {
if (!user) {
return 'Somebody';
}
if ((user as NamedUser).name) {
return (user as NamedUser).name;
}
if (user.first_name && user.last_name) {
return `${user.first_name} ${user.last_name}`;
}
if (user.first_name) {
return user.first_name;
}
return user.email;
};
export let pendingRequests: AbortController[] = [];
|