diff --git a/app/soapbox/__fixtures__/alex.json b/app/soapbox/__fixtures__/alex.json new file mode 100644 index 000000000..022978fde --- /dev/null +++ b/app/soapbox/__fixtures__/alex.json @@ -0,0 +1,127 @@ +{ + "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": 2378, + "following_count": 1571, + "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-02-20T04:14:49", + "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": 23477, + "url": "https://gleasonator.com/users/alex", + "username": "alex" +} diff --git a/app/soapbox/__fixtures__/fedibird-instance.json b/app/soapbox/__fixtures__/fedibird-instance.json new file mode 100644 index 000000000..31e17e34e --- /dev/null +++ b/app/soapbox/__fixtures__/fedibird-instance.json @@ -0,0 +1,185 @@ +{ + "uri": "fedibird.com", + "title": "Fedibird", + "short_description": "多くの独自機能を備えた、連合志向の汎用Mastodonサーバです。Fediverseの活動拠点としてご利用ください。", + "description": "多くの独自機能を備えた、連合志向の汎用Mastodonサーバです。Fediverseの活動拠点としてご利用ください。", + "email": "support@fedibird.com", + "version": "3.4.1", + "urls": { + "streaming_api": "wss://fedibird.com" + }, + "stats": { + "user_count": 1964, + "status_count": 4590304, + "domain_count": 9024 + }, + "thumbnail": "https://s3.fedibird.com/site_uploads/files/000/000/001/original/fedibird_hero_image.png", + "languages": [ + "ja" + ], + "registrations": true, + "approval_required": false, + "invites_enabled": true, + "configuration": { + "statuses": { + "max_characters": 500, + "max_media_attachments": 4, + "characters_reserved_per_url": 23, + "min_expiration": 60, + "max_expiration": 37152000, + "supported_expires_actions": [ + "delete", + "mark" + ] + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heif", + "image/heic", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/ogg", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + ], + "image_size_limit": 10485760, + "image_matrix_limit": 16777216, + "video_size_limit": 41943040, + "video_frame_rate_limit": 60, + "video_matrix_limit": 2304000 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 50, + "min_expiration": 300, + "max_expiration": 2629746 + }, + "emoji_reactions": { + "max_reactions": 20 + } + }, + "feature_quote": true, + "fedibird_capabilities": [ + "favourite_hashtag", + "favourite_domain", + "favourite_list", + "status_expire", + "follow_no_delivery", + "follow_hashtag", + "subscribe_account", + "subscribe_domain", + "subscribe_keyword", + "timeline_home_visibility", + "timeline_no_local", + "timeline_domain", + "timeline_group", + "timeline_group_directory", + "visibility_mutual", + "visibility_limited", + "emoji_reaction", + "misskey_birthday", + "misskey_location" + ], + "contact_account": { + "id": "1", + "username": "noellabo", + "acct": "noellabo", + "display_name": "のえる", + "locked": false, + "bot": false, + "cat": false, + "discoverable": true, + "group": false, + "created_at": "2019-08-15T00:00:00.000Z", + "note": "

主に、Fediverseへの関心に基づいた投稿を行うアカウントです。DTP・印刷に関する話をしたり、同人の話をしたり、カレーをブーストしたりします。

Mastodonサーバ『Fedibird』の管理者アカウントでもあります。ご連絡は当アカウントへ、サーバインフォメーションについては https://fedibird.com/about/more@info を参照してください。

", + "url": "https://fedibird.com/@noellabo", + "avatar": "https://s3.fedibird.com/accounts/avatars/000/000/001/original/6ef3b7f18f726755.png", + "avatar_static": "https://s3.fedibird.com/accounts/avatars/000/000/001/original/6ef3b7f18f726755.png", + "header": "https://s3.fedibird.com/accounts/headers/000/000/001/original/6a5a51722c094835.jpg", + "header_static": "https://s3.fedibird.com/accounts/headers/000/000/001/original/6a5a51722c094835.jpg", + "followers_count": 1560, + "following_count": 758, + "subscribing_count": 121, + "statuses_count": 61325, + "last_status_at": "2022-02-24", + "emojis": [ + { + "shortcode": "liberapay", + "url": "https://s3.fedibird.com/custom_emojis/images/000/025/634/original/5b8620742973f844.png", + "static_url": "https://s3.fedibird.com/custom_emojis/images/000/025/634/static/5b8620742973f844.png", + "visible_in_picker": true + }, + { + "shortcode": "mastodon", + "url": "https://s3.fedibird.com/custom_emojis/images/000/008/396/original/1317b6f8efcf8318.png", + "static_url": "https://s3.fedibird.com/custom_emojis/images/000/008/396/static/1317b6f8efcf8318.png", + "visible_in_picker": true + } + ], + "fields": [ + { + "name": ":liberapay: Liberapay", + "value": "https://liberapay.com/noellabo", + "verified_at": "2020-10-22T03:04:43.206+00:00" + }, + { + "name": ":mastodon: DTP-Mstdn.jp", + "value": "https://dtp-mstdn.jp/@noellabo", + "verified_at": "2020-05-23T00:14:02.232+00:00" + }, + { + "name": "別宅", + "value": "https://gorone.xyz/@noellabo", + "verified_at": "2021-08-11T07:48:53.479+00:00" + }, + { + "name": "bluesky community", + "value": "https://mastodon.blueskycommunity.net/@noellabo", + "verified_at": "2021-11-13T04:28:30.593+00:00" + } + ], + "other_settings": { + "birthday": null, + "location": "埼玉県", + "cat_ears_color": "#d5c5c0", + "noindex": false, + "hide_network": false, + "hide_statuses_count": false, + "hide_following_count": false, + "hide_followers_count": false, + "enable_reaction": true + } + }, + "rules": [ + { + "id": "2", + "text": "日本の法律と社会規範に従った行動を心がけてください" + }, + { + "id": "3", + "text": "不快や脅威に対してはブロック・ミュート・フィルターで距離をとってください" + }, + { + "id": "1", + "text": "投稿する際は、適切な公開範囲・CW・閲覧注意を使用してください" + } + ] +} diff --git a/app/soapbox/__fixtures__/friendica-instance.json b/app/soapbox/__fixtures__/friendica-instance.json new file mode 100644 index 000000000..cb6902d1f --- /dev/null +++ b/app/soapbox/__fixtures__/friendica-instance.json @@ -0,0 +1,46 @@ +{ + "uri": "https://ica.mkljczk.pl", + "title": "Friendica Social Network", + "short_description": "", + "description": "", + "email": "me@mkljczk.pl", + "version": "2022.05-dev", + "urls": null, + "stats": { + "user_count": 0, + "status_count": 0, + "domain_count": 0 + }, + "thumbnail": "https://ica.mkljczk.plimages/friendica-32.png", + "languages": [ + "pl" + ], + "max_toot_chars": 200000, + "registrations": true, + "approval_required": false, + "invites_enabled": false, + "contact_account": { + "id": "2", + "username": "nofriend", + "acct": "nofriend", + "display_name": "marcin mikołajczak", + "locked": true, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-02-19T14:51:00.000Z", + "note": "", + "url": "https://ica.mkljczk.pl/profile/nofriend", + "avatar": "https://ica.mkljczk.pl/photo/contact/300/68a16c11-1262-1103-d40b-806159848009?ts=1645292106", + "avatar_static": "https://ica.mkljczk.pl/photo/contact/300/68a16c11-1262-1103-d40b-806159848009?ts=1645292106", + "header": "https://ica.mkljczk.pl/photo/header/68a16c11-1262-1103-d40b-806159848009?ts=1645292106", + "header_static": "https://ica.mkljczk.pl/photo/header/68a16c11-1262-1103-d40b-806159848009?ts=1645292106", + "followers_count": 0, + "following_count": 1, + "statuses_count": 0, + "last_status_at": "2022-02-20", + "emojis": [], + "fields": [] + }, + "rules": [] +} diff --git a/app/soapbox/__fixtures__/friendica-status.json b/app/soapbox/__fixtures__/friendica-status.json new file mode 100644 index 000000000..fc64e430e --- /dev/null +++ b/app/soapbox/__fixtures__/friendica-status.json @@ -0,0 +1,53 @@ +{ + "id": "106", + "created_at": "2022-02-19T18:19:40.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "fa", + "uri": "https://ica.mkljczk.pl/objects/68a16c11-4262-1134-bc4e-0db298374337", + "url": "https://ica.mkljczk.pl/display/68a16c11-4262-1134-bc4e-0db298374337", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": true, + "content": "Hello to Friendica from fe.soapbox.pub!", + "reblog": null, + "application": { + "name": "Soapbox FE" + }, + "account": { + "id": "95", + "username": "alex", + "acct": "alex", + "display_name": "Alex Gleason", + "locked": true, + "bot": false, + "discoverable": false, + "group": false, + "created_at": "2022-02-19T18:17:43.000Z", + "note": "", + "url": "https://ica.mkljczk.pl/profile/alex", + "avatar": "https://ica.mkljczk.pl/photo/contact/300/68a16c11-1862-1134-4779-f98088458845?ts=1645294804", + "avatar_static": "https://ica.mkljczk.pl/photo/contact/300/68a16c11-1862-1134-4779-f98088458845?ts=1645294804", + "header": "https://ica.mkljczk.pl/photo/header/68a16c11-1862-1134-4779-f98088458845?ts=1645294804", + "header_static": "https://ica.mkljczk.pl/photo/header/68a16c11-1862-1134-4779-f98088458845?ts=1645294804", + "followers_count": 0, + "following_count": 0, + "statuses_count": 2, + "last_status_at": "2022-02-19", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null +} diff --git a/app/soapbox/__fixtures__/gotosocial-account.json b/app/soapbox/__fixtures__/gotosocial-account.json new file mode 100644 index 000000000..3700bc473 --- /dev/null +++ b/app/soapbox/__fixtures__/gotosocial-account.json @@ -0,0 +1,27 @@ +{ + "id": "00YSECR4P7E64BD5MBA639PRVT", + "username": "alex", + "acct": "alex", + "display_name": "Alex Gleason", + "locked": false, + "bot": false, + "created_at": "2022-02-23T22:43:55Z", + "note": "

My GoToSocial profile

", + "url": "http://localhost/@alex", + "avatar": "", + "avatar_static": "", + "header": "", + "header_static": "", + "followers_count": 0, + "following_count": 0, + "statuses_count": 1, + "last_status_at": "2022-02-23T22:54:14Z", + "emojis": [], + "fields": [], + "source": { + "privacy": "unlisted", + "language": "en", + "note": "

My GoToSocial profile

", + "fields": [] + } +} diff --git a/app/soapbox/__fixtures__/gotosocial-instance.json b/app/soapbox/__fixtures__/gotosocial-instance.json new file mode 100644 index 000000000..fdaf4c96b --- /dev/null +++ b/app/soapbox/__fixtures__/gotosocial-instance.json @@ -0,0 +1,42 @@ +{ + "uri": "http://localhost", + "title": "localhost", + "description": "", + "short_description": "", + "email": "", + "version": "0.2.0 31935ee", + "registrations": true, + "approval_required": true, + "invites_enabled": false, + "urls": { + "streaming_api": "wss://localhost" + }, + "stats": { + "domain_count": 0, + "status_count": 1, + "user_count": 1 + }, + "thumbnail": "", + "contact_account": { + "id": "", + "username": "", + "acct": "", + "display_name": "", + "locked": false, + "bot": false, + "created_at": "", + "note": "", + "url": "", + "avatar": "", + "avatar_static": "", + "header": "", + "header_static": "", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": "", + "emojis": null, + "fields": null + }, + "max_toot_chars": 5000 +} diff --git a/app/soapbox/__fixtures__/gotosocial-status.json b/app/soapbox/__fixtures__/gotosocial-status.json new file mode 100644 index 000000000..3546482b2 --- /dev/null +++ b/app/soapbox/__fixtures__/gotosocial-status.json @@ -0,0 +1,50 @@ +{ + "id": "01FWMCNM07GGDV8HF40NZ9YTGR", + "created_at": "2022-02-23T22:54:14Z", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "http://localhost/users/alex/statuses/01FWMCNM07GGDV8HF40NZ9YTGR", + "url": "http://localhost/@alex/statuses/01FWMCNM07GGDV8HF40NZ9YTGR", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

Hello GoToSocial!

", + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "account": { + "id": "00YSECR4P7E64BD5MBA639PRVT", + "username": "alex", + "acct": "alex", + "display_name": "alex", + "locked": false, + "bot": false, + "created_at": "2022-02-23T22:43:55Z", + "note": "", + "url": "http://localhost/@alex", + "avatar": "", + "avatar_static": "", + "header": "", + "header_static": "", + "followers_count": 0, + "following_count": 0, + "statuses_count": 1, + "last_status_at": "2022-02-23T22:54:14Z", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null, + "text": "Hello GoToSocial!" +} diff --git a/app/soapbox/__fixtures__/mastodon-reply-to-self.json b/app/soapbox/__fixtures__/mastodon-reply-to-self.json new file mode 100644 index 000000000..7cfc756f3 --- /dev/null +++ b/app/soapbox/__fixtures__/mastodon-reply-to-self.json @@ -0,0 +1,51 @@ +{ + "id": "107828148293766288", + "created_at": "2022-02-20T03:16:09.812Z", + "in_reply_to_id": "107828147870368566", + "in_reply_to_account_id": "106801667066418367", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://mastodon.social/users/benis911/statuses/107828148293766288", + "url": "https://mastodon.social/@benis911/107828148293766288", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "content": "

test reply to self

", + "reblog": null, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "account": { + "id": "106801667066418367", + "username": "benis911", + "acct": "benis911", + "display_name": "", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2021-08-22T00:00:00.000Z", + "note": "

", + "url": "https://mastodon.social/@benis911", + "avatar": "https://mastodon.social/avatars/original/missing.png", + "avatar_static": "https://mastodon.social/avatars/original/missing.png", + "header": "https://mastodon.social/headers/original/missing.png", + "header_static": "https://mastodon.social/headers/original/missing.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 3, + "last_status_at": "2022-02-20", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null +} diff --git a/app/soapbox/__fixtures__/mitra-instance.json b/app/soapbox/__fixtures__/mitra-instance.json new file mode 100644 index 000000000..2c476ba30 --- /dev/null +++ b/app/soapbox/__fixtures__/mitra-instance.json @@ -0,0 +1,13 @@ +{ + "uri": "mitra.social", + "title": "Mitra", + "short_description": "Federated social network with smart contracts", + "description": "This is an instance of [Mitra](https://codeberg.org/silverpill/mitra), federated social network built on [ActivityPub](https://activitypub.rocks/) protocol.\nRegistration is invitation-only.\nAdmin:\n - [@silverpill@mitra.social](https://mitra.social/profile/dd4ebc18-269d-4c7b-a310-03d29c6ab551)\n - Matrix: @silverpill:poa.st\n", + "version": "3.0.0 (compatible; Mitra 0.4.0)", + "registrations": false, + "login_message": "Sign this message to log in to https://mitra.social. Do not sign this message on other sites!", + "post_character_limit": 5000, + "blockchain_explorer_url": null, + "blockchain_contract_address": null, + "ipfs_gateway_url": "https://ipfs.mitra.social" +} diff --git a/app/soapbox/__fixtures__/status-custom-emoji.json b/app/soapbox/__fixtures__/status-custom-emoji.json new file mode 100644 index 000000000..ac3f184ee --- /dev/null +++ b/app/soapbox/__fixtures__/status-custom-emoji.json @@ -0,0 +1,126 @@ +{ + "account": { + "acct": "benis911", + "avatar": "https://gleasonator.com/images/avi.png", + "avatar_static": "https://gleasonator.com/images/avi.png", + "bot": false, + "created_at": "2021-03-26T20:42:11.000Z", + "display_name": "benis911", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 0, + "fqn": "benis911@gleasonator.com", + "header": "https://media.gleasonator.com/fc595bbbcf5aabefecd1c2adfe5b7f5457db59847992881668653a0338ba25bd.jpg", + "header_static": "https://media.gleasonator.com/fc595bbbcf5aabefecd1c2adfe5b7f5457db59847992881668653a0338ba25bd.jpg", + "id": "A5c5LK7EJTFR0u26Pg", + "last_status_at": "2022-02-23T17:31:08", + "locked": true, + "note": "hello world 2", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://gleasonator.com/users/alex", + "https://poa.st/users/alex" + ], + "ap_id": "https://gleasonator.com/users/benis911", + "background_image": null, + "birthday": "2000-01-25", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": true, + "hide_follows": true, + "hide_follows_count": true, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "hello world 2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 152, + "url": "https://gleasonator.com/users/benis911", + "username": "benis911" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "Hello :ablobcathyper: :ageblobcat: 😂 world 😋 test :blobcatphoto:", + "created_at": "2022-02-23T17:31:07.000Z", + "emojis": [ + { + "shortcode": "ablobcathyper", + "static_url": "https://gleasonator.com/emoji/blobcat/ablobcathyper.png", + "url": "https://gleasonator.com/emoji/blobcat/ablobcathyper.png", + "visible_in_picker": false + }, + { + "shortcode": "ageblobcat", + "static_url": "https://gleasonator.com/emoji/blobcat/ageblobcat.png", + "url": "https://gleasonator.com/emoji/blobcat/ageblobcat.png", + "visible_in_picker": false + }, + { + "shortcode": "blobcatphoto", + "static_url": "https://gleasonator.com/emoji/blobcat/blobcatphoto.png", + "url": "https://gleasonator.com/emoji/blobcat/blobcatphoto.png", + "visible_in_picker": false + } + ], + "favourited": false, + "favourites_count": 0, + "id": "AGm7uC9DaAIGUa4KYK", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "Hello :ablobcathyper: :ageblobcat: 😂 world 😋 test :blobcatphoto:" + }, + "conversation_id": "AGm7uC3BwZTOBtFW9w", + "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": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/2dc79219-aed6-40c0-8818-0c2d26ed3436", + "url": "https://gleasonator.com/notice/AGm7uC9DaAIGUa4KYK", + "visibility": "public" +} diff --git a/app/soapbox/__fixtures__/status-cw.json b/app/soapbox/__fixtures__/status-cw.json new file mode 100644 index 000000000..af9978cfb --- /dev/null +++ b/app/soapbox/__fixtures__/status-cw.json @@ -0,0 +1,63 @@ +{ + "id": "107831528995252317", + "created_at": "2022-02-20T17:35:55.224Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": true, + "spoiler_text": "testing", + "visibility": "public", + "language": "en", + "uri": "https://fedibird.com/users/alex/statuses/107831528995252317", + "url": "https://fedibird.com/@alex/107831528995252317", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "emoji_reactions_count": 0, + "emoji_reactions": [], + "content": "

hello world

", + "reblog": null, + "application": { + "name": "Web", + "website": null + }, + "account": { + "id": "66768", + "username": "alex", + "acct": "alex", + "display_name": "", + "locked": false, + "bot": false, + "cat": false, + "discoverable": null, + "group": false, + "created_at": "2020-01-27T00:00:00.000Z", + "note": "

", + "url": "https://fedibird.com/@alex", + "avatar": "https://fedibird.com/avatars/original/missing.png", + "avatar_static": "https://fedibird.com/avatars/original/missing.png", + "header": "https://fedibird.com/headers/original/missing.png", + "header_static": "https://fedibird.com/headers/original/missing.png", + "followers_count": 1, + "following_count": 1, + "subscribing_count": 0, + "statuses_count": 5, + "last_status_at": "2022-02-20", + "emojis": [], + "fields": [], + "other_settings": { + "noindex": false, + "hide_network": false, + "hide_statuses_count": false, + "hide_following_count": false, + "hide_followers_count": false, + "enable_reaction": true + } + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null, + "quote": null +} diff --git a/app/soapbox/__fixtures__/status-with-poll.json b/app/soapbox/__fixtures__/status-with-poll.json new file mode 100644 index 000000000..9dfd90d7e --- /dev/null +++ b/app/soapbox/__fixtures__/status-with-poll.json @@ -0,0 +1,201 @@ +{ + "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": 2390, + "following_count": 1574, + "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-02-23T17:54:41", + "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": 23502, + "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": 47, + "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": 26, + "replies_count": 14, + "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/app/soapbox/actions/importer/index.js b/app/soapbox/actions/importer/index.js index 3a019e636..56efcf651 100644 --- a/app/soapbox/actions/importer/index.js +++ b/app/soapbox/actions/importer/index.js @@ -2,7 +2,6 @@ import { getSettings } from '../settings'; import { normalizeAccount, - normalizeStatus, normalizePoll, } from './normalizer'; @@ -22,11 +21,17 @@ export function importAccounts(accounts) { } export function importStatus(status, idempotencyKey) { - return { type: STATUS_IMPORT, status, idempotencyKey }; + return (dispatch, getState) => { + const expandSpoilers = getSettings(getState()).get('expandSpoilers'); + return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers }); + }; } export function importStatuses(statuses) { - return { type: STATUSES_IMPORT, statuses }; + return (dispatch, getState) => { + const expandSpoilers = getSettings(getState()).get('expandSpoilers'); + return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers }); + }; } export function importPolls(polls) { @@ -60,11 +65,6 @@ export function importFetchedStatus(status, idempotencyKey) { // Skip broken statuses if (isBroken(status)) return; - const normalOldStatus = getState().getIn(['statuses', status.id]); - const expandSpoilers = getSettings(getState()).get('expandSpoilers'); - - const normalizedStatus = normalizeStatus(status, normalOldStatus, expandSpoilers); - if (status.reblog?.id) { dispatch(importFetchedStatus(status.reblog)); } @@ -83,7 +83,7 @@ export function importFetchedStatus(status, idempotencyKey) { } dispatch(importFetchedAccount(status.account)); - dispatch(importStatus(normalizedStatus, idempotencyKey)); + dispatch(importStatus(status, idempotencyKey)); }; } @@ -113,10 +113,7 @@ export function importFetchedStatuses(statuses) { // Skip broken statuses if (isBroken(status)) return; - const normalOldStatus = getState().getIn(['statuses', status.id]); - const expandSpoilers = getSettings(getState()).get('expandSpoilers'); - - normalStatuses.push(normalizeStatus(status, normalOldStatus, expandSpoilers)); + normalStatuses.push(status); accounts.push(status.account); if (status.reblog?.id) { diff --git a/app/soapbox/actions/importer/normalizer.js b/app/soapbox/actions/importer/normalizer.js index 7d40357f0..4de148c0b 100644 --- a/app/soapbox/actions/importer/normalizer.js +++ b/app/soapbox/actions/importer/normalizer.js @@ -1,12 +1,8 @@ import escapeTextContentForBrowser from 'escape-html'; -import { stripCompatibilityFeatures } from 'soapbox/utils/html'; - import emojify from '../../features/emoji/emoji'; import { unescapeHTML } from '../../utils/html'; -const domParser = new DOMParser(); - const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { obj[`:${emoji.shortcode}:`] = emoji; return obj; @@ -45,61 +41,6 @@ export function normalizeAccount(account) { return account; } -export function normalizeStatus(status, normalOldStatus, expandSpoilers) { - const normalStatus = { ...status }; - - // Some backends can return null, or omit these required fields - if (!normalStatus.emojis) normalStatus.emojis = []; - if (!normalStatus.spoiler_text) normalStatus.spoiler_text = ''; - - // Copy the pleroma object too, so we can modify our copy - if (status.pleroma) { - normalStatus.pleroma = { ...status.pleroma }; - } - - normalStatus.account = status.account.id; - - if (status.reblog?.id) { - normalStatus.reblog = status.reblog.id; - } - - if (status.poll?.id) { - normalStatus.poll = status.poll.id; - } - - if (status.pleroma?.quote?.id) { - // Normalize quote to the top-level, so delete the original for performance - normalStatus.quote = status.pleroma.quote.id; - delete normalStatus.pleroma.quote; - } else if (status.quote?.id) { - // Fedibird compatibility, because why not - normalStatus.quote = status.quote.id; - } else if (status.quote_id) { - // Fedibird: fall back to quote_id - normalStatus.quote = status.quote_id; - } - - // Only calculate these values when status first encountered - // Otherwise keep the ones already in the reducer - if (normalOldStatus) { - normalStatus.search_index = normalOldStatus.get('search_index'); - normalStatus.contentHtml = normalOldStatus.get('contentHtml'); - normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); - normalStatus.hidden = normalOldStatus.get('hidden'); - } else { - const spoilerText = normalStatus.spoiler_text || ''; - const searchContent = ([spoilerText, status.content].concat((status.poll?.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); - const emojiMap = makeEmojiMap(normalStatus); - - normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; - normalStatus.contentHtml = stripCompatibilityFeatures(emojify(normalStatus.content, emojiMap)); - normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); - normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; - } - - return normalStatus; -} - export function normalizePoll(poll) { const normalPoll = { ...poll }; diff --git a/app/soapbox/components/error_boundary.js b/app/soapbox/components/error_boundary.js index b73944575..2e7c3f717 100644 --- a/app/soapbox/components/error_boundary.js +++ b/app/soapbox/components/error_boundary.js @@ -4,6 +4,7 @@ import { FormattedMessage } from 'react-intl'; import Icon from 'soapbox/components/icon'; import { captureException } from 'soapbox/monitoring'; +import sourceCode from 'soapbox/utils/code'; export default class ErrorBoundary extends React.PureComponent { @@ -72,7 +73,7 @@ export default class ErrorBoundary extends React.PureComponent {

- + @@ -87,6 +88,7 @@ export default class ErrorBoundary extends React.PureComponent { {browser &&

{browser.getBrowserName()} {browser.getBrowserVersion()}

} +

{sourceCode.displayName} {sourceCode.version}

- - - @{status.getIn(['account', 'username'])} - - ), - more: false, - }} - /> -

- ); - } else { - // The reply-to is unknown. Rare, but it can happen. - return ( -
- -
- ); - } + return ( +
+ +
+ ); } - // The typical case with a reply-to and a list of mentions. return (
diff --git a/app/soapbox/features/scheduled_statuses/builder.js b/app/soapbox/features/scheduled_statuses/builder.js index 05cef642c..8e3417582 100644 --- a/app/soapbox/features/scheduled_statuses/builder.js +++ b/app/soapbox/features/scheduled_statuses/builder.js @@ -1,6 +1,7 @@ -import { fromJS } from 'immutable'; +import { Map as ImmutableMap } from 'immutable'; -import { normalizeStatus } from 'soapbox/actions/importer/normalizer'; +import { normalizeStatus } from 'soapbox/normalizers/status'; +import { calculateStatus } from 'soapbox/reducers/statuses'; import { makeGetAccount } from 'soapbox/selectors'; export const buildStatus = (state, scheduledStatus) => { @@ -10,37 +11,19 @@ export const buildStatus = (state, scheduledStatus) => { const params = scheduledStatus.get('params'); const account = getAccount(state, me); - const status = normalizeStatus({ + const status = ImmutableMap({ account, - application: null, - bookmarked: false, - card: null, content: params.get('text', '').replace(new RegExp('\n', 'g'), '
'), /* eslint-disable-line no-control-regex */ created_at: params.get('scheduled_at'), - emojis: [], - favourited: false, - favourites_count: 0, id: scheduledStatus.get('id'), - in_reply_to_account_id: null, in_reply_to_id: params.get('in_reply_to_id'), - language: null, media_attachments: scheduledStatus.get('media_attachments'), - mentions: [], - muted: false, - pinned: false, poll: params.get('poll'), - reblog: null, - reblogged: false, - reblogs_count: 0, - replies_count: 0, sensitive: params.get('sensitive'), - spoiler_text: '', - tags: [], - text: null, uri: `/scheduled_statuses/${scheduledStatus.get('id')}`, url: `/scheduled_statuses/${scheduledStatus.get('id')}`, visibility: params.get('visibility'), }); - return fromJS(status).set('account', account); + return calculateStatus(normalizeStatus(status)); }; diff --git a/app/soapbox/features/scheduled_statuses/components/scheduled_status.js b/app/soapbox/features/scheduled_statuses/components/scheduled_status.js index b47e2b6d0..c79de8af1 100644 --- a/app/soapbox/features/scheduled_statuses/components/scheduled_status.js +++ b/app/soapbox/features/scheduled_statuses/components/scheduled_status.js @@ -9,6 +9,7 @@ import Avatar from 'soapbox/components/avatar'; import DisplayName from 'soapbox/components/display_name'; import RelativeTimestamp from 'soapbox/components/relative_timestamp'; import StatusContent from 'soapbox/components/status_content'; +import StatusReplyMentions from 'soapbox/components/status_reply_mentions'; import PollPreview from 'soapbox/features/ui/components/poll_preview'; import { getDomain } from 'soapbox/utils/accounts'; @@ -63,6 +64,8 @@ class ScheduledStatus extends ImmutablePureComponent {
+ + { - const { status } = this.props; - - if (!status.get('in_reply_to_id')) { - return null; - } - - const to = status.get('mentions', []); - - if (to.size === 0) { - if (status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) { - return ( -
- @{status.getIn(['account', 'username'])}, - more: false, - }} - /> -
- ); - } else { - return ( -
- -
- ); - } - } - - - return ( -
- (<> - @{account.username} - {' '} - )), - more: to.size > 2 && , - }} - /> -
- ); - } - render() { const { status, className } = this.props; if (!status) return null; @@ -137,7 +88,7 @@ class PendingStatus extends ImmutablePureComponent { - {this.renderReplyMentions()} + { + if (pendingStatus.get('in_reply_to_id')) { + return ImmutableList(pendingStatus.get('to') || []).map(acct => ImmutableMap({ acct })); + } else { + return ImmutableList(); + } +}; + +const buildPoll = pendingStatus => { + if (pendingStatus.hasIn(['poll', 'options'])) { + return pendingStatus.get('poll').update('options', options => { + return options.map(title => ImmutableMap({ title })); + }); + } else { + return null; + } +}; export const buildStatus = (state, pendingStatus, idempotencyKey) => { - const getAccount = makeGetAccount(); - const getStatus = makeGetStatus(); - const me = state.get('me'); const account = getAccount(state, me); + const inReplyToId = pendingStatus.get('in_reply_to_id'); - let mentions; - if (pendingStatus.get('in_reply_to_id')) { - const inReplyTo = getStatus(state, { id: pendingStatus.get('in_reply_to_id') }); - - if (inReplyTo.getIn(['account', 'id']) === me) { - mentions = ImmutableOrderedSet([account.get('acct')]).union(pendingStatus.get('to', [])); - } else { - mentions = pendingStatus.get('to', []); - } - - mentions = mentions.map(mention => ({ - username: mention.split('@')[0], - })); - } - - const status = normalizeStatus({ + const status = ImmutableMap({ account, - application: null, - bookmarked: false, - card: null, content: pendingStatus.get('status', '').replace(new RegExp('\n', 'g'), '
'), /* eslint-disable-line no-control-regex */ - created_at: new Date(), - emojis: [], - favourited: false, - favourites_count: 0, id: `末pending-${idempotencyKey}`, - in_reply_to_account_id: null, - in_reply_to_id: pendingStatus.get('in_reply_to_id'), - language: null, - media_attachments: pendingStatus.get('media_ids').map(id => ({ id })), - mentions, - muted: false, - pinned: false, - poll: pendingStatus.get('poll', null), + in_reply_to_account_id: state.getIn(['statuses', inReplyToId, 'account'], null), + in_reply_to_id: inReplyToId, + media_attachments: pendingStatus.get('media_ids', ImmutableList()).map(id => ImmutableMap({ id })), + mentions: buildMentions(pendingStatus), + poll: buildPoll(pendingStatus), quote: pendingStatus.get('quote_id', null), - reblog: null, - reblogged: false, - reblogs_count: 0, - replies_count: 0, sensitive: pendingStatus.get('sensitive', false), - spoiler_text: '', - tags: [], - text: null, - uri: '', - url: '', visibility: pendingStatus.get('visibility', 'public'), }); - return fromJS(status).set('account', account); + return calculateStatus(normalizeStatus(status)); }; diff --git a/app/soapbox/normalizers/__tests__/instance-test.js b/app/soapbox/normalizers/__tests__/instance-test.js index 28eea3191..8f992bb84 100644 --- a/app/soapbox/normalizers/__tests__/instance-test.js +++ b/app/soapbox/normalizers/__tests__/instance-test.js @@ -98,4 +98,50 @@ describe('normalizeInstance()', () => { const result = normalizeInstance(instance); expect(result.toJS()).toMatchObject(expected); }); + + it('normalizes Fedibird instance', () => { + const instance = fromJS(require('soapbox/__fixtures__/fedibird-instance.json')); + const result = normalizeInstance(instance); + + // Sets description_limit + expect(result.get('description_limit')).toEqual(1500); + + // But otherwise, it's the same + expect(result.delete('description_limit')).toEqual(instance); + }); + + it('normalizes Mitra instance', () => { + const instance = fromJS(require('soapbox/__fixtures__/mitra-instance.json')); + const result = normalizeInstance(instance); + + // Adds configuration and description_limit + expect(result.get('configuration') instanceof ImmutableMap).toBe(true); + expect(result.get('description_limit')).toBe(1500); + }); + + it('normalizes GoToSocial instance', () => { + const instance = fromJS(require('soapbox/__fixtures__/gotosocial-instance.json')); + const result = normalizeInstance(instance); + + // Normalizes max_toot_chars + expect(result.getIn(['configuration', 'statuses', 'max_characters'])).toEqual(5000); + expect(result.has('max_toot_chars')).toBe(false); + + // Adds configuration and description_limit + expect(result.get('configuration') instanceof ImmutableMap).toBe(true); + expect(result.get('description_limit')).toBe(1500); + }); + + it('normalizes Friendica instance', () => { + const instance = fromJS(require('soapbox/__fixtures__/friendica-instance.json')); + const result = normalizeInstance(instance); + + // Normalizes max_toot_chars + expect(result.getIn(['configuration', 'statuses', 'max_characters'])).toEqual(200000); + expect(result.has('max_toot_chars')).toBe(false); + + // Adds configuration and description_limit + expect(result.get('configuration') instanceof ImmutableMap).toBe(true); + expect(result.get('description_limit')).toBe(1500); + }); }); diff --git a/app/soapbox/normalizers/__tests__/status-test.js b/app/soapbox/normalizers/__tests__/status-test.js new file mode 100644 index 000000000..26428e3ba --- /dev/null +++ b/app/soapbox/normalizers/__tests__/status-test.js @@ -0,0 +1,154 @@ +import { fromJS } from 'immutable'; + +import { normalizeStatus } from '../status'; + +describe('normalizeStatus', () => { + it('adds base fields', () => { + const status = fromJS({}); + const result = normalizeStatus(status); + + expect(result.get('emojis')).toEqual(fromJS([])); + expect(result.get('favourites_count')).toBe(0); + expect(result.get('mentions')).toEqual(fromJS([])); + expect(result.get('reblog')).toBe(null); + expect(result.get('uri')).toBe(''); + expect(result.get('visibility')).toBe('public'); + }); + + it('fixes the order of mentions', () => { + const status = fromJS(require('soapbox/__fixtures__/status-unordered-mentions.json')); + + const expected = ['NEETzsche', 'alex', 'Lumeinshin', 'sneeden']; + + const result = normalizeStatus(status) + .get('mentions') + .map(mention => mention.get('username')) + .toJS(); + + expect(result).toEqual(expected); + }); + + it('adds mention to self in self-reply on Mastodon', () => { + const status = fromJS(require('soapbox/__fixtures__/mastodon-reply-to-self.json')); + + const expected = fromJS([{ + id: '106801667066418367', + username: 'benis911', + acct: 'benis911', + url: 'https://mastodon.social/@benis911', + }]); + + const result = normalizeStatus(status).get('mentions'); + + expect(result).toEqual(expected); + }); + + it('normalizes mentions with only acct', () => { + const status = fromJS({ mentions: [{ acct: 'alex@gleasonator.com' }] }); + + const expected = fromJS([{ + acct: 'alex@gleasonator.com', + username: 'alex', + url: '', + }]); + + const result = normalizeStatus(status).get('mentions'); + + expect(result).toEqual(expected); + }); + + it('normalizes Mitra attachments', () => { + const status = fromJS(require('soapbox/__fixtures__/mitra-status-with-attachments.json')); + + const expected = fromJS([{ + id: '017eeb0e-e5df-30a4-77a7-a929145cb836', + type: 'image', + url: 'https://mitra.social/media/8e04e6091bbbac79641b5812508683ce72c38693661c18d16040553f2371e18d.png', + preview_url: 'https://mitra.social/media/8e04e6091bbbac79641b5812508683ce72c38693661c18d16040553f2371e18d.png', + remote_url: 'https://mitra.social/media/8e04e6091bbbac79641b5812508683ce72c38693661c18d16040553f2371e18d.png', + }, { + id: '017eeb0e-e5e4-2a48-2889-afdebf368a54', + type: 'unknown', + url: 'https://mitra.social/media/8f72dc2e98572eb4ba7c3a902bca5f69c448fc4391837e5f8f0d4556280440ac', + preview_url: 'https://mitra.social/media/8f72dc2e98572eb4ba7c3a902bca5f69c448fc4391837e5f8f0d4556280440ac', + remote_url: 'https://mitra.social/media/8f72dc2e98572eb4ba7c3a902bca5f69c448fc4391837e5f8f0d4556280440ac', + }, { + id: '017eeb0e-e5e5-79fd-6054-8b6869b1db49', + type: 'unknown', + url: 'https://mitra.social/media/55a81a090247cc4fc127e5716bcf7964f6e0df9b584f85f4696c0b994747a4d0.oga', + preview_url: 'https://mitra.social/media/55a81a090247cc4fc127e5716bcf7964f6e0df9b584f85f4696c0b994747a4d0.oga', + remote_url: 'https://mitra.social/media/55a81a090247cc4fc127e5716bcf7964f6e0df9b584f85f4696c0b994747a4d0.oga', + }, { + id: '017eeb0e-e5e6-c416-a444-21e560c47839', + type: 'unknown', + url: 'https://mitra.social/media/0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0', + preview_url: 'https://mitra.social/media/0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0', + remote_url: 'https://mitra.social/media/0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0', + }]); + + const result = normalizeStatus(status); + + expect(result.get('media_attachments')).toEqual(expected); + }); + + it('leaves Pleroma attachments alone', () => { + const status = fromJS(require('soapbox/__fixtures__/pleroma-status-with-attachments.json')); + const result = normalizeStatus(status); + + expect(status.get('media_attachments')).toEqual(result.get('media_attachments')); + }); + + it('normalizes Pleroma quote post', () => { + const status = fromJS(require('soapbox/__fixtures__/pleroma-quote-post.json')); + const result = normalizeStatus(status); + + expect(result.get('quote')).toEqual(status.getIn(['pleroma', 'quote'])); + expect(result.getIn(['pleroma', 'quote'])).toBe(undefined); + }); + + it('normalizes GoToSocial status', () => { + const status = fromJS(require('soapbox/__fixtures__/gotosocial-status.json')); + const result = normalizeStatus(status); + + // Adds missing fields + const missing = { + in_reply_to_account_id: null, + in_reply_to_id: null, + reblog: null, + pinned: false, + quote: null, + }; + + expect(result.toJS()).toMatchObject(missing); + }); + + it('normalizes Friendica status', () => { + const status = fromJS(require('soapbox/__fixtures__/friendica-status.json')); + const result = normalizeStatus(status); + + // Adds missing fields + const missing = { + pinned: false, + quote: null, + }; + + expect(result.toJS()).toMatchObject(missing); + }); + + it('normalizes poll and poll options', () => { + const status = fromJS({ poll: { options: [{ title: 'Apples' }] } }); + const result = normalizeStatus(status); + + const expected = { + options: [{ title: 'Apples', votes_count: 0 }], + emojis: [], + expired: false, + multiple: false, + voters_count: 0, + votes_count: 0, + }; + + expect(result.get('poll').toJS()).toMatchObject(expected); + expect(result.getIn(['poll', 'expires_at']) instanceof Date).toBe(true); + }); +}); diff --git a/app/soapbox/normalizers/status.js b/app/soapbox/normalizers/status.js new file mode 100644 index 000000000..8927546e8 --- /dev/null +++ b/app/soapbox/normalizers/status.js @@ -0,0 +1,161 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +import { accountToMention } from 'soapbox/utils/accounts'; + +// Some backends can return null, or omit these required fields +const baseStatus = ImmutableMap({ + application: null, + bookmarked: false, + card: null, + created_at: new Date(), + emojis: ImmutableList(), + favourited: false, + favourites_count: 0, + in_reply_to_account_id: null, + in_reply_to_id: null, + language: null, + mentions: ImmutableList(), + muted: false, + pinned: false, + reblog: null, + reblogged: false, + reblogs_count: 0, + replies_count: 0, + spoiler_text: '', + tags: ImmutableList(), + uri: '', + url: '', + visibility: 'public', +}); + +const basePollOption = ImmutableMap({ title: '', votes_count: 0 }); + +const basePoll = ImmutableMap({ + emojis: ImmutableList(), + expired: false, + expires_at: new Date(Date.now() + 1000 * (60 * 5)), // 5 minutes + multiple: false, + options: ImmutableList(), + voters_count: 0, + votes_count: 0, +}); + +// Merger function for only overriding undefined values +const mergeDefined = (oldVal, newVal) => oldVal === undefined ? newVal : oldVal; + +// Merge base status +const mergeBase = status => { + return status.mergeDeepWith(mergeDefined, baseStatus); +}; + +// Ensure attachments have required fields +// https://docs.joinmastodon.org/entities/attachment/ +const normalizeAttachment = attachment => { + const url = [ + attachment.get('url'), + attachment.get('preview_url'), + attachment.get('remote_url'), + ].find(url => url) || ''; + + const base = ImmutableMap({ + url, + preview_url: url, + remote_url: url, + }); + + return attachment.mergeWith(mergeDefined, base); +}; + +const normalizeAttachments = status => { + return status.update('media_attachments', ImmutableList(), attachments => { + return attachments.map(normalizeAttachment); + }); +}; + +// Normalize mentions +const normalizeMention = mention => { + const base = ImmutableMap({ + acct: '', + username: (mention.get('acct') || '').split('@')[0], + url: '', + }); + + return mention.mergeWith(mergeDefined, base); +}; + +const normalizeMentions = status => { + return status.update('mentions', ImmutableList(), mentions => { + return mentions.map(normalizeMention); + }); +}; + +// Normalize poll option +const normalizePollOption = option => { + return option.mergeWith(mergeDefined, basePollOption); +}; + +// Normalize poll +const normalizePoll = status => { + if (status.hasIn(['poll', 'options'])) { + return status.update('poll', ImmutableMap(), poll => { + return poll.mergeWith(mergeDefined, basePoll).update('options', options => { + return options.map(normalizePollOption); + }); + }); + } else { + return status.set('poll', null); + } +}; +// Fix order of mentions +const fixMentionsOrder = status => { + const mentions = status.get('mentions', ImmutableList()); + const inReplyToAccountId = status.get('in_reply_to_account_id'); + + // Sort the replied-to mention to the top + const sorted = mentions.sort((a, b) => { + if (a.get('id') === inReplyToAccountId) { + return -1; + } else { + return 0; + } + }); + + return status.set('mentions', sorted); +}; + +// Add self to mentions if it's a reply to self +const addSelfMention = status => { + const accountId = status.getIn(['account', 'id']); + + const isSelfReply = accountId === status.get('in_reply_to_account_id'); + const hasSelfMention = accountId === status.getIn(['mentions', 0, 'id']); + + if (isSelfReply && !hasSelfMention) { + const mention = accountToMention(status.get('account')); + return status.update('mentions', ImmutableList(), mentions => ( + ImmutableList([mention]).concat(mentions) + )); + } else { + return status; + } +}; + +// Move the quote to the top-level +const fixQuote = status => { + return status.withMutations(status => { + status.update('quote', quote => quote || status.getIn(['pleroma', 'quote']) || null); + status.deleteIn(['pleroma', 'quote']); + }); +}; + +export const normalizeStatus = status => { + return status.withMutations(status => { + mergeBase(status); + normalizeAttachments(status); + normalizeMentions(status); + normalizePoll(status); + fixMentionsOrder(status); + addSelfMention(status); + fixQuote(status); + }); +}; diff --git a/app/soapbox/reducers/__tests__/statuses-test.js b/app/soapbox/reducers/__tests__/statuses-test.js index 926c9a867..3a81f2004 100644 --- a/app/soapbox/reducers/__tests__/statuses-test.js +++ b/app/soapbox/reducers/__tests__/statuses-test.js @@ -1,7 +1,6 @@ import { Map as ImmutableMap, fromJS } from 'immutable'; import { STATUS_IMPORT } from 'soapbox/actions/importer'; -import { normalizeStatus } from 'soapbox/actions/importer/normalizer'; import { STATUS_CREATE_REQUEST, STATUS_CREATE_FAIL, @@ -34,8 +33,8 @@ describe('statuses reducer', () => { const quotedQuotePost = require('soapbox/__fixtures__/pleroma-quote-of-quote-post.json'); let state = undefined; - state = reducer(state, { type: STATUS_IMPORT, status: normalizeStatus(quotePost) }); - state = reducer(state, { type: STATUS_IMPORT, status: normalizeStatus(quotedQuotePost.pleroma.quote) }); + state = reducer(state, { type: STATUS_IMPORT, status: quotePost }); + state = reducer(state, { type: STATUS_IMPORT, status: quotedQuotePost.pleroma.quote }); expect(state.getIn(['AFmFMSpITT9xcOJKcK', 'quote'])).toEqual('AFmFLcd6XYVdjWCrOS'); }); @@ -43,7 +42,7 @@ describe('statuses reducer', () => { it('normalizes Mitra attachments', () => { const status = require('soapbox/__fixtures__/mitra-status-with-attachments.json'); - const state = reducer(undefined, { type: STATUS_IMPORT, status: normalizeStatus(status) }); + const state = reducer(undefined, { type: STATUS_IMPORT, status }); const expected = fromJS([{ id: '017eeb0e-e5df-30a4-77a7-a929145cb836', @@ -76,12 +75,52 @@ describe('statuses reducer', () => { it('leaves Pleroma attachments alone', () => { const status = require('soapbox/__fixtures__/pleroma-status-with-attachments.json'); - const action = { type: STATUS_IMPORT, status: normalizeStatus(status) }; + const action = { type: STATUS_IMPORT, status }; const state = reducer(undefined, action); const expected = fromJS(status.media_attachments); expect(state.getIn(['AGNkA21auFR5lnEAHw', 'media_attachments'])).toEqual(expected); }); + + it('hides CWs', () => { + const status = require('soapbox/__fixtures__/status-cw.json'); + const action = { type: STATUS_IMPORT, status }; + + const hidden = reducer(undefined, action).getIn(['107831528995252317', 'hidden']); + expect(hidden).toBe(true); + }); + + it('expands CWs when expandSpoilers is enabled', () => { + const status = require('soapbox/__fixtures__/status-cw.json'); + const action = { type: STATUS_IMPORT, status, expandSpoilers: true }; + + const hidden = reducer(undefined, action).getIn(['107831528995252317', 'hidden']); + expect(hidden).toBe(false); + }); + + it('parses custom emojis', () => { + const status = require('soapbox/__fixtures__/status-custom-emoji.json'); + const action = { type: STATUS_IMPORT, status }; + + const expected = 'Hello :ablobcathyper: :ageblobcat: 😂 world 😋 test :blobcatphoto:'; + + const result = reducer(undefined, action).getIn(['AGm7uC9DaAIGUa4KYK', 'contentHtml']); + expect(result).toBe(expected); + }); + + it('builds search_index', () => { + const status = require('soapbox/__fixtures__/status-with-poll.json'); + const action = { type: STATUS_IMPORT, status }; + + const expected = `What is tolerance? + +Banning, censoring, and deplatforming anyone you disagree with + +Promoting free speech, even for people and ideas you dislike`; + + const result = reducer(undefined, action).getIn(['103874034847713213', 'search_index']); + expect(result).toEqual(expected); + }); }); describe('STATUS_CREATE_REQUEST', () => { diff --git a/app/soapbox/reducers/statuses.js b/app/soapbox/reducers/statuses.js index 70c62244c..2f2b0ac7f 100644 --- a/app/soapbox/reducers/statuses.js +++ b/app/soapbox/reducers/statuses.js @@ -1,6 +1,10 @@ +import escapeTextContentForBrowser from 'escape-html'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import emojify from 'soapbox/features/emoji/emoji'; +import { normalizeStatus } from 'soapbox/normalizers/status'; import { simulateEmojiReact, simulateUnEmojiReact } from 'soapbox/utils/emoji_reacts'; +import { stripCompatibilityFeatures } from 'soapbox/utils/html'; import { EMOJI_REACT_REQUEST, @@ -26,55 +30,53 @@ import { } from '../actions/statuses'; import { TIMELINE_DELETE } from '../actions/timelines'; -// Ensure attachments have required fields -// https://docs.joinmastodon.org/entities/attachment/ -const normalizeAttachment = attachment => { - const url = [ - attachment.get('url'), - attachment.get('preview_url'), - attachment.get('remote_url'), - ].find(url => url) || ''; +const domParser = new DOMParser(); - const base = ImmutableMap({ - url, - preview_url: url, - remote_url: url, - }); +const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => { + obj[`:${emoji.get('shortcode')}:`] = emoji.toJS(); + return obj; +}, {}); - return attachment.mergeWith((o, n) => o || n, base); -}; - -const normalizeAttachments = status => { - return status.update('media_attachments', ImmutableList(), attachments => { - return attachments.map(normalizeAttachment); +const minifyStatus = status => { + return status.mergeWith((o, n) => n || o, { + account: status.getIn(['account', 'id']), + reblog: status.getIn(['reblog', 'id']), + poll: status.getIn(['poll', 'id']), + quote: status.getIn(['quote', 'id']), }); }; -// Fix order of mentions -const fixMentions = status => { - const mentions = status.get('mentions'); - const inReplyToAccountId = status.get('in_reply_to_account_id'); +// Only calculate these values when status first encountered +// Otherwise keep the ones already in the reducer +export const calculateStatus = (status, oldStatus, expandSpoilers = false) => { + if (oldStatus) { + return status.merge({ + search_index: oldStatus.get('search_index'), + contentHtml: oldStatus.get('contentHtml'), + spoilerHtml: oldStatus.get('spoilerHtml'), + hidden: oldStatus.get('hidden'), + }); + } else { + const spoilerText = status.get('spoiler_text') || ''; + const searchContent = (ImmutableList([spoilerText, status.get('content')]).concat(status.getIn(['poll', 'options'], ImmutableList()).map(option => option.get('title')))).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); + const emojiMap = makeEmojiMap(status); - // Sort the replied-to mention to the top - const sorted = mentions.sort((a, b) => { - if (a.get('id') === inReplyToAccountId) { - return -1; - } else { - return 0; - } - }); - - return status.set('mentions', sorted); + return status.merge({ + search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent, + contentHtml: stripCompatibilityFeatures(emojify(status.get('content'), emojiMap)), + spoilerHtml: emojify(escapeTextContentForBrowser(spoilerText), emojiMap), + hidden: expandSpoilers ? false : spoilerText.length > 0 || status.get('sensitive'), + }); + } }; +// Check whether a status is a quote by secondary characteristics const isQuote = status => { return Boolean(status.get('quote_id') || status.getIn(['pleroma', 'quote_url'])); }; // Preserve quote if an existing status already has it -const fixQuote = (state, status) => { - const oldStatus = state.get(status.get('id')); - +const fixQuote = (status, oldStatus) => { if (oldStatus && !status.get('quote') && isQuote(status)) { return status .set('quote', oldStatus.get('quote')) @@ -84,18 +86,22 @@ const fixQuote = (state, status) => { } }; -const normalizeStatus = (state, status) => { +const fixStatus = (state, status, expandSpoilers) => { + const oldStatus = state.get(status.get('id')); + return status.withMutations(status => { - fixMentions(status); - fixQuote(state, status); - normalizeAttachments(status); + normalizeStatus(status); + fixQuote(status, oldStatus); + calculateStatus(status, oldStatus, expandSpoilers); + minifyStatus(status); }); }; -const importStatus = (state, status) => state.set(status.id, normalizeStatus(state, fromJS(status))); +const importStatus = (state, status, expandSpoilers) => + state.set(status.id, fixStatus(state, fromJS(status), expandSpoilers)); -const importStatuses = (state, statuses) => - state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status))); +const importStatuses = (state, statuses, expandSpoilers) => + state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status, expandSpoilers))); const deleteStatus = (state, id, references) => { references.forEach(ref => { @@ -126,9 +132,9 @@ const initialState = ImmutableMap(); export default function statuses(state = initialState, action) { switch(action.type) { case STATUS_IMPORT: - return importStatus(state, action.status); + return importStatus(state, action.status, action.expandSpoilers); case STATUSES_IMPORT: - return importStatuses(state, action.statuses); + return importStatuses(state, action.statuses, action.expandSpoilers); case STATUS_CREATE_REQUEST: return importPendingStatus(state, action.params); case STATUS_CREATE_FAIL: diff --git a/app/soapbox/utils/__tests__/accounts-test.js b/app/soapbox/utils/__tests__/accounts-test.js index 15a42ec57..d3fd6f9ba 100644 --- a/app/soapbox/utils/__tests__/accounts-test.js +++ b/app/soapbox/utils/__tests__/accounts-test.js @@ -6,6 +6,7 @@ import { isStaff, isAdmin, isModerator, + accountToMention, } from '../accounts'; describe('getDomain', () => { @@ -115,3 +116,19 @@ describe('isModerator', () => { }); }); }); + +describe('accountToMention', () => { + it('converts the account to a mention', () => { + const account = fromJS(require('soapbox/__fixtures__/alex.json')); + + const expected = fromJS({ + id: '9v5bmRalQvjOy0ECcC', + username: 'alex', + acct: 'alex', + url: 'https://gleasonator.com/users/alex', + }); + + const result = accountToMention(account); + expect(result).toEqual(expected); + }); +}); diff --git a/app/soapbox/utils/accounts.js b/app/soapbox/utils/accounts.js index 6970f0050..3cf2c7cd5 100644 --- a/app/soapbox/utils/accounts.js +++ b/app/soapbox/utils/accounts.js @@ -67,3 +67,12 @@ export const isRemote = account => !isLocal(account); export const isVerified = account => ( account.getIn(['pleroma', 'tags'], ImmutableList()).includes('verified') ); + +export const accountToMention = account => { + return ImmutableMap({ + id: account.get('id'), + username: account.get('username'), + acct: account.get('acct'), + url: account.get('url'), + }); +}; diff --git a/app/styles/components/error-boundary.scss b/app/styles/components/error-boundary.scss index b809a1efa..fbf515c12 100644 --- a/app/styles/components/error-boundary.scss +++ b/app/styles/components/error-boundary.scss @@ -63,6 +63,11 @@ } } + &__version { + font-size: 12px; + margin: 6px 0; + } + p.help-text { text-align: left; font-style: italic;