diff --git a/packages/nicolium/.oxlintrc.json b/packages/nicolium/.oxlintrc.json index 4bedefd5d..bff1bf1e5 100644 --- a/packages/nicolium/.oxlintrc.json +++ b/packages/nicolium/.oxlintrc.json @@ -79,7 +79,8 @@ "typescript/consistent-type-exports": [ "error", { "fixMixedExportsWithInlineTypeSpecifier": true } - ] + ], + "typescript/no-import-type-side-effects": "error" }, "settings": { "jsx-a11y": { diff --git a/packages/nicolium/favicon.svg b/packages/nicolium/favicon.svg new file mode 100644 index 000000000..f086c3800 --- /dev/null +++ b/packages/nicolium/favicon.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/packages/nicolium/package.json b/packages/nicolium/package.json index 8f7016f82..523b21e9e 100644 --- a/packages/nicolium/package.json +++ b/packages/nicolium/package.json @@ -36,7 +36,7 @@ }, "dependencies": { "@emoji-mart/data": "^1.2.1", - "@floating-ui/react": "^0.27.18", + "@floating-ui/react": "^0.27.19", "@fontsource/inter": "^5.2.8", "@fontsource/noto-sans-javanese": "^5.2.8", "@fontsource/roboto-mono": "^5.2.8", @@ -57,21 +57,20 @@ "@reach/tabs": "^0.18.0", "@react-spring/web": "^10.0.3", "@reduxjs/toolkit": "^2.11.2", - "@sentry/browser": "^10.40.0", - "@sentry/core": "^10.40.0", - "@sentry/react": "^10.40.0", + "@sentry/browser": "^10.42.0", + "@sentry/core": "^10.42.0", + "@sentry/react": "^10.42.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-pacer": "^0.20.0", "@tanstack/react-query": "^5.90.21", - "@tanstack/react-router": "^1.163.3", + "@tanstack/react-router": "^1.166.2", "@transfem-org/sfm-js": "^0.26.1", "@twemoji/svg": "^15.0.0", "@uidotdev/usehooks": "^2.4.1", "@use-gesture/react": "^10.3.1", "@yornaath/batshit": "^0.14.0", - "abortcontroller-polyfill": "^1.7.8", "autoprefixer": "^10.4.27", "blurhash": "^2.0.5", "bowser": "^2.14.1", @@ -82,6 +81,7 @@ "cryptocurrency-icons": "^0.18.1", "cssnano": "^7.1.2", "detect-passive-events": "^2.0.3", + "dompurify": "^3.3.2", "emoji-datasource": "15.0.1", "emoji-mart": "^5.6.0", "exifr": "^7.1.3", @@ -90,10 +90,8 @@ "fuzzysort": "^3.1.0", "graphemesplit": "^2.6.0", "html-react-parser": "^5.2.17", - "intersection-observer": "^0.12.2", "intl-messageformat": "^11.1.2", "intl-pluralrules": "^2.0.1", - "isomorphic-dompurify": "^3.0.0", "leaflet": "^1.9.4", "lexical": "^0.41.0", "line-awesome": "^1.3.0", @@ -104,7 +102,7 @@ "object-to-formdata": "^4.5.1", "path-browserify": "^1.0.1", "pl-api": "workspace:*", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "process": "^0.11.10", "punycode": "^2.3.1", "qrcode.react": "^4.2.0", @@ -113,7 +111,7 @@ "react-color": "^2.19.3", "react-datepicker": "^9.1.0", "react-dom": "^19.2.4", - "react-helmet-async": "^2.0.5", + "react-helmet-async": "^3.0.0", "react-hot-toast": "^2.6.0", "react-inlinesvg": "^4.2.0", "react-intl": "^8.1.3", @@ -121,11 +119,10 @@ "react-sparklines": "^1.7.0", "react-sticky-box": "^2.0.5", "react-swipeable-views": "^0.14.1", - "react-virtuoso": "^4.18.1", + "react-virtuoso": "^4.18.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.1", - "resize-observer": "^1.0.4", "sass-embedded": "^1.97.3", "stringz": "^2.1.0", "tabbable": "^6.4.0", @@ -150,7 +147,7 @@ "@types/react-dom": "^19.2.3", "@types/react-sparklines": "^1.7.5", "@types/react-swipeable-views": "^0.13.6", - "@typescript/native-preview": "7.0.0-dev.20260301.1", + "@typescript/native-preview": "7.0.0-dev.20260306.1", "@vitejs/plugin-react": "^5.1.4", "@vitest/coverage-v8": "4.0.18", "eslint-plugin-formatjs": "^6.2.0", diff --git a/packages/nicolium/src/__fixtures__/pleroma-quote-of-quote-post.json b/packages/nicolium/src/__fixtures__/pleroma-quote-of-quote-post.json deleted file mode 100644 index 1156cdb3a..000000000 --- a/packages/nicolium/src/__fixtures__/pleroma-quote-of-quote-post.json +++ /dev/null @@ -1,371 +0,0 @@ -{ - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", - "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - }, - { - "name": "Donate (PayPal)", - "value": "https://paypal.me/gleasonator" - }, - { - "name": "$BTC", - "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" - }, - { - "name": "$ETH", - "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" - }, - { - "name": "$DOGE", - "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" - }, - { - "name": "$XMR", - "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" - } - ], - "followers_count": 2220, - "following_count": 1544, - "fqn": "alex@gleasonator.com", - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "last_status_at": "2022-01-24T21:02:44", - "locked": false, - "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", - "pleroma": { - "accepts_chat_messages": true, - "also_known_as": [], - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_confirmed": true, - "is_moderator": false, - "is_suggested": true, - "relationship": {}, - "skip_thread_containment": false, - "tags": [] - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - }, - { - "name": "Donate (PayPal)", - "value": "https://paypal.me/gleasonator" - }, - { - "name": "$BTC", - "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" - }, - { - "name": "$ETH", - "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" - }, - { - "name": "$DOGE", - "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" - }, - { - "name": "$XMR", - "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" - } - ], - "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", - "pleroma": { - "actor_type": "Person", - "discoverable": false - }, - "sensitive": false - }, - "statuses_count": 23004, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Soapbox FE", - "website": "https://soapbox.pub/" - }, - "bookmarked": false, - "card": null, - "content": "

Quote of quote post

", - "created_at": "2022-01-24T21:02:43.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "AFmFNKmfrR9CxtV01g", - "in_reply_to_account_id": null, - "in_reply_to_id": null, - "language": null, - "media_attachments": [], - "mentions": [ - { - "acct": "alex", - "id": "9v5bmRalQvjOy0ECcC", - "url": "https://gleasonator.com/users/alex", - "username": "alex" - } - ], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "Quote of quote post" - }, - "conversation_id": "AFmFNKkXzLRirIVIi8", - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": null, - "local": true, - "parent_visible": false, - "pinned_at": null, - "quote": { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", - "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - }, - { - "name": "Donate (PayPal)", - "value": "https://paypal.me/gleasonator" - }, - { - "name": "$BTC", - "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" - }, - { - "name": "$ETH", - "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" - }, - { - "name": "$DOGE", - "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" - }, - { - "name": "$XMR", - "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" - } - ], - "followers_count": 2220, - "following_count": 1544, - "fqn": "alex@gleasonator.com", - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "last_status_at": "2022-01-24T21:02:44", - "locked": false, - "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", - "pleroma": { - "accepts_chat_messages": true, - "also_known_as": [], - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_confirmed": true, - "is_moderator": false, - "is_suggested": true, - "relationship": {}, - "skip_thread_containment": false, - "tags": [] - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - }, - { - "name": "Donate (PayPal)", - "value": "https://paypal.me/gleasonator" - }, - { - "name": "$BTC", - "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" - }, - { - "name": "$ETH", - "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" - }, - { - "name": "$DOGE", - "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" - }, - { - "name": "$XMR", - "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" - } - ], - "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", - "pleroma": { - "actor_type": "Person", - "discoverable": false - }, - "sensitive": false - }, - "statuses_count": 23004, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Soapbox FE", - "website": "https://soapbox.pub/" - }, - "bookmarked": false, - "card": null, - "content": "

Quote post

", - "created_at": "2022-01-24T21:02:34.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "AFmFMSpITT9xcOJKcK", - "in_reply_to_account_id": null, - "in_reply_to_id": null, - "language": null, - "media_attachments": [], - "mentions": [ - { - "acct": "alex", - "id": "9v5bmRalQvjOy0ECcC", - "url": "https://gleasonator.com/users/alex", - "username": "alex" - } - ], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "Quote post" - }, - "conversation_id": "AFmFMSnWa3k3WtTur2", - "direct_conversation_id": null, - "emoji_reactions": [ - { - "count": 1, - "me": false, - "name": "👍" - } - ], - "expires_at": null, - "in_reply_to_account_acct": null, - "local": true, - "parent_visible": false, - "pinned_at": null, - "quote": null, - "quote_url": "https://gleasonator.com/objects/4f35159c-3794-4037-9269-a7c84f7137c7", - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/54d93075-7d04-4016-a128-81f3843bca79", - "url": "https://gleasonator.com/notice/AFmFMSpITT9xcOJKcK", - "visibility": "public" - }, - "quote_url": "https://gleasonator.com/objects/54d93075-7d04-4016-a128-81f3843bca79", - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 1, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/1e2cfb5a-ece5-42df-9ec1-13e5de6d9f5b", - "url": "https://gleasonator.com/notice/AFmFNKmfrR9CxtV01g", - "visibility": "public" -} diff --git a/packages/nicolium/src/__fixtures__/pleroma-status-vertical-video-without-metadata.json b/packages/nicolium/src/__fixtures__/pleroma-status-vertical-video-without-metadata.json deleted file mode 100644 index edb24b9ef..000000000 --- a/packages/nicolium/src/__fixtures__/pleroma-status-vertical-video-without-metadata.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "account": { - "acct": "alex", - "avatar": "https://freespeechextremist.com/images/avi.png", - "avatar_static": "https://freespeechextremist.com/images/avi.png", - "bot": false, - "created_at": "2022-02-28T01:55:05.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [], - "followers_count": 1, - "following_count": 0, - "header": "https://freespeechextremist.com/images/banner.png", - "header_static": "https://freespeechextremist.com/images/banner.png", - "id": "AGv8wCadU7DqWgMqNk", - "locked": false, - "note": "I'm testing out compatibility with an older Pleroma version", - "pleroma": { - "accepts_chat_messages": true, - "ap_id": "https://freespeechextremist.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "favicon": null, - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": false, - "is_moderator": false, - "relationship": {}, - "skip_thread_containment": false, - "tags": [] - }, - "source": { - "fields": [], - "note": "I'm testing out compatibility with an older Pleroma version", - "pleroma": { - "actor_type": "Person", - "discoverable": true - }, - "sensitive": false - }, - "statuses_count": 1, - "url": "https://freespeechextremist.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "
0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm", - "created_at": "2022-04-14T19:42:48.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "AIRxLeIzncpCtsr2hs", - "in_reply_to_account_id": null, - "in_reply_to_id": null, - "language": null, - "media_attachments": [ - { - "description": "0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm", - "id": "1142674091", - "pleroma": { - "mime_type": "video/webm" - }, - "preview_url": "https://freespeechextremist.com/media/3e34b808-1c84-4ef3-ba56-67cc86b7911a/0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm?name=0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm", - "remote_url": "https://freespeechextremist.com/media/3e34b808-1c84-4ef3-ba56-67cc86b7911a/0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm?name=0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm", - "text_url": "https://freespeechextremist.com/media/3e34b808-1c84-4ef3-ba56-67cc86b7911a/0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm?name=0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm", - "type": "video", - "url": "https://freespeechextremist.com/media/3e34b808-1c84-4ef3-ba56-67cc86b7911a/0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm?name=0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm" - } - ], - "mentions": [], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm" - }, - "conversation_id": 97191096, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": null, - "local": true, - "parent_visible": false, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://freespeechextremist.com/objects/419b2cad-656a-4dbc-b2b5-94bb75e0afc8", - "url": "https://freespeechextremist.com/notice/AIRxLeIzncpCtsr2hs", - "visibility": "public" -} diff --git a/packages/nicolium/src/__fixtures__/pleroma-status-with-poll-with-emojis.json b/packages/nicolium/src/__fixtures__/pleroma-status-with-poll-with-emojis.json deleted file mode 100644 index 1e1ee1f61..000000000 --- a/packages/nicolium/src/__fixtures__/pleroma-status-with-poll-with-emojis.json +++ /dev/null @@ -1,234 +0,0 @@ -{ - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", - "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - }, - { - "name": "Donate (PayPal)", - "value": "https://paypal.me/gleasonator" - }, - { - "name": "$BTC", - "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" - }, - { - "name": "$ETH", - "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" - }, - { - "name": "$DOGE", - "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" - }, - { - "name": "$XMR", - "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" - } - ], - "followers_count": 2467, - "following_count": 1581, - "fqn": "alex@gleasonator.com", - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "last_status_at": "2022-03-11T01:33:19", - "locked": false, - "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", - "pleroma": { - "accepts_chat_messages": true, - "also_known_as": ["https://mitra.social/users/alex"], - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "birthday": "1993-07-03", - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_confirmed": true, - "is_moderator": false, - "is_suggested": true, - "relationship": {}, - "skip_thread_containment": false, - "tags": [] - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - }, - { - "name": "Donate (PayPal)", - "value": "https://paypal.me/gleasonator" - }, - { - "name": "$BTC", - "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" - }, - { - "name": "$ETH", - "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" - }, - { - "name": "$DOGE", - "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" - }, - { - "name": "$XMR", - "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" - } - ], - "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", - "pleroma": { - "actor_type": "Person", - "discoverable": false - }, - "sensitive": false - }, - "statuses_count": 23651, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Soapbox FE", - "website": "https://soapbox.pub/" - }, - "bookmarked": false, - "card": null, - "content": "

Test poll

", - "created_at": "2022-03-11T01:33:18.000Z", - "emojis": [ - { - "shortcode": "gleason_excited", - "static_url": "https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png", - "url": "https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png", - "visible_in_picker": false - }, - { - "shortcode": "soapbox", - "static_url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", - "url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", - "visible_in_picker": false - } - ], - "favourited": false, - "favourites_count": 1, - "id": "AHHue68kB59xtUv7MO", - "in_reply_to_account_id": null, - "in_reply_to_id": null, - "language": null, - "media_attachments": [], - "mentions": [], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "Test poll" - }, - "conversation_id": "AHHue65YMwbjjbQZO4", - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": null, - "local": true, - "parent_visible": false, - "pinned_at": null, - "quote": null, - "quote_url": null, - "quote_visible": false, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": { - "emojis": [ - { - "shortcode": "gleason_excited", - "static_url": "https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png", - "url": "https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png", - "visible_in_picker": false - }, - { - "shortcode": "soapbox", - "static_url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", - "url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", - "visible_in_picker": false - } - ], - "expired": false, - "expires_at": "2022-03-12T01:33:18.000Z", - "id": "AHHue67gF2JDqCQGhc", - "multiple": false, - "options": [ - { - "title": "Regular emoji 😍 ", - "votes_count": 0 - }, - { - "title": "Custom emoji :gleason_excited: ", - "votes_count": 1 - }, - { - "title": "No emoji", - "votes_count": 0 - }, - { - "title": "🤔 😮 😠 ", - "votes_count": 1 - }, - { - "title": ":soapbox:", - "votes_count": 1 - } - ], - "voters_count": 3, - "votes_count": 3 - }, - "reblog": null, - "reblogged": false, - "reblogs_count": 1, - "replies_count": 1, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/46d2ab26-3497-442b-999f-612fe717b0a3", - "url": "https://gleasonator.com/notice/AHHue68kB59xtUv7MO", - "visibility": "public" -} diff --git a/packages/nicolium/src/__fixtures__/pleroma-status-with-poll.json b/packages/nicolium/src/__fixtures__/pleroma-status-with-poll.json deleted file mode 100644 index 74e464df4..000000000 --- a/packages/nicolium/src/__fixtures__/pleroma-status-with-poll.json +++ /dev/null @@ -1,199 +0,0 @@ -{ - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", - "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - }, - { - "name": "Donate (PayPal)", - "value": "https://paypal.me/gleasonator" - }, - { - "name": "$BTC", - "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" - }, - { - "name": "$ETH", - "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" - }, - { - "name": "$DOGE", - "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" - }, - { - "name": "$XMR", - "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" - } - ], - "followers_count": 2465, - "following_count": 1581, - "fqn": "alex@gleasonator.com", - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "last_status_at": "2022-03-10T18:19:50", - "locked": false, - "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", - "pleroma": { - "accepts_chat_messages": true, - "also_known_as": ["https://mitra.social/users/alex"], - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "birthday": "1993-07-03", - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_confirmed": true, - "is_moderator": false, - "is_suggested": true, - "relationship": {}, - "skip_thread_containment": false, - "tags": [] - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - }, - { - "name": "Donate (PayPal)", - "value": "https://paypal.me/gleasonator" - }, - { - "name": "$BTC", - "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" - }, - { - "name": "$ETH", - "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" - }, - { - "name": "$DOGE", - "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" - }, - { - "name": "$XMR", - "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" - } - ], - "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", - "pleroma": { - "actor_type": "Person", - "discoverable": false - }, - "sensitive": false - }, - "statuses_count": 23648, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": null, - "bookmarked": false, - "card": null, - "content": "

What is tolerance?

", - "created_at": "2020-03-23T19:33:06.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 49, - "id": "103874034847713213", - "in_reply_to_account_id": null, - "in_reply_to_id": null, - "language": null, - "media_attachments": [], - "mentions": [], - "muted": false, - "pinned": true, - "pleroma": { - "content": { - "text/plain": "What is tolerance?" - }, - "conversation_id": "3023268", - "direct_conversation_id": null, - "emoji_reactions": [ - { - "count": 3, - "me": false, - "name": "❤️" - } - ], - "expires_at": null, - "in_reply_to_account_acct": null, - "local": true, - "parent_visible": false, - "pinned_at": "2021-11-23T01:38:44.000Z", - "quote": null, - "quote_url": null, - "quote_visible": false, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": { - "emojis": [], - "expired": true, - "expires_at": "2020-03-24T19:33:06.000Z", - "id": "4930", - "multiple": false, - "options": [ - { - "title": "Banning, censoring, and deplatforming anyone you disagree with", - "votes_count": 2 - }, - { - "title": "Promoting free speech, even for people and ideas you dislike", - "votes_count": 36 - } - ], - "voters_count": 2, - "votes_count": 38 - }, - "reblog": null, - "reblogged": false, - "reblogs_count": 27, - "replies_count": 15, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/users/alex/statuses/103874034847713213", - "url": "https://gleasonator.com/notice/103874034847713213", - "visibility": "public" -} diff --git a/packages/nicolium/src/__fixtures__/pleroma-status.json b/packages/nicolium/src/__fixtures__/pleroma-status.json deleted file mode 100644 index bf795f5e4..000000000 --- a/packages/nicolium/src/__fixtures__/pleroma-status.json +++ /dev/null @@ -1,181 +0,0 @@ -{ - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", - "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - }, - { - "name": "Donate (PayPal)", - "value": "https://paypal.me/gleasonator" - }, - { - "name": "$BTC", - "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" - }, - { - "name": "$ETH", - "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" - }, - { - "name": "$DOGE", - "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" - }, - { - "name": "$XMR", - "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" - } - ], - "followers_count": 2465, - "following_count": 1581, - "fqn": "alex@gleasonator.com", - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "last_status_at": "2022-03-10T18:19:50", - "locked": false, - "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", - "pleroma": { - "accepts_chat_messages": true, - "also_known_as": ["https://mitra.social/users/alex"], - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "birthday": "1993-07-03", - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_confirmed": true, - "is_moderator": false, - "is_suggested": true, - "relationship": {}, - "skip_thread_containment": false, - "tags": [] - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - }, - { - "name": "Donate (PayPal)", - "value": "https://paypal.me/gleasonator" - }, - { - "name": "$BTC", - "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" - }, - { - "name": "$ETH", - "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" - }, - { - "name": "$DOGE", - "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" - }, - { - "name": "$XMR", - "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" - } - ], - "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", - "pleroma": { - "actor_type": "Person", - "discoverable": false - }, - "sensitive": false - }, - "statuses_count": 23648, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": null, - "bookmarked": false, - "card": null, - "content": "

Good morning! Hope you have a wonderful day.

", - "created_at": "2020-03-23T19:33:06.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 49, - "id": "103874034845713213", - "in_reply_to_account_id": null, - "in_reply_to_id": null, - "language": null, - "media_attachments": [], - "mentions": [], - "muted": false, - "pinned": true, - "pleroma": { - "content": { - "text/plain": "What is tolerance?" - }, - "conversation_id": "3023268", - "direct_conversation_id": null, - "emoji_reactions": [ - { - "count": 3, - "me": false, - "name": "❤️" - } - ], - "expires_at": null, - "in_reply_to_account_acct": null, - "local": true, - "parent_visible": false, - "pinned_at": "2021-11-23T01:38:44.000Z", - "quote": null, - "quote_url": null, - "quote_visible": false, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 27, - "replies_count": 15, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/users/alex/statuses/103874034847713213", - "url": "https://gleasonator.com/notice/103874034847713213", - "visibility": "public" -} diff --git a/packages/nicolium/src/actions/accounts.ts b/packages/nicolium/src/actions/accounts.ts index 00c7aa1de..0b3f5adc4 100644 --- a/packages/nicolium/src/actions/accounts.ts +++ b/packages/nicolium/src/actions/accounts.ts @@ -1,11 +1,7 @@ import { getClient } from '@/api'; import type { AppDispatch, RootState } from '@/store'; -import type { CreateAccountParams, Relationship } from 'pl-api'; - -const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS' as const; -const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS' as const; -const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS' as const; +import type { CreateAccountParams } from 'pl-api'; const createAccount = (params: CreateAccountParams) => (_dispatch: AppDispatch, getState: () => RootState) => @@ -13,18 +9,4 @@ const createAccount = .settings.createAccount(params) .then((response) => ({ params, response })); -type AccountsAction = { - type: - | typeof ACCOUNT_BLOCK_SUCCESS - | typeof ACCOUNT_MUTE_SUCCESS - | typeof ACCOUNT_UNFOLLOW_SUCCESS; - relationship: Relationship; -}; - -export { - ACCOUNT_BLOCK_SUCCESS, - ACCOUNT_MUTE_SUCCESS, - ACCOUNT_UNFOLLOW_SUCCESS, - createAccount, - type AccountsAction, -}; +export { createAccount }; diff --git a/packages/nicolium/src/actions/admin.ts b/packages/nicolium/src/actions/admin.ts index 44861a11f..0cb1e8ab6 100644 --- a/packages/nicolium/src/actions/admin.ts +++ b/packages/nicolium/src/actions/admin.ts @@ -4,11 +4,9 @@ import { queryClient } from '@/queries/client'; import { queryKeys } from '@/queries/keys'; import { useComposeStore } from '@/stores/compose'; import { useModalsStore } from '@/stores/modals'; +import { useTimelinesStore } from '@/stores/timelines'; import { filterBadges, getTagDiff } from '@/utils/badges'; -import { STATUS_FETCH_SOURCE_FAIL, type StatusesAction } from './statuses'; -import { deleteFromTimelines } from './timelines'; - import type { AppDispatch, RootState } from '@/store'; import type { PleromaConfig } from 'pl-api'; @@ -74,7 +72,7 @@ const deleteStatus = (statusId: string) => (dispatch: AppDispatch, getState: () getClient(getState) .admin.statuses.deleteStatus(statusId) .then(() => { - dispatch(deleteFromTimelines(statusId)); + useTimelinesStore.getState().actions.deleteStatus(statusId); return { statusId }; }); @@ -149,9 +147,6 @@ const redactStatus = (statusId: string) => (dispatch: AppDispatch, getState: () .getState() .actions.setComposeToStatus(status, poll, source, false, null, null, true); useModalsStore.getState().actions.openModal('COMPOSE'); - }) - .catch((error) => { - dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error }); }); }; diff --git a/packages/nicolium/src/actions/auth.ts b/packages/nicolium/src/actions/auth.ts index 737085ce9..9d9f2bdd1 100644 --- a/packages/nicolium/src/actions/auth.ts +++ b/packages/nicolium/src/actions/auth.ts @@ -7,7 +7,6 @@ * @see module:@/actions/security */ import { - credentialAccountSchema, PlApiClient, type Account, type CreateAccountParams, @@ -16,7 +15,6 @@ import { type Token, } from 'pl-api'; import { defineMessages } from 'react-intl'; -import * as v from 'valibot'; import { createAccount } from '@/actions/accounts'; import { createApp } from '@/actions/apps'; @@ -185,23 +183,9 @@ const verifyCredentials = return account; }) .catch((error) => { - if (error?.response?.status === 403 && error?.response?.json?.id) { - // The user is waitlisted - const account = error.response.json; - const parsedAccount = v.parse(credentialAccountSchema, error.response.json); - queryClient.setQueryData(queryKeys.accounts.show(parsedAccount.id), parsedAccount); - dispatch({ - type: VERIFY_CREDENTIALS_SUCCESS, - token, - account: parsedAccount, - }); - if (account.id === getState().me) dispatch(fetchMeSuccess(parsedAccount)); - return parsedAccount; - } else { - if (getState().me === null) dispatch(fetchMeFail(error)); - dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error }); - throw error; - } + if (getState().me === null) dispatch(fetchMeFail(error)); + dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error }); + throw error; }); }; diff --git a/packages/nicolium/src/actions/circle.ts b/packages/nicolium/src/actions/circle.ts index 47c94d9b6..018605756 100644 --- a/packages/nicolium/src/actions/circle.ts +++ b/packages/nicolium/src/actions/circle.ts @@ -49,6 +49,8 @@ const processCircle = const response = await (next?.() ?? client.accounts.getAccountStatuses(me, { limit: 40 })); response.items.forEach((status) => { + if (!['public', 'unlisted'].includes(status.visibility)) return; + if (status.reblog) { if (status.reblog.account.id === me) return; @@ -66,6 +68,18 @@ const processCircle = const interaction = interactions[status.in_reply_to_account_id]; interaction.replies += 1; + } else if (status.quote && 'quoted_status' in status.quote && status.quote.quoted_status) { + if (status.quote.quoted_status.account.id === me) return; + + initInteraction(status.quote.quoted_status.account.id); + const interaction = interactions[status.quote.quoted_status.account.id]; + interaction.acct = status.quote.quoted_status.account.acct; + interaction.avatar = + status.quote.quoted_status.account.avatar_static || + status.quote.quoted_status.account.avatar; + interaction.avatar_description = status.quote.quoted_status.account.avatar_description; + + interaction.reblogs += 1; } }); @@ -78,6 +92,7 @@ const processCircle = response.items.forEach((status) => { if (status.account.id === me) return; + if (!['public', 'unlisted'].includes(status.visibility)) return; initInteraction(status.account.id); const interaction = interactions[status.account.id]; diff --git a/packages/nicolium/src/actions/events.ts b/packages/nicolium/src/actions/events.ts index 9af044226..05f1a540b 100644 --- a/packages/nicolium/src/actions/events.ts +++ b/packages/nicolium/src/actions/events.ts @@ -5,11 +5,6 @@ import { useComposeStore } from '@/stores/compose'; import toast from '@/toast'; import { importEntities } from './importer'; -import { - STATUS_FETCH_SOURCE_FAIL, - STATUS_FETCH_SOURCE_REQUEST, - STATUS_FETCH_SOURCE_SUCCESS, -} from './statuses'; import type { AppDispatch, RootState } from '@/store'; import type { CreateEventParams, Location, MediaAttachment } from 'pl-api'; @@ -95,21 +90,15 @@ const cancelEventCompose = () => { }; const initEventEdit = (statusId: string) => (dispatch: AppDispatch, getState: () => RootState) => { - dispatch({ type: STATUS_FETCH_SOURCE_REQUEST, statusId }); - return getClient(getState()) .statuses.getStatusSource(statusId) .then((response) => { - dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS, statusId }); useComposeStore .getState() .actions.updateCompose(`compose-event-modal-${statusId}`, (draft) => { draft.text = response.text; }); return response; - }) - .catch((error) => { - dispatch({ type: STATUS_FETCH_SOURCE_FAIL, statusId, error }); }); }; diff --git a/packages/nicolium/src/actions/frontend-config.ts b/packages/nicolium/src/actions/frontend-config.ts index 2e0bd7b2d..acd1af99d 100644 --- a/packages/nicolium/src/actions/frontend-config.ts +++ b/packages/nicolium/src/actions/frontend-config.ts @@ -28,26 +28,22 @@ const fetchFrontendConfigurations = () => (dispatch: AppDispatch, getState: () = /** Conditionally fetches Nicolium config depending on backend features */ const fetchFrontendConfig = - (host: string | null) => (dispatch: AppDispatch, getState: () => RootState) => { + (host: string | null) => async (dispatch: AppDispatch, getState: () => RootState) => { const features = getState().auth.client.features; if (features.frontendConfigurations) { - return dispatch(fetchFrontendConfigurations()).then((data) => { - const legacyKey = 'pl_fe'; - const key = 'nicolium'; + const data = await dispatch(fetchFrontendConfigurations()); + const legacyKey = 'pl_fe'; + const key = 'nicolium'; - const foundData = data[key] || data[legacyKey]; + const foundData = data[key] || data[legacyKey]; - if (foundData) { - dispatch(importFrontendConfig(foundData, host)); - return foundData; - } else { - return dispatch(fetchFrontendConfigJson(host)); - } - }); - } else { - return dispatch(fetchFrontendConfigJson(host)); + if (foundData) { + dispatch(importFrontendConfig(foundData, host)); + return foundData; + } } + return dispatch(fetchFrontendConfigJson(host)); }; /** Tries to remember the config from browser storage before fetching it */ @@ -58,7 +54,6 @@ const loadFrontendConfig = () => async (dispatch: AppDispatch, getState: () => R if (result) { dispatch(fetchFrontendConfig(host)); - return; } else { return dispatch(fetchFrontendConfig(host)); } diff --git a/packages/nicolium/src/actions/interactions.ts b/packages/nicolium/src/actions/interactions.ts deleted file mode 100644 index fa7aaaa59..000000000 --- a/packages/nicolium/src/actions/interactions.ts +++ /dev/null @@ -1,11 +0,0 @@ -const PIN_SUCCESS = 'PIN_SUCCESS' as const; - -const UNPIN_SUCCESS = 'UNPIN_SUCCESS' as const; - -type InteractionsAction = { - type: typeof PIN_SUCCESS | typeof UNPIN_SUCCESS; - statusId: string; - accountId: string; -}; - -export { PIN_SUCCESS, UNPIN_SUCCESS, type InteractionsAction }; diff --git a/packages/nicolium/src/actions/me.ts b/packages/nicolium/src/actions/me.ts index f937712b4..2b29f3238 100644 --- a/packages/nicolium/src/actions/me.ts +++ b/packages/nicolium/src/actions/me.ts @@ -87,21 +87,52 @@ const patchMe = dispatch(patchMeSuccess(response)); }); -const fetchMeSuccess = (account: CredentialAccount) => { - setSentryAccount(account); +interface MeFetchSuccessAction { + type: typeof ME_FETCH_SUCCESS; + me: CredentialAccount; +} - useSettingsStore - .getState() - .actions.loadUserSettings( - account.settings_store?.[FE_NAME] || account.settings_store?.[LEGACY_FE_NAME], - ); - useComposeStore.getState().actions.importDefaultSettings(account); +const fetchMeSuccess = + (account: CredentialAccount) => async (dispatch: AppDispatch, getState: () => RootState) => { + const client = getClient(getState); - return { - type: ME_FETCH_SUCCESS, - me: account, + setSentryAccount(account); + + const settings = account.settings_store?.[FE_NAME] || account.settings_store?.[LEGACY_FE_NAME]; + + if (settings) { + useSettingsStore.getState().actions.loadUserSettings(settings); + } + + if (!client.features.frontendConfigurations && client.features.notes) { + const note = await getClient(getState) + .accounts.getRelationships([account.id]) + .then((relationships) => relationships[0]?.note); + + if (note) { + const match = note.match(/(.*)<\/nicolium-config>/); + + if (match) { + try { + const frontendConfig = JSON.parse(decodeURIComponent(match[1])); + if (typeof frontendConfig === 'object' && frontendConfig !== null) { + frontendConfig.storeSettingsInNotes = true; + } + useSettingsStore.getState().actions.loadUserSettings(frontendConfig); + return frontendConfig; + } catch (error) { + console.error('Failed to parse frontend config from account note', error); + } + } + } + } + useComposeStore.getState().actions.importDefaultSettings(account); + + return dispatch({ + type: ME_FETCH_SUCCESS, + me: account, + }); }; -}; const fetchMeFail = (error: unknown) => ({ type: ME_FETCH_FAIL, @@ -127,7 +158,7 @@ const patchMeSuccess = (me: CredentialAccount) => (dispatch: AppDispatch) => { }; type MeAction = - | ReturnType + | MeFetchSuccessAction | ReturnType | MeFetchSkipAction | MePatchSuccessAction; diff --git a/packages/nicolium/src/actions/settings.ts b/packages/nicolium/src/actions/settings.ts index f0db1562c..fce73e8e2 100644 --- a/packages/nicolium/src/actions/settings.ts +++ b/packages/nicolium/src/actions/settings.ts @@ -10,6 +10,7 @@ import { useSettingsStore } from '@/stores/settings'; import toast from '@/toast'; import { isLoggedIn } from '@/utils/auth'; +import type { Settings } from '@/schemas/frontend-settings'; import type { AppDispatch, RootState } from '@/store'; const LEGACY_FE_NAME = NODE_ENV === 'production' ? 'pl_fe' : 'pl_fe_dev'; @@ -30,33 +31,35 @@ const saveSuccessMessage = defineMessage({ const changeSetting = (path: string[], value: any, opts?: SettingOpts) => { useSettingsStore.getState().actions.changeSetting(path, value); - if (opts?.save !== false) return saveSettings(opts); + if (opts?.save !== false) return saveSettings(opts, path[0] === 'storeSettingsInNotes'); return () => {}; }; -const saveSettings = (opts?: SettingOpts) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; +const saveSettings = + (opts?: SettingOpts, isNotesChange?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; - const { - userSettings, - actions: { userSettingsSaving }, - } = useSettingsStore.getState(); - if (userSettings.saved) return; + const { + userSettings, + actions: { userSettingsSaving }, + } = useSettingsStore.getState(); + if (userSettings.saved) return; - const { saved, ...data } = userSettings; + const { saved, ...data } = userSettings; - dispatch(updateSettingsStore(data)) - .then(() => { - userSettingsSaving(); + dispatch(updateSettingsStore(data, isNotesChange)) + .then(() => { + userSettingsSaving(); - if (opts?.showAlert) { - toast.success(saveSuccessMessage); - } - }) - .catch((error) => { - toast.showAlertForError(error); - }); -}; + if (opts?.showAlert) { + toast.success(saveSuccessMessage); + } + }) + .catch((error) => { + toast.showAlertForError(error); + }); + }; /** Update settings store for Mastodon, etc. */ const updateAuthAccount = async (url: string, settings: any) => { @@ -73,7 +76,8 @@ const updateAuthAccount = async (url: string, settings: any) => { }; const updateSettingsStore = - (settings: any) => (dispatch: AppDispatch, getState: () => RootState) => { + (settings: Partial, isNotesChange?: boolean) => + async (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); const client = getClient(state); @@ -86,6 +90,25 @@ const updateSettingsStore = }), ); } else { + if (client.features.notes && (settings.storeSettingsInNotes || isNotesChange)) { + // Inspired by Phanpy and designed for compatibility with other software doing this + // https://github.com/cheeaun/phanpy/commit/a8b5c8cd64d456d30aab09dc56da7e4e20100e67 + const note = (await client.accounts.getRelationships([state.me as string]))[0]?.note; + const settingsNote = `${encodeURIComponent(JSON.stringify(settings))}`; + + let newNote; + if (settings.storeSettingsInNotes) { + if (/(.*)<\/nicolium-config>/.test(note || '')) { + newNote = note!.replace(/(.*)<\/nicolium-config>/, settingsNote); + } else { + newNote = `${note || ''}\n\n${settingsNote}`; + } + } else { + newNote = note ? note.replace(/(.*)<\/nicolium-config>/, '') : ''; + } + client.accounts.updateAccountNote(state.me as string, newNote); + } + const accountUrl = selectOwnAccount(state)!.url; return updateAuthAccount(accountUrl, settings); diff --git a/packages/nicolium/src/actions/statuses.ts b/packages/nicolium/src/actions/statuses.ts index 9da991396..b9b988c1d 100644 --- a/packages/nicolium/src/actions/statuses.ts +++ b/packages/nicolium/src/actions/statuses.ts @@ -8,25 +8,17 @@ import { useContextStore } from '@/stores/contexts'; import { useModalsStore } from '@/stores/modals'; import { usePendingStatusesStore } from '@/stores/pending-statuses'; import { useSettingsStore } from '@/stores/settings'; +import { useTimelinesStore } from '@/stores/timelines'; import { isLoggedIn } from '@/utils/auth'; import { shouldHaveCard } from '@/utils/status'; import { importEntities } from './importer'; -import { deleteFromTimelines } from './timelines'; import type { NormalizedStatus as Status } from '@/normalizers/status'; import type { AppDispatch, RootState } from '@/store'; -import type { CreateStatusParams, Status as BaseStatus, ScheduledStatus } from 'pl-api'; +import type { CreateStatusParams, Status as BaseStatus } from 'pl-api'; import type { IntlShape } from 'react-intl'; -const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST' as const; -const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS' as const; -const STATUS_CREATE_FAIL = 'STATUS_CREATE_FAIL' as const; - -const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST' as const; -const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS' as const; -const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL' as const; - const incrementReplyCount = ( params: Pick, ) => { @@ -86,16 +78,10 @@ const createStatus = if (!params.preview) { usePendingStatusesStore.getState().actions.importStatus(params, idempotencyKey); useContextStore.getState().actions.importPendingStatus(params.in_reply_to_id, idempotencyKey); + useTimelinesStore.getState().actions.importPendingStatus(params, idempotencyKey); if (!editedId) { incrementReplyCount(params); } - dispatch({ - type: STATUS_CREATE_REQUEST, - params, - idempotencyKey, - editing: !!editedId, - redacting, - }); } const client = getClient(getState()); @@ -122,14 +108,6 @@ const createStatus = queryClient.invalidateQueries(scheduledStatusesQueryOptions); } - dispatch({ - type: STATUS_CREATE_SUCCESS, - status, - params, - idempotencyKey, - editing: !!editedId, - }); - useContextStore .getState() .actions.deletePendingStatus( @@ -137,6 +115,12 @@ const createStatus = idempotencyKey, ); + if (status.scheduled_at === null) { + useTimelinesStore.getState().actions.replacePendingStatus(idempotencyKey, status.id); + } else { + useTimelinesStore.getState().actions.deletePendingStatus(idempotencyKey); + } + // Poll the backend for the updated card if (expectsCard) { const delay = 1000; @@ -161,19 +145,13 @@ const createStatus = }) .catch((error) => { usePendingStatusesStore.getState().actions.deleteStatus(idempotencyKey); + useTimelinesStore.getState().actions.deletePendingStatus(idempotencyKey); useContextStore .getState() .actions.deletePendingStatus(params.in_reply_to_id, idempotencyKey); if (!editedId) { decrementReplyCount(params); } - dispatch({ - type: STATUS_CREATE_FAIL, - error, - params, - idempotencyKey, - editing: !!editedId, - }); throw error; }); }; @@ -186,17 +164,11 @@ const editStatus = (statusId: string) => (dispatch: AppDispatch, getState: () => ? queryClient.getQueryData(queryKeys.statuses.polls.show(status.poll_id)) : undefined; - dispatch({ type: STATUS_FETCH_SOURCE_REQUEST }); - return getClient(getState()) .statuses.getStatusSource(statusId) .then((response) => { - dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS }); useComposeStore.getState().actions.setComposeToStatus(status, poll, response); useModalsStore.getState().actions.openModal('COMPOSE'); - }) - .catch((error) => { - dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error }); }); }; @@ -235,6 +207,7 @@ const deleteStatus = .statuses.deleteStatus(statusId) .then((source) => { usePendingStatusesStore.getState().actions.deleteStatus(statusId); + useTimelinesStore.getState().actions.deleteStatus(statusId); updateStatus( statusId, (s) => { @@ -242,7 +215,6 @@ const deleteStatus = }, queryClient, ); - dispatch(deleteFromTimelines(statusId)); if (withRedraft) { useComposeStore.getState().actions.setComposeToStatus(status, poll, source, withRedraft); @@ -267,6 +239,7 @@ const deleteStatusFromGroup = .experimental.groups.deleteGroupStatus(statusId, groupId) .then(() => { usePendingStatusesStore.getState().actions.deleteStatus(statusId); + useTimelinesStore.getState().actions.deleteStatus(statusId); updateStatus( statusId, (s) => { @@ -274,7 +247,6 @@ const deleteStatusFromGroup = }, queryClient, ); - dispatch(deleteFromTimelines(statusId)); }) .catch(() => { incrementReplyCount(status); @@ -375,39 +347,7 @@ const unfilterStatus = (statusId: string) => { ); }; -type StatusesAction = - | { - type: typeof STATUS_CREATE_REQUEST; - params: CreateStatusParams; - idempotencyKey: string; - editing: boolean; - redacting: boolean; - } - | { - type: typeof STATUS_CREATE_SUCCESS; - status: BaseStatus | ScheduledStatus; - params: CreateStatusParams; - idempotencyKey: string; - editing: boolean; - } - | { - type: typeof STATUS_CREATE_FAIL; - error: unknown; - params: CreateStatusParams; - idempotencyKey: string; - editing: boolean; - } - | { type: typeof STATUS_FETCH_SOURCE_REQUEST } - | { type: typeof STATUS_FETCH_SOURCE_SUCCESS } - | { type: typeof STATUS_FETCH_SOURCE_FAIL; error: unknown }; - export { - STATUS_CREATE_REQUEST, - STATUS_CREATE_SUCCESS, - STATUS_CREATE_FAIL, - STATUS_FETCH_SOURCE_REQUEST, - STATUS_FETCH_SOURCE_SUCCESS, - STATUS_FETCH_SOURCE_FAIL, createStatus, editStatus, fetchStatus, @@ -415,5 +355,4 @@ export { deleteStatusFromGroup, toggleMuteStatus, unfilterStatus, - type StatusesAction, }; diff --git a/packages/nicolium/src/actions/timelines.ts b/packages/nicolium/src/actions/timelines.ts deleted file mode 100644 index 52721ed80..000000000 --- a/packages/nicolium/src/actions/timelines.ts +++ /dev/null @@ -1,514 +0,0 @@ -import { getLocale } from '@/actions/settings'; -import { getClient } from '@/api'; -import { queryClient } from '@/queries/client'; -import { queryKeys } from '@/queries/keys'; -import { findStatuses } from '@/queries/statuses/use-status'; -import { useComposeStore } from '@/stores/compose'; -import { useContextStore } from '@/stores/contexts'; -import { usePendingStatusesStore } from '@/stores/pending-statuses'; -import { useSettingsStore } from '@/stores/settings'; -import { shouldFilter } from '@/utils/timelines'; - -import { importEntities } from './importer'; - -import type { AppDispatch, RootState } from '@/store'; -import type { - Account as BaseAccount, - AntennaTimelineParams, - GetAccountStatusesParams, - GetCircleStatusesParams, - GroupTimelineParams, - HashtagTimelineParams, - HomeTimelineParams, - ListTimelineParams, - PaginatedResponse, - PublicTimelineParams, - Status as BaseStatus, - LinkTimelineParams, -} from 'pl-api'; - -const TIMELINE_UPDATE = 'TIMELINE_UPDATE' as const; -const TIMELINE_DELETE = 'TIMELINE_DELETE' as const; -const TIMELINE_CLEAR = 'TIMELINE_CLEAR' as const; -const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE' as const; -const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE' as const; -const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP' as const; - -const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST' as const; -const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS' as const; -const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL' as const; - -const MAX_QUEUED_ITEMS = 40; - -const processTimelineUpdate = - (timeline: string, status: BaseStatus) => (dispatch: AppDispatch, getState: () => RootState) => { - const me = getState().me; - const ownStatus = status.account?.id === me; - - const hasPendingStatuses = Object.keys(usePendingStatusesStore.getState().statuses).length > 0; - - const columnSettings = useSettingsStore.getState().settings.timelines[timeline]; - const shouldSkipQueue = shouldFilter( - { - in_reply_to_id: status.in_reply_to_id, - visibility: status.visibility, - reblog_id: status.reblog?.id ?? null, - }, - columnSettings, - ); - - if (ownStatus && hasPendingStatuses) { - // WebSockets push statuses without the Idempotency-Key, - // so if we have pending statuses, don't import it from here. - // We implement optimistic non-blocking statuses. - return; - } - - importEntities({ statuses: [status] }); - - if (shouldSkipQueue) { - dispatch(updateTimeline(timeline, status.id)); - } else { - dispatch(updateTimelineQueue(timeline, status.id)); - } - }; - -const updateTimeline = (timeline: string, statusId: string) => ({ - type: TIMELINE_UPDATE, - timeline, - statusId, -}); - -const updateTimelineQueue = (timeline: string, statusId: string) => ({ - // if (typeof accept === 'function' && !accept(status)) { - // return; - // } - type: TIMELINE_UPDATE_QUEUE, - timeline, - statusId, -}); - -interface TimelineDequeueAction { - type: typeof TIMELINE_DEQUEUE; - timeline: string; -} - -const dequeueTimeline = - (timelineId: string, expandFunc?: () => void) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const queuedCount = state.timelines[timelineId]?.totalQueuedItemsCount || 0; - - if (queuedCount <= 0) return; - - if (queuedCount <= MAX_QUEUED_ITEMS) { - dispatch({ type: TIMELINE_DEQUEUE, timeline: timelineId }); - return; - } - - if (typeof expandFunc === 'function') { - dispatch(clearTimeline(timelineId)); - expandFunc(); - } else if (timelineId === 'home') { - dispatch(clearTimeline(timelineId)); - dispatch(fetchHomeTimeline()); - } else if (timelineId === 'public:local') { - dispatch(clearTimeline(timelineId)); - dispatch(fetchPublicTimeline({ local: true })); - } - }; - -interface TimelineDeleteAction { - type: typeof TIMELINE_DELETE; - statusId: string; - accountId: string; - references: Array<[string, string]>; - reblogOf: string | null; -} - -const deleteFromTimelines = (statusId: string) => (dispatch: AppDispatch) => { - const status = queryClient.getQueryData(queryKeys.statuses.show(statusId)); - const accountId = status?.account_id ?? ''; - const references: Array<[string, string]> = findStatuses((s) => s.reblog_id === statusId).map( - ([id, s]) => [id, s.account_id], - ); - const reblogOf = status?.reblog_id ?? null; - - useContextStore.getState().actions.deleteStatuses([statusId]); - useComposeStore.getState().actions.handleTimelineDelete(statusId); - - // Remove statuses from RQ cache - references.forEach(([refId]) => - queryClient.removeQueries({ queryKey: queryKeys.statuses.show(refId) }), - ); - queryClient.removeQueries({ queryKey: queryKeys.statuses.show(statusId) }); - - dispatch({ - type: TIMELINE_DELETE, - statusId, - accountId, - references, - reblogOf, - }); -}; - -const clearTimeline = (timeline: string) => ({ type: TIMELINE_CLEAR, timeline }); - -const noOp = () => {}; - -const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none') => - (tags[mode] || []).map((tag) => tag.value); - -const deduplicateStatuses = (statuses: Array) => { - const deduplicatedStatuses: Array }> = []; - - for (const status of statuses) { - const reblogged = - status.reblog && - deduplicatedStatuses.find( - (deduplicatedStatus) => deduplicatedStatus.reblog?.id === status.reblog?.id, - ); - - if (reblogged) { - reblogged.accounts.push(status.account); - reblogged.id += ':' + status.id; - } else if ( - !deduplicatedStatuses.some( - (deduplicatedStatus) => deduplicatedStatus.reblog?.id === status.id, - ) - ) { - deduplicatedStatuses.push({ accounts: [status.account], ...status }); - } - } - - return deduplicatedStatuses; -}; - -const handleTimelineExpand = - ( - timelineId: string, - fn: Promise>, - done = noOp, - onError?: (error: any) => void, - ) => - async (dispatch: AppDispatch) => { - dispatch(expandTimelineRequest(timelineId)); - - try { - const response = await fn; - - importEntities({ statuses: response.items }); - - const statuses = deduplicateStatuses(response.items); - importEntities({ statuses: statuses.filter((status) => status.accounts) }); - - dispatch( - expandTimelineSuccess( - timelineId, - statuses, - response.next, - response.previous, - response.partial, - ), - ); - done(); - } catch (error) { - dispatch(expandTimelineFail(timelineId, error)); - done(); - onError?.(error); - } - }; - -const fetchHomeTimeline = - (expand = false, done = noOp) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - - const params: HomeTimelineParams = {}; - if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - if (expand && state.timelines.home?.isLoading) return; - - const fn = - (expand && state.timelines.home?.next?.()) || getClient(state).timelines.homeTimeline(params); - - return dispatch(handleTimelineExpand('home', fn, done)); - }; - -const fetchPublicTimeline = - ( - { onlyMedia, local, instance }: Record = {}, - expand = false, - done = noOp, - onError = noOp, - ) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const timelineId = `${instance ? 'remote' : 'public'}${local ? ':local' : ''}${onlyMedia ? ':media' : ''}${instance ? `:${instance}` : ''}`; - - const params: PublicTimelineParams = { - only_media: onlyMedia, - local: instance ? false : local, - instance, - }; - if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - if (expand && state.timelines[timelineId]?.isLoading) return; - - const fn = - (expand && state.timelines[timelineId]?.next?.()) || - getClient(state).timelines.publicTimeline(params); - - return dispatch(handleTimelineExpand(timelineId, fn, done, onError)); - }; - -const fetchBubbleTimeline = - ({ onlyMedia }: Record = {}, expand = false, done = noOp) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const timelineId = `bubble${onlyMedia ? ':media' : ''}`; - - const params: PublicTimelineParams = { only_media: onlyMedia }; - if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - if (expand && state.timelines[timelineId]?.isLoading) return; - - const fn = - (expand && state.timelines[timelineId]?.next?.()) || - getClient(state).timelines.bubbleTimeline(params); - - return dispatch(handleTimelineExpand(timelineId, fn, done)); - }; - -const fetchWrenchedTimeline = - ({ onlyMedia }: Record = {}, expand = false, done = noOp) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const timelineId = `wrenched${onlyMedia ? ':media' : ''}`; - - const params: PublicTimelineParams = { only_media: onlyMedia }; - if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - if (expand && state.timelines[timelineId]?.isLoading) return; - - const fn = - (expand && state.timelines[timelineId]?.next?.()) || - getClient(state).timelines.wrenchedTimeline(params); - - return dispatch(handleTimelineExpand(timelineId, fn, done)); - }; - -const fetchAccountTimeline = - ( - accountId: string, - { exclude_replies, pinned, only_media, limit }: Record = {}, - expand = false, - done = noOp, - ) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const timelineId = `account:${accountId}${!exclude_replies ? ':with_replies' : ''}${pinned ? ':pinned' : only_media ? ':media' : ''}`; - - const params: GetAccountStatusesParams = { exclude_replies, pinned, only_media, limit }; - if (pinned || only_media) params.with_muted = true; - if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - if (!expand && state.timelines[timelineId]?.loaded) return; - if (expand && state.timelines[timelineId]?.isLoading) return; - - const fn = - (expand && state.timelines[timelineId]?.next?.()) || - getClient(state).accounts.getAccountStatuses(accountId, params); - - return dispatch(handleTimelineExpand(timelineId, fn, done)); - }; - -const fetchListTimeline = - (listId: string, expand = false, done = noOp) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const timelineId = `list:${listId}`; - - const params: ListTimelineParams = {}; - if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - if (expand && state.timelines[timelineId]?.isLoading) return; - - const fn = - (expand && state.timelines[timelineId]?.next?.()) || - getClient(state).timelines.listTimeline(listId, params); - - return dispatch(handleTimelineExpand(timelineId, fn, done)); - }; - -const fetchCircleTimeline = - (circleId: string, expand = false, done = noOp) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const timelineId = `circle:${circleId}`; - - const params: GetCircleStatusesParams = {}; - // if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - if (expand && state.timelines[timelineId]?.isLoading) return; - - const fn = - (expand && state.timelines[timelineId]?.next?.()) || - getClient(state).circles.getCircleStatuses(circleId, params); - - return dispatch(handleTimelineExpand(timelineId, fn, done)); - }; - -const fetchAntennaTimeline = - (antennaId: string, expand = false, done = noOp) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const timelineId = `antenna:${antennaId}`; - - const params: AntennaTimelineParams = {}; - // if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - if (expand && state.timelines[timelineId]?.isLoading) return; - - const fn = - (expand && state.timelines[timelineId]?.next?.()) || - getClient(state).timelines.antennaTimeline(antennaId, params); - - return dispatch(handleTimelineExpand(timelineId, fn, done)); - }; - -const fetchGroupTimeline = - (groupId: string, { only_media, limit }: Record = {}, expand = false, done = noOp) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const timelineId = `group:${groupId}${only_media ? ':media' : ''}`; - - const params: GroupTimelineParams = { only_media, limit }; - if (only_media) params.with_muted = true; - if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - if (expand && state.timelines[timelineId]?.isLoading) return; - - const fn = - (expand && state.timelines[timelineId]?.next?.()) || - getClient(state).timelines.groupTimeline(groupId, params); - - return dispatch(handleTimelineExpand(timelineId, fn, done)); - }; - -const fetchHashtagTimeline = - (hashtag: string, { tags }: Record = {}, expand = false, done = noOp) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const timelineId = `hashtag:${hashtag}`; - - const params: HashtagTimelineParams = { - any: parseTags(tags, 'any'), - all: parseTags(tags, 'all'), - none: parseTags(tags, 'none'), - }; - - if (expand && state.timelines[timelineId]?.isLoading) return; - - if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - const fn = - (expand && state.timelines[timelineId]?.next?.()) || - getClient(state).timelines.hashtagTimeline(hashtag, params); - - return dispatch(handleTimelineExpand(timelineId, fn, done)); - }; - -const fetchLinkTimeline = - (url: string, expand = false, done = noOp) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const timelineId = `link:${url}`; - - const params: LinkTimelineParams = {}; - - if (expand && state.timelines[timelineId]?.isLoading) return; - - if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale(); - - const fn = - (expand && state.timelines[timelineId]?.next?.()) || - getClient(state).timelines.linkTimeline(url, params); - - return dispatch(handleTimelineExpand(timelineId, fn, done)); - }; - -const expandTimelineRequest = (timeline: string) => ({ - type: TIMELINE_EXPAND_REQUEST, - timeline, -}); - -const expandTimelineSuccess = ( - timeline: string, - statuses: Array, - next: (() => Promise>) | null, - prev: (() => Promise>) | null, - partial: boolean, -) => ({ - type: TIMELINE_EXPAND_SUCCESS, - timeline, - statuses, - next, - prev, - partial, -}); - -const expandTimelineFail = (timeline: string, error: unknown) => ({ - type: TIMELINE_EXPAND_FAIL, - timeline, - error, -}); - -const scrollTopTimeline = (timeline: string, top: boolean) => ({ - type: TIMELINE_SCROLL_TOP, - timeline, - top, -}); - -// TODO: other actions -type TimelineAction = - | ReturnType - | TimelineDeleteAction - | ReturnType - | ReturnType - | TimelineDequeueAction - | ReturnType - | ReturnType - | ReturnType - | ReturnType; - -export { - TIMELINE_UPDATE, - TIMELINE_DELETE, - TIMELINE_CLEAR, - TIMELINE_UPDATE_QUEUE, - TIMELINE_DEQUEUE, - TIMELINE_SCROLL_TOP, - TIMELINE_EXPAND_REQUEST, - TIMELINE_EXPAND_SUCCESS, - TIMELINE_EXPAND_FAIL, - MAX_QUEUED_ITEMS, - processTimelineUpdate, - dequeueTimeline, - deleteFromTimelines, - clearTimeline, - fetchHomeTimeline, - fetchPublicTimeline, - fetchBubbleTimeline, - fetchWrenchedTimeline, - fetchAccountTimeline, - fetchListTimeline, - fetchCircleTimeline, - fetchAntennaTimeline, - fetchGroupTimeline, - fetchHashtagTimeline, - fetchLinkTimeline, - expandTimelineSuccess, - scrollTopTimeline, - type TimelineAction, -}; diff --git a/packages/nicolium/src/api/hooks/streaming/use-bubble-stream.ts b/packages/nicolium/src/api/hooks/streaming/use-bubble-stream.ts deleted file mode 100644 index 7415abc46..000000000 --- a/packages/nicolium/src/api/hooks/streaming/use-bubble-stream.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useTimelineStream } from './use-timeline-stream'; - -interface UseBubbleStreamOpts { - onlyMedia?: boolean; - enabled?: boolean; -} - -const useBubbleStream = ({ onlyMedia, enabled }: UseBubbleStreamOpts = {}) => - useTimelineStream(`bubble${onlyMedia ? ':media' : ''}`, {}, enabled); - -export { useBubbleStream }; diff --git a/packages/nicolium/src/api/hooks/streaming/use-community-stream.ts b/packages/nicolium/src/api/hooks/streaming/use-community-stream.ts deleted file mode 100644 index 7ec260687..000000000 --- a/packages/nicolium/src/api/hooks/streaming/use-community-stream.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useTimelineStream } from './use-timeline-stream'; - -interface UseCommunityStreamOpts { - onlyMedia?: boolean; - enabled?: boolean; -} - -const useCommunityStream = ({ onlyMedia, enabled }: UseCommunityStreamOpts = {}) => - useTimelineStream(`public:local${onlyMedia ? ':media' : ''}`, {}, enabled); - -export { useCommunityStream }; diff --git a/packages/nicolium/src/api/hooks/streaming/use-group-stream.ts b/packages/nicolium/src/api/hooks/streaming/use-group-stream.ts deleted file mode 100644 index 63f2a1f1e..000000000 --- a/packages/nicolium/src/api/hooks/streaming/use-group-stream.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useTimelineStream } from './use-timeline-stream'; - -const useGroupStream = (groupId: string) => useTimelineStream('group', { group: groupId }); - -export { useGroupStream }; diff --git a/packages/nicolium/src/api/hooks/streaming/use-hashtag-stream.ts b/packages/nicolium/src/api/hooks/streaming/use-hashtag-stream.ts deleted file mode 100644 index 9cd4d4115..000000000 --- a/packages/nicolium/src/api/hooks/streaming/use-hashtag-stream.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useTimelineStream } from './use-timeline-stream'; - -const useHashtagStream = (tag: string) => useTimelineStream('hashtag', { tag }); - -export { useHashtagStream }; diff --git a/packages/nicolium/src/api/hooks/streaming/use-list-stream.ts b/packages/nicolium/src/api/hooks/streaming/use-list-stream.ts deleted file mode 100644 index 59829a0bc..000000000 --- a/packages/nicolium/src/api/hooks/streaming/use-list-stream.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useLoggedIn } from '@/hooks/use-logged-in'; - -import { useTimelineStream } from './use-timeline-stream'; - -const useListStream = (listId: string) => { - const { isLoggedIn } = useLoggedIn(); - - return useTimelineStream('list', { list: listId }, isLoggedIn); -}; - -export { useListStream }; diff --git a/packages/nicolium/src/api/hooks/streaming/use-public-stream.ts b/packages/nicolium/src/api/hooks/streaming/use-public-stream.ts deleted file mode 100644 index 18b2a6a8a..000000000 --- a/packages/nicolium/src/api/hooks/streaming/use-public-stream.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useTimelineStream } from './use-timeline-stream'; - -interface UsePublicStreamOpts { - onlyMedia?: boolean; -} - -const usePublicStream = ({ onlyMedia }: UsePublicStreamOpts = {}) => - useTimelineStream(`public${onlyMedia ? ':media' : ''}`); - -export { usePublicStream }; diff --git a/packages/nicolium/src/api/hooks/streaming/use-remote-stream.ts b/packages/nicolium/src/api/hooks/streaming/use-remote-stream.ts deleted file mode 100644 index f737ce389..000000000 --- a/packages/nicolium/src/api/hooks/streaming/use-remote-stream.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useTimelineStream } from './use-timeline-stream'; - -interface UseRemoteStreamOpts { - instance: string; - onlyMedia?: boolean; -} - -const useRemoteStream = ({ instance, onlyMedia }: UseRemoteStreamOpts) => - useTimelineStream(`public:remote${onlyMedia ? ':media' : ''}`, { instance }); - -export { useRemoteStream }; diff --git a/packages/nicolium/src/api/hooks/streaming/use-user-stream.ts b/packages/nicolium/src/api/hooks/streaming/use-user-stream.ts index 51f181100..fb6140801 100644 --- a/packages/nicolium/src/api/hooks/streaming/use-user-stream.ts +++ b/packages/nicolium/src/api/hooks/streaming/use-user-stream.ts @@ -1,7 +1,6 @@ import { useCallback } from 'react'; import { importEntities } from '@/actions/importer'; -import { deleteFromTimelines, processTimelineUpdate } from '@/actions/timelines'; import { useStatContext } from '@/contexts/stat-context'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useLoggedIn } from '@/hooks/use-logged-in'; @@ -11,6 +10,7 @@ import { updateConversations } from '@/queries/conversations/use-conversations'; import { queryKeys } from '@/queries/keys'; import { useProcessStreamNotification } from '@/queries/notifications/use-notifications'; import { useSettings } from '@/stores/settings'; +import { useTimelinesStore } from '@/stores/timelines'; import { getUnreadChatsCount, updateChatListItem } from '@/utils/chats'; import { play, soundCache } from '@/utils/sounds'; @@ -110,14 +110,17 @@ const useUserStream = () => { const listener = useCallback((event: StreamingEvent) => { switch (event.event) { - case 'update': - dispatch(processTimelineUpdate(getTimelineFromStream(event.stream), event.payload)); + case 'update': { + const timelineId = getTimelineFromStream(event.stream); + importEntities({ statuses: [event.payload] }); + useTimelinesStore.getState().actions.receiveStreamingStatus(timelineId, event.payload); break; + } case 'status.update': importEntities({ statuses: [event.payload] }); break; case 'delete': - dispatch(deleteFromTimelines(event.payload)); + useTimelinesStore.getState().actions.deleteStatus(event.payload); break; case 'notification': processStreamNotification(event.payload); @@ -159,12 +162,16 @@ const useUserStream = () => { case 'announcement.delete': deleteAnnouncement(event.payload); break; - case 'marker': - queryClient.setQueryData( - queryKeys.markers.notifications, - event.payload.notifications ?? null, - ); + case 'marker': { + for (const timeline in event.payload) { + queryClient.setQueryData( + queryKeys.markers.timeline(timeline as 'home' | 'notifications'), + event.payload[timeline] ?? null, + ); + } + break; + } } }, []); diff --git a/packages/nicolium/src/columns/timeline.tsx b/packages/nicolium/src/columns/timeline.tsx index 2dfcd6c3a..a2937e029 100644 --- a/packages/nicolium/src/columns/timeline.tsx +++ b/packages/nicolium/src/columns/timeline.tsx @@ -1,15 +1,29 @@ +import { Link } from '@tanstack/react-router'; import clsx from 'clsx'; -import React from 'react'; +import React, { useMemo, useRef, useState } from 'react'; +import { defineMessages, FormattedList, FormattedMessage, useIntl } from 'react-intl'; -import LoadMore from '@/components/load-more'; -import ScrollableList from '@/components/scrollable-list'; -import Status from '@/components/statuses/status'; +import ScrollTopButton from '@/components/scroll-top-button'; +import ScrollableList, { type IScrollableList } from '@/components/scrollable-list'; +import Status, { StatusFollowedTagInfo } from '@/components/statuses/status'; +import StatusInfo from '@/components/statuses/status-info'; import Tombstone from '@/components/statuses/tombstone'; +import Button from '@/components/ui/button'; +import Icon from '@/components/ui/icon'; +import Portal from '@/components/ui/portal'; +import Stack from '@/components/ui/stack'; +import Emojify from '@/features/emoji/emojify'; import PlaceholderStatus from '@/features/placeholder/components/placeholder-status'; -import { useStatus } from '@/queries/statuses/use-status'; +import PendingStatus from '@/features/ui/components/pending-status'; +import { useAppSelector } from '@/hooks/use-app-selector'; +import { useFeatures } from '@/hooks/use-features'; +import { useAccounts } from '@/queries/accounts/use-accounts'; +import { type SelectedStatus, useStatus } from '@/queries/statuses/use-status'; import { + useAccountTimeline, useAntennaTimeline, useBubbleTimeline, + useCircleTimeline, useGroupTimeline, useHashtagTimeline, useHomeTimeline, @@ -18,17 +32,270 @@ import { usePublicTimeline, useWrenchedTimeline, } from '@/queries/timelines/use-timelines'; +import { selectChild } from '@/utils/scroll-utils'; import type { FilterContextType } from '@/queries/settings/use-filters'; -import type { TimelineEntry } from '@/queries/timelines/use-timeline'; +import type { TimelineEntry } from '@/stores/timelines'; +import type { VirtuosoHandle } from 'react-virtuoso'; + +const messages = defineMessages({ + queue: { + id: 'status_list.queue_label', + defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}', + }, + queueLiveRegion: { + id: 'status_list.queue_label.live_region', + defaultMessage: '{count} new {count, plural, one {post} other {posts}}.', + }, + gapExplanation: { + id: 'timeline.gap.explanation', + defaultMessage: 'Time elapsed between the two posts surrounding the gap.', + }, + gapExplanationFirst: { + id: 'timeline.gap.explanation_first', + defaultMessage: 'Time elapsed since the post before the gap.', + }, +}); + +const SkipPinned: React.FC> = ({ onClick }) => { + return ( + + ); +}; + +const PlaceholderTimelineStatus = () => ( +
+ +
+); + +interface ITimelinePendingStatus { + idempotencyKey: string; +} + +const TimelinePendingStatus: React.FC = ({ idempotencyKey }) => { + return ( +
+ +
+ ); +}; + +interface ITimelineGap { + gap: Extract; + onFillGap: ( + gap: Extract, + direction: 'up' | 'down', + ) => Promise; + firstEntry: boolean; +} + +const TimelineGap: React.FC = ({ gap, onFillGap, firstEntry }) => { + const [isLoading, setIsLoading] = useState(false); + const intl = useIntl(); + + const handleFill = async (direction: 'up' | 'down') => { + setIsLoading(true); + try { + await onFillGap(gap, direction); + } finally { + setIsLoading(false); + } + }; + + const renderTimeDistance = () => { + if (!gap.minDate) return null; + + const maxDate = gap.maxIdDate ? new Date(gap.maxIdDate) : new Date(); + const minDate = new Date(gap.minDate); + + const diff = Math.abs(maxDate.getTime() - minDate.getTime()); + if (diff < 60 * 1000) { + return ( + + ); + } else if (diff < 60 * 60 * 1000) { + const minutes = Math.round(diff / (60 * 1000)); + return ( + + ); + } else if (diff < 24 * 60 * 60 * 1000) { + const hours = Math.round(diff / (60 * 60 * 1000)); + return ( + + ); + } else if (diff < 30 * 24 * 60 * 60 * 1000) { + const days = Math.round(diff / (24 * 60 * 60 * 1000)); + return ( + + ); + } else if (diff < 365 * 24 * 60 * 60 * 1000) { + const months = Math.round(diff / (30 * 24 * 60 * 60 * 1000)); + return ( + + ); + } else { + const years = Math.round(diff / (365 * 24 * 60 * 60 * 1000)); + return ( + + ); + } + }; + + return ( + + +
+ + {renderTimeDistance()} + +
+ +
+ ); +}; + +interface ITimelineStatusInfo { + status: SelectedStatus; + rebloggedBy: Array; + timelineId: string; +} + +const TimelineStatusInfo: React.FC = ({ status, rebloggedBy, timelineId }) => { + const features = useFeatures(); + const isReblogged = rebloggedBy.length > 0; + + const { data: accounts } = useAccounts(rebloggedBy); + + if (isReblogged) { + const renderedAccounts = accounts.slice(0, 2).map( + (account) => + !!account && ( + + + + + + + + ), + ); + + if (accounts.length > 2) { + renderedAccounts.push( + , + ); + } + + const values = { + name: , + count: accounts.length, + }; + + return ( + + } + text={ + // status.visibility === 'private' ? ( + // + // ) : ( + + // ) + } + /> + ); + } + if (timelineId.split(':')[0] === 'home' && features.followHashtags) { + return ; + } +}; interface ITimelineStatus { id: string; + rebloggedBy: Array; + timelineId: string; contextType?: FilterContextType; isConnectedTop?: boolean; isConnectedBottom?: boolean; - onMoveUp?: (id: string) => void; - onMoveDown?: (id: string) => void; + onMoveUp?: (id: string) => void | boolean; + onMoveDown?: (id: string) => void | boolean; + featured?: boolean; } /** Status with reply-connector in threads. */ @@ -62,33 +329,102 @@ const TimelineStatus: React.FC = (props): React.JSX.Element => ); }; + const connector = renderConnector(); + + const status = statusQuery.isPending ? ( + + ) : statusQuery.data ? ( + + ) : null; + return (
- {renderConnector()} - {statusQuery.isPending ? ( - - ) : statusQuery.data ? ( - - ) : null} + {statusQuery.data && ( + + )} + {connector ? ( +
+ {connector} + {status} +
+ ) : ( + status + )}
); }; -interface ITimeline { +type IBaseTimeline = Pick< + IScrollableList, + 'emptyMessageIcon' | 'emptyMessageText' | 'onTopItemChanged' +> & { + featuredStatusIds?: Array; +}; + +interface ITimeline extends IBaseTimeline { query: ReturnType; contextType?: FilterContextType; } -const Timeline: React.FC = ({ query, contextType = 'public' }) => { - const { data, handleLoadMore, isLoading } = query; +const Timeline: React.FC = ({ + query, + contextType = 'public', + featuredStatusIds, + ...props +}) => { + const node = useRef(null); - const renderEntry = (entry: TimelineEntry) => { + const { + timelineId, + entries, + queuedCount, + fetchNextPage, + dequeueEntries, + fillGap, + isFetching, + isPending, + hasNextPage, + } = query; + + const handleMoveUp = (index: number) => { + selectChild(index - 1, node, document.getElementById('status-list') ?? undefined); + }; + + const handleMoveDown = (index: number) => { + selectChild( + index + 1, + node, + document.getElementById('status-list') ?? undefined, + entries.length, + ); + }; + + const handleSkipPinned = () => { + const skipPinned = () => { + selectChild( + featuredStatusIds?.length ?? 0, + node, + document.getElementById('status-list') ?? undefined, + (featuredStatusIds?.length ?? 0) + entries.length, + 'start', + ); + }; + + skipPinned(); + + setTimeout(() => skipPinned, 0); + }; + + const renderEntry = (entry: TimelineEntry, index: number) => { if (entry.type === 'status') { return ( = ({ query, contextType = 'public' }) => { isConnectedTop={entry.isConnectedTop} isConnectedBottom={entry.isConnectedBottom} contextType={contextType} - // onMoveUp={handleMoveUp} - // onMoveDown={handleMoveDown} - // contextType={timelineId} + onMoveUp={() => handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + rebloggedBy={entry.rebloggedBy} + timelineId={timelineId} // showGroup={showGroup} - // variant={divideType === 'border' ? 'slim' : 'rounded'} - // fromBookmarks={other.scrollKey === 'bookmarked_statuses'} /> ); - } - if (entry.type === 'page-end' || entry.type === 'page-start') { + } else if (entry.type === 'pending-status') { + return ; + } else if (entry.type === 'gap') { return ( -
- handleLoadMore(entry)} disabled={isLoading} /> -
+ ); } }; + const renderedEntries = useMemo(() => { + const rendered = []; + + if (featuredStatusIds && featuredStatusIds.length > 0) { + for (const id of featuredStatusIds) { + const index = rendered.length; + rendered.push( + handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + rebloggedBy={[]} + timelineId={timelineId} + featured + />, + ); + } + } + + for (const entry of entries) { + rendered.push(renderEntry(entry, rendered.length)); + } + + return rendered; + }, [entries, contextType, timelineId, featuredStatusIds]); + return ( - } - placeholderCount={20} - // className={className} - // listClassName={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', { - // 'divide-none': divideType !== 'border', - // })} - // itemClassName={clsx({ - // 'pb-3': divideType !== 'border', - // })} - // {...other} - > - {(data || []).map(renderEntry)} - + <> + + + + {featuredStatusIds && featuredStatusIds.length > 3 && entries?.length > 0 && ( + + )} + } + placeholderCount={20} + ref={node} + hasMore={hasNextPage} + onLoadMore={fetchNextPage} + {...props} + > + {renderedEntries} + + ); }; -const HomeTimelineColumn = () => { - const timelineQuery = useHomeTimeline(); - - return ; +const savePosition = (me: string, entry: TimelineEntry, index: number) => { + if (!entry || entry.type !== 'status') return; + return localStorage.setItem( + `nicolium:${me}:homeTimelinePosition`, + `${entry.originalId}|${index}|${Date.now()}`, + ); }; -interface IPublicTimelineColumn { +const getRestoredPosition = (me: string) => { + const markerKey = `nicolium:${me}:homeTimelinePosition`; + const marker = localStorage.getItem(markerKey); + if (!marker) return null; + + return marker.split('|'); +}; + +const HomeTimelineColumn: React.FC = (props) => { + const me = useAppSelector((state) => state.me); + + const maxId = useMemo(() => { + if (!me) return undefined; + + const position = getRestoredPosition(me); + + if (!position) return undefined; + + if (position[1] === '0') { + const timeDifference = Date.now() - parseInt(position[2], 10); + + if (timeDifference > 15 * 60 * 1000) { + return position[0]; + } + return undefined; + } + return position[0]; + }, []); + + const timelineQuery = useHomeTimeline(undefined, maxId); + + const handleTopItemChanged = (index: number) => { + const entry = timelineQuery.entries[index]; + if (me) savePosition(me, entry, index); + }; + + return ( + + ); +}; + +interface IPublicTimelineColumn extends IBaseTimeline { local?: boolean; remote?: boolean; instance?: string; } -const PublicTimelineColumn: React.FC = (params) => { - const timelineQuery = usePublicTimeline(params); +const PublicTimelineColumn: React.FC = ({ + local, + remote, + instance, + ...props +}) => { + const timelineQuery = usePublicTimeline({ local, remote, instance }); - return ; + return ; }; -interface IHashtagTimelineColumn { +interface IHashtagTimelineColumn extends IBaseTimeline { hashtag: string; } -const HashtagTimelineColumn: React.FC = ({ hashtag }) => { +const HashtagTimelineColumn: React.FC = ({ hashtag, ...props }) => { const timelineQuery = useHashtagTimeline(hashtag); - return ; + return ; }; -interface ILinkTimelineColumn { +interface ILinkTimelineColumn extends IBaseTimeline { url: string; } -const LinkTimelineColumn: React.FC = ({ url }) => { +const LinkTimelineColumn: React.FC = ({ url, ...props }) => { const timelineQuery = useLinkTimeline(url); - return ; + return ; }; -interface IListTimelineColumn { +interface IListTimelineColumn extends IBaseTimeline { listId: string; } -const ListTimelineColumn: React.FC = ({ listId }) => { +const ListTimelineColumn: React.FC = ({ listId, ...props }) => { const timelineQuery = useListTimeline(listId); - return ; + return ; }; -interface IGroupTimelineColumn { +interface IGroupTimelineColumn extends IBaseTimeline { groupId: string; } -const GroupTimelineColumn: React.FC = ({ groupId }) => { +const GroupTimelineColumn: React.FC = ({ groupId, ...props }) => { const timelineQuery = useGroupTimeline(groupId); - return ; + return ; }; -const BubbleTimelineColumn = () => { +const BubbleTimelineColumn: React.FC = (props) => { const timelineQuery = useBubbleTimeline(); - return ; + return ; }; -interface IAntennaTimelineColumn { +interface IAntennaTimelineColumn extends IBaseTimeline { antennaId: string; } -const AntennaTimelineColumn: React.FC = ({ antennaId }) => { +const AntennaTimelineColumn: React.FC = ({ antennaId, ...props }) => { const timelineQuery = useAntennaTimeline(antennaId); - return ; + return ; }; -const WrenchedTimelineColumn = () => { +interface ICircleTimelineColumn extends IBaseTimeline { + circleId: string; +} + +const CircleTimelineColumn: React.FC = ({ circleId, ...props }) => { + const timelineQuery = useCircleTimeline(circleId); + + return ; +}; + +const WrenchedTimelineColumn: React.FC = (props) => { const timelineQuery = useWrenchedTimeline(); - return ; + return ; +}; + +interface IAccountTimelineColumn extends IBaseTimeline { + accountId: string; + excludeReplies?: boolean; +} + +const AccountTimelineColumn: React.FC = ({ + accountId, + excludeReplies = false, + ...props +}) => { + const timelineQuery = useAccountTimeline(accountId, { exclude_replies: excludeReplies }); + + return ; }; export { @@ -226,5 +681,7 @@ export { GroupTimelineColumn, BubbleTimelineColumn, AntennaTimelineColumn, + CircleTimelineColumn, WrenchedTimelineColumn, + AccountTimelineColumn, }; diff --git a/packages/nicolium/src/components/accounts/account.tsx b/packages/nicolium/src/components/accounts/account.tsx index c680fc36d..cab293127 100644 --- a/packages/nicolium/src/components/accounts/account.tsx +++ b/packages/nicolium/src/components/accounts/account.tsx @@ -101,9 +101,8 @@ interface IProfilePopper { children: React.ReactNode; } -const ProfilePopper: React.FC = ({ condition, wrapper, children }) => ( - <>{condition ? wrapper(children) : children} -); +const ProfilePopper: React.FC = ({ condition, wrapper, children }) => + condition ? wrapper(children) : children; interface IAccount { account: AccountSchema; diff --git a/packages/nicolium/src/components/animated-number.tsx b/packages/nicolium/src/components/animated-number.tsx index 99d786f4b..9ad13f274 100644 --- a/packages/nicolium/src/components/animated-number.tsx +++ b/packages/nicolium/src/components/animated-number.tsx @@ -85,7 +85,7 @@ const AnimatedNumber: React.FC = ({ value, obfuscate, short, ma }); if (reduceMotion) { - return <>{formattedValue}; + return formattedValue; } return ( diff --git a/packages/nicolium/src/components/announcements/emoji.tsx b/packages/nicolium/src/components/announcements/emoji.tsx index 3ce569d63..f8b6da457 100644 --- a/packages/nicolium/src/components/announcements/emoji.tsx +++ b/packages/nicolium/src/components/announcements/emoji.tsx @@ -13,10 +13,10 @@ interface IEmoji { } const Emoji: React.FC = ({ emoji, emojiMap, hovered }) => { - const { autoPlayGif, systemEmojiFont } = useSettings(); + const { autoPlayGif, reduceMotion, systemEmojiFont } = useSettings(); if (unicodeMapping[emoji]) { - if (systemEmojiFont) return <>{emoji}; + if (systemEmojiFont) return emoji; const { unified, shortcode } = unicodeMapping[emoji]; const title = shortcode ? `:${shortcode}:` : ''; @@ -31,7 +31,8 @@ const Emoji: React.FC = ({ emoji, emojiMap, hovered }) => { /> ); } else if (emojiMap[emoji]) { - const filename = autoPlayGif || hovered ? emojiMap[emoji].url : emojiMap[emoji].static_url; + const filename = + (autoPlayGif && !reduceMotion) || hovered ? emojiMap[emoji].url : emojiMap[emoji].static_url; const shortCode = `:${emoji}:`; return ( diff --git a/packages/nicolium/src/components/announcements/reaction.tsx b/packages/nicolium/src/components/announcements/reaction.tsx index f01b6f8d5..0362617c9 100644 --- a/packages/nicolium/src/components/announcements/reaction.tsx +++ b/packages/nicolium/src/components/announcements/reaction.tsx @@ -1,6 +1,7 @@ import { animated, type AnimatedProps } from '@react-spring/web'; import clsx from 'clsx'; import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; import AnimatedNumber from '@/components/animated-number'; import unicodeMapping from '@/features/emoji/mapping'; @@ -10,6 +11,13 @@ import Emoji from './emoji'; import type { AnnouncementReaction, CustomEmoji } from 'pl-api'; +const messages = defineMessages({ + emojiCount: { + id: 'status.reactions.label', + defaultMessage: '{count} {count, plural, one {person} other {people}} reacted with {emoji}', + }, +}); + interface IReaction { announcementId: string; reaction: AnnouncementReaction; @@ -18,6 +26,7 @@ interface IReaction { } const Reaction: React.FC = ({ announcementId, reaction, emojiMap, style }) => { + const intl = useIntl(); const [hovered, setHovered] = useState(false); const { addReaction, removeReaction } = useAnnouncements(); @@ -46,25 +55,23 @@ const Reaction: React.FC = ({ announcementId, reaction, emojiMap, sty return ( - - - - + + +

- +

); }; diff --git a/packages/nicolium/src/components/announcements/reactions-bar.tsx b/packages/nicolium/src/components/announcements/reactions-bar.tsx index 463b1924c..479183782 100644 --- a/packages/nicolium/src/components/announcements/reactions-bar.tsx +++ b/packages/nicolium/src/components/announcements/reactions-bar.tsx @@ -1,15 +1,22 @@ import { useTransition } from '@react-spring/web'; import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; import EmojiPickerDropdown from '@/features/emoji/containers/emoji-picker-dropdown-container'; import { useAnnouncements } from '@/queries/announcements/use-announcements'; import { useSettings } from '@/stores/settings'; +import Icon from '../ui/icon'; + import Reaction from './reaction'; import type { Emoji, NativeEmoji } from '@/features/emoji'; import type { AnnouncementReaction, CustomEmoji } from 'pl-api'; +const messages = defineMessages({ + addEmoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, +}); + interface IReactionsBar { announcementId: string; reactions: Array; @@ -17,6 +24,7 @@ interface IReactionsBar { } const ReactionsBar: React.FC = ({ announcementId, reactions, emojiMap }) => { + const intl = useIntl(); const { reduceMotion } = useSettings(); const { addReaction } = useAnnouncements(); @@ -41,7 +49,7 @@ const ReactionsBar: React.FC = ({ announcementId, reactions, emoj }); return ( -
+
{transitions(({ scale }, reaction) => ( = ({ announcementId, reactions, emoj /> ))} - {visibleReactions.length < 8 && } + {visibleReactions.length < 8 && ( + + + + )}
); }; diff --git a/packages/nicolium/src/components/autosuggest-emoji.tsx b/packages/nicolium/src/components/autosuggest-emoji.tsx index e89a33042..8d03ab894 100644 --- a/packages/nicolium/src/components/autosuggest-emoji.tsx +++ b/packages/nicolium/src/components/autosuggest-emoji.tsx @@ -25,7 +25,7 @@ const AutosuggestEmoji: React.FC = ({ emoji }) => { /> ); } else { - if (systemEmojiFont) emojiElement = <>{emoji.native}; + if (systemEmojiFont) emojiElement = emoji.native; const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; diff --git a/packages/nicolium/src/components/dropdown-menu/dropdown-menu.tsx b/packages/nicolium/src/components/dropdown-menu/dropdown-menu.tsx index b160ee8b2..172814b1a 100644 --- a/packages/nicolium/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/nicolium/src/components/dropdown-menu/dropdown-menu.tsx @@ -148,6 +148,18 @@ const DropdownMenuContent: React.FC = ({ } document.addEventListener('keydown', handleKeyDown, false); + if (Component && !items?.length) { + const elements = Array.from( + ref.current?.querySelectorAll( + 'a, button:not([disabled]), input:not([disabled]), select:not([disabled])', + ) ?? [], + ).filter((element) => !element.hasAttribute('aria-hidden')); + const firstElement = elements[0]; + if (firstElement) { + firstElement.focus(); + } + } + return () => { document.removeEventListener('click', handleDocumentClick); document.removeEventListener('touchend', handleDocumentClick); diff --git a/packages/nicolium/src/components/modal-root.tsx b/packages/nicolium/src/components/modal-root.tsx index 95448c96e..fce5dc8af 100644 --- a/packages/nicolium/src/components/modal-root.tsx +++ b/packages/nicolium/src/components/modal-root.tsx @@ -255,7 +255,13 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type, mo onClick={handleOnClose} /> -
+
{children}
diff --git a/packages/nicolium/src/components/preview-card.tsx b/packages/nicolium/src/components/preview-card.tsx index ce54e0d5a..2dfa535a0 100644 --- a/packages/nicolium/src/components/preview-card.tsx +++ b/packages/nicolium/src/components/preview-card.tsx @@ -1,6 +1,6 @@ import { Link } from '@tanstack/react-router'; import clsx from 'clsx'; -import { sanitize } from 'isomorphic-dompurify'; +import DOMPurify from 'dompurify'; import { type MediaAttachment, type PreviewCard as CardEntity, @@ -69,7 +69,7 @@ interface IPreviewCardVideo { const _PreviewCardVideo: React.FC = React.memo( React.forwardRef(({ card }, ref) => { - const html = sanitize(handleIframeUrl(card.html, card.url, card.provider_name), { + const html = DOMPurify.sanitize(handleIframeUrl(card.html, card.url, card.provider_name), { ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'referrerpolicy'], }); diff --git a/packages/nicolium/src/components/scrollable-list.tsx b/packages/nicolium/src/components/scrollable-list.tsx index d4ebbc435..ffce7f58b 100644 --- a/packages/nicolium/src/components/scrollable-list.tsx +++ b/packages/nicolium/src/components/scrollable-list.tsx @@ -70,6 +70,8 @@ interface IScrollableList extends VirtuosoProps { onScrollToTop?: () => void; /** Callback when the list is scrolled. */ onScroll?: () => void; + /** Callback when the topmost visible item index changes. */ + onTopItemChanged?: (index: number) => void; /** Placeholder component to render while loading. */ placeholderComponent?: React.ComponentType | React.NamedExoticComponent; /** Number of placeholders to render while loading. */ @@ -104,6 +106,7 @@ const ScrollableList = React.forwardRef( onScroll, onScrollToTop, onLoadMore, + onTopItemChanged, className, listClassName, itemClassName, @@ -223,6 +226,7 @@ const ScrollableList = React.forwardRef( // HACK: using the first index can be buggy. // Track the second item instead, unless the endIndex comes before it (eg one 1 item in view). topIndex.current = Math.min(range.startIndex + 1, range.endIndex); + onTopItemChanged?.(range.startIndex); handleScroll(); }; @@ -271,7 +275,7 @@ const ScrollableList = React.forwardRef( itemClassName, }} components={{ - Header: prepend ? () => <>{prepend} : undefined, + Header: prepend ? () => prepend : undefined, ScrollSeekPlaceholder: Placeholder as React.ComponentType, EmptyPlaceholder: renderEmpty, List, diff --git a/packages/nicolium/src/components/site-logo.tsx b/packages/nicolium/src/components/site-logo.tsx index 57d25eade..2ef246dd0 100644 --- a/packages/nicolium/src/components/site-logo.tsx +++ b/packages/nicolium/src/components/site-logo.tsx @@ -5,7 +5,7 @@ import { defineMessages, useIntl } from 'react-intl'; import { useLogo } from '@/hooks/use-logo'; const messages = defineMessages({ - logo: { id: 'generic.logo', defaultMessage: 'Logo' }, + logo: { id: 'common.logo', defaultMessage: 'Logo' }, }); interface ISiteLogo extends React.ComponentProps<'img'> { diff --git a/packages/nicolium/src/components/snow-container.tsx b/packages/nicolium/src/components/snow-container.tsx new file mode 100644 index 000000000..7d6fc3167 --- /dev/null +++ b/packages/nicolium/src/components/snow-container.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useRef } from 'react'; + +const SNOWFLAKE_INTERVAL = 500; +const SNOWFLAKE_LIFETIME = 21000; + +const SnowContainer: React.FC = () => { + const snowContainerRef = useRef(null); + const timersRef = useRef>>(new Set()); + + useEffect(() => { + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + if (prefersReducedMotion.matches) return; + + const addSnowflake = () => { + const container = snowContainerRef.current; + if (!container || document.hidden) return; + + const size = Math.random() * 10 + 4; + const left = Math.random() * window.innerWidth; + const opacity = Math.random() * 0.6 + 0.4; + const fallDuration = Math.random() * 10 + 15; + const drift = Math.random() * 150 - 75; // -75px to +75px + + const snowflake = document.createElement('div'); + const sizeStr = `${size}px`; + snowflake.style.width = sizeStr; + snowflake.style.height = sizeStr; + snowflake.style.left = `${left}px`; + snowflake.style.top = '0'; + snowflake.style.position = 'absolute'; + snowflake.style.pointerEvents = 'none'; + snowflake.style.opacity = opacity.toString(); + snowflake.style.backgroundImage = + 'url(https://cdn.nicecrew.digital/static-assets/snowflake2.svg)'; + snowflake.style.backgroundSize = 'contain'; + snowflake.style.backgroundRepeat = 'no-repeat'; + snowflake.style.setProperty('--snow-drift', `${drift}px`); + snowflake.style.animation = `fall ${fallDuration}s linear, snowflake-fade ${fallDuration}s ease-in-out`; + + container.appendChild(snowflake); + + const timer = setTimeout(() => { + snowflake.remove(); + timersRef.current.delete(timer); + }, SNOWFLAKE_LIFETIME); + timersRef.current.add(timer); + }; + + const interval = setInterval(addSnowflake, SNOWFLAKE_INTERVAL); + + return () => { + clearInterval(interval); + for (const timer of timersRef.current) { + clearTimeout(timer); + } + timersRef.current.clear(); + }; + }, []); + + return
; +}; + +export default SnowContainer; diff --git a/packages/nicolium/src/components/statuses/parsed-content.tsx b/packages/nicolium/src/components/statuses/parsed-content.tsx index 46006c43f..c9db814b5 100644 --- a/packages/nicolium/src/components/statuses/parsed-content.tsx +++ b/packages/nicolium/src/components/statuses/parsed-content.tsx @@ -1,10 +1,10 @@ +import DOMPurify from 'dompurify'; import parse, { Element, type HTMLReactParserOptions, domToReact, type DOMNode, } from 'html-react-parser'; -import { sanitize } from 'isomorphic-dompurify'; import groupBy from 'lodash/groupBy'; import minBy from 'lodash/minBy'; import React from 'react'; @@ -345,7 +345,7 @@ function parseContent( }; let content = parse( - sanitize(html, { ADD_ATTR: ['target'], USE_PROFILES: { html: true } }), + DOMPurify.sanitize(html, { ADD_ATTR: ['target'], USE_PROFILES: { html: true } }), options, ); diff --git a/packages/nicolium/src/components/statuses/status-content.tsx b/packages/nicolium/src/components/statuses/status-content.tsx index 5d20384fd..ca86f5908 100644 --- a/packages/nicolium/src/components/statuses/status-content.tsx +++ b/packages/nicolium/src/components/statuses/status-content.tsx @@ -327,7 +327,7 @@ const StatusContent: React.FC = React.memo( if (onClick) { return
{output}
; } else { - return <>{output}; + return output; } }, ); diff --git a/packages/nicolium/src/components/statuses/status-list.tsx b/packages/nicolium/src/components/statuses/status-list.tsx index eb60c15e5..04d06944b 100644 --- a/packages/nicolium/src/components/statuses/status-list.tsx +++ b/packages/nicolium/src/components/statuses/status-list.tsx @@ -11,22 +11,8 @@ import PendingStatus from '@/features/ui/components/pending-status'; import { timelineToFilterContextType } from '@/queries/settings/use-filters'; import { selectChild } from '@/utils/scroll-utils'; -import Icon from '../ui/icon'; - import type { VirtuosoHandle } from 'react-virtuoso'; -const SkipPinned: React.FC> = ({ onClick }) => { - return ( - - ); -}; - interface IStatusList extends Omit { /** Unique key to preserve the scroll position when navigating back. */ scrollKey: string; @@ -34,8 +20,6 @@ interface IStatusList extends Omit { statusIds: Array; /** Last _unfiltered_ status ID (maxId) for pagination. */ lastStatusId?: string; - /** Pinned statuses to show at the top of the feed. */ - featuredStatusIds?: Array; /** Pagination callback when the end of the list is reached. */ onLoadMore?: (lastStatusId: string) => void; /** Whether the data is currently being fetched. */ @@ -44,8 +28,6 @@ interface IStatusList extends Omit { isPartial?: boolean; /** Whether we expect an additional page of data. */ hasMore: boolean; - /** Message to display when the list is loaded but empty. */ - emptyMessage?: React.ReactNode; /** ID of the timeline in Redux. */ timelineId?: string; /** Whether to show group information. */ @@ -56,7 +38,6 @@ interface IStatusList extends Omit { const StatusList: React.FC = ({ statusIds, lastStatusId, - featuredStatusIds, onLoadMore, timelineId, isLoading, @@ -69,23 +50,17 @@ const StatusList: React.FC = ({ const contextType = timelineToFilterContextType(timelineId); - const getFeaturedStatusCount = () => featuredStatusIds?.length ?? 0; - - const getCurrentStatusIndex = (id: string, featured: boolean): number => { - if (featured) { - return featuredStatusIds?.findIndex((key) => key === id) ?? 0; - } else { - return statusIds.findIndex((key) => key === id) + getFeaturedStatusCount(); - } + const getCurrentStatusIndex = (id: string): number => { + return statusIds.findIndex((key) => key === id); }; - const handleMoveUp = (id: string, featured: boolean = false) => { - const elementIndex = getCurrentStatusIndex(id, featured) - 1; + const handleMoveUp = (id: string) => { + const elementIndex = getCurrentStatusIndex(id) - 1; selectChild(elementIndex, node, document.getElementById('status-list') ?? undefined); }; - const handleMoveDown = (id: string, featured: boolean = false) => { - const elementIndex = getCurrentStatusIndex(id, featured) + 1; + const handleMoveDown = (id: string) => { + const elementIndex = getCurrentStatusIndex(id) + 1; selectChild( elementIndex, node, @@ -108,22 +83,6 @@ const StatusList: React.FC = ({ [onLoadMore, lastStatusId, statusIds.at(-1)], ); - const handleSkipPinned = () => { - const skipPinned = () => { - selectChild( - getFeaturedStatusCount(), - node, - document.getElementById('status-list') ?? undefined, - scrollableContent.length, - 'start', - ); - }; - - skipPinned(); - - setTimeout(() => skipPinned, 0); - }; - const renderLoadGap = (index: number) => { const ids = statusIds; const nextId = ids[index + 1]; @@ -157,23 +116,6 @@ const StatusList: React.FC = ({ }; const scrollableContent = useMemo(() => { - const renderFeaturedStatuses = (): React.ReactNode[] => { - if (!featuredStatusIds) return []; - - return featuredStatusIds.map((statusId) => ( - - )); - }; - const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.length > 0) { return statusIds.reduce((acc, statusId, index) => { @@ -195,15 +137,10 @@ const StatusList: React.FC = ({ } }; - const featuredStatuses = renderFeaturedStatuses(); const statuses = renderStatuses(); - if (featuredStatuses && statuses) { - return featuredStatuses.concat(statuses); - } else { - return statuses; - } - }, [featuredStatusIds, statusIds, isLoading, timelineId, showGroup]); + return statuses; + }, [statusIds, isLoading, timelineId, showGroup]); if (isPartial) { return ( @@ -227,26 +164,21 @@ const StatusList: React.FC = ({ } return ( - <> - {featuredStatusIds && featuredStatusIds.length > 3 && statusIds.length > 0 && ( - - )} - } - placeholderCount={20} - ref={node} - listClassName={clsx('⁂-status-list', className)} - {...other} - > - {scrollableContent} - - + } + placeholderCount={20} + ref={node} + listClassName={clsx('⁂-status-list', className)} + {...other} + > + {scrollableContent} + ); }; -export { type IStatusList, StatusList as default }; +export { StatusList as default }; diff --git a/packages/nicolium/src/components/statuses/status-reactions-bar.tsx b/packages/nicolium/src/components/statuses/status-reactions-bar.tsx index 30e4e2395..30357438d 100644 --- a/packages/nicolium/src/components/statuses/status-reactions-bar.tsx +++ b/packages/nicolium/src/components/statuses/status-reactions-bar.tsx @@ -90,7 +90,6 @@ const StatusReaction: React.FC = ({ className={clsx('⁂-status-reactions-bar__button', { '⁂-status-reactions-bar__button--active': reaction.me, })} - key={reaction.name} onClick={handleClick} title={intl.formatMessage(messages.emojiCount, { emoji: `:${shortCode}:`, diff --git a/packages/nicolium/src/components/statuses/status.tsx b/packages/nicolium/src/components/statuses/status.tsx index 201b18021..061b2472a 100644 --- a/packages/nicolium/src/components/statuses/status.tsx +++ b/packages/nicolium/src/components/statuses/status.tsx @@ -83,11 +83,16 @@ const AccountInfo: React.FC = React.memo(({ status }) => ( AccountInfo.displayName = 'AccountInfo'; interface IStatusFollowedTagInfo { + className?: string; status: SelectedStatus; avatarSize: number; } -const StatusFollowedTagInfo: React.FC = ({ status, avatarSize }) => { +const StatusFollowedTagInfo: React.FC = ({ + className, + status, + avatarSize, +}) => { const { data: followedTags } = useFollowedTags(); const filteredTags = useMemo( @@ -119,7 +124,7 @@ const StatusFollowedTagInfo: React.FC = ({ status, avata return ( = React.memo((props) => { const handleHotkeyMoveUp = () => { if (onMoveUp) { - onMoveUp(status.id, featured); + return onMoveUp(status.id, featured); } + return false; }; const handleHotkeyMoveDown = () => { if (onMoveDown) { - onMoveDown(status.id, featured); + return onMoveDown(status.id, featured); } + return false; }; const handleHotkeyToggleSensitive = () => { @@ -498,7 +505,7 @@ const Status: React.FC = React.memo((props) => { } else if (fromHomeTimeline) { return ( features.followHashtags && ( - + ) ); } @@ -655,4 +662,4 @@ const Status: React.FC = React.memo((props) => { Status.displayName = 'Status'; -export { type IStatus, Status as default }; +export { type IStatus, Status as default, StatusFollowedTagInfo }; diff --git a/packages/nicolium/src/components/statuses/tombstone.tsx b/packages/nicolium/src/components/statuses/tombstone.tsx index 702b63893..cf52fcf49 100644 --- a/packages/nicolium/src/components/statuses/tombstone.tsx +++ b/packages/nicolium/src/components/statuses/tombstone.tsx @@ -6,8 +6,8 @@ import { Hotkeys } from '@/features/ui/components/hotkeys'; interface ITombstone { id: string; - onMoveUp?: (statusId: string) => void; - onMoveDown?: (statusId: string) => void; + onMoveUp?: (statusId: string) => void | boolean; + onMoveDown?: (statusId: string) => void | boolean; deleted?: boolean; } diff --git a/packages/nicolium/src/components/ui/button/index.tsx b/packages/nicolium/src/components/ui/button/index.tsx index 8ebd0f180..4a9fca70e 100644 --- a/packages/nicolium/src/components/ui/button/index.tsx +++ b/packages/nicolium/src/components/ui/button/index.tsx @@ -111,7 +111,6 @@ const Button = React.forwardRef( to={props.to} params={props.params} search={props.search} - tabIndex={-1} > {buttonChildren} diff --git a/packages/nicolium/src/components/ui/emoji.tsx b/packages/nicolium/src/components/ui/emoji.tsx index 76eb4ca28..3287d1780 100644 --- a/packages/nicolium/src/components/ui/emoji.tsx +++ b/packages/nicolium/src/components/ui/emoji.tsx @@ -17,7 +17,7 @@ interface IEmoji extends Pick< } /** A single emoji image. */ -const Emoji: React.FC = (props): React.JSX.Element | null => { +const Emoji: React.FC = (props) => { const { disableUserProvidedMedia, systemEmojiFont } = useSettings(); const { emoji, alt, src, staticSrc, noGroup, ...rest } = props; @@ -31,8 +31,7 @@ const Emoji: React.FC = (props): React.JSX.Element | null => { if (!filename && !src) return null; if (src) { - if (disableUserProvidedMedia) - return <>{alt ?? {emoji}}; + if (disableUserProvidedMedia) return alt ?? {emoji}; return ( ( /> )} -

{title}

+ {onClose && ( = ({ >
    {visibleOptions.length === 0 && ( - {intl.formatMessage(messages.noOptions)} + + + )} {visibleOptions.map((option, i) => (
  • = ({ let content; if (!data) { - content = ( - <> - {/* - */} - - ); + content = null; + { + /* + */ + } } else { const measure = data[0]; const percentChange = diff --git a/packages/nicolium/src/features/auth-login/components/login-form.tsx b/packages/nicolium/src/features/auth-login/components/login-form.tsx index 15630e9ee..95d440a4c 100644 --- a/packages/nicolium/src/features/auth-login/components/login-form.tsx +++ b/packages/nicolium/src/features/auth-login/components/login-form.tsx @@ -55,7 +55,7 @@ const LoginForm: React.FC = ({ isLoading, handleSubmit }) => { + } diff --git a/packages/nicolium/src/features/auth-login/components/otp-auth-form.tsx b/packages/nicolium/src/features/auth-login/components/otp-auth-form.tsx index e35aedd10..ecde2244c 100644 --- a/packages/nicolium/src/features/auth-login/components/otp-auth-form.tsx +++ b/packages/nicolium/src/features/auth-login/components/otp-auth-form.tsx @@ -13,12 +13,6 @@ import Input from '@/components/ui/input'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; const messages = defineMessages({ - otpCodeHint: { - id: 'login.fields.otp_code_hint', - defaultMessage: - 'Enter the two-factor code generated by your phone app or use one of your recovery codes', - }, - otpCodeLabel: { id: 'login.fields.otp_code_label', defaultMessage: 'Two-factor code:' }, otpLoginFail: { id: 'login.otp_log_in.fail', defaultMessage: 'Invalid code, please try again.' }, }); @@ -64,8 +58,15 @@ const OtpAuthForm: React.FC = ({ mfa_token, small }) => { const form = (
    + } + hintText={ + + } errors={codeError ? [intl.formatMessage(messages.otpLoginFail)] : []} > diff --git a/packages/nicolium/src/features/auth-login/components/registration-form.tsx b/packages/nicolium/src/features/auth-login/components/registration-form.tsx index 7b79d8891..7a413e212 100644 --- a/packages/nicolium/src/features/auth-login/components/registration-form.tsx +++ b/packages/nicolium/src/features/auth-login/components/registration-form.tsx @@ -279,154 +279,148 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { return (
    - <> - - } - errors={ - usernameUnavailable ? [intl.formatMessage(messages.usernameUnavailable)] : undefined - } - > - - - - {domains && ( - - - - )} - + } + errors={ + usernameUnavailable ? [intl.formatMessage(messages.usernameUnavailable)] : undefined + } + > + + {domains && ( + + + + )} + + + + + + + - - - - - {birthdayRequired && ( - - )} - - {needsApproval && ( - - } - > -