Compare commits

...
Author SHA1 Message Date
HampusandGitHub 86497155cd fix(app): keep avatars round in webkit browsers (#1982) 2026-08-24 17:57:19 +02:00
HampusandGitHub af82974c8a fix(api): keep the harvest download route literal for schema generation (#1980) 2026-08-24 14:55:11 +02:00
HampusandGitHub bd4117fa0a feat(api): stream harvest downloads from the configured s3 bucket (#1979) 2026-08-24 14:39:05 +02:00
HampusandGitHub f004685424 fix(repo): make the workspace lint, typecheck and test clean (#1978) 2026-08-24 13:35:22 +02:00
HampusandGitHub b268320406 chore(i18n): re-extract the message catalogs for the new strings (#1977) 2026-08-24 12:56:47 +02:00
HampusandGitHub 7a33b3198a fix(app): stop reprobing image format support on every build (#1976) 2026-08-24 12:56:26 +02:00
HampusandGitHub 66791d3ca2 fix(app): stop fetching a tiny avatar for the large voice tile (#1975) 2026-08-24 12:56:16 +02:00
HampusandGitHub a0d4a33fd4 fix(app): write the first voice session restore snapshot on startup (#1974) 2026-08-24 12:56:04 +02:00
HampusandGitHub fdd12194b1 fix(app): record voice messages in a widely playable container (#1973) 2026-08-24 12:55:53 +02:00
HampusandGitHub bf11445299 fix(app): terminate the voice e2ee worker with the room that owns it (#1972) 2026-08-24 12:55:42 +02:00
HampusandGitHub 61bf8f6ad3 perf(app): load voice background tiles only near the viewport (#1971) 2026-08-24 12:55:32 +02:00
HampusandGitHub 52282bfccf refactor(app): collapse the wrapped voice avatar render branches into one (#1970) 2026-08-24 12:55:22 +02:00
HampusandGitHub cf3a31ac42 perf(app): keep more twemoji urls cached instead of dropping them all (#1969) 2026-08-24 12:55:11 +02:00
HampusandGitHub f56ccf5ef5 fix(app): keep tooltips inside the fullscreen element (#1968) 2026-08-24 12:55:00 +02:00
HampusandGitHub 51e2eee15a fix(app): stop aborting shared uploads when cancelling one attachment (#1967) 2026-08-24 12:54:50 +02:00
HampusandGitHub de97bf908d fix(app): accept tenor links with a regional locale prefix (#1966) 2026-08-24 12:54:39 +02:00
HampusandGitHub 099fb80da2 perf(app): stop double-loading stream previews into the image cache (#1965) 2026-08-24 12:54:29 +02:00
HampusandGitHub 022ccc794d perf(app): render guild stickers only near the modal viewport (#1964) 2026-08-24 12:54:19 +02:00
HampusandGitHub 987768e8dc fix(app): only animate stickers that actually have animation (#1963) 2026-08-24 12:54:08 +02:00
HampusandGitHub a3ffe963c3 fix(app): inherit the parent gap inside stepped carousel panes (#1962) 2026-08-24 12:53:58 +02:00
HampusandGitHub b51562d0e3 fix(app): keep focus and its ring on a spoiler after it is revealed (#1961) 2026-08-24 12:53:47 +02:00
HampusandGitHub 38e86acffe fix(app): let a sound restart immediately after being stopped (#1960) 2026-08-24 12:53:34 +02:00
HampusandGitHub 38a299d852 fix(app): show community avatars in the search user filter (#1959) 2026-08-24 12:53:20 +02:00
HampusandGitHub 55b25c919d fix(app): announce search result counts to screen readers (#1958) 2026-08-24 12:53:10 +02:00
HampusandGitHub 2af4cc98fd feat(app): suggest filters and people while typing search text (#1957) 2026-08-24 12:52:58 +02:00
HampusandGitHub e68b5b8b5a fix(app): stop animations running under reduced motion (#1956) 2026-08-24 12:52:47 +02:00
HampusandGitHub 317b4e26c0 fix(app): stop the pointer hijacking quick switcher selection (#1955) 2026-08-24 12:52:36 +02:00
HampusandGitHub af5bee9149 fix(app): stop refetching profile art when menus appear (#1954) 2026-08-24 12:52:26 +02:00
HampusandGitHub 26939eccce fix(app): let the profile popout banner fill its header (#1953) 2026-08-24 12:52:16 +02:00
HampusandGitHub 54360708d9 fix(app): stop rewriting profile bio emoji urls on hover (#1952) 2026-08-24 12:52:06 +02:00
HampusandGitHub e441c7229c fix(app): version profile badge assets so they refresh (#1951) 2026-08-24 12:51:56 +02:00
HampusandGitHub 54e5fe6ef9 fix(app): stop premium upsell preview emoji from stretching (#1950) 2026-08-24 12:51:45 +02:00
HampusandGitHub 6fc0f1ccd7 fix(app): remove popout stylesheets when the main document drops them (#1949) 2026-08-24 12:51:35 +02:00
HampusandGitHub 6cd30d893a feat(app): let the pinned messages popout be resized (#1948) 2026-08-24 12:51:25 +02:00
HampusandGitHub dceb9061d9 perf(app): recompute picker grid layout in scroll chunks (#1947) 2026-08-24 12:51:13 +02:00
HampusandGitHub 21438b2d8f fix(app): fade the picker thumbhash out instead of fading images in (#1946) 2026-08-24 12:50:59 +02:00
HampusandGitHub 793e853eb3 fix(app): fit media to its real box and gate the zoom buttons (#1945) 2026-08-24 12:50:48 +02:00
HampusandGitHub 9cbe0efbbb fix(app): rank permission override member search by the worker order (#1944) 2026-08-24 12:50:27 +02:00
HampusandGitHub 9219b886fe fix(app): add a quick modal motion preset and settle instant modals (#1943) 2026-08-24 12:50:16 +02:00
HampusandGitHub 2377a4c9d0 fix(app): honour motion settings in the mobile meme picker (#1942) 2026-08-24 12:50:06 +02:00
HampusandGitHub 986c586683 fix(app): show message actions only on keyboard focus (#1941) 2026-08-24 12:49:52 +02:00
HampusandGitHub 7be52fa9fc perf(app): stop redundant member list presence updates (#1940) 2026-08-24 12:49:40 +02:00
HampusandGitHub 32d7ae3eef fix(app): track member list width with a media query (#1939) 2026-08-24 12:49:29 +02:00
HampusandGitHub ef6fb4903f perf(app): redraw the media timestamp only when the second changes (#1938) 2026-08-24 12:49:19 +02:00
HampusandGitHub 0fe3f7523d fix(app): leave fullscreen when the video player unmounts (#1937) 2026-08-24 12:49:08 +02:00
HampusandGitHub 9991534c83 perf(app): reuse parsed markdown across message renders (#1936) 2026-08-24 12:48:57 +02:00
HampusandGitHub 455681b24c fix(app): build settings preview state lazily at first use (#1935) 2026-08-24 12:48:47 +02:00
HampusandGitHub 14e63085dd feat(app): resize the inbox popout from any edge (#1934) 2026-08-24 12:48:36 +02:00
HampusandGitHub e8cb1cefbd fix(app): reject oversized images before cropping an emoji or sticker (#1933) 2026-08-24 12:48:23 +02:00
HampusandGitHub 919e890011 fix(app): show guild initials until the guild icon has painted (#1932) 2026-08-24 12:48:11 +02:00
HampusandGitHub 299172c8aa fix(app): animate guild icons through the shared motion gate (#1931) 2026-08-24 12:47:59 +02:00
HampusandGitHub 6cac93ef39 fix(app): use guild initials for community picker rows (#1930) 2026-08-24 12:47:49 +02:00
HampusandGitHub e5c8ef7d60 perf(app): stop loading the animated guild banner where it is hidden (#1929) 2026-08-24 12:47:37 +02:00
HampusandGitHub d0b4688a6c perf(app): reuse pooled gif videos instead of caching blobs (#1928) 2026-08-24 12:47:13 +02:00
HampusandGitHub e0464ee5be fix(app): pick gif picker previews by format and skip empty sources (#1927) 2026-08-24 12:47:01 +02:00
HampusandGitHub cbcd299bd9 fix(app): keep a child's own data-flx when wrapped in a focus ring (#1926) 2026-08-24 12:46:51 +02:00
HampusandGitHub 1300e5a690 fix(app): refresh favorite gif previews that point at a provider page (#1925) 2026-08-24 12:46:40 +02:00
HampusandGitHub fbd653d8e0 feat(app): resize the expression picker and snap to emoji columns (#1924) 2026-08-24 12:46:30 +02:00
HampusandGitHub eb4f41e80c perf(app): preload picker images through the shared image cache (#1923) 2026-08-24 12:46:19 +02:00
HampusandGitHub 589a01a2da fix(app): load expression grid images only when they scroll into view (#1922) 2026-08-24 12:46:08 +02:00
HampusandGitHub aae6b99761 fix(app): stop the skin tone selector listening while closed (#1921) 2026-08-24 12:45:57 +02:00
HampusandGitHub 5d35047734 fix(app): reserve emoji picker cells while their image loads (#1920) 2026-08-24 12:45:47 +02:00
HampusandGitHub 98d10215d6 fix(app): blur mature embed images and videos (#1919) 2026-08-24 12:45:36 +02:00
HampusandGitHub 6656628bba fix(app): load small list avatars eagerly (#1918) 2026-08-24 12:45:24 +02:00
HampusandGitHub 37f46fc62d fix(app): hide decorative country and region flags from screen readers (#1917) 2026-08-24 12:45:13 +02:00
HampusandGitHub 9efd2b0a73 fix(app): replace the whole +: token when picking a reaction emoji (#1916) 2026-08-24 12:45:02 +02:00
HampusandGitHub b1fb2c14a1 fix(app): keep composer markdown highlighting aligned on long messages (#1915) 2026-08-24 12:44:51 +02:00
HampusandGitHub c5d910425e fix(app): honour the animation setting for composer custom emoji (#1914) 2026-08-24 12:44:40 +02:00
HampusandGitHub 0a9e64d36a fix(app): keep the character counter inside the composer box (#1913) 2026-08-24 12:44:30 +02:00
HampusandGitHub 7d02b7959f fix(app): honour motion settings in composer autocomplete previews (#1912) 2026-08-24 12:44:19 +02:00
HampusandGitHub 0670380360 fix(app): reuse highlighted code instead of flashing plain text (#1911) 2026-08-24 12:44:09 +02:00
HampusandGitHub 904987795a fix(app): let custom branding override the built-in meta description (#1910) 2026-08-24 12:43:58 +02:00
HampusandGitHub 4ee1b2294a fix(app): show a still ban image under reduced motion or data saver (#1909) 2026-08-24 12:43:47 +02:00
HampusandGitHub 97eb84fd46 fix(app): keep avatar stack entries keyed by user across re-renders (#1908) 2026-08-24 12:43:37 +02:00
HampusandGitHub 5eb7822691 fix(app): show cached auth splash art without a fade-in flash (#1907) 2026-08-24 12:43:26 +02:00
HampusandGitHub 79cf57abe4 perf(app): keep gateway and layer manager off the auth session path (#1906) 2026-08-24 12:43:15 +02:00
HampusandGitHub 075bdb5128 fix(app): size auth entity icons to their reserved box (#1905) 2026-08-24 12:43:04 +02:00
HampusandGitHub 65698051ac fix(app): keep paging when the jumped-to message is missing (#1904) 2026-08-24 12:42:54 +02:00
HampusandGitHub 95559f17d8 fix(app): drive the message list window from one load state machine (#1903) 2026-08-24 12:42:42 +02:00
HampusandGitHub aabc13e3cb perf(app): window sticker picker rows by offset instead of observers (#1902) 2026-08-24 12:42:31 +02:00
HampusandGitHub b71ced9b2e fix(app): tell the user when a search query has nothing to search (#1901) 2026-08-24 12:42:20 +02:00
HampusandGitHub bb34c73069 perf(app): load sheet and popout media against their own scrollers (#1900) 2026-08-24 12:42:09 +02:00
HampusandGitHub 94bbbd1021 fix(app): only track hover on reactions that have an animated emoji (#1899) 2026-08-24 12:41:58 +02:00
HampusandGitHub 670a3a970e fix(app): stop pickers re-slicing their grid on first paint (#1898) 2026-08-24 12:41:47 +02:00
HampusandGitHub 7a938c85f6 fix(app): stop rewriting message emoji urls to toggle animation (#1897) 2026-08-24 12:41:36 +02:00
HampusandGitHub 3a6bc4bc7b perf(app): show the message action bar from css not remounting (#1896) 2026-08-24 12:41:24 +02:00
HampusandGitHub c54d7bcc88 perf(app): render member list rows in scroll chunks (#1895) 2026-08-24 12:41:14 +02:00
HampusandGitHub b81bfc80fd fix(app): stop the inbox reshuffling unread channels as you read them (#1894) 2026-08-24 12:41:03 +02:00
HampusandGitHub d8bad50eec perf(app): defer video embed metadata probes until hover (#1893) 2026-08-24 12:40:53 +02:00
HampusandGitHub 3fe6217809 fix(app): keep animated embed images animated (#1892) 2026-08-24 12:40:41 +02:00
HampusandGitHub 50a947a722 fix(app): fade image embed placeholders out instead of images in (#1891) 2026-08-24 12:40:29 +02:00
HampusandGitHub 7fe5aad16e feat(app): suggest recent speakers and stop autocomplete list flicker (#1890) 2026-08-24 12:40:19 +02:00
HampusandGitHub 97d559f749 fix(app): label pending composer attachments for screen readers (#1889) 2026-08-24 12:40:07 +02:00
HampusandGitHub 188f783fae fix(app): request attachment images at their real mosaic tile size (#1888) 2026-08-24 12:39:56 +02:00
HampusandGitHub 3a064dd728 feat(app): render search filters as inline tokens you can type through (#1887) 2026-08-24 12:39:45 +02:00
HampusandGitHub 202856c0f7 feat(app): keep search history per conversation and allow in: in DMs (#1886) 2026-08-24 12:39:34 +02:00
HampusandGitHub 406340fa65 fix(app): open channels at the unread divider and settle jump scrolling (#1885) 2026-08-24 12:39:23 +02:00
HampusandGitHub 735ecc1cca fix(api): distinguish a deleted reply target from no reply (#1884) 2026-08-24 12:39:11 +02:00
HampusandGitHub 7b646f891e fix(app): animate reaction emoji only while hovering them (#1883) 2026-08-24 12:37:48 +02:00
HampusandGitHub 19264d7f81 perf(app): preload reaction menu emoji at the size they render (#1882) 2026-08-24 12:37:38 +02:00
HampusandGitHub 76ce060d13 fix(app): stop the quick switcher list rebuilding while you navigate (#1881) 2026-08-24 12:37:27 +02:00
HampusandGitHub 9b3dc19037 perf(app): load the keyboard mode intro modal only when it is shown (#1880) 2026-08-24 12:37:12 +02:00
HampusandGitHub 5821a68816 fix(app): show the server avatar on message notifications (#1879) 2026-08-24 12:37:01 +02:00
HampusandGitHub e21544eac2 fix(app): order member mention results by search relevance (#1878) 2026-08-24 12:36:50 +02:00
HampusandGitHub 1f0f7d1222 perf(app): play gif embeds from the viewport not a js decoder (#1877) 2026-08-24 12:36:38 +02:00
HampusandGitHub 69e0086144 feat(app): warm full-size media while hovering an attachment (#1876) 2026-08-24 12:36:27 +02:00
HampusandGitHub 61ce8729de fix(app): stop overlaying a sharpening canvas in the media viewer (#1875) 2026-08-24 12:36:15 +02:00
HampusandGitHub 44869b0ce4 fix(app): stop seeking and hidden tabs from stranding playback (#1874) 2026-08-24 12:35:58 +02:00
HampusandGitHub 213dd53ee8 fix(app): size youtube embeds with the shared embed limits (#1873) 2026-08-24 12:35:42 +02:00
HampusandGitHub 844952db51 feat(app): drop the compact attachments media size preference (#1872) 2026-08-24 12:35:28 +02:00
HampusandGitHub eda29c0e34 fix(app): contain embedded media inside its box instead of overflowing (#1871) 2026-08-24 12:35:15 +02:00
HampusandGitHub 5834e32aa5 perf(app): window emoji picker rows by offset instead of observers (#1870) 2026-08-24 12:35:02 +02:00
HampusandGitHub a59f3d0d0c fix(app): animate message emoji only while hovering the message (#1869) 2026-08-24 12:34:52 +02:00
HampusandGitHub 5b8a46d087 fix(app): draw the mention badge cutout from the badge itself (#1868) 2026-08-24 12:34:37 +02:00
HampusandGitHub 677e6e5d6e fix(app): stop animating emoji and stickers that have no animation (#1867) 2026-08-24 12:34:26 +02:00
HampusandGitHub 62f40116be fix(app): stop discovery animating on first render (#1866) 2026-08-24 12:34:15 +02:00
HampusandGitHub d2d199e136 perf(app): fetch text attachment previews only when they scroll near (#1865) 2026-08-24 12:34:04 +02:00
HampusandGitHub 39e2c89d5c fix(app): only animate media that has an animated variant (#1864) 2026-08-24 12:33:53 +02:00
HampusandGitHub 15f4417c68 fix(app): scope near-viewport loading to the surrounding scroller (#1863) 2026-08-24 12:33:39 +02:00
HampusandGitHub 943b948de7 fix(app): keep hydrated state across gateway session resumes (#1862) 2026-08-24 12:33:27 +02:00
HampusandGitHub b7f75e25ad fix(app): keep unsent messages and stop the unread jump on reconnect (#1861) 2026-08-24 12:33:17 +02:00
HampusandGitHub 2af0405317 fix(app): keep the chat scroller pinned to the end while it resizes (#1860) 2026-08-24 12:33:06 +02:00
HampusandGitHub a2099fb0e2 refactor(app): name mute and unread state accessors by intent (#1859) 2026-08-24 12:32:55 +02:00
HampusandGitHub 52781cfa2d refactor(app): drop the unread jump anchor now the scroller finds it (#1858) 2026-08-24 12:32:44 +02:00
HampusandGitHub af7469fa1f fix(app): request custom emoji at one canonical image size (#1857) 2026-08-24 12:32:28 +02:00
HampusandGitHub 4c14ba0a5e fix(app): paint avatars from a real image with a fading placeholder (#1856) 2026-08-24 12:32:14 +02:00
HampusandGitHub b49cf349a8 perf(app): load images immediately instead of queueing four at a time (#1855) 2026-08-24 12:32:02 +02:00
HampusandGitHub 02406925d7 refactor(app): collapse the duplicate emoji shortcode matchers into one (#1854) 2026-08-24 12:31:51 +02:00
HampusandGitHub aad7e1a551 fix(app): freeze embed author and footer icons under motion settings (#1853) 2026-08-24 12:31:40 +02:00
HampusandGitHub a98a9fe6a9 feat(app): understand date words and DM channels in search filters (#1852) 2026-08-24 12:31:29 +02:00
HampusandGitHub ac6fcc8c40 fix(app): close the modal you opened, not whatever is on top (#1851) 2026-08-24 12:31:14 +02:00
HampusandGitHub b0e882645e fix(app): match member search on accents, display names and ids (#1850) 2026-08-24 12:31:02 +02:00
HampusandGitHub 8c431c7562 fix(app): retry hydrating message authors after a reconnect (#1849) 2026-08-24 12:30:50 +02:00
HampusandGitHub 3e267b8104 fix(app): keep the member list in sync when switching channels (#1848) 2026-08-24 12:30:38 +02:00
HampusandGitHub 7c5cab2144 fix(app): make the media volume slider track loudness (#1847) 2026-08-24 12:30:13 +02:00
HampusandGitHub 5cb893245f fix(app): hide the video controls again after a few idle seconds (#1846) 2026-08-24 12:30:01 +02:00
HampusandGitHub aa1c636cc2 fix(app): keep local image previews from disappearing before upload (#1845) 2026-08-24 12:29:49 +02:00
HampusandGitHub c285854b71 refactor(app): rename ComponentDispatch to ComponentBus (#1844) 2026-08-24 12:29:38 +02:00
HampusandGitHub 01a29d629b fix(app): size the chat skeleton from the remembered viewport height (#1843) 2026-08-24 12:29:26 +02:00
HampusandGitHub bd381ccc74 fix(app): request avatar and banner sizes the media proxy serves (#1842) 2026-08-24 12:29:16 +02:00
HampusandGitHub c3e747aaa4 fix(media-proxy): stop re-encoding jpeg and video frames losslessly (#1841) 2026-08-24 12:29:05 +02:00
HampusandGitHub 480d56125d fix(admin): render sticker previews at 320px instead of 160px (#1840) 2026-08-24 12:28:54 +02:00
HampusandGitHub 7ec155eac1 fix(desktop): stop reading whole voice backgrounds to classify them (#1839) 2026-08-24 12:28:43 +02:00
HampusandGitHub c8ff177f85 fix(unfurl): cache empty results briefly and key entries by provider (#1838) 2026-08-24 12:28:32 +02:00
HampusandGitHub f276bb1cd2 perf(app): stop the service worker caching static assets (#1837) 2026-08-24 12:28:21 +02:00
HampusandGitHub 208632e648 fix(common): stop advertising static assets as immutable (#1836) 2026-08-24 12:28:10 +02:00
HampusandGitHub 8cfac127f5 fix(media-proxy): stop proxy paths carrying fragments or credentials (#1835) 2026-08-24 12:27:58 +02:00
HampusandGitHub ac76bee5a3 fix(media-proxy): clamp the webp effort override to the encoder maximum (#1834) 2026-08-24 12:27:46 +02:00
HampusandGitHub 171dc7e5b7 fix(media-proxy): serve and request images on one size ladder (#1833) 2026-08-24 12:27:33 +02:00
HampusandGitHub 051985b767 fix(media-proxy): stop marking cached media immutable (#1832) 2026-08-24 12:27:21 +02:00
HampusandGitHub 1eb85410bf fix(common): keep the text after a block spoiler's closing pipes (#1831) 2026-08-24 12:27:08 +02:00
HampusandGitHub 6697db7cf8 build(app): minify css with lightningcss in production builds (#1830) 2026-08-24 12:26:55 +02:00
HampusandGitHub 56f6009390 feat(gifs): serve every medium, tiny and nano variant klipy offers (#1829) 2026-08-24 12:26:44 +02:00
HampusandGitHub d339b82f8e fix(api): give desktop downloads a lifetime that follows the key (#1828) 2026-08-24 12:26:33 +02:00
HampusandGitHub 82eb87bc47 perf(app-proxy): preconnect the media origin and revalidate the app shell (#1827) 2026-08-24 12:26:21 +02:00
HampusandGitHub 991be1c5a2 fix(api): log every error once and keep the cause chain (#1826) 2026-08-24 12:11:42 +02:00
HampusandGitHub 0d96a4574a fix(api): read the log level and environment from the process env (#1824) 2026-08-23 21:44:32 +02:00
HampusandGitHub 61b4511ae4 fix(schema): group category channels text before voice when ordering (#1823) 2026-08-23 17:49:26 +02:00
HampusandGitHub 137edc7cfb fix(app): share stream audio by default and reset the opt-out per stream (#1819) 2026-08-22 10:14:45 +02:00
HampusandGitHub 14e772a751 fix(api): serialise elapsed temp bans as null for the admin panel (#1817) 2026-08-21 20:34:43 +02:00
HampusandGitHub 32afbf12d6 fix(api): stop treating accounts pending deletion as already deleted (#1816) 2026-08-21 20:31:32 +02:00
HampusandGitHub ffaf5119d8 fix(app): only warn about software encoding when no layer is accelerated (#1815) 2026-08-21 18:49:51 +02:00
HampusandGitHub 10bc8c1efa fix(desktop): stop the windows audio probe timing out against its own budget (#1814) 2026-08-21 17:29:12 +02:00
HampusandGitHub 85a03a9e39 fix(app): apply the screen share audio toggle to the surface being shared (#1813) 2026-08-21 17:04:22 +02:00
51ee6567b4 chore(i18n): update public marketing catalogs (#1812)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-21 16:31:01 +02:00
090220a29d chore(marketing): advance pointer f39eced → 02e3a2c (#1811)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-21 16:30:54 +02:00
HampusandGitHub e7973b8be0 fix(api): derive voice reconciliation candidate ttl from real sweep spacing (#1810) 2026-08-21 15:49:50 +02:00
HampusandGitHub b5324c9223 perf(gateway): stop materializing all members on the guild connect path (#1809) 2026-08-21 14:00:19 +02:00
HampusandGitHub edb8d80077 ci: source the s3 provider for downloads and static from repo variables (#1803) 2026-08-20 21:46:26 +02:00
HampusandGitHub ddee116339 feat(api): route downloads through the configured downloads provider (#1802) 2026-08-20 21:44:53 +02:00
HampusandGitHub bdacaea4a8 feat(config): add an optional separate s3 provider for downloads (#1801) 2026-08-20 21:34:19 +02:00
HampusandGitHub 9e28e02b5d ci(rust): pin the floating toolchains to the version images build with (#1800) 2026-08-20 21:27:12 +02:00
HampusandGitHub 3527dc95a2 fix(rust): silence result_large_err on axum response error paths (#1799) 2026-08-20 21:18:18 +02:00
HampusandGitHub 27c7b2722d chore(admin): regenerate openapi schemas for the admin acl cap (#1798) 2026-08-20 21:17:04 +02:00
HampusandGitHub ba96f52ed6 fix(schema): allow assigning every admin ACL to a user (#1797) 2026-08-20 21:09:56 +02:00
HampusandGitHub 2c8b3ff45c fix(build): build the messages and users images with scylla support (#1796) 2026-08-20 20:30:16 +02:00
HampusandGitHub 631bc2307a fix(slowmode): stop the local cooldown from outgrowing the channel setting (#1795) 2026-08-20 19:56:02 +02:00
HampusandGitHub 8f4f9a8601 refactor(media): share one external proxy url codec across every service (#1794) 2026-08-20 19:55:40 +02:00
HampusandGitHub 1c920f966e feat(media): emit external proxy urls with a readable path and extension (#1793) 2026-08-20 18:44:45 +02:00
HampusandGitHub 2b1de38949 chore(i18n): regenerate catalogs after the voice engine removal (#1791) 2026-08-20 17:45:49 +02:00
HampusandGitHub d5daf61dbd fix(voice): recalibrate stored participant and stream volumes too (#1790) 2026-08-20 17:45:17 +02:00
HampusandGitHub 06c42ce02f refactor(desktop): delete the unused rust voice module (#1789) 2026-08-20 17:44:55 +02:00
HampusandGitHub 4f21430880 refactor(voice): drop the native only surface from voice_engine_v2 and narrow the desktop bridge (#1788) 2026-08-20 17:44:37 +02:00
HampusandGitHub 9731bac40f refactor(voice): remove the native voice engine from the renderer (#1787) 2026-08-20 17:43:38 +02:00
HampusandGitHub b144e650f2 fix(voice): make deafen reach watched screen share audio (#1786) 2026-08-20 17:43:19 +02:00
HampusandGitHub ef6536644c fix(voice): stop the in call speaker slider clamping a boosted output volume (#1785) 2026-08-20 17:42:59 +02:00
HampusandGitHub 17eab43245 fix(voice): retune the remote playback leveller and measure it in float (#1784) 2026-08-20 17:42:40 +02:00
HampusandGitHub fbd7f1e3b8 fix(voice): compose participant gain in linear space and split the ceilings (#1783) 2026-08-20 17:42:21 +02:00
HampusandGitHub 25af7516a4 fix(voice): widen the volume boost leg and add a master soft clip limiter (#1782) 2026-08-20 17:42:02 +02:00
HampusandGitHub c020eded31 fix(voice): stop forcing automatic gain control off on every capture profile (#1781) 2026-08-20 17:41:44 +02:00
HampusandGitHub 5331c0216a fix(voice): keep a remote track pinned at zero volume across re-attach (#1780) 2026-08-20 17:40:53 +02:00
HampusandGitHub 528777926c fix(api): stop one unreachable community from breaking bookmarks (#1779) 2026-08-20 15:50:51 +02:00
HampusandGitHub 47b5c3d4f0 fix(app): expose the guild id on guild list items again (#1778) 2026-08-20 14:39:24 +02:00
HampusandGitHub d9bfca66d6 fix(app): keep search results in server order instead of per channel (#1777) 2026-08-20 13:10:47 +02:00
HampusandGitHub ded51b65d3 fix(app): restore the mobile voice message recorder (#1776) 2026-08-20 04:33:24 +02:00
HampusandGitHub ddc8837d4f fix(app): scale embed and attachment width caps with zoom (#1775) 2026-08-20 04:33:17 +02:00
HampusandGitHub df16957c95 fix(app): scale tooltip max-width with app zoom (#1774) 2026-08-20 04:33:10 +02:00
HampusandGitHub f38b19d4bd fix(app): let the caret move past emoji and mentions in the composer (#1768) 2026-08-19 23:41:05 +02:00
HampusandGitHub f7cd4f2c74 docs(operator): explain how to reclaim space after upgrades (#1767) 2026-08-19 23:31:15 +02:00
a078392888 chore(i18n): update public marketing catalogs (#1719)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-19 22:46:33 +02:00
HampusandGitHub 3060f23f8c chore(marketing): bump the marketing submodule pointer (#1766) 2026-08-19 22:29:10 +02:00
HampusandGitHub 6a5f0d4d29 fix(i18n): simplify the marketing translation and saved media strings (#1764) 2026-08-19 22:25:01 +02:00
HampusandGitHub 91d989dc7c fix(app): point the language notice at Weblate instead of email (#1763) 2026-08-19 22:05:39 +02:00
HampusandGitHub 7c82804df6 fix(app): stop sensitive content options overlapping in long locales (#1762) 2026-08-19 19:53:46 +02:00
HampusandGitHub b7de83f7fc fix(app): stop the Firefox microphone failure and self-mute storm (#1761) 2026-08-19 19:32:41 +02:00
HampusandGitHub 97bd022bee fix(app): render the discovery language icon at bold weight (#1760) 2026-08-19 19:08:48 +02:00
HampusandGitHub 62324df039 feat(app): add a language filter button to the discovery navbar (#1759) 2026-08-19 18:46:39 +02:00
HampusandGitHub 9f78b3d9a6 feat(app): retry failed image loads with escalating delay and connectivity waits (#1758) 2026-08-19 18:25:34 +02:00
HampusandGitHub 362a89f2b2 fix(app): keep embed icons mounted so they reserve space while loading (#1757) 2026-08-19 18:00:08 +02:00
HampusandGitHub ce41960fcd perf(app): load embed author and footer icons through the image cache (#1756) 2026-08-19 17:38:00 +02:00
HampusandGitHub 2083eaddd6 feat(app): wrap pasted links in every composer that allows masked links (#1755) 2026-08-19 17:19:39 +02:00
HampusandGitHub d398ebc44b test: drop desktop test files that no runner executes (#1753) 2026-08-19 14:58:59 +02:00
HampusandGitHub 59887ad404 fix(app): wrap pasted links from any source, not just Fluxer's own clipboard (#1752) 2026-08-19 14:57:13 +02:00
HampusandGitHub 5aa283e8e0 fix(desktop): repair a stale Linux .desktop entry instead of preserving it (#1751) 2026-08-19 14:56:29 +02:00
HampusandGitHub d9ed256a4b fix(desktop): pin Electron to 43.4.0 to restore KDE and XFCE tray icons (#1750) 2026-08-19 14:45:58 +02:00
HampusandGitHub a297f89b83 fix(markdown): parse a table that follows a text line without a blank line (#1749) 2026-08-19 14:24:33 +02:00
938 changed files with 49684 additions and 61597 deletions
+4 -4
View File
@@ -106,10 +106,10 @@ jobs:
- name: upload assets to S3 static bucket
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
STATIC_BUCKET: fluxer-static
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ vars.STATIC_S3_ENDPOINT }}
STATIC_BUCKET: ${{ vars.STATIC_S3_BUCKET }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
--step upload_assets
+8 -8
View File
@@ -139,10 +139,10 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
S3_BUCKET: fluxer-downloads
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
@@ -524,11 +524,11 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
S3_BUCKET: fluxer-downloads
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+2 -2
View File
@@ -89,7 +89,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: stable
toolchain: "1.93.0"
components: clippy, rustfmt
- name: Install pnpm
@@ -286,7 +286,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: stable
toolchain: "1.93.0"
components: rustfmt
- name: Sync ci helper dependencies
Generated
+10
View File
@@ -1777,6 +1777,7 @@ dependencies = [
"axum",
"base64",
"clap",
"fluxer_common",
"hmac 0.13.0",
"hyper 1.10.1",
"hyper-util",
@@ -1800,6 +1801,7 @@ dependencies = [
"anyhow",
"base64",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"moka",
"reqwest",
@@ -1836,6 +1838,7 @@ dependencies = [
"cc",
"clap",
"criterion",
"fluxer_common",
"hex",
"hmac 0.13.0",
"http 1.4.2",
@@ -1875,6 +1878,7 @@ dependencies = [
"chrono",
"criterion",
"fluxer-svc",
"fluxer_common",
"fluxer_markdown_parser",
"futures",
"hmac 0.13.0",
@@ -1943,6 +1947,7 @@ dependencies = [
"encoding_rs",
"entities",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"infer",
"moka",
@@ -2038,12 +2043,17 @@ dependencies = [
"aws-credential-types",
"aws-sigv4",
"axum",
"base64",
"hmac 0.13.0",
"maxminddb",
"moka",
"reqwest",
"serde_json",
"sha2 0.11.0",
"thiserror",
"time",
"tracing",
"url",
"urlencoding",
]
+8 -1
View File
@@ -145,7 +145,14 @@
"!fluxer_static",
"!packages/fonts",
"!fluxer_admin/static/htmx.min.js",
"!fluxer_api/src/api/openapi/openapi.json"
"!fluxer_api/src/api/openapi/openapi.json",
"!fluxer_api/pkgs/email/src/email_i18n/locales",
"!fluxer_api/pkgs/email/src/email_i18n/weblate",
"!fluxer_api/src/api/content_i18n/locales",
"!fluxer_api/src/api/content_i18n/weblate",
"!packages/errors/src/i18n/locales",
"!packages/errors/src/i18n/weblate",
"!**/auto-i18n-reviewed-unchanged.json"
],
"ignoreUnknown": true
}
+3 -3
View File
@@ -13669,7 +13669,7 @@
"required": ["participants"]
},
"referenced_message": {
"description": "The message that this message is replying to or forwarding",
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
"nullable": true,
"type": "object",
"properties": {
@@ -14913,7 +14913,7 @@
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"deletion_public_reason": {"nullable": true, "type": "string"},
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 115},
"traits": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
"has_totp": {"type": "boolean"},
"authenticator_types": {"type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}, "maxItems": 10},
@@ -15481,7 +15481,7 @@
"acls": {
"type": "array",
"items": {"type": "string"},
"maxItems": 100,
"maxItems": 115,
"description": "List of access control permissions to assign"
}
},
+1
View File
@@ -112,6 +112,7 @@ fn is_urlencoded_form(request: &Request) -> bool {
})
}
#[allow(clippy::result_large_err)]
async fn extract_csrf_from_form_body(
request: Request,
) -> Result<(Request, Option<String>), Response> {
@@ -29,12 +29,16 @@
"main": "./src/MediaProxyUtils.ts",
"types": "./src/MediaProxyUtils.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@types/node": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:"
"@typescript/native-preview": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "catalog:"
}
}
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildExternalMediaProxyPath,
buildOpaqueExternalMediaProxyPath,
reconstructOriginalUrl,
} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
import {describe, expect, it} from 'vitest';
const ROUND_TRIP_URLS = [
'https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
'https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here',
'https://example.com:8443/a.png',
'https://avatars.githubusercontent.com/u/241303489?v=4',
'http://example.com/plain.gif',
'https://example.com/deep/nested/path/to/file.jpeg',
'https://example.com/file.png?a=1&b=2&c=3',
'https://example.com/spaced%20name.png',
'https://example.com/unicode/%C3%A5%C3%A4%C3%B6.png',
'https://example.com/file.png?redirect=https%3A%2F%2Fother.example%2Fx.png',
'https://sub.domain.example.co.uk/a/b.webp',
];
describe('buildExternalMediaProxyPath', () => {
it('emits the plain path shape with the extension last', () => {
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp')).toBe(
'https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
);
});
it('puts an encoded query, leading question mark included, ahead of the protocol', () => {
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here')).toBe(
'%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp',
);
});
it('keeps a non default port on the host segment', () => {
expect(buildExternalMediaProxyPath('https://example.com:8443/a.png')).toBe('https/example.com:8443/a.png');
});
it('preserves the http scheme', () => {
expect(buildExternalMediaProxyPath('http://example.com/a.gif')).toBe('http/example.com/a.gif');
});
it('handles a root url with no path', () => {
expect(buildExternalMediaProxyPath('https://example.com/')).toBe('https/example.com');
});
it('never emits the v2 prefix any more', () => {
for (const url of ROUND_TRIP_URLS) {
expect(buildExternalMediaProxyPath(url).startsWith('v2/')).toBe(false);
}
});
it('ends in the source file extension so extension based cdn caching applies', () => {
for (const [url, ext] of [
['https://example.com/a.webp', '.webp'],
['https://example.com/a.png?x=1', '.png'],
['https://example.com/a/b/c.jpeg', '.jpeg'],
] as const) {
expect(buildExternalMediaProxyPath(url).endsWith(ext)).toBe(true);
}
});
it('rejects a url it cannot parse', () => {
expect(() => buildExternalMediaProxyPath('not a url')).toThrow();
});
});
describe('reconstructOriginalUrl', () => {
it('round trips every supported shape', () => {
for (const url of ROUND_TRIP_URLS) {
expect(reconstructOriginalUrl(buildExternalMediaProxyPath(url))).toBe(url);
}
});
it('decodes an externally produced path verbatim', () => {
expect(
reconstructOriginalUrl('%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp'),
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
});
it('does not double the question mark when the query segment carries one', () => {
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
expect(decoded).toBe('https://example.com/x.png?a=1');
expect(decoded).not.toContain('??');
});
it('still accepts a query segment without a leading question mark', () => {
expect(reconstructOriginalUrl('a%3D1/https/example.com/x.png')).toBe('https://example.com/x.png?a=1');
});
it('still decodes v2 paths so links already sent keep working', () => {
expect(reconstructOriginalUrl(buildOpaqueExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe(
'https://example.com/a.png?x=1',
);
});
it('rejects a path that has no host after the protocol', () => {
expect(() => reconstructOriginalUrl('https')).toThrow();
});
it('rejects an empty v2 payload', () => {
expect(() => reconstructOriginalUrl('v2/')).toThrow();
});
});
@@ -2,21 +2,21 @@
const BASE64_URL_PADDING_REGEX = /=*$/;
const LEGACY_PROTOCOL_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*$/;
const V2_PATH_PREFIX = 'v2/';
const OPAQUE_PATH_PREFIX = 'v2/';
function encodeV2PathComponent(value: string): string {
function encodeOpaquePathComponent(value: string): string {
return Buffer.from(value, 'utf8').toString('base64url').replace(BASE64_URL_PADDING_REGEX, '');
}
function decodeV2PathComponent(value: string): string {
function decodeOpaquePathComponent(value: string): string {
return Buffer.from(value, 'base64url').toString('utf8');
}
function decodeLegacyComponent(component: string): string {
function decodeSegmentedComponent(component: string): string {
return decodeURIComponent(component);
}
function getLegacyProtocolIndex(parts: Array<string>): number {
function getSegmentedProtocolIndex(parts: Array<string>): number {
const firstPart = parts[0];
if (firstPart && LEGACY_PROTOCOL_REGEX.test(firstPart)) {
return 0;
@@ -33,15 +33,15 @@ function getLegacyProtocolIndex(parts: Array<string>): number {
throw new Error('Protocol is missing in the proxy URL path.');
}
interface LegacyHostAndPort {
interface SegmentedHostAndPort {
hostname: string;
port: string;
}
function decodeLegacyHostAndPort(hostPart: string): LegacyHostAndPort {
function decodeSegmentedHostAndPort(hostPart: string): SegmentedHostAndPort {
const separatorIndex = hostPart.lastIndexOf(':');
if (separatorIndex === -1) {
const hostname = decodeLegacyComponent(hostPart);
const hostname = decodeSegmentedComponent(hostPart);
if (!hostname) {
throw new Error('Hostname is invalid in the proxy URL path.');
}
@@ -53,14 +53,14 @@ function decodeLegacyHostAndPort(hostPart: string): LegacyHostAndPort {
throw new Error('Hostname is invalid in the proxy URL path.');
}
return {
hostname: decodeLegacyComponent(encodedHostname),
port: encodedPort ? decodeLegacyComponent(encodedPort) : '',
hostname: decodeSegmentedComponent(encodedHostname),
port: encodedPort ? decodeSegmentedComponent(encodedPort) : '',
};
}
function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
function reconstructSegmentedOriginalUrl(proxyUrlPath: string): string {
const parts = proxyUrlPath.split('/');
const protocolIndex = getLegacyProtocolIndex(parts);
const protocolIndex = getSegmentedProtocolIndex(parts);
const protocol = parts[protocolIndex];
if (!protocol) {
throw new Error('Protocol is missing in the proxy URL path.');
@@ -71,29 +71,47 @@ function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
}
const encodedQuery = parts.slice(0, protocolIndex).join('/');
const encodedPath = parts.slice(protocolIndex + 2).join('/');
const query = encodedQuery ? decodeLegacyComponent(encodedQuery) : '';
const path = decodeLegacyComponent(encodedPath);
const {hostname, port} = decodeLegacyHostAndPort(hostPart);
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`;
const query = encodedQuery ? decodeSegmentedComponent(encodedQuery) : '';
const path = decodeSegmentedComponent(encodedPath);
const {hostname, port} = decodeSegmentedHostAndPort(hostPart);
const normalizedQuery = query.startsWith('?') ? query.slice(1) : query;
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${normalizedQuery ? `?${normalizedQuery}` : ''}`;
}
function reconstructV2OriginalUrl(proxyUrlPath: string): string {
const encodedOriginalUrl = proxyUrlPath.slice(V2_PATH_PREFIX.length);
function reconstructOpaqueOriginalUrl(proxyUrlPath: string): string {
const encodedOriginalUrl = proxyUrlPath.slice(OPAQUE_PATH_PREFIX.length);
if (!encodedOriginalUrl) {
throw new Error('Encoded URL is missing in the proxy URL path.');
}
return decodeV2PathComponent(encodedOriginalUrl);
return decodeOpaquePathComponent(encodedOriginalUrl);
}
export function buildOpaqueExternalMediaProxyPath(inputUrl: string): string {
const parsedUrl = new URL(inputUrl);
return `${OPAQUE_PATH_PREFIX}${encodeOpaquePathComponent(parsedUrl.toString())}`;
}
export function buildExternalMediaProxyPath(inputUrl: string): string {
const parsedUrl = new URL(inputUrl);
return `${V2_PATH_PREFIX}${encodeV2PathComponent(parsedUrl.toString())}`;
const protocol = parsedUrl.protocol.replace(/:$/u, '');
const host = parsedUrl.port ? `${parsedUrl.hostname}:${parsedUrl.port}` : parsedUrl.hostname;
const path = parsedUrl.pathname
.replace(/^\//u, '')
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
const segments = parsedUrl.search ? [encodeURIComponent(parsedUrl.search)] : [];
segments.push(protocol, host);
if (path) {
segments.push(path);
}
return segments.join('/');
}
export function reconstructOriginalUrl(proxyUrlPath: string): string {
const reconstructedUrl = proxyUrlPath.startsWith(V2_PATH_PREFIX)
? reconstructV2OriginalUrl(proxyUrlPath)
: reconstructLegacyOriginalUrl(proxyUrlPath);
const reconstructedUrl = proxyUrlPath.startsWith(OPAQUE_PATH_PREFIX)
? reconstructOpaqueOriginalUrl(proxyUrlPath)
: reconstructSegmentedOriginalUrl(proxyUrlPath);
new URL(reconstructedUrl);
return reconstructedUrl;
}
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfigs/package.json",
"compilerOptions": {
"paths": {
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
"@pkgs/*": ["../*"]
}
},
"include": ["src/**/*"]
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
+11
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
@@ -237,6 +238,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
},
s3Downloads: resolveDownloadsProvider(master),
s3: {
endpoint: s3Config.endpoint,
presignedUrlBase: s3Config.presigned_url_base,
@@ -430,6 +432,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
presignedHarvestDownloadsEnabled: master.services.api.presigned_harvest_downloads_enabled ?? true,
attachmentDecayEnabled: master.attachment_decay_enabled,
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
@@ -461,6 +464,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
voiceReconciliation: {
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
},
laneConcurrencyOverrides: {
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
+2 -1
View File
@@ -84,7 +84,8 @@ export async function mapUserToAdminResponse(
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
suspicious_activity_flags: user.suspiciousActivityFlags,
phone_verification_deferred: ((user.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0,
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
temp_banned_until:
user.tempBannedUntil && user.tempBannedUntil.getTime() > Date.now() ? user.tempBannedUntil.toISOString() : null,
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
deletion_reason_code: user.deletionReasonCode,
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MEDIA_PROXY_ICON_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
ListGuildMembersRequest,
@@ -18,6 +19,8 @@ import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IUserRepository} from '../../../user/IUserRepository';
import {mapGuildsToAdminResponse} from '../../models/GuildTypes';
const ADMIN_STICKER_MEDIA_RUNG: MediaProxyImageSize = 320;
interface AdminGuildLookupServiceDeps {
guildRepository: IGuildRepositoryAggregate;
userRepository: IUserRepository;
@@ -201,6 +204,6 @@ export class AdminGuildLookupService {
}
private buildStickerMediaUrl(id: string, animated: boolean): string {
return `${Config.endpoints.media}/stickers/${id}.webp?size=${MEDIA_PROXY_ICON_SIZE_DEFAULT}${animated ? '&animated=true' : ''}`;
return `${Config.endpoints.media}/stickers/${id}.webp?size=${ADMIN_STICKER_MEDIA_RUNG}${animated ? '&animated=true' : ''}`;
}
}
@@ -929,9 +929,8 @@ export class MessageSendService {
algorithm: 'leaky_bucket',
});
if (!slowmodeResult.allowed) {
const retryAfter = Math.max(0, slowmodeResult.resetTime.getTime() - Date.now());
throw new SlowmodeRateLimitError({
retryAfter,
retryAfter: slowmodeResult.retryAfter,
retryAfterDecimal: slowmodeResult.retryAfterDecimal,
});
}
@@ -160,12 +160,8 @@ export class MessageValidationService {
}
}
calculateMessageFlags(data: {flags?: number; favorite_meme_id?: bigint | null}): number {
let flags = data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0;
if (data.favorite_meme_id) {
flags |= MessageFlags.COMPACT_ATTACHMENTS;
}
return flags;
calculateMessageFlags(data: {flags?: number}): number {
return data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0;
}
validateTotalAttachmentSize(
@@ -52,4 +52,27 @@ describe('Slowmode Enforcement', () => {
expect(messages).toHaveLength(1);
expect(messages[0]?.id).toBe(firstMessage.id);
});
it('reports the slowmode retry window in seconds on the Retry-After header', async () => {
const rateLimitPerUser = 5;
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
await ensureSessionStarted(harness, member.token);
const channel = await createChannel(harness, owner.token, guild.id, 'slowmode-channel');
await updateChannel(harness, owner.token, channel.id, {
rate_limit_per_user: rateLimitPerUser,
});
await sendChannelMessage(harness, member.token, channel.id, 'first message');
const {response, json} = await createBuilder<{code: string; retry_after: number}>(harness, member.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'second message'})
.expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED)
.executeWithResponse();
const headerRetryAfter = Number(response.headers.get('Retry-After'));
expect(Number.isInteger(headerRetryAfter)).toBe(true);
expect(headerRetryAfter).toBeGreaterThan(0);
expect(headerRetryAfter).toBeLessThanOrEqual(rateLimitPerUser);
expect(json.retry_after).toBeGreaterThan(0);
expect(json.retry_after).toBeLessThanOrEqual(rateLimitPerUser);
expect(headerRetryAfter - json.retry_after).toBeLessThan(1);
});
});
+11
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
@@ -150,6 +151,7 @@ export interface APIConfig {
static: string;
};
};
s3Downloads: ResolvedDownloadsProvider;
email: {
enabled: boolean;
provider: 'smtp' | 'none';
@@ -328,6 +330,7 @@ export interface APIConfig {
};
presignedAttachmentUploadsEnabled: boolean;
presignedDownloadsEnabled: boolean;
presignedHarvestDownloadsEnabled: boolean;
attachmentDecayEnabled: boolean;
deletionGracePeriodHours: number;
inactivityDeletionThresholdDays?: number;
@@ -359,6 +362,14 @@ export interface APIConfig {
taskName?: WorkerTaskName;
enableCronScheduler?: boolean;
enableVoiceReconciliation: boolean;
voiceReconciliation: {
intervalMs: number | undefined;
staggerDelayMs: number | undefined;
lockTtlSeconds: number | undefined;
cadenceTtlSeconds: number | undefined;
gatewayOnlyGraceMs: number | undefined;
liveKitOnlyGraceMs: number | undefined;
};
laneConcurrencyOverrides: {
realtime?: number;
unfurl?: number;
@@ -18,7 +18,12 @@ import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
import type {HonoEnv} from '../types/HonoEnv';
import {Validator} from '../Validator';
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from './DownloadService';
import {DESKTOP_REDIRECT_PREFIX, DOWNLOAD_PREFIX, UnsatisfiableRangeError} from './DownloadService';
import {
DESKTOP_REDIRECT_PREFIX,
DOWNLOAD_PREFIX,
downloadCacheControlForKey,
UnsatisfiableRangeError,
} from './DownloadService';
function artifactFilename(key: string, filenameOverride?: string): string {
return filenameOverride ?? key.split('/').pop() ?? 'download';
@@ -290,7 +295,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
if (!checksum) {
return ctx.text('Not Found', 404);
}
return checksumFileResponse(ctx, checksum, 'public, max-age=86400');
return checksumFileResponse(ctx, checksum, downloadCacheControlForKey(checksum.key));
},
);
routes.on(
@@ -316,7 +321,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
if (!key) {
return ctx.text('Not Found', 404);
}
return streamArtifactResponse(ctx, downloadService, key, 'public, max-age=86400');
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
},
);
routes.on(
@@ -340,7 +345,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
if (!key) {
return ctx.text('Not Found', 404);
}
return streamArtifactResponse(ctx, downloadService, key, 'public, max-age=300');
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
},
);
}
+35 -4
View File
@@ -51,6 +51,35 @@ function desktopBucketPrefix(test?: boolean): string {
return test ? DESKTOP_TEST_BUCKET_PREFIX : DESKTOP_BUCKET_PREFIX;
}
const MUTABLE_DOWNLOAD_CACHE_CONTROL = 'public, max-age=300';
const VERSIONED_ARTIFACT_CACHE_CONTROL = 'public, max-age=31536000';
function isDesktopReleaseFeedFilename(filename: string): boolean {
return (
filename === 'manifest.json' ||
filename.endsWith('.yml') ||
filename.endsWith('.yaml') ||
filename.startsWith('RELEASES') ||
(filename.startsWith('releases') && filename.endsWith('.json')) ||
(filename.startsWith('assets') && filename.endsWith('.json'))
);
}
function isVersionedDesktopArtifactKey(key: string): boolean {
if (!key.startsWith(`${DESKTOP_BUCKET_PREFIX}/`)) {
return false;
}
const filename = key.split('/').pop() ?? '';
if (filename.length === 0) {
return false;
}
return !isDesktopReleaseFeedFilename(filename);
}
export function downloadCacheControlForKey(key: string): string {
return isVersionedDesktopArtifactKey(key) ? VERSIONED_ARTIFACT_CACHE_CONTROL : MUTABLE_DOWNLOAD_CACHE_CONTROL;
}
function desktopArtifactPrefix(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
@@ -108,6 +137,7 @@ type VersionInfo = {
files: Record<string, VersionFile>;
};
export type DesktopChecksumFile = {
key: string;
filename: string;
sha256: string;
body: string;
@@ -461,7 +491,7 @@ export class DownloadService {
return null;
}
const filename = this.filenameFromKey(key);
return this.buildDesktopChecksumFile(filename, file.sha256);
return this.buildDesktopChecksumFile(key, filename, file.sha256);
}
async resolveVersionedDesktopChecksumFile(params: {
@@ -479,14 +509,14 @@ export class DownloadService {
const filename = this.filenameFromKey(key);
const objectSha256 = await this.readDesktopSha256ForArtifactKey(key);
if (objectSha256) {
return this.buildDesktopChecksumFile(filename, objectSha256);
return this.buildDesktopChecksumFile(key, filename, objectSha256);
}
const latest = await this.getLatestDesktopVersion(params);
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
return null;
}
return this.buildDesktopChecksumFile(filename, file.sha256);
return this.buildDesktopChecksumFile(key, filename, file.sha256);
}
async resolveDownloadKey(params: {path: string; test?: boolean}): Promise<string | null> {
@@ -1151,8 +1181,9 @@ export class DownloadService {
return key.split('/').pop() ?? 'download';
}
private buildDesktopChecksumFile(filename: string, sha256: string): DesktopChecksumFile {
private buildDesktopChecksumFile(key: string, filename: string, sha256: string): DesktopChecksumFile {
return {
key,
filename,
sha256,
body: `${sha256} ${filename}\n`,
@@ -2,6 +2,7 @@
import {Logger} from '@fluxer/logger/src/Logger';
import type {ResolvedGifEntrySchema} from '@fluxer/schema/src/domains/gif/FavoriteGifSchemas';
import {inferFormatContentType, PREVIEW_FORMAT_PRIORITY} from '@fluxer/schema/src/domains/gif/GifMediaFormatKeys';
import type {GifMediaFormat, GifResponse} from '@fluxer/schema/src/domains/gif/GifSchemas';
import type {EmbedMediaResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {tryExtractGifProviderSlug} from '../gif/GifProviderUtils';
@@ -10,17 +11,6 @@ import type {IGifProvider} from '../gif/IGifProvider';
import type {IMediaService, MediaProxyMetadataResponse} from '../infrastructure/IMediaService';
import type {IUnfurlerService} from '../infrastructure/IUnfurlerService';
const PREVIEW_FORMAT_PRIORITY = ['webm', 'mp4', 'tinywebm', 'tinymp4', 'webp', 'gif', 'tinygif', 'nanogif'] as const;
const FORMAT_CONTENT_TYPES: Record<string, string> = {
webm: 'video/webm',
tinywebm: 'video/webm',
mp4: 'video/mp4',
tinymp4: 'video/mp4',
webp: 'image/webp',
gif: 'image/gif',
tinygif: 'image/gif',
nanogif: 'image/gif',
};
const logger = new Logger('FavoriteGifResolver');
function pickFavoriteGifPreviewFormat(
@@ -221,10 +211,6 @@ function isUsableGifMediaFormat(format: GifMediaFormat | undefined): format is G
return Boolean(format?.src && format.proxy_src && format.width > 0 && format.height > 0);
}
function inferFormatContentType(formatKey: string): string {
return FORMAT_CONTENT_TYPES[formatKey] ?? '';
}
function isRenderableMediaType(contentType: string | null | undefined): boolean {
if (!contentType) return false;
return contentType.startsWith('image/') || contentType.startsWith('video/');
@@ -187,3 +187,26 @@ describe('StorageService.copyObjectWithMetadataStripping', () => {
]);
});
});
describe('provider selection', () => {
interface ClientProbe {
client: {config: {region: () => Promise<string>; endpoint?: () => Promise<{hostname: string}>}};
}
it('defaults to the shared S3 configuration', async () => {
const service = new StorageService() as unknown as ClientProbe;
expect(await service.client.config.region()).toBe(Config.s3.region);
});
it('uses an explicitly supplied provider instead of the shared one', async () => {
const service = new StorageService({
endpoint: 'https://downloads.example.net',
forcePathStyle: false,
region: 'eu-central-9',
accessKeyId: 'DL_KEY',
secretAccessKey: 'DL_SECRET',
}) as unknown as ClientProbe;
expect(await service.client.config.region()).toBe('eu-central-9');
expect(await service.client.config.region()).not.toBe(Config.s3.region);
});
});
@@ -26,6 +26,7 @@ import {
} from '@aws-sdk/client-s3';
import {Upload} from '@aws-sdk/lib-storage';
import {getSignedUrl} from '@aws-sdk/s3-request-presigner';
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
import {isSupportedMediaContentType} from '@pkgs/mime_utils/src/ContentTypeUtils';
import {seconds} from 'itty-time';
import {temporaryFile} from 'tempy';
@@ -105,27 +106,36 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
export class StorageService implements IStorageService {
private readonly client: S3Client;
private readonly presignClient: S3Client;
private readonly provider: S3ProviderSettings;
constructor() {
this.client = buildPooledS3Client({
constructor(provider?: S3ProviderSettings) {
this.provider = provider ?? {
endpoint: Config.s3.endpoint,
presignedUrlBase: Config.s3.presignedUrlBase,
forcePathStyle: Config.s3.forcePathStyle,
region: Config.s3.region,
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
};
this.client = buildPooledS3Client({
endpoint: this.provider.endpoint,
region: this.provider.region,
accessKeyId: this.provider.accessKeyId,
secretAccessKey: this.provider.secretAccessKey,
forcePathStyle: true,
});
this.presignClient = buildPooledS3Client({
endpoint: this.resolvePresignEndpoint(),
region: Config.s3.region,
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
forcePathStyle: Config.s3.forcePathStyle,
region: this.provider.region,
accessKeyId: this.provider.accessKeyId,
secretAccessKey: this.provider.secretAccessKey,
forcePathStyle: this.provider.forcePathStyle,
});
}
private resolvePresignEndpoint(): string {
const fallbackEndpoint = Config.s3.endpoint;
const configuredEndpoint = Config.s3.presignedUrlBase;
const fallbackEndpoint = this.provider.endpoint;
const configuredEndpoint = this.provider.presignedUrlBase;
if (!configuredEndpoint) {
return fallbackEndpoint;
}
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {Config} from '../Config';
import {createDownloadsStorageService} from './StorageServiceFactory';
describe('createDownloadsStorageService', () => {
it('returns null when no downloads override is configured', () => {
expect(Config.s3Downloads.isOverridden).toBe(false);
expect(createDownloadsStorageService()).toBeNull();
});
it('resolves the downloads provider to the shared provider by default', () => {
expect(Config.s3Downloads.settings.endpoint).toBe(Config.s3.endpoint);
expect(Config.s3Downloads.settings.region).toBe(Config.s3.region);
expect(Config.s3Downloads.settings.accessKeyId).toBe(Config.s3.accessKeyId);
});
});
@@ -1,8 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '../Config';
import type {IStorageService} from './IStorageService';
import {StorageService} from './StorageService';
export function createStorageService(): IStorageService {
return new StorageService();
}
export function createDownloadsStorageService(): IStorageService | null {
if (!Config.s3Downloads.isOverridden) {
return null;
}
return new StorageService(Config.s3Downloads.settings);
}
@@ -60,7 +60,7 @@ import {KVActivityTracker} from '../infrastructure/KVActivityTracker';
import {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService';
import {NatsUnfurlerService} from '../infrastructure/NatsUnfurlerService';
import {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService';
import {createStorageService} from '../infrastructure/StorageServiceFactory';
import {createDownloadsStorageService, createStorageService} from '../infrastructure/StorageServiceFactory';
import {UserCacheService} from '../infrastructure/UserCacheService';
import {createUsersServiceClient} from '../infrastructure/UsersServiceClient';
import {VirusScanService} from '../infrastructure/VirusScanService';
@@ -193,6 +193,10 @@ export const getStorageService: () => IStorageService = (() => {
const fallback = singleton(() => createStorageService());
return () => _injectedStorageService ?? fallback();
})();
const getDownloadsStorageService: () => IStorageService = (() => {
const override = singleton(() => createDownloadsStorageService());
return () => override() ?? getStorageService();
})();
export const getErrorI18nService = singleton(() => new ErrorI18nService());
export const getLimitConfigService = singleton(
() => new LimitConfigService(getInstanceConfigRepository(), getCacheService(), getKVClient()),
@@ -262,7 +266,7 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
return accountDeletionQueue;
}
export const getDownloadService = singleton(() => new DownloadService(getStorageService()));
export const getDownloadService = singleton(() => new DownloadService(getDownloadsStorageService()));
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
const getNcmecRepository = singleton(() => new NcmecRepository());
+67 -4
View File
@@ -10588,6 +10588,70 @@
]
}
},
"/harvest-downloads/{harvestId}": {
"get": {
"operationId": "download_data_harvest_archive",
"summary": "Download data harvest archive",
"tags": ["Users"],
"responses": {
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Download data harvest archive"}},
"description": "Streams a completed data harvest archive. Authorised by a signed, expiring token rather than a session, so the link works from the harvest completion email. Only active when presigned harvest downloads are disabled.",
"parameters": [
{
"name": "harvestId",
"in": "path",
"required": true,
"schema": {"type": "string"},
"description": "The harvestId"
}
]
}
},
"/invites/{invite_code}": {
"get": {
"operationId": "get_invite",
@@ -26851,7 +26915,7 @@
},
{"type": "null"}
],
"description": "The message that this message is replying to or forwarding"
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence."
}
},
"required": [
@@ -26885,8 +26949,7 @@
"value": "4096",
"description": "This message will not trigger push or desktop notifications"
},
{"name": "VOICE_MESSAGE", "value": "8192", "description": "This message is a voice message"},
{"name": "COMPACT_ATTACHMENTS", "value": "131072", "description": "Display attachments in a compact format"}
{"name": "VOICE_MESSAGE", "value": "8192", "description": "This message is a voice message"}
],
"description": "Message flags bitfield"
},
@@ -35896,7 +35959,7 @@
"HarvestDownloadUrlResponse": {
"type": "object",
"properties": {
"download_url": {"type": "string", "description": "The presigned URL to download the harvest archive"},
"download_url": {"type": "string", "description": "The temporary URL to download the harvest archive"},
"expires_at": {"type": "string", "description": "ISO 8601 timestamp when the harvest download expires"}
},
"required": ["download_url", "expires_at"]
@@ -216,6 +216,10 @@ export const UserRateLimitConfigs = {
bucket: 'user:harvest:download',
config: {limit: 10, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_HARVEST_DOWNLOAD_FILE: {
bucket: 'user:harvest:download_file',
config: {limit: 60, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_ENTRANCE_SOUND_LIST: {
bucket: 'user:entrance_sound:list',
config: {limit: 30, windowMs: ms('1 minute')},
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {HarvestIdParam, MessageIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {MessageListResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {
@@ -269,6 +270,50 @@ export function UserContentController(app: HonoApp) {
return ctx.json(harvest, 200);
},
);
app.get(
'/harvest-downloads/:harvestId',
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD_FILE),
Validator('param', HarvestIdParam),
OpenAPI({
operationId: 'download_data_harvest_archive',
summary: 'Download data harvest archive',
responseSchema: null,
statusCode: 200,
security: [],
tags: ['Users'],
description:
'Streams a completed data harvest archive. Authorised by a signed, expiring token rather than a session, so the link works from the harvest completion email. Only active when presigned harvest downloads are disabled.',
}),
async (ctx) => {
const {harvestId} = ctx.req.valid('param');
const token = ctx.req.query('token');
if (!token) {
return ctx.text('Not Found', 404);
}
const result = await ctx.get('userContentRequestService').streamHarvestDownload({
harvestId,
token,
range: ctx.req.header('range') ?? undefined,
storageService: ctx.get('storageService'),
});
if (!result) {
return ctx.text('Not Found', 404);
}
const headers = new Headers();
headers.set('Content-Type', result.contentType ?? 'application/zip');
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(result.filename)}"`);
headers.set('Content-Length', String(result.contentLength));
headers.set('Cache-Control', 'private, no-store');
headers.set('Accept-Ranges', 'bytes');
if (result.contentRange) {
headers.set('Content-Range', result.contentRange);
}
return new Response(Readable.toWeb(result.body) as ReadableStream, {
status: result.contentRange ? 206 : 200,
headers,
});
},
);
app.get(
'/users/@me/harvest/:harvestId/download',
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD),
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHmac, timingSafeEqual} from 'node:crypto';
import {isJsonRecord, parseJsonWithGuard} from '../../utils/JsonBoundaryUtils';
const KEY_DERIVATION_LABEL = 'fluxer.harvest-download.v1';
interface HarvestDownloadTokenPayload {
userId: string;
harvestId: string;
storageKey: string;
expiresAt: number;
}
function deriveKey(secret: string): Buffer {
return createHmac('sha256', secret).update(KEY_DERIVATION_LABEL).digest();
}
function computeSignature(payloadBase64: string, secret: string): Buffer {
return createHmac('sha256', deriveKey(secret)).update(payloadBase64).digest();
}
function isHarvestDownloadTokenPayload(value: unknown): value is HarvestDownloadTokenPayload {
if (!isJsonRecord(value)) return false;
return (
typeof value['userId'] === 'string' &&
typeof value['harvestId'] === 'string' &&
typeof value['storageKey'] === 'string' &&
typeof value['expiresAt'] === 'number' &&
Number.isFinite(value['expiresAt'])
);
}
export function signHarvestDownloadToken(payload: HarvestDownloadTokenPayload, secret: string): string {
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = computeSignature(payloadBase64, secret).toString('base64url');
return `${payloadBase64}.${signature}`;
}
export function verifyHarvestDownloadToken(token: string, secret: string): HarvestDownloadTokenPayload | null {
const dotIndex = token.indexOf('.');
if (dotIndex === -1) {
return null;
}
const payloadBase64 = token.slice(0, dotIndex);
const signatureBase64 = token.slice(dotIndex + 1);
const expectedSignature = computeSignature(payloadBase64, secret);
let providedSignature: Buffer;
try {
providedSignature = Buffer.from(signatureBase64, 'base64url');
} catch {
return null;
}
if (expectedSignature.length !== providedSignature.length) {
return null;
}
if (!timingSafeEqual(expectedSignature, providedSignature)) {
return null;
}
let payload: HarvestDownloadTokenPayload | null;
try {
const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf-8');
payload = parseJsonWithGuard(payloadJson, isHarvestDownloadTokenPayload);
} catch {
return null;
}
if (!payload || Date.now() > payload.expiresAt) {
return null;
}
return payload;
}
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '../../Config';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {signHarvestDownloadToken} from './HarvestDownloadToken';
const HARVEST_DOWNLOAD_PATH = '/harvest-downloads';
export async function buildHarvestDownloadUrl(params: {
userId: bigint;
harvestId: bigint;
storageKey: string;
expiresInSeconds: number;
storageService: IStorageService;
}): Promise<string> {
const {userId, harvestId, storageKey, expiresInSeconds, storageService} = params;
if (Config.presignedHarvestDownloadsEnabled) {
return storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.harvests,
key: storageKey,
expiresIn: expiresInSeconds,
});
}
const token = signHarvestDownloadToken(
{
userId: userId.toString(),
harvestId: harvestId.toString(),
storageKey,
expiresAt: Date.now() + expiresInSeconds * 1000,
},
Config.auth.connectionInitiationSecret,
);
const base = Config.endpoints.apiPublic.replace(/\/+$/u, '');
return `${base}${HARVEST_DOWNLOAD_PATH}/${harvestId}?token=${encodeURIComponent(token)}`;
}
@@ -8,11 +8,11 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import type {ApiContext} from '../../ApiContext';
import {Config} from '../../Config';
import * as AuthPassword from '../../auth/AuthPassword';
import * as AuthSession from '../../auth/AuthSession';
import type {SudoVerificationResult} from '../../auth/services/SudoVerificationService';
import {deriveSudoMethods, userHasMfa} from '../../auth/services/SudoVerificationService';
import {Config} from '../../Config';
import type {UserRow} from '../../database/types/UserTypes';
import type {IDiscriminatorService} from '../../infrastructure/DiscriminatorService';
import type {LimitConfigService} from '../../limits/LimitConfigService';
@@ -23,7 +23,6 @@ import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
import type {ChannelID, UserID} from '../../BrandedTypes';
import {createChannelID, createMessageID, createUserID} from '../../BrandedTypes';
import {mapChannelToResponse} from '../../channel/ChannelMappers';
import {SYSTEM_USER_ID} from '../../constants/Core';
import type {IChannelRepository} from '../../channel/IChannelRepository';
import type {ChannelService} from '../../channel/services/ChannelService';
import {dispatchMessageCreateBroadcast} from '../../channel/services/message/MessageGatewayDispatch';
@@ -31,6 +30,7 @@ import {
createMessageResponseDataService,
messageResponseAccessForGuild,
} from '../../channel/services/message/MessageResponseDataService';
import {SYSTEM_USER_ID} from '../../constants/Core';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
@@ -145,6 +145,15 @@ export class UserContentRequestService {
return this.userContentService.getHarvestDownloadUrl(params.userId, params.harvestId, params.storageService);
}
async streamHarvestDownload(params: {
harvestId: bigint;
token: string;
range?: string;
storageService: IStorageService;
}) {
return this.userContentService.streamHarvestDownload(params);
}
private async mapSavedMessageEntry(userId: UserID, entry: SavedMessageEntry): Promise<SavedMessageEntryResponse> {
return {
id: entry.messageId.toString(),
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {describe, expect, it} from 'vitest';
import {UserContentServiceTestHooks} from './UserContentService';
const {isUnreachableEntityError} = UserContentServiceTestHooks;
describe('isUnreachableEntityError', () => {
it('treats a deleted or left community as unreachable rather than fatal', () => {
expect(isUnreachableEntityError(new UnknownGuildError())).toBe(true);
});
it('treats a gone channel and a lost permission as unreachable', () => {
expect(isUnreachableEntityError(new UnknownChannelError())).toBe(true);
expect(isUnreachableEntityError(new MissingPermissionsError())).toBe(true);
});
it('leaves a deleted message to the delete path instead of marking it unavailable', () => {
expect(isUnreachableEntityError(new UnknownMessageError())).toBe(false);
});
it('still lets unexpected failures surface', () => {
expect(isUnreachableEntityError(new Error('database is on fire'))).toBe(false);
expect(isUnreachableEntityError(null)).toBe(false);
});
});
@@ -1,11 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
import type {Readable} from 'node:stream';
import {MAX_BOOKMARKS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
@@ -23,7 +25,7 @@ import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {ms} from 'itty-time';
import type {ApiContext} from '../../ApiContext';
import {type ChannelID, createChannelID, type MessageID, type UserID} from '../../BrandedTypes';
import {type ChannelID, createChannelID, createUserID, type MessageID, type UserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import type {IChannelRepository} from '../../channel/IChannelRepository';
import type {ChannelService} from '../../channel/services/ChannelService';
@@ -47,6 +49,8 @@ import type {IUserContentRepository} from '../repositories/IUserContentRepositor
import {UserHarvest, type UserHarvestResponse} from '../UserHarvestModel';
import {UserHarvestRepository} from '../UserHarvestRepository';
import {BaseUserUpdatePropagator} from './BaseUserUpdatePropagator';
import {verifyHarvestDownloadToken} from './HarvestDownloadToken';
import {buildHarvestDownloadUrl} from './HarvestDownloadUrl';
export interface SavedMessageEntry {
channelId: ChannelID;
@@ -94,6 +98,13 @@ function normalizeProviderEnvironment(
return platform === 'ios_apns' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
}
const isUnreachableEntityError = (error: unknown): boolean =>
error instanceof MissingPermissionsError ||
error instanceof UnknownChannelError ||
error instanceof UnknownGuildError;
export const UserContentServiceTestHooks = {isUnreachableEntityError};
export class UserContentService {
private readonly updatePropagator: BaseUserUpdatePropagator;
private readonly userRepository: UserContentRepository;
@@ -138,11 +149,7 @@ export class UserContentService {
messageId: mention.messageId,
});
} catch (error) {
if (
error instanceof UnknownMessageError ||
error instanceof MissingPermissionsError ||
error instanceof UnknownChannelError
) {
if (error instanceof UnknownMessageError || isUnreachableEntityError(error)) {
return null;
}
throw error;
@@ -188,7 +195,7 @@ export class UserContentService {
await this.userRepository.deleteSavedMessage(userId, savedMessage.messageId);
return null;
}
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
if (isUnreachableEntityError(error)) {
status = 'missing_permissions';
} else {
throw error;
@@ -468,10 +475,12 @@ export class UserContentService {
throw new HarvestExpiredError();
}
const ZIP_EXPIRY_MS = ms('7 days');
const downloadUrl = await storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.harvests,
key: harvest.storageKey,
expiresIn: ZIP_EXPIRY_MS / 1000,
const downloadUrl = await buildHarvestDownloadUrl({
userId,
harvestId,
storageKey: harvest.storageKey,
expiresInSeconds: ZIP_EXPIRY_MS / 1000,
storageService,
});
const expiresAt = new Date(Date.now() + ZIP_EXPIRY_MS);
return {
@@ -480,6 +489,61 @@ export class UserContentService {
};
}
async streamHarvestDownload(params: {
harvestId: bigint;
token: string;
range?: string;
storageService: IStorageService;
}): Promise<{
body: Readable;
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
filename: string;
} | null> {
if (Config.presignedHarvestDownloadsEnabled) {
return null;
}
const payload = verifyHarvestDownloadToken(params.token, Config.auth.connectionInitiationSecret);
if (!payload || payload.harvestId !== params.harvestId.toString()) {
Logger.debug({harvestId: params.harvestId.toString()}, 'Harvest download rejected: invalid or expired token');
return null;
}
let userId: UserID;
try {
userId = createUserID(BigInt(payload.userId));
} catch {
return null;
}
const harvestRepository = new UserHarvestRepository();
const harvest = await harvestRepository.findByUserAndHarvestId(userId, params.harvestId);
if (!harvest || !harvest.completedAt || !harvest.storageKey || harvest.failedAt) {
return null;
}
if (harvest.downloadUrlExpiresAt && harvest.downloadUrlExpiresAt < new Date()) {
return null;
}
if (harvest.storageKey !== payload.storageKey) {
Logger.debug({harvestId: params.harvestId.toString()}, 'Harvest download rejected: storage key mismatch');
return null;
}
const object = await params.storageService.streamObject({
bucket: Config.s3.buckets.harvests,
key: harvest.storageKey,
range: params.range,
});
if (!object) {
return null;
}
return {
body: object.body,
contentLength: object.contentLength,
contentRange: object.contentRange,
contentType: object.contentType ?? 'application/zip',
filename: `fluxer-data-${params.harvestId}.zip`,
};
}
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
const {userId, delayMs = ms('1 day')} = params;
const scheduledAt = new Date(Date.now() + delayMs);
@@ -808,6 +808,9 @@ export class UserRelationshipService {
if (!user) {
return false;
}
if (user.pendingDeletionAt !== null) {
return false;
}
return (user.flags & UserFlags.DELETED) === UserFlags.DELETED;
}
}
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, test} from 'vitest';
import {signHarvestDownloadToken, verifyHarvestDownloadToken} from '../HarvestDownloadToken';
const SECRET = 'test-connection-secret';
function payload(overrides: Partial<Parameters<typeof signHarvestDownloadToken>[0]> = {}) {
return {
userId: '123',
harvestId: '456',
storageKey: 'exports/123/456/user-data.zip',
expiresAt: Date.now() + 60_000,
...overrides,
};
}
describe('HarvestDownloadToken', () => {
test('round trips a valid token', () => {
const original = payload();
const verified = verifyHarvestDownloadToken(signHarvestDownloadToken(original, SECRET), SECRET);
expect(verified).toEqual(original);
});
test('rejects a token signed with a different secret', () => {
const token = signHarvestDownloadToken(payload(), SECRET);
expect(verifyHarvestDownloadToken(token, 'other-secret')).toBeNull();
});
test('rejects an expired token', () => {
const token = signHarvestDownloadToken(payload({expiresAt: Date.now() - 1}), SECRET);
expect(verifyHarvestDownloadToken(token, SECRET)).toBeNull();
});
test('rejects a tampered payload', () => {
const token = signHarvestDownloadToken(payload(), SECRET);
const [encoded, signature] = token.split('.');
const decoded = JSON.parse(Buffer.from(encoded as string, 'base64url').toString('utf-8'));
decoded.userId = '999';
const forged = `${Buffer.from(JSON.stringify(decoded)).toString('base64url')}.${signature}`;
expect(verifyHarvestDownloadToken(forged, SECRET)).toBeNull();
});
test('rejects a tampered signature', () => {
const token = signHarvestDownloadToken(payload(), SECRET);
expect(verifyHarvestDownloadToken(`${token.slice(0, -2)}xy`, SECRET)).toBeNull();
});
test('rejects malformed input', () => {
for (const bad of ['', '.', 'nodot', 'a.b', '..', 'a.']) {
expect(verifyHarvestDownloadToken(bad, SECRET)).toBeNull();
}
});
test('rejects a payload missing required fields', () => {
const encoded = Buffer.from(JSON.stringify({userId: '1'})).toString('base64url');
const token = signHarvestDownloadToken(payload(), SECRET);
const signature = token.split('.')[1];
expect(verifyHarvestDownloadToken(`${encoded}.${signature}`, SECRET)).toBeNull();
});
test('is not verifiable as a raw HMAC of the base secret', () => {
const {createHmac} = require('node:crypto') as typeof import('node:crypto');
const encoded = Buffer.from(JSON.stringify(payload())).toString('base64url');
const rawSignature = createHmac('sha256', SECRET).update(encoded).digest('base64url');
expect(verifyHarvestDownloadToken(`${encoded}.${rawSignature}`, SECRET)).toBeNull();
});
});
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {getConfig} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {signHarvestDownloadToken} from '../services/HarvestDownloadToken';
import {fetchHarvestDownload, markHarvestCompleted, requestHarvest} from './HarvestTestUtils';
const ZIP_BYTES = new TextEncoder().encode('PK pretend zip');
async function withStreamingHarvestDownloads(callback: () => Promise<void>): Promise<void> {
const config = getConfig();
const original = config.presignedHarvestDownloadsEnabled;
config.presignedHarvestDownloadsEnabled = false;
try {
await callback();
} finally {
config.presignedHarvestDownloadsEnabled = original;
}
}
async function completedHarvest(harness: ApiTestHarness) {
const account = await createTestAccount(harness);
const {harvest_id} = await requestHarvest(harness, account.token);
await markHarvestCompleted(account.userId, harvest_id, new Date(Date.now() + 6 * 24 * 60 * 60 * 1000));
const storageKey = `test/${harvest_id}.zip`;
await harness.storageService.uploadObject({
bucket: getConfig().s3.buckets.harvests,
key: storageKey,
body: ZIP_BYTES,
contentType: 'application/zip',
});
return {account, harvestId: harvest_id, storageKey};
}
function downloadPath(url: string): string {
return url.slice(url.indexOf('/harvest-downloads'));
}
async function get(harness: ApiTestHarness, path: string): Promise<Response> {
return harness.app.request(path, {headers: {'x-forwarded-for': '203.0.113.7'}});
}
describe('Harvest download streaming', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('defaults to presigned URLs and leaves the streaming route closed', async () => {
const {account, harvestId, storageKey} = await completedHarvest(harness);
expect(getConfig().presignedHarvestDownloadsEnabled).toBe(true);
const download = await fetchHarvestDownload(harness, account.token, harvestId);
expect(download.download_url).toBe('https://presigned.url/test');
const token = signHarvestDownloadToken(
{userId: account.userId, harvestId, storageKey, expiresAt: Date.now() + 60_000},
getConfig().auth.connectionInitiationSecret,
);
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
test('serves the archive through the API when presigned downloads are disabled', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId} = await completedHarvest(harness);
const download = await fetchHarvestDownload(harness, account.token, harvestId);
expect(download.download_url).not.toContain('presigned.url');
expect(download.download_url).toContain(`/harvest-downloads/${harvestId}`);
expect(download.download_url).toContain('token=');
const response = await get(harness, downloadPath(download.download_url));
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/zip');
expect(response.headers.get('content-disposition')).toContain('attachment');
expect(response.headers.get('cache-control')).toBe('private, no-store');
expect(new Uint8Array(await response.arrayBuffer())).toEqual(ZIP_BYTES);
});
});
test('rejects a tampered token', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId} = await completedHarvest(harness);
const download = await fetchHarvestDownload(harness, account.token, harvestId);
const path = downloadPath(download.download_url);
const response = await get(harness, `${path.slice(0, -2)}xy`);
expect(response.status).toBe(404);
});
});
test('rejects a token signed with the wrong secret', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId, storageKey} = await completedHarvest(harness);
const token = signHarvestDownloadToken(
{userId: account.userId, harvestId, storageKey, expiresAt: Date.now() + 60_000},
'not-the-configured-secret',
);
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
});
test('rejects an expired token even while the harvest is still valid', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId, storageKey} = await completedHarvest(harness);
const token = signHarvestDownloadToken(
{userId: account.userId, harvestId, storageKey, expiresAt: Date.now() - 1},
getConfig().auth.connectionInitiationSecret,
);
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
});
test('rejects a token replayed against a different harvest id', async () => {
await withStreamingHarvestDownloads(async () => {
const first = await completedHarvest(harness);
const second = await completedHarvest(harness);
const token = signHarvestDownloadToken(
{
userId: first.account.userId,
harvestId: first.harvestId,
storageKey: first.storageKey,
expiresAt: Date.now() + 60_000,
},
getConfig().auth.connectionInitiationSecret,
);
const response = await get(harness, `/harvest-downloads/${second.harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
});
test('rejects a token whose storage key no longer matches the harvest', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId} = await completedHarvest(harness);
const token = signHarvestDownloadToken(
{userId: account.userId, harvestId, storageKey: 'test/some-other-key.zip', expiresAt: Date.now() + 60_000},
getConfig().auth.connectionInitiationSecret,
);
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
});
test('rejects a token minted for a different user', async () => {
await withStreamingHarvestDownloads(async () => {
const {harvestId, storageKey} = await completedHarvest(harness);
const other = await createTestAccount(harness);
const token = signHarvestDownloadToken(
{userId: other.userId, harvestId, storageKey, expiresAt: Date.now() + 60_000},
getConfig().auth.connectionInitiationSecret,
);
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
});
test('requires a token', async () => {
await withStreamingHarvestDownloads(async () => {
const {harvestId} = await completedHarvest(harness);
const response = await get(harness, `/harvest-downloads/${harvestId}`);
expect(response.status).toBe(404);
});
});
});
@@ -5,6 +5,7 @@ import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, unclaimAccount} from '../../auth/tests/AuthTestUtils';
import {createChannelID, createUserID} from '../../BrandedTypes';
import {authorizeBot, createTestBotAccount} from '../../bot/tests/BotTestUtils';
import {
acceptInvite,
@@ -19,13 +20,12 @@ import {
type MinimalChannelResponse,
sendChannelMessage,
} from '../../channel/tests/ChannelTestUtils';
import {createChannelID, createUserID} from '../../BrandedTypes';
import {SYSTEM_USER_ID} from '../../constants/Core';
import {UserRepository} from '../../user/repositories/UserRepository';
import {ensureSessionStarted} from '../../message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {UserRepository} from '../../user/repositories/UserRepository';
interface PrivateChannelsResponse extends Array<MinimalChannelResponse> {}
@@ -19,6 +19,16 @@ import {
} from './RelationshipTestUtils';
import {fetchUserMe} from './UserTestUtils';
async function markUserScheduledForDeletion(harness: ApiTestHarness, userId: string): Promise<void> {
const pendingDeletionAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
await createBuilder(harness, '')
.post(`/test/users/${userId}/set-pending-deletion`)
.body({pending_deletion_at: pendingDeletionAt, set_self_deleted_flag: false})
.expect(HTTP_STATUS.OK)
.execute();
await markUserDeleted(harness, userId);
}
async function markUserDeleted(harness: ApiTestHarness, userId: string): Promise<void> {
await createBuilder(harness, '')
.patch(`/test/users/${userId}/flags`)
@@ -221,6 +231,16 @@ describe('UserRelationshipStateTransitions', () => {
.expect(HTTP_STATUS.BAD_REQUEST, 'FRIEND_REQUEST_BLOCKED')
.execute();
});
test('can accept a friend request from a user scheduled for deletion', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await sendFriendRequest(harness, bob.token, alice.userId);
await markUserScheduledForDeletion(harness, bob.userId);
const {json: friendship} = await acceptFriendRequest(harness, alice.token, bob.userId);
assertRelationshipType(friendship, RelationshipTypes.FRIEND);
const {json: aliceAfter} = await listRelationships(harness, alice.token);
assertRelationshipType(findRelationship(aliceAfter, bob.userId)!, RelationshipTypes.FRIEND);
});
test('cannot send friend request to user who blocked you', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {candidateTtlSecondsFor} from './VoiceReconciliationWorker';
const INTERVAL_MS = 15000;
const GATEWAY_ONLY_GRACE_MS = 10000;
function ttlFor(observedSweepSpacingMs: number): number {
return candidateTtlSecondsFor({
intervalMs: INTERVAL_MS,
observedSweepSpacingMs,
graceMs: GATEWAY_ONLY_GRACE_MS,
});
}
describe('candidateTtlSecondsFor', () => {
it('outlives the gap between two consecutive observations of the same key', () => {
for (const observedSweepSpacingMs of [0, 45_000, 136_000, 300_000, 596_000, 900_000]) {
expect(ttlFor(observedSweepSpacingMs) * 1000).toBeGreaterThan(observedSweepSpacingMs);
}
});
it('outlives a sweep gap far longer than the tick interval', () => {
expect(ttlFor(596_000) * 1000).toBeGreaterThan(596_000);
});
it('grows with the observed sweep spacing rather than the tick interval', () => {
expect(ttlFor(596_000)).toBeGreaterThan(ttlFor(136_000));
expect(ttlFor(136_000)).toBeGreaterThan(ttlFor(0));
});
it('keeps a floor that survives a single long sweep before any spacing is observed', () => {
expect(ttlFor(0)).toBeGreaterThanOrEqual(300);
});
it('stays bounded so a stale candidate cannot outlive its connection indefinitely', () => {
expect(ttlFor(Number.MAX_SAFE_INTEGER)).toBeLessThanOrEqual(3600);
});
});
@@ -96,9 +96,24 @@ const DEFAULT_STAGGER_DELAY_MS = 25;
const DEFAULT_LOCK_TTL_SECONDS = 180;
const DEFAULT_GATEWAY_ONLY_GRACE_MS = 10000;
const DEFAULT_LIVEKIT_ONLY_GRACE_MS = 60000;
const MIN_CANDIDATE_TTL_SECONDS = 300;
const MAX_CANDIDATE_TTL_SECONDS = 3600;
const CANDIDATE_TTL_SWEEP_MULTIPLIER = 3;
const LAST_SWEEP_KEY_TTL_SECONDS = 86400;
const ROOM_KEY_PREFIX = 'voice:room:server:';
export function candidateTtlSecondsFor(input: {
intervalMs: number;
observedSweepSpacingMs: number;
graceMs: number;
}): number {
const spacingMs = Math.max(input.intervalMs, input.observedSweepSpacingMs);
const ttlSeconds = Math.ceil((spacingMs * CANDIDATE_TTL_SWEEP_MULTIPLIER + input.graceMs * 2) / 1000);
return Math.min(MAX_CANDIDATE_TTL_SECONDS, Math.max(MIN_CANDIDATE_TTL_SECONDS, ttlSeconds));
}
const VOICE_RECONCILIATION_LOCK_KEY = 'voice:reconcile:lock';
const VOICE_RECONCILIATION_CADENCE_KEY = 'voice:reconcile:cadence';
const VOICE_RECONCILIATION_LAST_SWEEP_KEY = 'voice:reconcile:last-sweep-at';
const GATEWAY_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:gateway-only:';
const LIVEKIT_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:livekit-only:';
@@ -115,8 +130,7 @@ export class VoiceReconciliationWorker {
private readonly cadenceTtlSeconds: number;
private readonly gatewayOnlyGraceMs: number;
private readonly liveKitOnlyGraceMs: number;
private readonly gatewayOnlyCandidateTtlSeconds: number;
private readonly liveKitOnlyCandidateTtlSeconds: number;
private observedSweepSpacingMs = 0;
private intervalHandle: NodeJS.Timeout | null = null;
private reconciling = false;
private reconciliationLockLost = false;
@@ -136,14 +150,6 @@ export class VoiceReconciliationWorker {
this.cadenceTtlSeconds = options.cadenceTtlSeconds ?? Math.max(1, Math.ceil((this.intervalMs * 3) / 1000));
this.gatewayOnlyGraceMs = options.gatewayOnlyGraceMs ?? DEFAULT_GATEWAY_ONLY_GRACE_MS;
this.liveKitOnlyGraceMs = options.liveKitOnlyGraceMs ?? DEFAULT_LIVEKIT_ONLY_GRACE_MS;
this.gatewayOnlyCandidateTtlSeconds = Math.max(
60,
Math.ceil((this.intervalMs * 4 + this.gatewayOnlyGraceMs * 4) / 1000),
);
this.liveKitOnlyCandidateTtlSeconds = Math.max(
60,
Math.ceil((this.intervalMs * 4 + this.liveKitOnlyGraceMs * 4) / 1000),
);
}
start(): void {
@@ -156,6 +162,7 @@ export class VoiceReconciliationWorker {
intervalMs: this.intervalMs,
gatewayOnlyGraceMs: this.gatewayOnlyGraceMs,
liveKitOnlyGraceMs: this.liveKitOnlyGraceMs,
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
},
'Starting VoiceReconciliationWorker',
);
@@ -175,6 +182,7 @@ export class VoiceReconciliationWorker {
async reconcile(): Promise<void> {
const startTime = Date.now();
await this.recordSweepSpacing(startTime);
const discovery = await this.discoverActiveRooms();
this.logger.info(
{
@@ -233,6 +241,8 @@ export class VoiceReconciliationWorker {
const durationMs = Date.now() - startTime;
this.logger.info(
{
observedSweepSpacingMs: this.observedSweepSpacingMs,
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
roomsChecked,
totalConfirmed,
totalRepaired,
@@ -300,6 +310,37 @@ export class VoiceReconciliationWorker {
}
}
private candidateTtlSeconds(graceMs: number): number {
return candidateTtlSecondsFor({
intervalMs: this.intervalMs,
observedSweepSpacingMs: this.observedSweepSpacingMs,
graceMs,
});
}
private async recordSweepSpacing(startedAt: number): Promise<void> {
try {
const previous = await this.kvClient.get(VOICE_RECONCILIATION_LAST_SWEEP_KEY);
const previousAt = previous === null ? Number.NaN : Number(previous);
if (Number.isFinite(previousAt) && startedAt > previousAt) {
this.observedSweepSpacingMs = Math.max(this.observedSweepSpacingMs, startedAt - previousAt);
const gatewayOnlyCandidateTtlSeconds = this.candidateTtlSeconds(this.gatewayOnlyGraceMs);
if (this.observedSweepSpacingMs >= gatewayOnlyCandidateTtlSeconds * 1000) {
this.logger.warn(
{
observedSweepSpacingMs: this.observedSweepSpacingMs,
gatewayOnlyCandidateTtlSeconds,
},
'Reconciliation sweeps are further apart than the candidate TTL; divergent voice states will be deferred forever',
);
}
}
await this.kvClient.setex(VOICE_RECONCILIATION_LAST_SWEEP_KEY, LAST_SWEEP_KEY_TTL_SECONDS, String(startedAt));
} catch (error) {
this.logger.warn({error}, 'Failed to record reconciliation sweep spacing');
}
}
private async acquireCadenceLease(): Promise<boolean> {
try {
return await this.kvClient.setnx(VOICE_RECONCILIATION_CADENCE_KEY, '1', this.cadenceTtlSeconds);
@@ -993,7 +1034,7 @@ export class VoiceReconciliationWorker {
}
await this.kvClient.setex(
key,
this.gatewayOnlyCandidateTtlSeconds,
this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
);
return false;
@@ -1024,7 +1065,7 @@ export class VoiceReconciliationWorker {
}
await this.kvClient.setex(
key,
this.liveKitOnlyCandidateTtlSeconds,
this.candidateTtlSeconds(this.liveKitOnlyGraceMs),
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
);
return false;
@@ -243,6 +243,12 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
voiceRoomStore,
kvClient,
logger: Logger,
intervalMs: Config.worker.voiceReconciliation.intervalMs,
staggerDelayMs: Config.worker.voiceReconciliation.staggerDelayMs,
lockTtlSeconds: Config.worker.voiceReconciliation.lockTtlSeconds,
cadenceTtlSeconds: Config.worker.voiceReconciliation.cadenceTtlSeconds,
gatewayOnlyGraceMs: Config.worker.voiceReconciliation.gatewayOnlyGraceMs,
liveKitOnlyGraceMs: Config.worker.voiceReconciliation.liveKitOnlyGraceMs,
})
: null;
if (Config.voice.enabled && voiceTopology !== null) {
@@ -50,6 +50,7 @@ import type {User} from '../../models/User';
import type {UserGuildSettings} from '../../models/UserGuildSettings';
import type {UserSettings} from '../../models/UserSettings';
import type {WebAuthnCredential} from '../../models/WebAuthnCredential';
import {buildHarvestDownloadUrl} from '../../user/services/HarvestDownloadUrl';
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
import {createArchiveJsonBuffer} from '../utils/ArchiveJson';
import {appendAssetToArchive, buildHashedAssetKey, getAnimatedAssetExtension} from '../utils/AssetArchiveHelpers';
@@ -685,10 +686,12 @@ async function createAndUploadArchive(params: ArchiveParams): Promise<ArchiveRes
contentType: 'application/zip',
expiresAt: expiresAt,
});
const downloadUrl = await storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.harvests,
key: storageKey,
expiresIn: ZIP_EXPIRY_MS / 1000,
const downloadUrl = await buildHarvestDownloadUrl({
userId,
harvestId,
storageKey,
expiresInSeconds: ZIP_EXPIRY_MS / 1000,
storageService,
});
return {fileSize, storageKey, expiresAt, downloadUrl};
} finally {
+2
View File
@@ -7,6 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content, maximum-scale=1, user-scalable=no">
<meta name="description" content="Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.">
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" sizes="32x32" href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png">
<link rel="apple-touch-icon" sizes="180x180" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png">
+2 -2
View File
@@ -18,8 +18,8 @@
"build": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && pnpm lingui:compile && rm -rf dist && rspack build --mode production && pnpm tsx scripts/build-sw.mjs",
"dev": "TOKIO_WORKER_THREADS=4 RAYON_NUM_THREADS=4 cargo run --manifest-path ../tools/ci/Cargo.toml -- app-dev-server",
"test": "vitest run",
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts src/features/member/state/MemberSidebar.bench.ts",
"bench:gif-picker": "vitest bench --run src/features/channel/components/pickers/gif/GifPickerStateMachine.bench.ts src/features/channel/components/pickers/gif/GifPickerGridData.bench.ts src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.bench.ts src/features/channel/components/pickers/shared/MasonryListComputer.bench.ts",
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts",
"bench:gif-picker": "vitest bench --run src/features/channel/components/pickers/gif/GifPickerStateMachine.bench.ts src/features/channel/components/pickers/gif/GifPickerGridData.bench.ts src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.bench.ts src/features/channel/components/pickers/shared/TileFlowSolver.bench.ts",
"bench:messages": "vitest bench --run src/features/messaging/utils/MessageGroupingUtils.bench.ts src/features/channel/components/ChannelMessageStreamUtils.bench.ts src/features/messaging/components/markdown/MarkdownRendering.bench.ts src/features/channel/components/MessageAttachmentUtils.bench.ts src/features/messaging/utils/MessagePaginationUtils.bench.ts src/features/platform/utils/ScrollPosition.bench.ts src/features/messaging/state/ReactionStateMachine.bench.ts",
"test:watch": "vitest",
"typecheck": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && tsgo --noEmit",
@@ -52,6 +52,15 @@ source edits in this package.
Forces Opus RED/FEC, stereo signaling, 10 ms packet time, no DTX, and a
510 kbps maximum average bitrate in local offers and remote answers.
10. **Remote audio volume restore at exactly zero** (`src/room/track/RemoteAudioTrack.ts`)
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered
`elementVolume` with a truthiness check, so a track deliberately held at `0`
came back at full volume whenever it was re-attached or its Web Audio graph
was rebuilt. All three guards now test `!== undefined`. Note that remote
gains above `1.0` are only legal because `setVolume()` takes the Web Audio
`gainNode` branch; the `el.volume` branch would throw `IndexSizeError`.
`webAudioMix` must stay unconditional.
## Updating from upstream
1. Check the upstream changelog for the target version.
@@ -64,7 +64,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
}
getVolume(): number {
if (this.elementVolume) {
if (this.elementVolume !== undefined) {
return this.elementVolume;
}
if (isReactNative()) {
@@ -113,7 +113,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
element.muted = true;
}
if (this.elementVolume) {
if (this.elementVolume !== undefined) {
this.setVolume(this.elementVolume);
}
@@ -169,7 +169,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
lastNode.connect(this.gainNode);
this.gainNode.connect(context.destination);
if (this.elementVolume) {
if (this.elementVolume !== undefined) {
this.gainNode.gain.setTargetAtTime(this.elementVolume, 0, 0.1);
}
+8 -1
View File
@@ -3,7 +3,13 @@
import {existsSync, mkdirSync, readdirSync, writeFileSync} from 'node:fs';
import path, {dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
import {CopyRspackPlugin, DefinePlugin, HtmlRspackPlugin, SwcJsMinimizerRspackPlugin} from '@rspack/core';
import {
CopyRspackPlugin,
DefinePlugin,
HtmlRspackPlugin,
LightningCssMinimizerRspackPlugin,
SwcJsMinimizerRspackPlugin,
} from '@rspack/core';
import {createPoFileRule, getLinguiSwcPluginConfig} from './scripts/build/rspack/lingui.mjs';
import {staticFilesPlugin} from './scripts/build/rspack/static-files.mjs';
@@ -579,6 +585,7 @@ export default () => {
mangle: true,
format: {comments: false},
}),
new LightningCssMinimizerRspackPlugin(),
],
},
devServer: {
+17 -17
View File
@@ -444,74 +444,74 @@ for (const size of SIZES) {
const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4);
const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
output += ` <mask id="svg-mask-avatar-default-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
output += ` <mask id="flx-mask-avatar-plain-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask>
<mask id="svg-mask-avatar-status-round-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-avatar-notch-circle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
</mask>
<mask id="svg-mask-avatar-status-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-avatar-notch-phone-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<rect fill="black" x="${formatNumber(cutoutPhoneX)}" y="${formatNumber(cutoutPhoneY)}" width="${formatNumber(cutoutPhoneWidth)}" height="${formatNumber(cutoutPhoneHeight)}" rx="${formatNumber(cutoutPhoneRx)}" ry="${formatNumber(cutoutPhoneRx)}" />
</mask>
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-avatar-notch-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="${formatNumber(typingCutoutRightCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" />
<rect fill="black" x="${formatNumber(typingCutoutLeftCx)}" y="${formatNumber(typingCutoutY)}" width="${formatNumber(typingExtensionPx / size)}" height="${formatNumber(typingCutoutHeight)}" />
<circle fill="black" cx="${formatNumber(typingCutoutLeftCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" />
</mask>
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
</mask>
<mask id="svg-mask-status-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
<rect fill="black" x="${mobileScreenX}" y="${mobileScreenY}" width="${mobileScreenWidth}" height="${mobileScreenHeight}" rx="${mobileScreenRx}" ry="${mobileScreenRy}" />
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
</mask>
<mask id="svg-mask-status-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
<circle fill="black" cx="${formatNumber(idleCutoutCx)}" cy="${formatNumber(idleCutoutCy)}" r="${formatNumber(idleCutoutR)}" />
</mask>
<mask id="svg-mask-status-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
<rect fill="black" x="${formatNumber(dndBarX)}" y="${formatNumber(dndBarY)}" width="${formatNumber(dndBarWidth)}" height="${formatNumber(dndBarHeight)}" rx="${formatNumber(dndBarRx)}" ry="${formatNumber(dndBarRx)}" />
</mask>
<mask id="svg-mask-status-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
<circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(offlineInnerR)}" />
</mask>
<mask id="svg-mask-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="${formatNumber(typingX)}" y="${formatNumber(typingY)}" width="${formatNumber(typingWidth)}" height="${formatNumber(typingHeight)}" rx="${formatNumber(typingRx)}" ry="${formatNumber(typingRx)}" />
</mask>
`;
}
output += ` <mask id="svg-mask-status-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
output += ` <mask id="flx-mask-presence-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask>
<mask id="svg-mask-status-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="0.25" cy="0.25" r="0.375" />
</mask>
<mask id="svg-mask-status-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<rect fill="black" x="0.125" y="0.375" width="0.75" height="0.25" rx="0.125" ry="0.125" />
</mask>
<mask id="svg-mask-status-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="0.5" cy="0.5" r="0.25" />
</mask>
<mask id="svg-mask-status-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="0.5" ry="0.5" />
</mask>
<mask id="svg-mask-status-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-presence-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
<rect fill="black" x="${((1 - DESIGN_RULES.mobileScreenWidth) / 2).toFixed(4)}" y="${DESIGN_RULES.mobileScreenY}" width="${DESIGN_RULES.mobileScreenWidth}" height="${(DESIGN_RULES.mobileScreenHeight * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" rx="0.04" ry="${(0.04 * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(3)}" />
</mask>
<mask id="svg-mask-avatar-default" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<mask id="flx-mask-avatar-plain" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask>
</defs>
+7 -7
View File
@@ -6,8 +6,8 @@ import sharp from 'sharp';
import {convertToCodePoints} from '../src/features/expressions/utils/EmojiCodepointUtils';
const EMOJI_SPRITES = {
nonDiversityPerRow: 42,
diversityPerRow: 10,
basePerRow: 42,
skinTonePerRow: 10,
pickerPerRow: 11,
pickerCount: 50,
} as const;
@@ -173,10 +173,10 @@ async function generateMainSpriteSheet(
base.push({surrogates: obj.surrogates});
}
}
await renderSpriteSheet(base, EMOJI_SPRITES.nonDiversityPerRow, 'spritesheet-emoji', outputDir);
await renderSpriteSheet(base, EMOJI_SPRITES.basePerRow, 'spritesheet-emoji', outputDir);
}
async function generateDiversitySpriteSheets(
async function generateSkinToneSpriteSheets(
emojiData: Record<string, Array<EmojiObject>>,
outputDir: string,
): Promise<void> {
@@ -195,7 +195,7 @@ async function generateDiversitySpriteSheets(
if (skinEntries.length === 0) {
continue;
}
await renderSpriteSheet(skinEntries, EMOJI_SPRITES.diversityPerRow, `spritesheet-${skinCodepoint}`, outputDir);
await renderSpriteSheet(skinEntries, EMOJI_SPRITES.skinTonePerRow, `spritesheet-${skinCodepoint}`, outputDir);
}
}
@@ -245,8 +245,8 @@ async function main(): Promise<void> {
const emojiData: Record<string, Array<EmojiObject>> = JSON.parse(readFileSync(emojiDataPath, 'utf-8'));
console.log('Generating main sprite sheet...');
await generateMainSpriteSheet(emojiData, outputDir);
console.log('Generating diversity sprite sheets...');
await generateDiversitySpriteSheets(emojiData, outputDir);
console.log('Generating skin tone sprite sheets...');
await generateSkinToneSpriteSheets(emojiData, outputDir);
console.log('Generating picker sprite sheet...');
await generatePickerSpriteSheet(outputDir);
console.log('Emoji sprites generated successfully.');
+8 -5
View File
@@ -186,6 +186,7 @@ var {
--typing-indicator-height: 1rem;
--typing-pill-height: 1rem;
--slowmode-indicator-height: 1rem;
--message-filler-padding-bottom: 0.75rem;
--scroller-spacer-height: 1.75rem;
--typing-avatar-size: 0.75rem;
--typing-indicator-animation-size: 1rem;
@@ -681,9 +682,11 @@ img::before {
}
}
html.reduced-motion * {
animation-duration: 0.01ms;
animation-iteration-count: 1;
transition-duration: 0.01ms;
scroll-behavior: auto;
html.reduced-motion *,
html.reduced-motion *::before,
html.reduced-motion *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
@@ -50,7 +50,7 @@
}
:global(html.reduced-motion) .heart {
animation: nekoHeartFade 0.8s ease-out forwards;
animation: nekoHeartFade 0.8s ease-out forwards !important;
}
@keyframes nekoHeartFade {
@@ -23,7 +23,6 @@ import {
ChannelTypingIndicatorMode as ProtoChannelTypingIndicatorMode,
DmMessagePreviewMode as ProtoDmMessagePreviewMode,
HdrDisplayMode as ProtoHdrDisplayMode,
MediaDimensionSize as ProtoMediaDimensionSize,
} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/accessibility_pb';
import {makeAutoObservable, reaction, runInAction} from 'mobx';
@@ -513,11 +512,6 @@ export enum GuildChannelPresenceIndicatorMode {
HIDDEN = 2,
}
export enum MediaDimensionSize {
SMALL = 'small',
LARGE = 'large',
}
export enum DMMessagePreviewMode {
ALL = 0,
UNREAD_ONLY = 1,
@@ -582,8 +576,6 @@ export interface AccessibilitySettings {
showCustomEmojisInExpressionAutocomplete: boolean;
showStickersInExpressionAutocomplete: boolean;
showMemesInExpressionAutocomplete: boolean;
attachmentMediaDimensionSize: MediaDimensionSize;
embedMediaDimensionSize: MediaDimensionSize;
voiceChannelJoinRequiresDoubleClick: boolean;
customThemeCss: string | null;
customThemeCssSyncAcrossDevices: boolean;
@@ -631,15 +623,6 @@ const DM_PREVIEW_FROM_PROTO: Record<ProtoDmMessagePreviewMode, DMMessagePreviewM
[ProtoDmMessagePreviewMode.UNREAD_ONLY]: DMMessagePreviewMode.UNREAD_ONLY,
[ProtoDmMessagePreviewMode.NONE]: DMMessagePreviewMode.NONE,
};
const MEDIA_DIMENSION_TO_PROTO: Record<MediaDimensionSize, ProtoMediaDimensionSize> = {
[MediaDimensionSize.SMALL]: ProtoMediaDimensionSize.SMALL,
[MediaDimensionSize.LARGE]: ProtoMediaDimensionSize.LARGE,
};
const MEDIA_DIMENSION_FROM_PROTO: Record<ProtoMediaDimensionSize, MediaDimensionSize> = {
[ProtoMediaDimensionSize.UNSPECIFIED]: MediaDimensionSize.SMALL,
[ProtoMediaDimensionSize.SMALL]: MediaDimensionSize.SMALL,
[ProtoMediaDimensionSize.LARGE]: MediaDimensionSize.LARGE,
};
const HDR_TO_PROTO: Record<HdrDisplayMode, ProtoHdrDisplayMode> = {
[HdrDisplayMode.FULL]: ProtoHdrDisplayMode.FULL,
[HdrDisplayMode.STANDARD]: ProtoHdrDisplayMode.STANDARD,
@@ -697,8 +680,6 @@ class Accessibility {
showCustomEmojisInExpressionAutocomplete = true;
showStickersInExpressionAutocomplete = true;
showMemesInExpressionAutocomplete = true;
attachmentMediaDimensionSize = MediaDimensionSize.LARGE;
embedMediaDimensionSize = MediaDimensionSize.SMALL;
voiceChannelJoinRequiresDoubleClick = false;
systemReducedMotion = false;
customThemeCss: string | null = null;
@@ -811,8 +792,6 @@ class Accessibility {
'showCustomEmojisInExpressionAutocomplete',
'showStickersInExpressionAutocomplete',
'showMemesInExpressionAutocomplete',
'attachmentMediaDimensionSize',
'embedMediaDimensionSize',
'voiceChannelJoinRequiresDoubleClick',
'customThemeCssSyncAcrossDevices',
'showFavorites',
@@ -871,8 +850,6 @@ class Accessibility {
showCustomEmojisInAutocomplete: s.showCustomEmojisInExpressionAutocomplete,
showStickersInAutocomplete: s.showStickersInExpressionAutocomplete,
showMemesInAutocomplete: s.showMemesInExpressionAutocomplete,
attachmentMediaDimensionSize: MEDIA_DIMENSION_TO_PROTO[s.attachmentMediaDimensionSize],
embedMediaDimensionSize: MEDIA_DIMENSION_TO_PROTO[s.embedMediaDimensionSize],
voiceChannelJoinRequiresDoubleClick: s.voiceChannelJoinRequiresDoubleClick,
customThemeCss: s.customThemeCssSyncAcrossDevices ? (s.customThemeCss ?? '') : (s.serverCustomThemeCss ?? ''),
showFavorites: s.showFavorites,
@@ -948,8 +925,6 @@ class Accessibility {
if (m.showStickersInAutocomplete !== undefined)
s.showStickersInExpressionAutocomplete = m.showStickersInAutocomplete;
if (m.showMemesInAutocomplete !== undefined) s.showMemesInExpressionAutocomplete = m.showMemesInAutocomplete;
s.attachmentMediaDimensionSize = MEDIA_DIMENSION_FROM_PROTO[m.attachmentMediaDimensionSize];
s.embedMediaDimensionSize = MEDIA_DIMENSION_FROM_PROTO[m.embedMediaDimensionSize];
if (m.voiceChannelJoinRequiresDoubleClick !== undefined)
s.voiceChannelJoinRequiresDoubleClick = m.voiceChannelJoinRequiresDoubleClick;
if (m.customThemeCss !== undefined) {
@@ -1278,10 +1253,6 @@ class Accessibility {
this.showStickersInExpressionAutocomplete = validated.showStickersInExpressionAutocomplete;
if (validated.showMemesInExpressionAutocomplete !== undefined)
this.showMemesInExpressionAutocomplete = validated.showMemesInExpressionAutocomplete;
if (validated.attachmentMediaDimensionSize !== undefined)
this.attachmentMediaDimensionSize = validated.attachmentMediaDimensionSize;
if (validated.embedMediaDimensionSize !== undefined)
this.embedMediaDimensionSize = validated.embedMediaDimensionSize;
if (validated.voiceChannelJoinRequiresDoubleClick !== undefined)
this.voiceChannelJoinRequiresDoubleClick = validated.voiceChannelJoinRequiresDoubleClick;
if (hasCustomThemeCssUpdate) {
@@ -1390,8 +1361,6 @@ class Accessibility {
data.showStickersInExpressionAutocomplete ?? this.showStickersInExpressionAutocomplete,
showMemesInExpressionAutocomplete:
data.showMemesInExpressionAutocomplete ?? this.showMemesInExpressionAutocomplete,
attachmentMediaDimensionSize: data.attachmentMediaDimensionSize ?? this.attachmentMediaDimensionSize,
embedMediaDimensionSize: data.embedMediaDimensionSize ?? this.embedMediaDimensionSize,
voiceChannelJoinRequiresDoubleClick:
data.voiceChannelJoinRequiresDoubleClick ?? this.voiceChannelJoinRequiresDoubleClick,
customThemeCss: data.customThemeCss !== undefined ? data.customThemeCss : this.customThemeCss,
@@ -75,8 +75,8 @@ const setBadge = (badge: number): void => {
export const AppBadge: React.FC = observer(() => {
const relationships = Relationships.getRelationships();
const unreadMessageBadgeEnabled = Notification.unreadMessageBadgeEnabled;
const mentionCount = GuildReadState.getTotalMentionCount();
const hasUnread = GuildReadState.hasAnyUnread;
const mentionCount = GuildReadState.mentionCountAcrossGuilds();
const hasUnread = GuildReadState.anyGuildUnread;
const pendingCount = RuntimeConfig.directMessagesDisabled
? 0
: relationships.filter((relationship) => relationship.type === RelationshipTypes.INCOMING_REQUEST).length;
@@ -205,5 +205,5 @@ function findHookOrderHazards(): Array<HookOrderFinding> {
describe('React hook order stability', () => {
it('keeps guard returns outside hook-bearing component bodies', () => {
expect(findHookOrderHazards()).toEqual([]);
}, 10_000);
}, 60_000);
});
@@ -34,7 +34,7 @@ export const FavoritesGuildHeaderBottomSheet: React.FC<FavoritesGuildHeaderBotto
({isOpen, onClose}) => {
const {i18n} = useLingui();
const hideMutedChannels = Favorites.hideMutedChannels;
const settings = UserGuildSettings.getSettings(FAVORITES_GUILD_ID);
const settings = UserGuildSettings.getSettingsForScope(FAVORITES_GUILD_ID);
const isMuted = settings?.muted ?? false;
const handleAddChannel = () => {
ModalCommands.pushAfterBottomSheetClose(
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/app/components/dialogs/Modal.module.css';
import {resolveModalBackdropMotionSpec, resolveModalMotionSpec} from '@app/features/app/components/dialogs/ModalMotion';
import {useModalBackHandler} from '@app/features/app/hooks/useModalBackHandler';
import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
@@ -103,51 +104,16 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
},
[ref],
);
const mobileFullscreenAnimations = {
initial: {opacity: 0},
animate: {opacity: 1},
exit: {opacity: 0},
};
const profileSlideAnimations = prefersReducedMotion
? {
initial: {opacity: 0},
animate: {opacity: 1},
exit: {opacity: 0},
}
: {
initial: {opacity: 0, x: 14},
animate: {opacity: 1, x: 0},
exit: {opacity: 0, x: 14},
};
const defaultAnimations = prefersReducedMotion
? {
initial: {opacity: 0},
animate: {opacity: 1},
exit: {opacity: 0},
}
: {
initial: {opacity: 0, scale: 0.95},
animate: {opacity: 1, scale: 1},
exit: {opacity: 0, scale: 0.95},
};
const animations = isFullscreenOnMobile
? mobileFullscreenAnimations
: transitionPreset === 'profile-slide'
? profileSlideAnimations
: defaultAnimations;
const rootTransition: Transition =
prefersReducedMotion || transitionPreset === 'instant'
? {duration: 0}
: isFullscreenOnMobile
? {duration: 0.15}
: transitionPreset === 'profile-slide'
? {duration: 0.14, ease: 'easeOut'}
: {
type: 'spring',
stiffness: 400,
damping: 30,
mass: 0.8,
};
const motionSpec = resolveModalMotionSpec({
transitionPreset,
prefersReducedMotion,
isFullscreenOnMobile,
});
const backdropMotionSpec = resolveModalBackdropMotionSpec({
transitionPreset,
prefersReducedMotion,
isMobile,
});
const handleBackdropClickEvent = useCallback(
(event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
@@ -226,23 +192,16 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
};
}, [isVisible]);
const isCenteredOnMobile = isMobile && !useFullscreenLayer;
const shouldInstantBackdrop = isMobile && !prefersReducedMotion;
return (
<FloatingPortal root={portalHost ?? undefined} data-flx="app.modal.floating-portal">
{needsBackdrop && isVisible && (
<motion.div
className={styles.modalBackdrop}
style={{zIndex: backdropZIndex}}
initial={{opacity: shouldInstantBackdrop || isInstantTransition ? 0.85 : 0}}
animate={{opacity: 0.85}}
exit={{opacity: 0}}
transition={
prefersReducedMotion || isInstantTransition
? {duration: 0}
: shouldInstantBackdrop
? {duration: 0.15}
: {duration: 0.2}
}
initial={backdropMotionSpec.initial}
animate={backdropMotionSpec.animate}
exit={backdropMotionSpec.exit}
transition={backdropMotionSpec.transition as Transition}
data-flx="app.modal.modal-backdrop"
/>
)}
@@ -326,8 +285,10 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
className,
)}
data-flx="app.modal.root"
{...animations}
transition={rootTransition}
initial={motionSpec.initial}
animate={motionSpec.animate}
exit={motionSpec.exit}
transition={motionSpec.transition as Transition}
onAnimationStart={handleAnimationStart}
onAnimationComplete={handleAnimationComplete}
ref={setMotionElementRef}
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ModalTransitionPreset} from '@app/features/ui/utils/ModalUtils';
export interface ModalMotionInput {
transitionPreset: ModalTransitionPreset;
prefersReducedMotion: boolean;
isFullscreenOnMobile: boolean;
}
export interface ModalBackdropMotionInput {
transitionPreset: ModalTransitionPreset;
prefersReducedMotion: boolean;
isMobile: boolean;
}
const SETTLED_SURFACE = {opacity: 1, scale: 1} as const;
const BACKDROP_RESTING_OPACITY = 0.85;
const BACKDROP_DISMISS_SPRING = {type: 'spring', stiffness: 1000, damping: 48, mass: 1} as const;
const QUICK_TRANSITION = {duration: 0.12, ease: 'easeOut'} as const;
const QUICK_SURFACE_OFFSET = 0.98;
export function resolveModalMotionSpec({
transitionPreset,
prefersReducedMotion,
isFullscreenOnMobile,
}: ModalMotionInput) {
if (transitionPreset === 'instant') {
return {
initial: SETTLED_SURFACE,
animate: SETTLED_SURFACE,
exit: SETTLED_SURFACE,
transition: {duration: 0},
};
}
if (prefersReducedMotion) {
return {
initial: {opacity: 0},
animate: {opacity: 1},
exit: {opacity: 0},
transition: {duration: 0},
};
}
if (isFullscreenOnMobile) {
return {
initial: {opacity: 0},
animate: {opacity: 1},
exit: {opacity: 0},
transition: {duration: 0.15},
};
}
if (transitionPreset === 'quick') {
return {
initial: {opacity: 0, scale: QUICK_SURFACE_OFFSET},
animate: {opacity: 1, scale: 1},
exit: {opacity: 0, scale: QUICK_SURFACE_OFFSET},
transition: QUICK_TRANSITION,
};
}
if (transitionPreset === 'profile-slide') {
return {
initial: {opacity: 0, x: 14},
animate: {opacity: 1, x: 0},
exit: {opacity: 0, x: 14},
transition: {duration: 0.14, ease: 'easeOut'},
};
}
return {
initial: {opacity: 0, scale: 0.95},
animate: {opacity: 1, scale: 1},
exit: {opacity: 0, scale: 0.95},
transition: {type: 'spring', stiffness: 400, damping: 30, mass: 0.8},
};
}
export function resolveModalBackdropMotionSpec({
transitionPreset,
prefersReducedMotion,
isMobile,
}: ModalBackdropMotionInput) {
const mountsAtRest = (isMobile && !prefersReducedMotion) || transitionPreset === 'instant';
const surface = {
initial: {opacity: mountsAtRest ? BACKDROP_RESTING_OPACITY : 0},
animate: {opacity: BACKDROP_RESTING_OPACITY},
exit: {opacity: 0},
};
if (prefersReducedMotion) {
return {...surface, transition: {duration: 0}};
}
if (transitionPreset === 'instant') {
return {...surface, transition: BACKDROP_DISMISS_SPRING};
}
if (transitionPreset === 'quick') {
return {...surface, transition: QUICK_TRANSITION};
}
if (isMobile) {
return {...surface, transition: {duration: 0.15}};
}
return {...surface, transition: {duration: 0.2}};
}
@@ -31,7 +31,7 @@ import {
SEARCH_SETTINGS_PLACEHOLDER_DESCRIPTOR,
SIGN_OUT_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
@@ -223,7 +223,7 @@ const SidebarSections: React.FC<SidebarSectionsProps> = observer(
scrollSpyContext.scrollToSection(sectionId);
return;
}
ComponentDispatch.dispatch('USER_SETTINGS_TAB_SELECT', {tab: tabType, section: sectionId});
ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {tab: tabType, section: sectionId});
};
return (
<SettingsModalSidebarSubItems
@@ -21,7 +21,7 @@ import {SubscriptionCard} from '@app/features/app/components/dialogs/components/
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import GeoIP from '@app/features/app/state/GeoIP';
import Guilds from '@app/features/guild/state/Guilds';
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
import PremiumState from '@app/features/premium/state/PremiumState';
import {
@@ -122,7 +122,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
perksSectionRef.current?.scrollIntoView({behavior: 'auto', block: 'start'});
}, []);
const navigateToRedeemGift = useCallback(() => {
ComponentDispatch.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'});
ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'});
}, []);
const handleCancelSubscriptionConfirmed = useCallback(() => {
ModalCommands.push(
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/app/components/dialogs/components/plutonium/GiftInventoryBanner.module.css';
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import {Button} from '@app/features/ui/button/Button';
import type {User} from '@app/features/user/models/User';
@@ -37,7 +37,7 @@ export const GiftInventoryBanner: React.FC<GiftInventoryBannerProps> = observer(
<Button
variant="secondary"
small
onClick={() => ComponentDispatch.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'})}
onClick={() => ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'})}
data-flx="app.plutonium.gift-inventory-banner.button.dispatch"
>
<Trans>View gifts</Trans>
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildMember} from '@app/features/member/models/GuildMember';
import {buildMemberSearchRank} from '@app/features/messaging/utils/AutocompleteOptionBuilders';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {matchSorter} from 'match-sorter';
export interface ParsedOverrideMemberQuery {
usernameQuery: string;
tagQuery: string | null;
hasTagSeparator: boolean;
}
export interface OverrideMemberSelection {
cachedMembers: ReadonlyArray<GuildMember>;
workerMemberIds: ReadonlyArray<string>;
resolveMember: (userId: string) => GuildMember | null;
excludedIds: ReadonlySet<string>;
guildId: string;
query: string;
limit: number;
}
export function parseOverrideMemberQuery(query: string): ParsedOverrideMemberQuery {
const hashIndex = query.indexOf('#');
if (hashIndex === -1) {
return {usernameQuery: query, tagQuery: null, hasTagSeparator: false};
}
return {
usernameQuery: query.slice(0, hashIndex),
tagQuery: query.slice(hashIndex + 1),
hasTagSeparator: true,
};
}
export function getOverrideMemberLabel(member: GuildMember, guildId: string): string {
return NicknameUtils.getNickname(member.user, guildId);
}
function compareByLabel(a: GuildMember, b: GuildMember, guildId: string): number {
return getOverrideMemberLabel(a, guildId)
.toLowerCase()
.localeCompare(getOverrideMemberLabel(b, guildId).toLowerCase());
}
function matchOverrideMembers(
members: ReadonlyArray<GuildMember>,
guildId: string,
parsed: ParsedOverrideMemberQuery,
): Array<GuildMember> {
if (parsed.hasTagSeparator) {
const usernameLower = parsed.usernameQuery.toLowerCase();
const tagLower = parsed.tagQuery?.toLowerCase() ?? '';
return members.filter((member) => {
const nick = member.nick?.toLowerCase() ?? '';
const username = member.user.username.toLowerCase();
const matchesUsername =
usernameLower.length === 0 || username.startsWith(usernameLower) || nick.startsWith(usernameLower);
const matchesTag = tagLower.length === 0 || member.user.discriminator.startsWith(tagLower);
return matchesUsername && matchesTag;
});
}
const trimmed = parsed.usernameQuery.trim();
if (trimmed.length === 0) {
return [...members];
}
return matchSorter([...members], trimmed, {
keys: [(member: GuildMember) => getOverrideMemberLabel(member, guildId), 'nick', 'user.username', 'user.tag'],
});
}
export function selectOverrideMembers({
cachedMembers,
workerMemberIds,
resolveMember,
excludedIds,
guildId,
query,
limit,
}: OverrideMemberSelection): Array<GuildMember> {
const candidates = new Map<string, GuildMember>();
for (const member of cachedMembers) {
if (excludedIds.has(member.user.id)) continue;
candidates.set(member.user.id, member);
}
const workerRanked: Array<GuildMember> = [];
for (const userId of workerMemberIds) {
if (excludedIds.has(userId)) continue;
const cached = candidates.get(userId);
if (cached != null) {
workerRanked.push(cached);
continue;
}
const member = resolveMember(userId);
if (member == null) continue;
candidates.set(userId, member);
workerRanked.push(member);
}
const trimmed = query.trim();
if (trimmed.length === 0) {
return [...candidates.values()].sort((a, b) => compareByLabel(a, b, guildId)).slice(0, limit);
}
const matched = matchOverrideMembers([...candidates.values()], guildId, parseOverrideMemberQuery(trimmed));
const searchRank = buildMemberSearchRank(workerRanked);
if (searchRank.size === 0) {
return [...matched].sort((a, b) => compareByLabel(a, b, guildId)).slice(0, limit);
}
const noRankSentinel = Number.MAX_SAFE_INTEGER;
return [...matched]
.sort((a, b) => {
const rankA = searchRank.get(a.user.id) ?? noRankSentinel;
const rankB = searchRank.get(b.user.id) ?? noRankSentinel;
if (rankA !== rankB) return rankA - rankB;
return compareByLabel(a, b, guildId);
})
.slice(0, limit);
}
@@ -1,10 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
getOverrideMemberLabel,
parseOverrideMemberQuery,
selectOverrideMembers,
} from '@app/features/app/components/dialogs/shared/AddOverrideMemberSearch';
import styles from '@app/features/app/components/dialogs/shared/AddOverridePopout.module.css';
import {DEFAULT_ROLE_COLOR_HEX, getRoleColor} from '@app/features/app/components/dialogs/shared/PermissionComponents';
import Guilds from '@app/features/guild/state/Guilds';
import {ROLES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import type {GuildMember} from '@app/features/member/models/GuildMember';
import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch, {type SearchContext} from '@app/features/member/state/MemberSearch';
import {openRoleContextMenu} from '@app/features/ui/action_menu/RoleContextMenu';
@@ -14,7 +18,6 @@ import {
type SearchableListPopoutItem,
type SearchableListPopoutSection,
} from '@app/features/ui/popover/searchable_list_popout/SearchableListPopout';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {matchSorter} from 'match-sorter';
@@ -46,74 +49,6 @@ const MEMBERS_LIMIT = 10;
const WORKER_RESULT_LIMIT = 25;
const SERVER_DEBOUNCE_MS = 300;
interface ParsedMemberQuery {
usernameQuery: string;
tagQuery: string | null;
hasTagSeparator: boolean;
}
function parseMemberQuery(query: string): ParsedMemberQuery {
const hashIndex = query.indexOf('#');
if (hashIndex === -1) {
return {usernameQuery: query, tagQuery: null, hasTagSeparator: false};
}
return {
usernameQuery: query.slice(0, hashIndex),
tagQuery: query.slice(hashIndex + 1),
hasTagSeparator: true,
};
}
function getMemberDisplayName(member: GuildMember, guildId: string): string {
return NicknameUtils.getNickname(member.user, guildId);
}
function compareByDisplayName(a: GuildMember, b: GuildMember, guildId: string): number {
const aName = getMemberDisplayName(a, guildId).toLowerCase();
const bName = getMemberDisplayName(b, guildId).toLowerCase();
return aName.localeCompare(bName);
}
function filterMembers(
members: Array<GuildMember>,
guildId: string,
parsed: ParsedMemberQuery,
stableOrder?: Map<string, number>,
): Array<GuildMember> {
let matched: Array<GuildMember>;
if (parsed.hasTagSeparator) {
const usernameLower = parsed.usernameQuery.toLowerCase();
const tagLower = parsed.tagQuery?.toLowerCase() ?? '';
matched = members.filter((member) => {
const nick = member.nick?.toLowerCase() ?? '';
const username = member.user.username.toLowerCase();
const matchesUsername =
usernameLower.length === 0 || username.startsWith(usernameLower) || nick.startsWith(usernameLower);
const matchesTag = tagLower.length === 0 || member.user.discriminator.startsWith(tagLower);
return matchesUsername && matchesTag;
});
} else {
const trimmed = parsed.usernameQuery.trim();
if (trimmed.length === 0) {
matched = [...members];
} else {
matched = matchSorter(members, trimmed, {
keys: [(member) => getMemberDisplayName(member, guildId), 'nick', 'user.username', 'user.tag'],
});
}
}
if (stableOrder && stableOrder.size > 0) {
const NEW_RANK = Number.MAX_SAFE_INTEGER;
return [...matched].sort((a, b) => {
const ra = stableOrder.get(a.user.id) ?? NEW_RANK;
const rb = stableOrder.get(b.user.id) ?? NEW_RANK;
if (ra !== rb) return ra - rb;
return compareByDisplayName(a, b, guildId);
});
}
return [...matched].sort((a, b) => compareByDisplayName(a, b, guildId));
}
export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(function AddOverridePopout({
guildId,
existingOverwriteIds,
@@ -126,11 +61,6 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
const [serverMemberIds, setServerMemberIds] = useState<Array<string>>([]);
const searchContextRef = useRef<SearchContext | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const sessionRef = useRef<{key: string; order: Map<string, number>; nextRank: number}>({
key: '',
order: new Map(),
nextRank: 0,
});
useEffect(() => {
const context = MemberSearch.getSearchContext((results) => {
setServerMemberIds(results.map((result) => result.id));
@@ -147,7 +77,7 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
}, []);
useEffect(() => {
const trimmed = searchQuery.trim();
const parsed = parseMemberQuery(trimmed);
const parsed = parseOverrideMemberQuery(trimmed);
const queryForServer = parsed.usernameQuery.trim();
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
@@ -155,11 +85,11 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
}
const context = searchContextRef.current;
if (queryForServer.length === 0) {
context?.clearQuery();
context?.cancelSearch();
setServerMemberIds([]);
return;
}
context?.setQuery(queryForServer, {guild: guildId});
context?.beginSearch(queryForServer, {guild: guildId});
if (GuildMembers.isGuildFullyLoaded(guildId)) {
return;
}
@@ -176,40 +106,15 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
}, [guild, existingOverwriteIds]);
const members = useMemo(() => {
if (!guild) return [];
const cached = GuildMembers.getMembers(guildId).filter((member) => !existingOverwriteIds.has(member.user.id));
const trimmed = searchQuery.trim();
const parsed = parseMemberQuery(trimmed);
const hasQuery = trimmed.length > 0;
if (!hasQuery) {
sessionRef.current = {key: `${guildId}:`, order: new Map(), nextRank: 0};
return [...cached].sort((a, b) => compareByDisplayName(a, b, guildId)).slice(0, MEMBERS_LIMIT);
}
const sessionKey = `${guildId}:${trimmed}`;
if (sessionRef.current.key !== sessionKey) {
sessionRef.current = {key: sessionKey, order: new Map(), nextRank: 0};
}
const merged = new Map<string, GuildMember>();
for (const member of cached) {
merged.set(member.user.id, member);
}
for (const id of serverMemberIds) {
if (existingOverwriteIds.has(id) || merged.has(id)) continue;
const member = GuildMembers.getMember(guildId, id);
if (member) {
merged.set(id, member);
}
}
const result = filterMembers(Array.from(merged.values()), guildId, parsed, sessionRef.current.order).slice(
0,
MEMBERS_LIMIT,
);
const session = sessionRef.current;
for (const member of result) {
if (!session.order.has(member.user.id)) {
session.order.set(member.user.id, session.nextRank++);
}
}
return result;
return selectOverrideMembers({
cachedMembers: GuildMembers.getMembers(guildId),
workerMemberIds: serverMemberIds,
resolveMember: (userId) => GuildMembers.getMember(guildId, userId),
excludedIds: existingOverwriteIds,
guildId,
query: searchQuery,
limit: MEMBERS_LIMIT,
});
}, [guild, guildId, existingOverwriteIds, searchQuery, serverMemberIds]);
const filteredRoles = useMemo(() => {
const trimmed = searchQuery.trim();
@@ -244,7 +149,7 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
}, [filteredRoles, onClose, onSelect]);
const memberItems = useMemo<Array<SearchableListPopoutItem>>(() => {
return members.map((member) => {
const displayName = getMemberDisplayName(member, guildId);
const displayName = getOverrideMemberLabel(member, guildId);
return {
id: `member-${member.user.id}`,
ariaLabel: displayName,
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {clsx} from 'clsx';
import type React from 'react';
@@ -26,6 +26,8 @@ export interface GuildComboboxRenderersConfig<T extends GuildComboboxOption> {
getNotice?: (option: T, disabled: boolean) => React.ReactNode;
}
const ROW_INITIALS_MAX_LENGTH = 1;
const renderRow = <T extends GuildComboboxOption>(
option: T,
disabled: boolean,
@@ -34,9 +36,8 @@ const renderRow = <T extends GuildComboboxOption>(
getNotice?: (option: T, disabled: boolean) => React.ReactNode,
) => {
const isGlobal = !option.value;
const iconUrl =
option.iconUrl ?? (option.icon ? AvatarUtils.getGuildIconURL({id: option.value, icon: option.icon}) : null);
const initial = option.label.charAt(0).toUpperCase();
const iconUrl = option.iconUrl ?? null;
const initial = truncateInitials(getInitialsFromName(option.label), ROW_INITIALS_MAX_LENGTH).toUpperCase();
const notice = getNotice?.(option, disabled);
return (
<div
@@ -392,7 +392,7 @@ export const MobileSectionNav: React.FC<MobileSectionNavProps> = observer(
const scrollerRef = useRef<ScrollerHandle | null>(null);
useEffect(() => {
if (!activeSectionId) return;
const node = scrollerRef.current?.getScrollerNode();
const node = scrollerRef.current?.getViewportElement();
if (!node) return;
const activeButton = node.querySelector(`[data-section-id="${activeSectionId}"]`);
if (activeButton instanceof HTMLElement) {
@@ -125,7 +125,7 @@ export const SettingsModalDesktopScroll: React.FC<SettingsModalDesktopScrollProp
if (!scrollerRef) {
return;
}
const node = internalRef.current?.getScrollerNode() ?? null;
const node = internalRef.current?.getViewportElement() ?? null;
if (typeof scrollerRef === 'function') {
scrollerRef(node);
} else {
@@ -1,49 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {mergeFrozenUnreadOrder} from './UnreadChannelOrder';
import {selectFrozenUnreadChannels} from './UnreadChannelOrder';
function ids(channels: ReadonlyArray<{id: string}>): Array<string> {
return channels.map((channel) => channel.id);
}
describe('mergeFrozenUnreadOrder', () => {
describe('selectFrozenUnreadChannels', () => {
it('keeps channels in their frozen position while the list is open', () => {
const order = new Map([
['a', 0],
['b', 1],
['c', 2],
]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]);
expect(ids(merged)).toEqual(['a', 'b', 'c']);
expect(ids(selectFrozenUnreadChannels(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]))).toEqual(['a', 'b', 'c']);
});
it('shows a channel that becomes unread after the order was frozen', () => {
it('hides a channel that becomes unread after the order was frozen', () => {
const order = new Map([['a', 0]]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]);
expect(ids(merged)).toEqual(['a', 'late']);
expect(ids(selectFrozenUnreadChannels(order, [{id: 'a'}, {id: 'late'}]))).toEqual(['a']);
});
it('appends late arrivals after existing entries rather than reshuffling', () => {
const order = new Map([
['a', 0],
['b', 1],
]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'b'}, {id: 'a'}]);
expect(ids(merged)).toEqual(['a', 'b', 'late']);
});
it('remembers a late arrival position once assigned', () => {
it('never grows the list when every live channel is new to the order', () => {
const order = new Map([['a', 0]]);
mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'a'}]);
expect(ids(merged)).toEqual(['a', 'late']);
expect(ids(selectFrozenUnreadChannels(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]))).toEqual([]);
});
it('drops nothing when every channel is new', () => {
const order = new Map<string, number>();
const merged = mergeFrozenUnreadOrder(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]);
expect(ids(merged)).toEqual(['x', 'y', 'z']);
it('yields nothing for an empty order', () => {
expect(ids(selectFrozenUnreadChannels(new Map<string, number>(), [{id: 'x'}]))).toEqual([]);
});
it('does not resurrect channels that are no longer unread', () => {
@@ -51,7 +36,12 @@ describe('mergeFrozenUnreadOrder', () => {
['a', 0],
['gone', 1],
]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}]);
expect(ids(merged)).toEqual(['a']);
expect(ids(selectFrozenUnreadChannels(order, [{id: 'a'}]))).toEqual(['a']);
});
it('does not mutate the order it was given', () => {
const order = new Map([['a', 0]]);
selectFrozenUnreadChannels(order, [{id: 'a'}, {id: 'late'}]);
expect([...order.keys()]).toEqual(['a']);
});
});
@@ -1,21 +1,62 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
function resolveUnreadOrderIndex(order: Map<string, number>, channelId: string): number {
const index = order.get(channelId);
if (index == null) {
return Number.POSITIVE_INFINITY;
}
return index;
import {useEffect, useMemo, useRef, useState} from 'react';
export const UNREAD_REFREEZE_COOLDOWN_MS = 10_000;
export interface FrozenUnreadOrder {
order: Map<string, number>;
}
export function mergeFrozenUnreadOrder<T extends {id: string}>(
order: Map<string, number>,
current: ReadonlyArray<T>,
export function selectFrozenUnreadChannels<T extends {id: string}>(
order: ReadonlyMap<string, number>,
live: ReadonlyArray<T>,
): Array<T> {
for (const channel of current) {
if (!order.has(channel.id)) {
order.set(channel.id, order.size);
}
const held: Array<{index: number; channel: T}> = [];
for (const channel of live) {
const index = order.get(channel.id);
if (index == null) continue;
held.push({index, channel});
}
return [...current].sort((a, b) => resolveUnreadOrderIndex(order, a.id) - resolveUnreadOrderIndex(order, b.id));
held.sort((a, b) => a.index - b.index);
return held.map((entry) => entry.channel);
}
export function useFrozenUnreadOrder<S extends FrozenUnreadOrder, T extends {id: string}>(
takeSnapshot: () => S,
live: ReadonlyArray<T>,
): {snapshot: S; channels: Array<T>} {
const [snapshot, setSnapshot] = useState(takeSnapshot);
const [cooldownAttempt, setCooldownAttempt] = useState(0);
const snapshotTakenAtRef = useRef(Date.now());
const hasShownFrozenListRef = useRef(false);
const channels = useMemo(() => selectFrozenUnreadChannels(snapshot.order, live), [snapshot, live]);
useEffect(() => {
if (channels.length > 0) {
hasShownFrozenListRef.current = true;
return undefined;
}
const next = takeSnapshot();
if (next.order.size === 0) {
if (snapshot.order.size > 0) {
setSnapshot(next);
}
return undefined;
}
if (hasShownFrozenListRef.current) {
const heldFor = Date.now() - snapshotTakenAtRef.current;
if (heldFor < UNREAD_REFREEZE_COOLDOWN_MS) {
const timer = setTimeout(
() => setCooldownAttempt((attempt) => attempt + 1),
UNREAD_REFREEZE_COOLDOWN_MS - heldFor,
);
return () => clearTimeout(timer);
}
}
snapshotTakenAtRef.current = Date.now();
hasShownFrozenListRef.current = false;
setSnapshot(next);
return undefined;
}, [channels, snapshot, cooldownAttempt]);
return {snapshot, channels};
}
@@ -9,12 +9,20 @@
--inbox-icon-button-radius: 0.375rem;
--inbox-collapse-button-size: 1.375rem;
--inbox-collapse-button-radius: 0.375rem;
--inbox-sticky-header-height: 3.5rem;
flex: 1;
min-height: 0;
min-width: 0;
padding: 0 var(--inbox-scroller-padding-x) var(--inbox-scroller-padding-bottom);
}
.stickyChannelHeader {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--form-surface-background);
}
.guildGroup {
min-width: 0;
}
@@ -100,6 +108,8 @@
.messageRow {
position: relative;
isolation: isolate;
scroll-margin-top: var(--inbox-sticky-header-height);
}
.messageActions {
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
import {useFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
import {
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
@@ -27,6 +27,7 @@ import {ensureMembersForMessages} from '@app/features/messaging/commands/Message
import {InboxMessageHeader} from '@app/features/messaging/components/popouts/InboxMessageHeader';
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy';
import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport';
import {Message} from '@app/features/messaging/models/MessagingMessage';
import {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
import {createChannelStream} from '@app/features/messaging/utils/MessageGroupingUtils';
@@ -136,6 +137,29 @@ interface ChannelPreviewData {
const INITIAL_VISIBLE_CHANNELS = 10;
const LOAD_MORE_CHUNK = 10;
interface FrozenUnreadCounts {
mentionCount: number;
unreadCount: number;
}
interface FrozenUnreadChannels {
order: Map<string, number>;
counts: Map<string, FrozenUnreadCounts>;
}
function freezeUnreadChannels(): FrozenUnreadChannels {
const order = new Map<string, number>();
const counts = new Map<string, FrozenUnreadCounts>();
for (const [index, channel] of getUnreadChannels().entries()) {
order.set(channel.id, index);
counts.set(channel.id, {
mentionCount: ReadStates.getMentionCount(channel.id),
unreadCount: ReadStates.getUnreadCount(channel.id),
});
}
return {order, counts};
}
interface CacheEntry {
cacheKey: string;
data: ChannelPreviewData;
@@ -350,22 +374,24 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
channel,
headingId,
previewData,
mentionCount,
unreadCount,
}: {
channel: Channel;
headingId: string;
previewData: ChannelPreviewData | null;
mentionCount: number;
unreadCount: number;
}) {
const {i18n} = useLingui();
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
const messageGroupSpacing = Accessibility.getMessageGroupSpacingValue(messageDisplayCompact);
const mentionCount = ReadStates.getMentionCount(channel.id);
const isCollapsed = UnreadChannels.isCollapsed(channel.id);
const isMuted = UserGuildSettings.isGuildOrCategoryOrChannelMuted(channel.guildId ?? null, channel.id);
const isMuted = UserGuildSettings.isMutedAtAnyLevel(channel.guildId ?? null, channel.id);
const previewMessages = previewData?.messages ?? [];
const oldestUnreadMessageId = previewData?.oldestUnreadMessageId ?? null;
const totalUnreadCount = ReadStates.getUnreadCount(channel.id);
const hasMoreUnreadThanShown =
oldestUnreadMessageId != null && totalUnreadCount > 0 && totalUnreadCount > previewMessages.length;
oldestUnreadMessageId != null && unreadCount > 0 && unreadCount > previewMessages.length;
const spammerOverrideVersion = LocalUserSpamOverride.version;
useEffect(() => {
if (previewMessages.length === 0) return;
@@ -445,7 +471,7 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
highlightedMessageId: null,
messageDisplayCompact,
messageGroupSpacing,
revealedMessageId: null,
unblurredMessageId: null,
onMessageEdit: undefined,
onReveal: undefined,
messageRowClassName: styles.messageRow,
@@ -497,6 +523,7 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
</h2>
<InboxMessageHeader
channel={channel}
className={styles.stickyChannelHeader}
onClick={handleHeaderClick}
mentionCount={mentionCount}
leftAdornment={
@@ -648,19 +675,14 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
const {i18n} = useLingui();
const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveInboxScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const readStateVersion = ReadStates.version;
const settingsVersion = UserGuildSettings.version;
const frozenOrderRef = useRef<Map<string, number> | null>(null);
if (frozenOrderRef.current === null) {
frozenOrderRef.current = new Map(getUnreadChannels().map((channel, index) => [channel.id, index]));
}
const allUnreadChannels = useMemo(() => {
const order = frozenOrderRef.current;
if (order == null) {
return [];
}
return mergeFrozenUnreadOrder(order, getUnreadChannels());
}, [readStateVersion, settingsVersion]);
const liveUnreadChannels = useMemo(() => getUnreadChannels(), [readStateVersion, settingsVersion]);
const {snapshot: frozenUnread, channels: allUnreadChannels} = useFrozenUnreadOrder(
freezeUnreadChannels,
liveUnreadChannels,
);
const [loadedCount, setLoadedCount] = useState(INITIAL_VISIBLE_CHANNELS);
const visibleChannels = useMemo(() => allUnreadChannels.slice(0, loadedCount), [allUnreadChannels, loadedCount]);
const channelPreviews = useBulkChannelPreviews(visibleChannels);
@@ -697,19 +719,29 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
return groups;
}, [visibleChannels, channelPreviews, i18n.locale]);
const unreadRows = useMemo(() => {
const rows: Array<{channel: Channel; endsGroup: boolean; groupLabel: string; key: string}> = [];
const rows: Array<{
channel: Channel;
endsGroup: boolean;
groupLabel: string;
key: string;
mentionCount: number;
unreadCount: number;
}> = [];
for (const group of groupedChannels) {
for (const [index, channel] of group.channels.entries()) {
const counts = frozenUnread.counts.get(channel.id);
rows.push({
channel,
endsGroup: index === group.channels.length - 1,
groupLabel: group.label,
key: channel.id,
mentionCount: counts?.mentionCount ?? 0,
unreadCount: counts?.unreadCount ?? 0,
});
}
}
return rows;
}, [groupedChannels]);
}, [groupedChannels, frozenUnread]);
const hasMore = loadedCount < allUnreadChannels.length;
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
@@ -734,7 +766,7 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
const target = event.target as Element | null;
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return;
if (target instanceof HTMLElement && target.isContentEditable) return;
const scrollerNode = scrollerRef.current?.getScrollerNode() ?? null;
const scrollerNode = scrollerRef.current?.getViewportElement() ?? null;
if (!scrollerNode) return;
if (target && !scrollerNode.contains(target) && document.activeElement !== document.body) return;
const sections = Array.from(scrollerNode.querySelectorAll<HTMLElement>('[data-inbox-channel-section]'));
@@ -780,40 +812,44 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
);
}
return (
<Scroller
key="unread-channels-scroller"
className={styles.scroller}
ref={scrollerRef}
onScroll={handleScroll}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="app.floating.unread-channels-content.scroller"
>
<div className={styles.list} role="list" data-flx="app.floating.unread-channels-content.list">
{unreadRows.map((row, rowIndex) => (
<div
key={row.key}
className={clsx(styles.row, row.endsGroup && styles.rowGroupEnd)}
role="listitem"
aria-posinset={rowIndex + 1}
aria-setsize={unreadRows.length}
data-flx="app.floating.unread-channels-content.row"
>
<section
className={styles.guildGroup}
aria-label={row.groupLabel}
data-flx="app.floating.unread-channels-content.guild-group"
<NearViewportSurfaceContext.Provider value={resolveInboxScrollSurface}>
<Scroller
key="unread-channels-scroller"
className={styles.scroller}
ref={scrollerRef}
onScroll={handleScroll}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="app.floating.unread-channels-content.scroller"
>
<div className={styles.list} role="list" data-flx="app.floating.unread-channels-content.list">
{unreadRows.map((row, rowIndex) => (
<div
key={row.key}
className={clsx(styles.row, row.endsGroup && styles.rowGroupEnd)}
role="listitem"
aria-posinset={rowIndex + 1}
aria-setsize={unreadRows.length}
data-flx="app.floating.unread-channels-content.row"
>
<UnreadChannelCard
channel={row.channel}
headingId={`inbox-unread-channel-${row.channel.id}`}
previewData={channelPreviews.get(row.channel.id) ?? null}
data-flx="app.floating.unread-channels-content.unread-channel-card"
/>
</section>
</div>
))}
</div>
</Scroller>
<section
className={styles.guildGroup}
aria-label={row.groupLabel}
data-flx="app.floating.unread-channels-content.guild-group"
>
<UnreadChannelCard
channel={row.channel}
headingId={`inbox-unread-channel-${row.channel.id}`}
previewData={channelPreviews.get(row.channel.id) ?? null}
mentionCount={row.mentionCount}
unreadCount={row.unreadCount}
data-flx="app.floating.unread-channels-content.unread-channel-card"
/>
</section>
</div>
))}
</div>
</Scroller>
</NearViewportSurfaceContext.Provider>
);
});
@@ -41,7 +41,7 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
const registerFormDraftsRef = useRef<Map<string, AuthRegisterFormDraft>>(new Map());
const scrollerRef = useRef<ScrollerHandle>(null);
const location = useLocation();
const {patternReady, splashLoaded, splashDimensions} = useAuthBackground(splashUrl, foodPatternUrl);
const {patternReady, splashDimensions} = useAuthBackground(splashUrl, foodPatternUrl);
const handleSetSplashUrl = useCallback(
(url: string | null) => {
if (splashUrlRef.current === url) return;
@@ -69,7 +69,7 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
};
}, []);
useEffect(() => {
scrollerRef.current?.scrollToTop();
scrollerRef.current?.jumpToStartEdge();
}, [location.pathname]);
const splashScale = useMemo(() => {
if (!splashDimensions) return null;
@@ -196,7 +196,6 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
>
<AuthBackground
splashUrl={splashUrl}
splashLoaded={splashLoaded}
splashDimensions={splashDimensions}
splashScale={splashScale}
patternReady={patternReady}
@@ -238,7 +238,7 @@ export const ChannelItem = observer(
const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId);
const mobileLayout = MobileLayout;
const isMuted = UserGuildSettings.isGuildOrChannelMuted(guild.id, channel.id);
const isChannelDirectlyMuted = UserGuildSettings.isChannelMuted(guild.id, channel.id);
const isChannelDirectlyMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, channel.id);
const currentUserCount =
channelIsVoice && channel.userLimit != null && channel.userLimit > 0
? Object.keys(MediaEngine.getAllVoiceStatesInChannel(guild.id, channel.id)).length
@@ -164,7 +164,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
const {i18n} = useLingui();
const channels = Channels.getGuildChannels(guild.id);
const location = useLocation();
const userGuildSettings = UserGuildSettings.getSettings(guild.id);
const userGuildSettings = UserGuildSettings.getSettingsForScope(guild.id);
const isDraggingAnything = useDragLayer((monitor) => {
if (!monitor.isDragging()) return false;
const itemType = monitor.getItemType();
@@ -242,7 +242,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
() => [channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels],
[channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels],
);
const getChannelScrollContainer = useCallback(() => scrollerRef.current?.getScrollerNode() ?? null, [scrollerRef]);
const getChannelScrollContainer = useCallback(() => scrollerRef.current?.getViewportElement() ?? null, [scrollerRef]);
useDragAutoScroll({active: isDraggingAnything, getScrollElement: getChannelScrollContainer});
const handleChannelDrop = useCallback(
(item: DragItem, result: DropResult) => {
@@ -306,15 +306,15 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
}, [guild.id]);
const handleResize = useCallback((_entry: ResizeObserverEntry, _type: 'container' | 'content') => {
if (stickToBottomRef.current && scrollerRef.current) {
scrollerRef.current.scrollToBottom({animate: false});
scrollerRef.current.jumpToEndEdge({animate: false});
}
}, []);
useEffect(() => {
const guildDimensions = Dimension.getGuildDimensions(guild.id);
const guildDimensions = Dimension.guildDimensionsFor(guild.id);
if (guildDimensions.scrollTo) {
const element = document.querySelector(`[data-channel-id="${guildDimensions.scrollTo}"]`);
if (element && scrollerRef.current) {
scrollerRef.current.scrollIntoViewNode({node: element as HTMLElement, shouldScrollToStart: false});
scrollerRef.current.revealElement({node: element as HTMLElement, preferStartEdge: false});
}
DimensionCommands.clearChannelListScrollTo(guild.id);
} else if (guildDimensions.scrollTop && guildDimensions.scrollTop > 0 && scrollerRef.current) {
@@ -337,7 +337,8 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
const unreadCount = ReadStates.getUnreadCount(channelId);
const mentionCount = ReadStates.getMentionCount(channelId);
const isMuted =
UserGuildSettings.isCategoryMuted(guild.id, channelId) || UserGuildSettings.isChannelMuted(guild.id, channelId);
UserGuildSettings.isParentCategoryMuted(guild.id, channelId) ||
UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId);
const channel = Channels.getChannel(channelId);
const unreadBadgesLevel = channel
? UserGuildSettings.resolvedUnreadBadgesLevel({
@@ -360,13 +361,15 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
for (const group of channelGroups) {
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
const isNullSpace = !group.category;
const isCategoryMuted = group.category ? UserGuildSettings.isChannelMuted(guild.id, group.category.id) : false;
const isCategoryMuted = group.category
? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id)
: false;
let filteredTextChannels: Array<Channel>;
let filteredVoiceChannels: Array<Channel>;
if (hideMutedChannels) {
filteredTextChannels = [];
for (const ch of group.textChannels) {
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
if (
shouldShowChannelWhenHidingMutedChannels({
isMuted,
@@ -380,7 +383,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
}
filteredVoiceChannels = [];
for (const ch of group.voiceChannels) {
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
if (
shouldShowChannelWhenHidingMutedChannels({
isMuted,
@@ -206,11 +206,11 @@ const FavoriteChannelResolvedItem = observer(
const channelDisplayName = channel.isPrivate() ? ChannelUtils.getDMDisplayName(channel) : channel.name;
const displayName = favoriteChannel.nickname || channelDisplayName || i18n._(UNKNOWN_CHANNEL_DESCRIPTOR);
const isChannelDirectlyMuted = channel.guildId
? UserGuildSettings.isChannelMuted(channel.guildId, channel.id)
? UserGuildSettings.isChannelDirectlyMuted(channel.guildId, channel.id)
: false;
const isMuted =
isChannelDirectlyMuted ||
(channel.guildId ? UserGuildSettings.isCategoryMuted(channel.guildId, channel.id) : false);
(channel.guildId ? UserGuildSettings.isParentCategoryMuted(channel.guildId, channel.id) : false);
const unreadBadgesLevel = channel.guildId
? UserGuildSettings.resolvedUnreadBadgesLevel({
id: channel.id,
@@ -651,7 +651,7 @@ export const FavoritesChannelListContent = observer(() => {
const getScrollElement = useCallback(() => {
const scroller = scrollerRef.current;
if (scroller == null) return null;
return scroller.getScrollerNode();
return scroller.getViewportElement();
}, []);
useDragAutoScroll({active: isDraggingFavorite, getScrollElement});
const favorites = Favorites.sortedChannels;
@@ -164,7 +164,7 @@ function resolveScrollerNode(scroller: ScrollerHandle | null): HTMLElement | nul
if (scroller == null) {
return null;
}
return scroller.getScrollerNode();
return scroller.getViewportElement();
}
function resolveOwnerWindow(container: HTMLElement): Window & typeof globalThis {
@@ -221,7 +221,7 @@ function cancelOwnedSettleTimer(timer: OwnedSettleTimer | null): void {
}
function alignMountedTarget({animate, direction, scroller, targetNode}: AlignMountedTargetRequest): boolean {
const container = scroller.getScrollerNode();
const container = scroller.getViewportElement();
if (container == null) return false;
const containerRect = container.getBoundingClientRect();
const targetRect = targetNode.getBoundingClientRect();
@@ -451,7 +451,7 @@ export const FloatingUnreadIndicators = observer(
if (scrollTarget(edge.id, direction)) {
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
if (scroller == null) return;
const scrollNode = scroller.getScrollerNode();
const scrollNode = scroller.getViewportElement();
if (scrollNode == null) return;
const ownerWindow = resolveOwnerWindow(scrollNode);
cancelOwnedAnimationFrame(targetAlignmentFrameRef.current);
@@ -467,7 +467,7 @@ export const FloatingUnreadIndicators = observer(
}
scheduleRefresh(FloatingUnreadRefreshScope.SCROLL);
if (scroller == null) return;
const scrollNode = scroller.getScrollerNode();
const scrollNode = scroller.getViewportElement();
if (scrollNode == null) return;
const ownerWindow = resolveOwnerWindow(scrollNode);
cancelOwnedSettleTimer(settleTimerRef.current);
@@ -20,21 +20,22 @@ export const GuildDetachedBanner = observer(function GuildDetachedBanner({guild}
: undefined,
[guild.bannerHeight, guild.bannerWidth],
);
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
const staticBannerURL = useMemo(
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, false) || null,
[guild.banner, guild.id],
() =>
isDetachedBanner ? AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, false) || null : null,
[guild.banner, guild.id, isDetachedBanner],
);
const animatedBannerURL = useMemo(
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null,
[guild.banner, guild.id],
() => (isDetachedBanner ? AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null : null),
[guild.banner, guild.id, isDetachedBanner],
);
const {hoverRef: bannerHoverRef, imageUrl: bannerURL} = useAnimatedImageUrl({
staticUrl: staticBannerURL,
animatedUrl: animatedBannerURL,
kind: 'gif',
});
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
if (!bannerURL || !isDetachedBanner) return null;
if (!bannerURL) return null;
const maxHeight = `${MAX_VIEWPORT_HEIGHT_FRACTION * 100}vh`;
const bannerHeight = guild.bannerHeight ?? DEFAULT_BANNER_HEIGHT;
return (
@@ -66,20 +66,20 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
const {popouts} = Popout;
const isOpen = 'guild-header' in popouts;
const isMobile = MobileLayout.isMobileLayout();
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
const staticBannerURL = useMemo(
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, false) || null,
[guild.banner, guild.id],
);
const animatedBannerURL = useMemo(
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null,
[guild.banner, guild.id],
() => (isDetachedBanner ? null : AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null),
[guild.banner, guild.id, isDetachedBanner],
);
const {hoverRef: bannerHoverRef, imageUrl: bannerURL} = useAnimatedImageUrl({
staticUrl: staticBannerURL,
animatedUrl: animatedBannerURL,
kind: 'gif',
});
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
const showIntegratedBanner = Boolean(bannerURL && !isDetachedBanner);
const bannerAspectRatio =
guild.bannerWidth && guild.bannerHeight
@@ -27,7 +27,7 @@ import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import Permission from '@app/features/permissions/state/Permission';
import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
import {useParams} from '@app/features/platform/components/router/RouterReact';
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
@@ -433,7 +433,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
useEffect(() => {
if (prevNagbarCount.current !== nagbarCount) {
prevNagbarCount.current = nagbarCount;
ComponentDispatch.dispatch('LAYOUT_RESIZED');
ComponentBus.dispatch('LAYOUT_RESIZED');
}
}, [nagbarCount]);
useEffect(() => {
@@ -306,14 +306,9 @@
isolation: isolate;
}
.guildBadgeActive::before,
.guildBadge:not(:empty)::before {
content: '';
position: absolute;
inset: -0.1875rem;
z-index: -1;
border-radius: calc(var(--radius-md) + 0.1875rem);
background-color: var(--guild-badge-surface);
.guildBadgeActive,
.guildBadge:not(:empty) {
--mention-badge-cutout-surface: var(--guild-badge-surface);
}
.guildVoiceBadge {
@@ -90,7 +90,7 @@ import {openMacPermissionsModal} from '@app/features/permissions/system/commands
import MacPermissions from '@app/features/permissions/system/state/MacPermissions';
import {useLocation} from '@app/features/platform/components/router/RouterReact';
import {Platform} from '@app/features/platform/types/Platform';
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import ReadStates from '@app/features/read_state/state/ReadStates';
import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
import {AxisOrientation, type VerticalEdge} from '@app/features/ui/AxisOrientation';
@@ -184,7 +184,7 @@ function resolveGuildFolderId(folderId: number | null): number {
function resolveGuildListScrollerNode(scroller: ScrollerHandle | null): HTMLElement | null {
if (scroller == null) return null;
return scroller.getScrollerNode();
return scroller.getViewportElement();
}
type GuildNavigationRow =
@@ -665,7 +665,7 @@ function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverit
const unreadState = getChannelUnreadState({
unreadCount: ReadStates.getPrivateChannelUnreadCount(channelId),
mentionCount,
isMuted: UserGuildSettings.isChannelMuted(null, channelId),
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
});
if (mentionCount > 0) return 'mention';
@@ -674,8 +674,8 @@ function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverit
}
function getGuildScrollIndicatorSeverity(guildId: string): ScrollIndicatorSeverity | null {
if (GuildReadState.getMentionCount(guildId) > 0) return 'mention';
if (GuildReadState.hasUnread(guildId)) return 'unread';
if (GuildReadState.mentionCountForGuild(guildId) > 0) return 'mention';
if (GuildReadState.guildIsUnread(guildId)) return 'unread';
return null;
}
@@ -1327,10 +1327,10 @@ const GUILD_RAIL_INDICATORS_BY_TOKEN: Record<string, SkeletonGuildRailItemIndica
function resolveGuildRailIndicator(guildIds: ReadonlyArray<string>): SkeletonGuildRailItemIndicator {
let hasUnread = false;
for (const guildId of guildIds) {
if (GuildReadState.getMentionCount(guildId) > 0) {
if (GuildReadState.mentionCountForGuild(guildId) > 0) {
return SkeletonGuildRailItemIndicator.MENTION;
}
if (GuildReadState.hasUnread(guildId)) {
if (GuildReadState.guildIsUnread(guildId)) {
hasUnread = true;
}
}
@@ -1583,7 +1583,7 @@ const GuildList = observer(() => {
const visibleDMListRows = useFrameBatchedDMListRows(targetDMListRows);
const shouldRenderGuildListItems = hasUnavailableGuilds || organizedItems.length > 0;
const initialGuildListScrollTop = useMemo(() => {
const liveScrollTop = untracked(() => Dimension.getGuildListDimensions().scrollTop);
const liveScrollTop = untracked(() => Dimension.currentGuildListDimensions().scrollTop);
if (liveScrollTop > 0) {
return liveScrollTop;
}
@@ -1859,7 +1859,7 @@ const GuildList = observer(() => {
const node = scrollTargetRegistry.getTargetNode(targetId);
const scroller = scrollRef.current;
if (node == null || scroller == null) return false;
scroller.scrollIntoViewNode({
scroller.revealElement({
node,
alignment,
animate: Accessibility.useSmoothScrolling,
@@ -2166,7 +2166,7 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) =
useEffect(() => {
if (prevNagbarCount.current !== activeNagbars.length) {
prevNagbarCount.current = activeNagbars.length;
ComponentDispatch.dispatch('LAYOUT_RESIZED');
ComponentBus.dispatch('LAYOUT_RESIZED');
}
}, [activeNagbars.length]);
useEffect(() => {
@@ -3,7 +3,7 @@
import {Routes} from '@app/app/Routes';
import {CHANNEL_TEXTAREA_SELECTOR} from '@app/features/app/keybindings/utils/EditableElement';
import {useLocation} from '@app/features/platform/components/router/RouterReact';
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import {
@@ -52,7 +52,7 @@ export const KeyboardModeListener = observer(() => {
useEffect(() => {
let lastWindowFocusTime = document.hasFocus() ? 0 : -Infinity;
const REFOCUS_THRESHOLD_MS = 100;
const handleWindowFocus = () => {
const handleWindowFocused = () => {
lastWindowFocusTime = performance.now();
};
const handleKeyDown = (event: KeyboardEvent) => {
@@ -61,7 +61,7 @@ export const KeyboardModeListener = observer(() => {
const composer = document.querySelector<HTMLTextAreaElement>(CHANNEL_TEXTAREA_SELECTOR);
if (canRedirectTabToComposer(composer)) {
event.preventDefault();
ComponentDispatch.dispatch('FOCUS_TEXTAREA', {enterKeyboardMode: true});
ComponentBus.dispatch('FOCUS_TEXTAREA', {enterKeyboardMode: true});
return;
}
}
@@ -75,12 +75,12 @@ export const KeyboardModeListener = observer(() => {
KeyboardMode.exitKeyboardMode();
}
};
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('focus', handleWindowFocused);
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('mousedown', handlePointer, true);
window.addEventListener('pointerdown', handlePointer, true);
return () => {
window.removeEventListener('focus', handleWindowFocus);
window.removeEventListener('focus', handleWindowFocused);
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('mousedown', handlePointer, true);
window.removeEventListener('pointerdown', handlePointer, true);
@@ -145,14 +145,9 @@
isolation: isolate;
}
.folderBadgeActive::before,
.folderBadge:not(:empty)::before {
content: '';
position: absolute;
inset: -0.1875rem;
z-index: -1;
border-radius: calc(var(--radius-md) + 0.1875rem);
background-color: var(--guild-badge-surface);
.folderBadgeActive,
.folderBadge:not(:empty) {
--mention-badge-cutout-surface: var(--guild-badge-surface);
}
.expandedFolderBackground {
@@ -316,8 +316,8 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
}) as CSSProperties,
[folderColor],
);
const hasUnreadMessages = guilds.some((guild) => GuildReadState.hasUnread(guild.id));
const totalMentionCount = guilds.reduce((sum, guild) => sum + GuildReadState.getMentionCount(guild.id), 0);
const hasUnreadMessages = guilds.some((guild) => GuildReadState.guildIsUnread(guild.id));
const totalMentionCount = guilds.reduce((sum, guild) => sum + GuildReadState.mentionCountForGuild(guild.id), 0);
let folderScrollTargetRef: React.RefCallback<HTMLElement> | null = null;
if (registerScrollTarget != null) {
folderScrollTargetRef = registerScrollTarget(folderId);
@@ -759,14 +759,14 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
);
});
const MINI_GUILD_INITIALS_MAX_LENGTH = 2;
interface MiniGuildIconProps {
readonly guild: Guild;
}
const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
const iconURL = AvatarSourceUtils.getGuildIconURL(guild, false);
const initials = getInitialsFromName(guild.name);
const displayInitials = truncateInitials(initials, 2);
if (iconURL !== '') {
return (
<flx-app-mini-guild-icon
@@ -785,7 +785,7 @@ const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
className={styles.miniGuildInitials}
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-initials"
>
{displayInitials}
{truncateInitials(getInitialsFromName(guild.name), MINI_GUILD_INITIALS_MAX_LENGTH)}
</span>
</flx-app-mini-guild-icon>
);
@@ -72,7 +72,7 @@ export function resolveDMListItemUnreadState(channelId: string): ChannelUnreadSt
return getChannelUnreadState({
unreadCount: ReadStates.getUnreadCount(channelId),
mentionCount: ReadStates.getMentionCount(channelId),
isMuted: UserGuildSettings.isChannelMuted(null, channelId),
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
unreadBadgesLevel: null,
});
@@ -124,7 +124,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
const contextMenuOpen = useContextMenuHoverState(buttonRef, !isMobileExperience);
const [isFocused, setIsFocused] = useState(false);
const {keyboardModeEnabled} = KeyboardMode;
const isMuted = UserGuildSettings.isChannelMuted(null, channel.id);
const isMuted = UserGuildSettings.isChannelDirectlyMuted(null, channel.id);
const mentionCount = ReadStates.getMentionCount(channel.id);
const unreadState = resolveDMListItemUnreadState(channel.id);
const directMessageName = recipient ? NicknameUtils.getNickname(recipient, null, channel.id) : null;
@@ -313,6 +313,7 @@ export const GuildListItemPresentation = forwardRef<
[backgroundImage, draggingCursor],
);
const guildIconTarget = useMemo(() => ({borderRadius: iconBorderRadius}), [iconBorderRadius]);
const hasPaintedIcon = backgroundImage != null;
return (
<LongPressable
{...domProps}
@@ -332,6 +333,7 @@ export const GuildListItemPresentation = forwardRef<
role="button"
tabIndex={0}
data-guild-list-focus-item="true"
data-guild-id={guild.id}
onLongPress={onLongPress}
disabled={false}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-list-item.click"
@@ -363,7 +365,7 @@ export const GuildListItemPresentation = forwardRef<
tabIndex={-1}
className={clsx(
styles.guildIcon,
guild.icon == null && styles.guildIconNoImage,
!hasPaintedIcon && styles.guildIconNoImage,
isSelected && styles.guildIconSelected,
)}
to={guildIconTarget}
@@ -373,7 +375,7 @@ export const GuildListItemPresentation = forwardRef<
style={guildIconStyle}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon"
>
{guild.icon == null && (
{!hasPaintedIcon && (
<span
className={styles.guildIconInitials}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon-initials"
@@ -55,13 +55,6 @@ function resolveGuildIconBackgroundImage(iconURL: string, displayedIconURL: stri
return `url(${displayedIconURL})`;
}
function resolveInitiallyLoadedImageURL(imageURL: string): string | null {
if (ImageCacheUtils.hasImage(imageURL)) {
return imageURL;
}
return null;
}
export function useGuildListItemInteraction({
guild,
isDesktopLayout,
@@ -79,16 +72,16 @@ export function useGuildListItemInteraction({
});
const iconURL = AvatarSourceUtils.getGuildIconURL(guild, false);
const hoverIconURL = AvatarSourceUtils.getGuildIconURL(guild, true);
const [loadedAnimatedURL, setLoadedAnimatedURL] = useState<string | null>(() =>
resolveInitiallyLoadedImageURL(hoverIconURL),
);
const isAnimatableIcon = hoverIconURL !== iconURL;
const [loadedAnimatedURL, setLoadedAnimatedURL] = useState<string | null>(null);
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
useEffect(() => ImageCacheUtils.pinImage(iconURL), [iconURL]);
useEffect(() => {
if (!isAnimatableIcon) return;
if (!isHovering && !contextMenuOpen) return;
if (loadedAnimatedURL === hoverIconURL) return;
return ImageCacheUtils.loadImage(hoverIconURL, () => setLoadedAnimatedURL(hoverIconURL));
}, [contextMenuOpen, hoverIconURL, isHovering, loadedAnimatedURL]);
}, [contextMenuOpen, hoverIconURL, isAnimatableIcon, isHovering, loadedAnimatedURL]);
const handleSelect = useCallback(() => {
markDirectSelection(DirectSelectionSurface.GUILD_RAIL);
preloadChannelNow();
@@ -126,7 +119,7 @@ export function useGuildListItemInteraction({
if (!isSortingList && isMobileExperience) setBottomSheetOpen(true);
}, [isMobileExperience, isSortingList]);
const handleCloseBottomSheet = useCallback(() => setBottomSheetOpen(false), []);
const shouldPlayAnimated = (isHovering || contextMenuOpen) && loadedAnimatedURL === hoverIconURL;
const shouldPlayAnimated = isAnimatableIcon && (isHovering || contextMenuOpen) && loadedAnimatedURL === hoverIconURL;
const displayedIconURL = resolveDisplayedGuildIconURL({hoverIconURL, iconURL, shouldPlayAnimated});
const backgroundImage = resolveGuildIconBackgroundImage(iconURL, displayedIconURL);
return {
@@ -107,9 +107,9 @@ export function useGuildListItemState({
isSelected,
selectedGuildIndex,
}: UseGuildListItemStateOptions): GuildListItemState {
const hasUnreadMessages = GuildReadState.hasUnread(guild.id);
const mentionCount = GuildReadState.getMentionCount(guild.id);
const settings = UserGuildSettings.getSettings(guild.id);
const hasUnreadMessages = GuildReadState.guildIsUnread(guild.id);
const mentionCount = GuildReadState.mentionCountForGuild(guild.id);
const settings = UserGuildSettings.getSettingsForScope(guild.id);
const isMuted = settings.muted;
const muteConfig = settings.mute_config;
let muteEndTime: string | null = null;
@@ -55,10 +55,16 @@ export const YouPage = observer(({onAvatarClick}: YouPageProps) => {
return normalizeCustomStatus(Presence.getCustomStatus(user.id));
}, [user]);
const hasCustomStatus = Boolean(normalizedCustomStatus);
const staticBannerUrl = user?.banner ? AvatarUtils.getUserBannerURL({id: user.id, banner: user.banner}, false) : null;
const animatedBannerUrl = user?.banner
? AvatarUtils.getUserBannerURL({id: user.id, banner: user.banner}, true)
: null;
const userId = user?.id;
const userBanner = user?.banner;
const staticBannerUrl = useMemo(
() => (userId && userBanner ? AvatarUtils.getUserBannerURL({id: userId, banner: userBanner}, false) : null),
[userId, userBanner],
);
const animatedBannerUrl = useMemo(
() => (userId && userBanner ? AvatarUtils.getUserBannerURL({id: userId, banner: userBanner}, true) : null),
[userId, userBanner],
);
const {hoverRef: bannerHoverRef, imageUrl: bannerUrl} = useAnimatedImageUrl({
staticUrl: staticBannerUrl,
animatedUrl: animatedBannerUrl,
@@ -13,6 +13,7 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {ensureMembersForMessages} from '@app/features/messaging/commands/MessageCommands';
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy';
import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils';
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
@@ -79,6 +80,7 @@ export const ChannelPinsContent = observer(({channel, onJump}: ChannelPinsConten
const canUnpin = isDMChannel || Permission.can(Permissions.MANAGE_MESSAGES, channel);
const mobileLayout = MobileLayout;
const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolvePinnedScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const [menuOpen, setMenuOpen] = useState(false);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const pinMessagesPermissionLabel = formatPermissionLabel(i18n, Permissions.PIN_MESSAGES);
@@ -187,107 +189,114 @@ export const ChannelPinsContent = observer(({channel, onJump}: ChannelPinsConten
}
return (
<>
<Scroller
className={clsx(previewStyles.scroller, mobileLayout.enabled && previewStyles.scrollerMobile)}
key="channel-pins-scroller"
onScroll={handleScroll}
ref={scrollerRef}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="app.channel-pins-content.scroller"
>
{mobileLayout.enabled && <div className={previewStyles.topSpacer} data-flx="app.channel-pins-content.div--5" />}
{pinnedPins.slice().map(({message}) => {
const cardClasses = clsx(previewStyles.previewCard, mobileLayout.enabled && previewStyles.previewCardMobile);
const messageChannel =
Channels.getChannel(message.channelId) ?? (message.channelId === channel.id ? channel : null);
if (!messageChannel) return null;
if (mobileLayout.enabled) {
<NearViewportSurfaceContext.Provider value={resolvePinnedScrollSurface}>
<Scroller
className={clsx(previewStyles.scroller, mobileLayout.enabled && previewStyles.scrollerMobile)}
key="channel-pins-scroller"
onScroll={handleScroll}
ref={scrollerRef}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="app.channel-pins-content.scroller"
>
{mobileLayout.enabled && (
<div className={previewStyles.topSpacer} data-flx="app.channel-pins-content.div--5" />
)}
{pinnedPins.slice().map(({message}) => {
const cardClasses = clsx(
previewStyles.previewCard,
mobileLayout.enabled && previewStyles.previewCardMobile,
);
const messageChannel =
Channels.getChannel(message.channelId) ?? (message.channelId === channel.id ? channel : null);
if (!messageChannel) return null;
if (mobileLayout.enabled) {
return (
<LongPressable
key={message.id}
className={cardClasses}
data-message-id={message.id}
data-is-group-start="true"
role="button"
tabIndex={0}
onClick={() => handleTap(message)}
onKeyDown={(e) => {
if (isKeyboardActivationKey(e.key)) {
e.preventDefault();
handleTap(message);
}
}}
onLongPress={() => {
if (!canUnpin) return;
setSelectedMessage(message);
setMenuOpen(true);
}}
data-flx="app.channel-pins-content.button.tap"
>
<MessageComponent
message={message}
channel={messageChannel}
previewContext={MessagePreviewContext.LIST_POPOUT}
data-flx="app.channel-pins-content.message-component"
/>
</LongPressable>
);
}
return (
<LongPressable
<div
key={message.id}
className={cardClasses}
data-message-id={message.id}
data-is-group-start="true"
role="button"
tabIndex={0}
onClick={() => handleTap(message)}
onKeyDown={(e) => {
if (isKeyboardActivationKey(e.key)) {
e.preventDefault();
handleTap(message);
}
}}
onLongPress={() => {
if (!canUnpin) return;
setSelectedMessage(message);
setMenuOpen(true);
}}
data-flx="app.channel-pins-content.button.tap"
data-flx="app.channel-pins-content.div--6"
>
<MessageComponent
message={message}
channel={messageChannel}
previewContext={MessagePreviewContext.LIST_POPOUT}
data-flx="app.channel-pins-content.message-component"
data-flx="app.channel-pins-content.message-component--2"
/>
</LongPressable>
<div className={previewStyles.actionButtons} data-flx="app.channel-pins-content.div--7">
<FocusRing offset={-2} data-flx="app.channel-pins-content.focus-ring">
<button
type="button"
className={previewStyles.actionButton}
onClick={() => handleJump(message.channelId, message.id)}
data-flx="app.channel-pins-content.button.jump"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
</FocusRing>
{renderUnpinButton(message)}
</div>
</div>
);
}
return (
<div
key={message.id}
className={cardClasses}
data-message-id={message.id}
data-is-group-start="true"
data-flx="app.channel-pins-content.div--6"
>
<MessageComponent
message={message}
channel={messageChannel}
previewContext={MessagePreviewContext.LIST_POPOUT}
data-flx="app.channel-pins-content.message-component--2"
/>
<div className={previewStyles.actionButtons} data-flx="app.channel-pins-content.div--7">
<FocusRing offset={-2} data-flx="app.channel-pins-content.focus-ring">
<button
type="button"
className={previewStyles.actionButton}
onClick={() => handleJump(message.channelId, message.id)}
data-flx="app.channel-pins-content.button.jump"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
</FocusRing>
{renderUnpinButton(message)}
})}
{isLoading && (
<div className={previewStyles.loadingState} data-flx="app.channel-pins-content.div--8">
<Spinner data-flx="app.channel-pins-content.spinner--2" />
</div>
)}
{!hasMore && (
<div className={previewStyles.endState} data-flx="app.channel-pins-content.div--9">
<div className={previewStyles.endStateContent} data-flx="app.channel-pins-content.div--10">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="app.channel-pins-content.flag-checkered-icon"
/>
<div className={previewStyles.endStateTextContainer} data-flx="app.channel-pins-content.div--11">
<h3 className={previewStyles.endStateTitle} data-flx="app.channel-pins-content.h3--2">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="app.channel-pins-content.p--2">
{endStateDescription}
</p>
</div>
</div>
</div>
);
})}
{isLoading && (
<div className={previewStyles.loadingState} data-flx="app.channel-pins-content.div--8">
<Spinner data-flx="app.channel-pins-content.spinner--2" />
</div>
)}
{!hasMore && (
<div className={previewStyles.endState} data-flx="app.channel-pins-content.div--9">
<div className={previewStyles.endStateContent} data-flx="app.channel-pins-content.div--10">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="app.channel-pins-content.flag-checkered-icon"
/>
<div className={previewStyles.endStateTextContainer} data-flx="app.channel-pins-content.div--11">
<h3 className={previewStyles.endStateTitle} data-flx="app.channel-pins-content.h3--2">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="app.channel-pins-content.p--2">
{endStateDescription}
</p>
</div>
</div>
</div>
)}
</Scroller>
)}
</Scroller>
</NearViewportSurfaceContext.Provider>
{mobileLayout.enabled && selectedMessage && (
<MenuBottomSheet
isOpen={menuOpen}
@@ -5,7 +5,6 @@ import styles from '@app/features/app/components/shared/GroupDMAvatar.module.css
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants';
import type {Channel} from '@app/features/channel/models/Channel';
import {getGroupDMAccentColor} from '@app/features/channel/utils/GroupDMColorUtils';
import {cdnUrl} from '@app/features/messaging/utils/MessagingUrlUtils';
import Presence from '@app/features/presence/state/Presence';
import TransientPresence from '@app/features/presence/state/TransientPresence';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
@@ -147,7 +146,7 @@ function renderGroupStatusDot(status: StatusType, size: number, isTyping?: boole
width={1}
height={1}
fill={statusColor}
mask={`url(#svg-mask-status-${renderableStatus})`}
mask={`url(#flx-mask-presence-${renderableStatus})`}
data-flx="app.group-dm-avatar.render-group-status-dot.rect"
/>
</svg>
@@ -446,13 +445,9 @@ export const GroupDMAvatar: React.FC<GroupDMAvatarProps> = observer(
const user = Users.getUser(userId);
const {top, left, avatarSize} = getAvatarPosition(count, index, clusterSize);
const avatarMaskId = `${groupMaskId}-avatar-${index}`;
let avatarUrl: string;
if (user) {
avatarUrl = AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar});
} else {
const avatarIndex = index % 6;
avatarUrl = cdnUrl(`avatars/${avatarIndex}.png`);
}
const avatarUrl = user
? AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar})
: AvatarUtils.getDefaultAvatarURL(userId);
return (
<image
key={userId}
@@ -4,6 +4,7 @@ import {LongPressable} from '@app/features/app/components/LongPressable';
import styles from '@app/features/app/components/shared/MessageReactionsContent.module.css';
import {useHover} from '@app/features/app/hooks/useHover';
import reactionStyles from '@app/features/channel/components/MessageReactions.module.css';
import {ReactionImage} from '@app/features/messaging/components/ReactionImage';
import {
emojiEquals,
getEmojiName,
@@ -49,6 +50,7 @@ const ReactionFilterButton = observer(
({reaction, isSelected, onSelect, onContextMenu, showTooltip}: ReactionFilterButtonProps) => {
const {i18n} = useLingui();
const [hoverRef, isHovering] = useHover();
const emojiHasAnimatedVariant = reaction.emoji.animated === true;
const emojiName = getEmojiName(reaction.emoji);
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering});
const reactionCountText = plural(
@@ -67,7 +69,7 @@ const ReactionFilterButton = observer(
aria-pressed={isSelected}
onClick={onSelect}
onContextMenu={onContextMenu}
ref={hoverRef}
ref={emojiHasAnimatedVariant ? hoverRef : undefined}
className={clsx(
reactionStyles.reactionButton,
styles.filterButton,
@@ -80,10 +82,11 @@ const ReactionFilterButton = observer(
data-flx="app.message-reactions-content.reaction-filter-button.div"
>
{emojiUrl ? (
<img
<ReactionImage
className={clsx('emoji', reactionStyles.emoji)}
src={emojiUrl}
alt={emojiName}
aria-hidden={true}
draggable={false}
data-flx="app.message-reactions-content.reaction-filter-button.emoji"
/>
@@ -5,7 +5,8 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import type {CSSProperties, FC, ReactNode} from 'react';
import type {FC, ReactNode} from 'react';
import {useCallback, useLayoutEffect, useRef} from 'react';
const REVEAL_SPOILER_DESCRIPTOR = msg({
message: 'Reveal spoiler',
@@ -23,24 +24,31 @@ interface SpoilerOverlayProps {
label?: string;
inline?: boolean;
className?: string;
style?: CSSProperties;
}
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({
hidden,
onReveal,
children,
label,
inline,
className,
style,
}) => {
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({hidden, onReveal, children, label, inline, className}) => {
const {i18n} = useLingui();
const wrapperElementRef = useRef<HTMLDivElement | null>(null);
const pendingFocusHandoffRef = useRef(false);
const ariaLabel = label ?? i18n._(REVEAL_SPOILER_DESCRIPTOR);
const handleRevealRequest = useCallback(() => {
pendingFocusHandoffRef.current = true;
onReveal();
}, [onReveal]);
useLayoutEffect(() => {
if (hidden) {
pendingFocusHandoffRef.current = false;
return;
}
if (!pendingFocusHandoffRef.current) return;
pendingFocusHandoffRef.current = false;
wrapperElementRef.current?.focus({preventScroll: true});
}, [hidden]);
return (
<div
ref={wrapperElementRef}
tabIndex={-1}
className={clsx(styles.container, inline && styles.inline, hidden && styles.hidden, className)}
style={style}
data-flx="app.spoiler-overlay.container"
>
<div className={styles.content} aria-hidden={hidden} data-flx="app.spoiler-overlay.content">
@@ -51,7 +59,7 @@ export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({
<button
type="button"
className={styles.overlayButton}
onClick={onReveal}
onClick={handleRevealRequest}
aria-label={ariaLabel}
data-flx="app.spoiler-overlay.overlay-button.reveal"
>
@@ -7,10 +7,10 @@ import {
getEmojiAttribution,
} from '@app/features/emoji/components/emojis/EmojiAttributionSubtext';
import Emoji from '@app/features/emoji/state/Emoji';
import {buildCustomEmojiURL} from '@app/features/expressions/utils/CustomEmojiImageUrl';
import {getEmojiURL} from '@app/features/expressions/utils/EmojiUtils';
import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis';
import Guilds from '@app/features/guild/state/Guilds';
import {setUrlQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
import {usePresenceCustomStatus} from '@app/features/presence/hooks/usePresenceCustomStatus';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {EmojiTooltipContent} from '@app/features/ui/emoji_tooltip_content/EmojiTooltipContent';
@@ -22,7 +22,6 @@ import {HoverFloatingTooltipTrigger} from '@app/features/ui/tooltip/HoverFloatin
import {Tooltip, type TooltipPosition} from '@app/features/ui/tooltip/Tooltip';
import {useHoverFloatingTooltip} from '@app/features/ui/tooltip/useHoverFloatingTooltip';
import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '@app/features/user/state/CustomStatus';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {Trans} from '@lingui/react/macro';
import {PencilIcon, SmileyIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
@@ -62,14 +61,17 @@ interface ClampedStyle extends React.CSSProperties {
const sanitizeText = (text: string): string => {
return text.replace(/[\r\n]+/g, ' ').trim();
};
const getStatusEmojiAnimatable = (status: CustomStatus): boolean => {
if (!status.emojiId) {
return false;
}
return Emoji.getEmojiById(status.emojiId)?.animated ?? status.emojiAnimated ?? false;
};
const getTooltipEmojiUrl = (status: CustomStatus, animationAllowed: boolean): string | null => {
if (status.emojiId) {
const emoji = Emoji.getEmojiById(status.emojiId);
const isAnimated = (emoji?.animated ?? status.emojiAnimated ?? false) && animationAllowed;
return setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: isAnimated}), {
size: 96,
quality: 'lossless',
});
const isAnimatable = getStatusEmojiAnimatable(status);
return buildCustomEmojiURL({id: status.emojiId, animated: animationAllowed && isAnimatable});
}
if (status.emojiName) {
return getEmojiURL(status.emojiName);
@@ -99,12 +101,17 @@ const StatusEmojiWithTooltip = observer(
return `:${status.emojiName}:`;
}
if (status.emojiName) {
return UnicodeEmojis.convertSurrogateToName(status.emojiName, true, status.emojiName);
return UnicodeEmojis.nameForSurrogate(status.emojiName, true, status.emojiName);
}
return '';
};
const emojiName = getEmojiDisplayName();
const animationAllowed = useShouldAnimate({kind: 'custom_status_emoji', isHovering: true});
const isAnimatable = getStatusEmojiAnimatable(status);
const animationAllowed = useShouldAnimate({
kind: 'custom_status_emoji',
isAnimated: isAnimatable,
isHovering: isAnimatable,
});
const tooltipEmojiUrl = getTooltipEmojiUrl(status, animationAllowed);
const TriggerComponent = isButton ? 'button' : 'span';
const triggerProps = isButton
@@ -167,24 +174,15 @@ const renderStatusEmoji = (
animationAllowed: boolean = true,
): EmojiRenderResult | null => {
if (status.emojiId) {
const emoji = Emoji.getEmojiById(status.emojiId);
const altText = `:${status.emojiName}:`;
const isAnimated = (emoji?.animated ?? status.emojiAnimated ?? false) && animationAllowed;
const staticUrl = setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: false}), {
size: 96,
quality: 'lossless',
});
const animatedUrl = isAnimated
? setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: true}), {
size: 96,
quality: 'lossless',
})
: null;
const isAnimatable = getStatusEmojiAnimatable(status);
const staticUrl = buildCustomEmojiURL({id: status.emojiId, animated: false});
const animatedUrl = isAnimatable ? buildCustomEmojiURL({id: status.emojiId, animated: true}) : null;
if (alwaysAnimate && animatedUrl) {
return {
node: (
<img
src={animatedUrl}
src={animationAllowed ? animatedUrl : staticUrl}
alt={status.emojiName ?? undefined}
draggable={false}
className={clsx(styles.statusEmoji, emojiClassName)}
@@ -208,13 +206,15 @@ const renderStatusEmoji = (
className={clsx(styles.statusEmoji, styles.staticEmoji, emojiClassName)}
data-flx="app.custom-status-display.custom-status-display.render-status-emoji.status-emoji--2"
/>
<img
src={animatedUrl}
alt={status.emojiName ?? undefined}
draggable={false}
className={clsx(styles.statusEmoji, styles.animatedEmoji, emojiClassName)}
data-flx="app.custom-status-display.custom-status-display.render-status-emoji.status-emoji--3"
/>
{animationAllowed && (
<img
src={animatedUrl}
alt={status.emojiName ?? undefined}
draggable={false}
className={clsx(styles.statusEmoji, styles.animatedEmoji, emojiClassName)}
data-flx="app.custom-status-display.custom-status-display.render-status-emoji.status-emoji--3"
/>
)}
</span>
),
altText,
@@ -285,9 +285,11 @@ export const CustomStatusDisplay = observer(
checkVertical: maxLines > 1,
measureTextRange: true,
});
const emojiAnimatable = normalized ? getStatusEmojiAnimatable(normalized) : false;
const animationAllowed = useShouldAnimate({
kind: 'custom_status_emoji',
isHovering: Boolean(animateOnParentHover) || Boolean(alwaysAnimate),
isAnimated: emojiAnimatable,
isHovering: animateOnParentHover || alwaysAnimate,
});
if (!normalized) {
if (showPlaceholder && isEditable && onEdit) {
@@ -182,7 +182,7 @@
overflow: hidden;
padding-left: var(--chat-mobile-horizontal-padding);
padding-right: var(--chat-mobile-horizontal-padding);
padding-bottom: var(--messages-bottom-clearance);
padding-bottom: calc(var(--messages-bottom-clearance) - var(--message-filler-padding-bottom));
}
@media (min-width: 768px) {
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/app/components/skeleton/ChatSkeleton.module.css';
import {usePlaceholderSpecs} from '@app/features/app/components/skeleton/PlaceholderSpecs';
import {useMessageListPlaceholderSpecs} from '@app/features/app/components/skeleton/PlaceholderSpecs';
import {
ChatSkeletonChannelKind,
type ChatSkeletonPresentation,
@@ -34,8 +34,6 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
import {Fragment, useState} from 'react';
const MESSAGE_LIST_SEED_KEY = 'chat-skeleton-message-list';
const HEADER_BACK_ICON_SIZE = skeletonSurfaceVar('--channel-header-back-icon-size');
const HEADER_ICON_SIZE = skeletonSurfaceVar('--channel-header-icon-size');
const HEADER_DM_AVATAR_SIZE = remFromPx(CHANNEL_HEADER_DM_AVATAR_SIZE_PX);
@@ -176,12 +174,11 @@ export const ChatSkeleton = observer(function ChatSkeleton({presentation}: ChatS
composerActionCount = composerLayout.mobileActionCount;
composerDividerVisible = false;
}
const messageSpecs = usePlaceholderSpecs({
const messageSpecs = useMessageListPlaceholderSpecs({
channelId: presentation.channelId,
compact: messagePresentation.compact,
compactAvatarsVisible: messagePresentation.compactAvatarsVisible,
groupSpacing: messagePresentation.groupSpacingPx,
viewportHeightPx: messagePresentation.viewportHeightPx,
seedKey: MESSAGE_LIST_SEED_KEY,
});
const rootStyle: ChatSkeletonRootStyle = {
'--chat-horizontal-padding': remFromPx(messagePresentation.messageGutterPx),
@@ -59,7 +59,6 @@
}
.itemSlot {
--guild-rail-skeleton-badge-size: var(--mention-badge-small-size);
position: relative;
display: flex;
align-items: center;
@@ -82,18 +81,6 @@
min-height: var(--guild-list-item-box-size);
}
.mentionBadge {
position: absolute;
right: calc(50% - var(--guild-icon-size) / 2 - 0.25rem);
bottom: calc(50% - var(--guild-icon-size) / 2 - 0.25rem);
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface);
}
.folderSlot .mentionBadge {
right: -0.25rem;
bottom: -0.25rem;
}
.itemsTrailingGapCancel {
margin-bottom: calc(-1 * var(--guild-list-item-gap));
}
@@ -34,7 +34,6 @@ import {useState} from 'react';
const GUILD_ICON_SIZE = skeletonSurfaceVar('--guild-icon-size');
const FOLDER_ICON_SIZE = skeletonSurfaceVar('--guild-list-item-box-size');
const MENTION_BADGE_SIZE = 'var(--guild-rail-skeleton-badge-size)';
const GUILD_PLACEHOLDER_COUNT = 6;
const SHOWS_DOWNLOAD_ACTION = !Platform.isElectron && !Platform.isPWA;
const FALLBACK_ORGANIZED_ITEMS: ReadonlyArray<RememberedSkeletonGuildRailItem> = Object.freeze(
@@ -190,17 +189,6 @@ function showsSkeletonIndicator({indicator, selected}: SkeletonIndicatorVisibili
return indicator !== SkeletonGuildRailItemIndicator.NONE;
}
const MentionBadgePlaceholder = () => (
<SkeletonBlock
width={MENTION_BADGE_SIZE}
height={MENTION_BADGE_SIZE}
radius={SkeletonRadius.MEDIUM}
emphasis={SkeletonEmphasis.MUTED}
className={styles.mentionBadge}
data-flx="app.skeleton.guild-rail-skeleton.mention-badge-placeholder.mention-badge"
/>
);
const GuildPlaceholder = ({
indicator,
selected,
@@ -223,9 +211,6 @@ const GuildPlaceholder = ({
size={GUILD_ICON_SIZE}
data-flx="app.skeleton.guild-rail-skeleton.guild-placeholder.skeleton-circle"
/>
{indicator === SkeletonGuildRailItemIndicator.MENTION && (
<MentionBadgePlaceholder data-flx="app.skeleton.guild-rail-skeleton.guild-placeholder.mention-badge-placeholder" />
)}
</flx-app-guild-rail-skeleton-item-slot>
);
@@ -326,9 +311,6 @@ const CollapsedFolderPlaceholder = ({
</flx-app-guild-rail-skeleton-collapsed-folder-grid>
)}
</flx-app-guild-rail-skeleton-collapsed-folder>
{indicator === SkeletonGuildRailItemIndicator.MENTION && (
<MentionBadgePlaceholder data-flx="app.skeleton.guild-rail-skeleton.collapsed-folder-placeholder.mention-badge-placeholder" />
)}
</flx-app-guild-rail-skeleton-item-slot>
);
@@ -354,9 +336,6 @@ const ExpandedFolderGuildPlaceholder = ({
size={GUILD_ICON_SIZE}
data-flx="app.skeleton.guild-rail-skeleton.expanded-folder-guild-placeholder.skeleton-circle"
/>
{indicator === SkeletonGuildRailItemIndicator.MENTION && (
<MentionBadgePlaceholder data-flx="app.skeleton.guild-rail-skeleton.expanded-folder-guild-placeholder.mention-badge-placeholder" />
)}
</flx-app-guild-rail-skeleton-expanded-folder-guild>
);
@@ -467,7 +446,7 @@ export const GuildRailSkeleton: React.FC<GuildRailSkeletonProps> = ({
const [mountState] = useState(() => {
const rememberedLayout = getRememberedSkeletonGuildRailLayout();
const remScale = getRemScaleForDocument(document);
const liveScrollTop = Math.max(0, Dimension.getGuildListDimensions().scrollTop);
const liveScrollTop = Math.max(0, Dimension.currentGuildListDimensions().scrollTop);
let scrollTop = liveScrollTop;
if (liveScrollTop === 0 && rememberedLayout != null) {
scrollTop = rememberedLayout.scrollTopPx * remScale;

Some files were not shown because too many files have changed in this diff Show More